Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- <!DOCTYPE html>
- <html lang="pl">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Driver Statistics — Raport</title>
- <style>
- :root {
- --bg: #0d0f14;
- --bg2: #13161e;
- --bg3: #1a1e2a;
- --border: #2a2f3f;
- --accent: #f0a500;
- --accent2: #3dd6f5;
- --danger: #e05252;
- --success: #4cca8a;
- --text: #d0d6e8;
- --text-dim: #6b7494;
- --mono: 'Consolas', 'Cascadia Code', 'Courier New', monospace;
- --body: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
- }
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- body {
- background: var(--bg);
- color: var(--text);
- font-family: var(--body);
- font-size: 14px;
- min-height: 100vh;
- }
- /* HEADER */
- .header {
- background: var(--bg2);
- border-bottom: 1px solid var(--border);
- padding: 20px 32px;
- display: flex;
- align-items: center;
- gap: 20px;
- position: sticky;
- top: 0;
- z-index: 100;
- }
- .header-icon {
- font-size: 28px;
- }
- .header-title {
- font-family: var(--mono);
- font-size: 18px;
- color: var(--accent);
- letter-spacing: 2px;
- text-transform: uppercase;
- }
- .header-sub {
- font-size: 11px;
- color: var(--text-dim);
- font-family: var(--mono);
- letter-spacing: 1px;
- margin-top: 2px;
- }
- .header-stats {
- margin-left: auto;
- display: flex;
- gap: 24px;
- }
- .hstat {
- text-align: right;
- }
- .hstat-val {
- font-family: var(--mono);
- font-size: 18px;
- color: var(--accent2);
- }
- .hstat-label {
- font-size: 10px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 1px;
- }
- /* FILTERS */
- .filters {
- padding: 18px 32px;
- background: var(--bg2);
- border-bottom: 1px solid var(--border);
- display: flex;
- gap: 16px;
- flex-wrap: wrap;
- align-items: flex-end;
- }
- .filter-group {
- display: flex;
- flex-direction: column;
- gap: 5px;
- }
- .filter-group label {
- font-size: 10px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 1px;
- font-family: var(--mono);
- }
- select, input[type="date"] {
- background: var(--bg3);
- border: 1px solid var(--border);
- color: var(--text);
- font-family: var(--mono);
- font-size: 13px;
- padding: 7px 12px;
- border-radius: 4px;
- outline: none;
- min-width: 180px;
- cursor: pointer;
- transition: border-color .2s;
- }
- select:focus, input[type="date"]:focus {
- border-color: var(--accent);
- }
- .btn-reset {
- background: transparent;
- border: 1px solid var(--border);
- color: var(--text-dim);
- font-family: var(--mono);
- font-size: 12px;
- padding: 7px 16px;
- border-radius: 4px;
- cursor: pointer;
- letter-spacing: 1px;
- transition: all .2s;
- align-self: flex-end;
- }
- .btn-reset:hover {
- border-color: var(--accent);
- color: var(--accent);
- }
- /* VIEW TOGGLE */
- .view-toggle {
- margin-left: auto;
- display: flex;
- gap: 8px;
- align-self: flex-end;
- }
- .btn-view {
- background: var(--bg3);
- border: 1px solid var(--border);
- color: var(--text-dim);
- font-family: var(--mono);
- font-size: 11px;
- padding: 7px 14px;
- border-radius: 4px;
- cursor: pointer;
- letter-spacing: 1px;
- transition: all .2s;
- }
- .btn-view.active {
- background: var(--accent);
- border-color: var(--accent);
- color: #000;
- font-weight: 700;
- }
- /* SUMMARY CARDS */
- .summary-cards {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
- gap: 12px;
- padding: 20px 32px;
- }
- .card {
- background: var(--bg2);
- border: 1px solid var(--border);
- border-radius: 6px;
- padding: 14px 16px;
- position: relative;
- overflow: hidden;
- }
- .card::before {
- content: '';
- position: absolute;
- top: 0; left: 0; right: 0;
- height: 2px;
- background: var(--accent);
- }
- .card.blue::before { background: var(--accent2); }
- .card.green::before { background: var(--success); }
- .card.red::before { background: var(--danger); }
- .card-val {
- font-family: var(--mono);
- font-size: 22px;
- color: var(--accent);
- line-height: 1;
- }
- .card.blue .card-val { color: var(--accent2); }
- .card.green .card-val { color: var(--success); }
- .card.red .card-val { color: var(--danger); }
- .card-label {
- font-size: 10px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 1px;
- margin-top: 5px;
- }
- /* TABLE */
- .table-wrap {
- padding: 0 32px 40px;
- overflow-x: auto;
- }
- /* GROUP VIEW */
- .group-section {
- margin-bottom: 32px;
- }
- .group-header {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 0 8px;
- border-bottom: 1px solid var(--border);
- margin-bottom: 8px;
- cursor: pointer;
- user-select: none;
- }
- .group-header:hover .group-name { color: var(--accent); }
- .group-name {
- font-family: var(--mono);
- font-size: 14px;
- color: var(--accent2);
- letter-spacing: 1px;
- transition: color .2s;
- }
- .group-badge {
- font-family: var(--mono);
- font-size: 10px;
- background: var(--bg3);
- border: 1px solid var(--border);
- padding: 2px 8px;
- border-radius: 10px;
- color: var(--text-dim);
- }
- .group-chevron {
- margin-left: auto;
- color: var(--text-dim);
- font-size: 12px;
- transition: transform .2s;
- }
- .group-chevron.open { transform: rotate(180deg); }
- table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
- }
- thead th {
- background: var(--bg3);
- color: var(--text-dim);
- font-family: var(--mono);
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 1px;
- padding: 8px 12px;
- text-align: left;
- border-bottom: 1px solid var(--border);
- white-space: nowrap;
- cursor: pointer;
- user-select: none;
- transition: color .2s;
- }
- thead th:hover { color: var(--accent); }
- thead th .sort-icon { margin-left: 4px; opacity: 0.4; }
- thead th.sort-asc .sort-icon::after { content: '▲'; opacity: 1; color: var(--accent); }
- thead th.sort-desc .sort-icon::after { content: '▼'; opacity: 1; color: var(--accent); }
- tbody tr {
- border-bottom: 1px solid rgba(42,47,63,0.5);
- transition: background .15s;
- }
- tbody tr:hover { background: rgba(255,165,0,0.04); }
- td {
- padding: 8px 12px;
- white-space: nowrap;
- }
- td.mono { font-family: var(--mono); }
- td.num { text-align: right; font-family: var(--mono); }
- td.map-name {
- font-family: var(--mono);
- color: var(--accent2);
- font-size: 12px;
- }
- .pill {
- display: inline-block;
- padding: 2px 8px;
- border-radius: 3px;
- font-family: var(--mono);
- font-size: 11px;
- font-weight: 700;
- }
- .pill-ok { background: rgba(76,202,138,0.15); color: var(--success); }
- .pill-warn { background: rgba(240,165,0,0.15); color: var(--accent); }
- .pill-danger { background: rgba(224,82,82,0.15); color: var(--danger); }
- /* FLAT VIEW */
- #flat-table thead th:first-child { width: 180px; }
- /* No results */
- .no-results {
- text-align: center;
- padding: 60px;
- color: var(--text-dim);
- font-family: var(--mono);
- letter-spacing: 2px;
- }
- .no-results .icon { font-size: 40px; margin-bottom: 12px; }
- /* Scrollbar */
- ::-webkit-scrollbar { width: 6px; height: 6px; }
- ::-webkit-scrollbar-track { background: var(--bg); }
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
- ::-webkit-scrollbar-thumb:hover { background: var(--accent); }
- /* Anim */
- @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
- .fade-in { animation: fadeIn .3s ease both; }
- .collapsed { display: none; }
- /* FILE PICKER */
- #file-overlay {
- position: fixed; inset: 0;
- background: var(--bg);
- display: flex; align-items: center; justify-content: center;
- z-index: 999;
- flex-direction: column;
- gap: 24px;
- }
- #file-overlay.hidden { display: none; }
- .file-drop-zone {
- border: 2px dashed var(--border);
- border-radius: 12px;
- padding: 60px 80px;
- text-align: center;
- cursor: pointer;
- transition: border-color .2s, background .2s;
- position: relative;
- }
- .file-drop-zone:hover, .file-drop-zone.drag-over {
- border-color: var(--accent);
- background: rgba(240,165,0,0.05);
- }
- .file-drop-zone input[type=file] {
- position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
- }
- .drop-icon { font-size: 48px; margin-bottom: 16px; }
- .drop-title {
- font-family: var(--mono);
- font-size: 16px;
- color: var(--accent);
- letter-spacing: 2px;
- text-transform: uppercase;
- margin-bottom: 8px;
- }
- .drop-sub {
- font-size: 12px;
- color: var(--text-dim);
- font-family: var(--mono);
- letter-spacing: 1px;
- }
- .overlay-logo {
- font-family: var(--mono);
- font-size: 22px;
- color: var(--accent);
- letter-spacing: 3px;
- text-transform: uppercase;
- }
- #file-error {
- font-family: var(--mono);
- font-size: 12px;
- color: var(--danger);
- letter-spacing: 1px;
- display: none;
- }
- .btn-load-another {
- background: transparent;
- border: 1px solid var(--border);
- color: var(--text-dim);
- font-family: var(--mono);
- font-size: 11px;
- padding: 6px 14px;
- border-radius: 4px;
- cursor: pointer;
- letter-spacing: 1px;
- transition: all .2s;
- margin-left: 12px;
- }
- .btn-load-another:hover { border-color: var(--accent); color: var(--accent); }
- #main-content { display: none; }
- </style>
- </head>
- <body>
- <div id="file-overlay">
- <div class="overlay-logo">🚌 Driver Statistics</div>
- <div class="file-drop-zone" id="drop-zone">
- <input type="file" id="file-input" accept=".dat,.xml">
- <div class="drop-icon">📂</div>
- <div class="drop-title">Wczytaj plik danych</div>
- <div class="drop-sub">Kliknij lub przeciągnij plik .dat / .xml</div>
- </div>
- <div id="file-error">⚠ Nie udało się odczytać pliku. Upewnij się, że to prawidłowy plik XML.</div>
- </div>
- <div id="main-content">
- <div class="header-icon">🚌</div>
- <div>
- <div class="header-title">Driver Statistics</div>
- <div class="header-sub" id="date-range-label">Wczytywanie danych...</div>
- </div>
- <div class="header-stats">
- <div class="hstat">
- <div class="hstat-val" id="hstat-sessions">—</div>
- <div class="hstat-label">Sesji</div>
- </div>
- <div class="hstat">
- <div class="hstat-val" id="hstat-maps">—</div>
- <div class="hstat-label">Map</div>
- </div>
- </div>
- </div>
- <div class="filters">
- <div class="filter-group">
- <label>🗺 Mapa</label>
- <select id="filter-map">
- <option value="">Wszystkie mapy</option>
- </select>
- </div>
- <div class="filter-group">
- <label>📅 Data od</label>
- <input type="date" id="filter-date-from">
- </div>
- <div class="filter-group">
- <label>📅 Data do</label>
- <input type="date" id="filter-date-to">
- </div>
- <button class="btn-reset" onclick="resetFilters()">✕ Resetuj</button>
- <div class="view-toggle">
- <button class="btn-view active" id="btn-grouped" onclick="setView('grouped')">Pogrupowane</button>
- <button class="btn-view" id="btn-flat" onclick="setView('flat')">Wszystkie sesje</button>
- <button class="btn-view" id="btn-summary" onclick="setView('summary')">Sumy per mapa</button>
- </div>
- </div>
- <div class="summary-cards" id="summary-cards"></div>
- <div class="table-wrap" id="table-wrap"></div>
- </div><!-- /main-content -->
- <script>
- // =============================================================================
- // SEKCJA 1: WCZYTYWANIE PLIKU
- // Używamy File API — wbudowanego w przeglądarkę mechanizmu do odczytu plików
- // z dysku użytkownika. Bez tego nie można otworzyć pliku lokalnie bez serwera.
- // =============================================================================
- // Pobieramy referencje do elementów HTML, żeby móc nimi sterować z JS.
- // getElementById zwraca element o podanym id="" ze strony.
- const fileInput = document.getElementById('file-input'); // input type=file (ukryty)
- const dropZone = document.getElementById('drop-zone'); // ramka "przeciągnij plik"
- const overlay = document.getElementById('file-overlay'); // cały ekran powitalny
- const fileError = document.getElementById('file-error'); // komunikat błędu
- // Obsługa kliknięcia / wyboru pliku przez okno dialogowe.
- // Zdarzenie 'change' odpala się gdy użytkownik wybierze plik.
- // e.target.files[0] to pierwszy wybrany plik (obsługujemy tylko jeden).
- fileInput.addEventListener('change', e => {
- if (e.target.files[0]) readFile(e.target.files[0]);
- });
- // Obsługa przeciągania pliku (drag & drop) na strefę upuszczania.
- // 'dragover' — kursor z plikiem jest nad strefą (musimy wywołać preventDefault,
- // żeby przeglądarka nie otwierała pliku sama w zakładce)
- // 'dragleave' — kursor opuścił strefę — usuwamy efekt wizualny
- // 'drop' — użytkownik upuścił plik — pobieramy go z e.dataTransfer.files
- dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
- dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
- dropZone.addEventListener('drop', e => {
- e.preventDefault();
- dropZone.classList.remove('drag-over');
- if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]);
- });
- // Odczyt zawartości pliku jako tekstu (UTF-8).
- // FileReader to wbudowany obiekt przeglądarki działający asynchronicznie —
- // nie blokuje strony podczas czytania. Gdy skończy, wywołuje callback onload.
- function readFile(file) {
- const reader = new FileReader();
- // onload odpala się po pomyślnym wczytaniu — e.target.result to treść pliku jako string
- reader.onload = e => {
- try {
- loadData(e.target.result, file.name);
- } catch(err) {
- // Jeśli XML jest uszkodzony lub plik nie jest właściwym formatem — pokaż błąd
- showFileError();
- }
- };
- reader.onerror = showFileError; // Błąd I/O (np. brak dostępu do pliku)
- reader.readAsText(file, 'utf-8');
- }
- // Pokazuje czerwony komunikat błędu pod strefą upuszczania
- function showFileError() {
- fileError.style.display = 'block';
- }
- // =============================================================================
- // SEKCJA 2: FUNKCJE POMOCNICZE (formatowanie danych)
- // =============================================================================
- // Parsowanie czasu w formacie ISO 8601 (np. "PT1H30M15S") na sekundy.
- // Format: P = Period, T = Time, H = godziny, M = minuty, S = sekundy.
- // Regex (?:(\d+)H)? oznacza "opcjonalnie: cyfry przed H" — każda część jest opcjonalna.
- // Operator + zamienia string na liczbę; ||0 zabezpiecza gdy dana część nie wystąpi.
- function parseDuration(s) {
- if (!s) return 0;
- const m = s.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
- if (!m) return 0;
- return (+(m[1]||0)) * 3600 // godziny → sekundy
- + (+(m[2]||0)) * 60 // minuty → sekundy
- + (+(m[3]||0)); // sekundy
- }
- // Zamienia liczbę sekund z powrotem na czytelny tekst (np. 5400 → "1h 30m").
- // Math.floor() zaokrągla w dół (np. 3661 sekund to 1h 1m, nie 1h 1m 1s).
- // Modulo (%) wyciąga resztę z dzielenia, np. 3661 % 3600 = 61 sekund → 1 minuta.
- function fmtDuration(secs) {
- const h = Math.floor(secs / 3600);
- const m = Math.floor((secs % 3600) / 60);
- const s = secs % 60;
- if (h > 0) return `${h}h ${m}m`;
- if (m > 0) return `${m}m ${s}s`;
- return `${s}s`;
- }
- // Usuwa prefix "maps/" i końcowy ukośnik z nazwy mapy (np. "maps/golczewo 2.0/" → "golczewo 2.0").
- // replace() z /\/$/ to wyrażenie regularne dopasowujące ukośnik na końcu stringa.
- function fmtMapName(raw) {
- return raw.replace('maps/', '').replace(/\/$/, '');
- }
- // Formatuje liczbę zmiennoprzecinkową z podaną liczbą miejsc po przecinku,
- // zamieniając kropkę na przecinek (polska notacja dziesiętna).
- function fmtNum(n, dec = 2) {
- return n.toFixed(dec).replace('.', ',');
- }
- // Zamienia obiekt Date na string "RRRR-MM-DD" (pierwsze 10 znaków z formatu ISO).
- function fmtDate(d) {
- return d.toISOString().slice(0, 10);
- }
- // =============================================================================
- // SEKCJA 3: STAN APLIKACJI
- // Zmienne globalne przechowujące aktualny stan widoku i filtrów.
- // "Stan" to w uproszczeniu: co aktualnie widzisz i jakie masz ustawione filtry.
- // =============================================================================
- let currentView = 'grouped'; // aktywny widok: 'grouped' | 'flat' | 'summary'
- let sortCol = null, sortDir = 1; // (nieużywane globalnie — każdy widok ma własne)
- let filterMap = ''; // aktualnie wybrana mapa (pusty string = wszystkie)
- let filterFrom = ''; // data początkowa filtra
- let filterTo = ''; // data końcowa filtra
- let allSessions = []; // tablica wszystkich sesji wczytanych z pliku
- // Podpinamy słuchaczy zdarzeń do kontrolek filtrów.
- // Za każdym razem gdy użytkownik zmieni wartość, aktualizujemy zmienną i przerysowujemy widok.
- const mapSel = document.getElementById('filter-map');
- mapSel.addEventListener('change', e => { filterMap = e.target.value; render(); });
- document.getElementById('filter-date-from').addEventListener('change', e => { filterFrom = e.target.value; render(); });
- document.getElementById('filter-date-to').addEventListener('change', e => { filterTo = e.target.value; render(); });
- // =============================================================================
- // SEKCJA 4: WCZYTYWANIE I PARSOWANIE DANYCH XML
- // =============================================================================
- // Główna funkcja wywoływana po odczytaniu pliku przez FileReader.
- // xmlText — zawartość pliku jako string, filename — nazwa pliku do wyświetlenia w nagłówku.
- function loadData(xmlText, filename) {
- fileError.style.display = 'none'; // ukryj ewentualny poprzedni błąd
- // DOMParser to wbudowany parser przeglądarki — zamienia string XML na drzewo DOM,
- // po którym można nawigować tak samo jak po strukturze HTML strony.
- const parser = new DOMParser();
- const doc = parser.parseFromString(xmlText, 'application/xml');
- // Jeśli XML jest nieprawidłowy, przeglądarka wstawia element <parsererror>.
- // Rzucamy wyjątek, który złapie blok catch w readFile() i pokaże błąd.
- if (doc.querySelector('parsererror')) throw new Error('XML parse error');
- // querySelectorAll zwraca wszystkie węzły XML o nazwie tblMapStatistics jako tablicę.
- // Array.from() jest potrzebne, bo querySelectorAll zwraca NodeList, nie Array.
- const allNodes = Array.from(doc.querySelectorAll('tblMapStatistics'));
- // Mapujemy każdy węzeł XML na płaski obiekt JavaScript — łatwiej z nim pracować.
- // querySelector('tagName').textContent odczytuje zawartość tekstową danego elementu.
- // Operator + przed stringiem zamienia go na liczbę (np. +'16' === 16).
- // parseFloat obsługuje liczby zmiennoprzecinkowe.
- allSessions = allNodes.map(n => ({
- mapRaw: n.querySelector('mapName').textContent, // oryginalna nazwa z XML
- map: fmtMapName(n.querySelector('mapName').textContent), // oczyszczona nazwa
- date: new Date(n.querySelector('date').textContent), // obiekt Date do sortowania
- dateStr: n.querySelector('date').textContent.slice(0, 10), // string "RRRR-MM-DD" do wyświetlania
- timeActive: parseDuration(n.querySelector('timeActive').textContent), // czas jazdy w sekundach
- km: parseFloat(n.querySelector('kilometersDriven').textContent), // kilometry
- stopsPassedBy: +n.querySelector('stopsPassedBy').textContent, // przystanki obsłużone
- stopsMissed: +n.querySelector('stopsMissed').textContent, // przystanki ominięte
- stopsTooEarly: +n.querySelector('stopsTooEarly').textContent, // odjazdy za wcześnie
- stopsTooLate: +n.querySelector('stopsTooLate').textContent, // odjazdy za późno
- passengers: +n.querySelector('passengersServed').textContent, // liczba pasażerów
- revenue: parseFloat(n.querySelector('revenueEarned').textContent), // przychód
- }))
- // Odrzucamy "puste" sesje (0 km i 0 pasażerów) — to zwykle przypadkowe uruchomienia gry.
- // filter() zwraca nową tablicę zawierającą tylko elementy, dla których callback zwraca true.
- .filter(s => !(s.km === 0 && s.passengers === 0));
- // Sortujemy chronologicznie — odejmowanie dwóch obiektów Date zwraca różnicę w ms.
- // Jeśli wynik ujemny, a idzie przed b; jeśli dodatni — odwrotnie.
- allSessions.sort((a, b) => a.date - b.date);
- // Budujemy listę unikalnych map do dropdownu filtra.
- // new Set() usuwa duplikaty, spread [...] zamienia Set z powrotem na tablicę.
- mapSel.innerHTML = '<option value="">Wszystkie mapy</option>'; // reset dropdownu
- const maps = [...new Set(allSessions.map(s => s.map))].sort();
- maps.forEach(m => {
- const o = document.createElement('option');
- o.value = m;
- o.textContent = m;
- mapSel.appendChild(o);
- });
- // Ustawiamy domyślny zakres dat na cały dostępny przedział danych.
- const dates = allSessions.map(s => s.dateStr);
- filterMap = '';
- filterFrom = dates[0];
- filterTo = dates[dates.length - 1];
- document.getElementById('filter-date-from').value = filterFrom;
- document.getElementById('filter-date-to').value = filterTo;
- // Aktualizujemy nagłówek strony.
- document.getElementById('date-range-label').textContent =
- `${filename} · ${dates[0]} — ${dates[dates.length - 1]} · ${allSessions.length} sesji`;
- document.getElementById('hstat-sessions').textContent = allSessions.length;
- document.getElementById('hstat-maps').textContent = maps.length;
- // Ukrywamy ekran powitalny i pokazujemy właściwą treść raportu.
- // classList.add/remove/toggle manipulują klasami CSS elementu.
- overlay.classList.add('hidden');
- document.getElementById('main-content').style.display = 'block';
- render(); // pierwsze rysowanie widoku
- }
- // =============================================================================
- // SEKCJA 5: OBSŁUGA FILTRÓW I WIDOKÓW
- // =============================================================================
- // Resetuje wszystkie filtry do wartości domyślnych (pełny zakres danych).
- // Odczytuje daty bezpośrednio z wartości inputów (pewniejsze niż ze zmiennych).
- function resetFilters() {
- const dateInputFrom = document.getElementById('filter-date-from');
- const dateInputTo = document.getElementById('filter-date-to');
- // Pobieramy min i max z aktualnych sesji
- const dates = allSessions.map(s => s.dateStr);
- filterMap = '';
- filterFrom = dates[0];
- filterTo = dates[dates.length - 1];
- mapSel.value = '';
- dateInputFrom.value = filterFrom;
- dateInputTo.value = filterTo;
- render();
- }
- // Przełącza aktywny widok i aktualizuje wygląd przycisków.
- // classList.toggle(cls, condition) — dodaje klasę gdy condition=true, usuwa gdy false.
- function setView(v) {
- currentView = v;
- document.getElementById('btn-grouped').classList.toggle('active', v === 'grouped');
- document.getElementById('btn-flat').classList.toggle('active', v === 'flat');
- document.getElementById('btn-summary').classList.toggle('active', v === 'summary');
- render();
- }
- // Zwraca przefiltrowaną tablicę sesji na podstawie aktualnych ustawień filtrów.
- // Porównanie stringów dat (format RRRR-MM-DD) działa leksykograficznie — poprawnie!
- // Np. '2025-10-15' < '2025-11-01' jest prawdą, bo porównujemy znak po znaku.
- function getFiltered() {
- return allSessions.filter(s => {
- if (filterMap && s.map !== filterMap) return false; // filtr mapy
- if (filterFrom && s.dateStr < filterFrom) return false; // dolna granica dat
- if (filterTo && s.dateStr > filterTo) return false; // górna granica dat
- return true;
- });
- }
- // =============================================================================
- // SEKCJA 6: KARTY PODSUMOWANIA (górny rząd kafelków z liczbami)
- // =============================================================================
- // Oblicza sumy dla aktualnie widocznych sesji i wstawia karty do DOM.
- // reduce() to metoda tablicy — "składa" wszystkie elementy w jedną wartość.
- // Np. [1,2,3].reduce((suma, x) => suma + x, 0) === 6
- function renderCards(sessions) {
- const totalKm = sessions.reduce((a, s) => a + s.km, 0);
- const totalSecs = sessions.reduce((a, s) => a + s.timeActive, 0);
- const totalPass = sessions.reduce((a, s) => a + s.passengers, 0);
- const totalRev = sessions.reduce((a, s) => a + s.revenue, 0);
- const totalMissed = sessions.reduce((a, s) => a + s.stopsMissed, 0);
- const totalLate = sessions.reduce((a, s) => a + s.stopsTooLate, 0);
- const totalEarly = sessions.reduce((a, s) => a + s.stopsTooEarly, 0);
- // new Set().size zlicza unikalne wartości — ile różnych map jest w wynikach
- const mapCount = new Set(sessions.map(s => s.map)).size;
- // Template literals (backtick ``) pozwalają wstawiać wyrażenia JS w ${} wewnątrz stringa.
- // innerHTML zastępuje całą zawartość elementu nowym HTML.
- document.getElementById('summary-cards').innerHTML = `
- <div class="card fade-in"><div class="card-val">${sessions.length}</div><div class="card-label">Sesji</div></div>
- <div class="card blue fade-in"><div class="card-val">${mapCount}</div><div class="card-label">Map</div></div>
- <div class="card fade-in"><div class="card-val">${fmtNum(totalKm, 1)} km</div><div class="card-label">Łączny dystans</div></div>
- <div class="card blue fade-in"><div class="card-val">${fmtDuration(totalSecs)}</div><div class="card-label">Łączny czas</div></div>
- <div class="card green fade-in"><div class="card-val">${totalPass.toLocaleString('pl')}</div><div class="card-label">Pasażerów</div></div>
- <div class="card green fade-in"><div class="card-val">${fmtNum(totalRev, 0)}</div><div class="card-label">Przychód</div></div>
- <div class="card red fade-in"><div class="card-val">${totalMissed}</div><div class="card-label">Przystanki ominięte</div></div>
- <div class="card fade-in"><div class="card-val">${totalLate}</div><div class="card-label">Za późno</div></div>
- <div class="card fade-in"><div class="card-val">${totalEarly}</div><div class="card-label">Za wcześnie</div></div>
- `;
- }
- // =============================================================================
- // SEKCJA 7: GŁÓWNA FUNKCJA RENDEROWANIA
- // Wywoływana za każdym razem gdy coś się zmienia (filtr, widok, wczytanie pliku).
- // =============================================================================
- function render() {
- const sessions = getFiltered(); // pobierz przefiltrowane dane
- renderCards(sessions); // odśwież karty podsumowania
- // Aktualizuj liczniki w nagłówku
- document.getElementById('hstat-sessions').textContent = sessions.length;
- document.getElementById('hstat-maps').textContent = new Set(sessions.map(s => s.map)).size;
- // Jeśli po filtrach nic nie zostało — pokaż komunikat zamiast pustej tabeli
- if (sessions.length === 0) {
- document.getElementById('table-wrap').innerHTML =
- `<div class="no-results"><div class="icon">🚫</div>BRAK WYNIKÓW</div>`;
- return; // return przerywa funkcję — dalszy kod się nie wykona
- }
- // Wywołaj odpowiednią funkcję renderowania w zależności od aktywnego widoku
- if (currentView === 'grouped') renderGrouped(sessions);
- else if (currentView === 'flat') renderFlat(sessions);
- else renderSummary(sessions);
- }
- // =============================================================================
- // SEKCJA 8: WIDOK POGRUPOWANY (sesje zgrupowane po nazwie mapy)
- // =============================================================================
- function renderGrouped(sessions) {
- // Grupujemy sesje w obiekt słownikowy: { "nazwa mapy": [sesja1, sesja2, ...] }
- // Jeśli klucz jeszcze nie istnieje, inicjalizujemy go pustą tablicą.
- const byMap = {};
- sessions.forEach(s => {
- if (!byMap[s.map]) byMap[s.map] = [];
- byMap[s.map].push(s);
- });
- // Sortujemy nazwy map alfabetycznie dla przejrzystości
- const mapsSorted = Object.keys(byMap).sort();
- let html = '';
- mapsSorted.forEach(map => {
- const rows = byMap[map];
- // Generujemy unikalny id dla każdej grupy (do zwijania/rozwijania).
- // replace(/[^a-z0-9]/gi, '_') zamienia wszystkie znaki inne niż litery/cyfry na _
- // żeby id było prawidłowym identyfikatorem HTML.
- const id = `grp-${map.replace(/[^a-z0-9]/gi, '_')}`;
- html += `
- <div class="group-section fade-in">
- <div class="group-header" onclick="toggleGroup('${id}')">
- <span class="group-name">🗺 ${map}</span>
- <span class="group-badge">${rows.length} sesji</span>
- <span class="group-badge">${fmtNum(rows.reduce((a, r) => a + r.km, 0), 1)} km</span>
- <span class="group-badge">${rows.reduce((a, r) => a + r.passengers, 0)} pasażerów</span>
- <span class="group-chevron open" id="chev-${id}">▼</span>
- </div>
- <div id="${id}">
- <table>
- <thead><tr>
- <th>Data</th><th>Czas</th><th>Km</th>
- <th>Przystanki</th><th>Ominięte</th><th>Za wcześnie</th><th>Za późno</th>
- <th>Pasażerowie</th><th>Przychód</th>
- </tr></thead>
- <tbody>
- ${rows.map(r => `<tr>
- <td class="mono">${r.dateStr}</td>
- <td class="mono">${fmtDuration(r.timeActive)}</td>
- <td class="num">${fmtNum(r.km, 2)}</td>
- <td class="num">${r.stopsPassedBy}</td>
- <td class="num">${r.stopsMissed > 0 ? `<span class="pill pill-danger">${r.stopsMissed}</span>` : '—'}</td>
- <td class="num">${r.stopsTooEarly > 0 ? `<span class="pill pill-warn">${r.stopsTooEarly}</span>` : '—'}</td>
- <td class="num">${r.stopsTooLate > 0 ? `<span class="pill pill-warn">${r.stopsTooLate}</span>` : '—'}</td>
- <td class="num">${r.passengers}</td>
- <td class="num">${fmtNum(r.revenue, 2)}</td>
- </tr>`).join('')}
- </tbody>
- </table>
- </div>
- </div>`;
- });
- document.getElementById('table-wrap').innerHTML = html;
- }
- // Zwija lub rozwija sekcję grupy klikając w jej nagłówek.
- // classList.toggle() — dodaje klasę jeśli jej nie ma, usuwa jeśli jest.
- // Klasa CSS "collapsed" ma display:none — ukrywa element.
- function toggleGroup(id) {
- const el = document.getElementById(id);
- const chev = document.getElementById('chev-' + id);
- el.classList.toggle('collapsed'); // ukryj/pokaż zawartość
- chev.classList.toggle('open'); // obróć strzałkę (▼ / ▲ przez CSS transform)
- }
- // =============================================================================
- // SEKCJA 9: WIDOK PŁASKI (wszystkie sesje w jednej tabeli, z sortowaniem)
- // =============================================================================
- // Zmienne stanu sortowania dla tego widoku — poza funkcją, żeby przeżyły re-render.
- let flatSortCol = 'date'; // domyślnie sortuj po dacie
- let flatSortDir = 1; // 1 = rosnąco, -1 = malejąco
- function renderFlat(sessions) {
- // Definicja kolumn: klucz w obiekcie sesji + etykieta nagłówka.
- // Przechowywanie ich w tablicy pozwala generować th i td w jednej pętli.
- const cols = [
- { key: 'map', label: 'Mapa' },
- { key: 'date', label: 'Data' },
- { key: 'timeActive', label: 'Czas' },
- { key: 'km', label: 'Km' },
- { key: 'stopsPassedBy',label: 'Przystanki' },
- { key: 'stopsMissed', label: 'Ominięte' },
- { key: 'stopsTooEarly',label: 'Za wcześnie' },
- { key: 'stopsTooLate', label: 'Za późno' },
- { key: 'passengers', label: 'Pasażerowie' },
- { key: 'revenue', label: 'Przychód' },
- ];
- // Tworzymy kopię tablicy ([...sessions]) przed sortowaniem — sort() modyfikuje tablicę
- // w miejscu, a my nie chcemy zmieniać oryginalnej kolejności allSessions.
- const sorted = [...sessions].sort((a, b) => {
- let av = a[flatSortCol], bv = b[flatSortCol];
- // Dla stringów używamy localeCompare (obsługuje polskie znaki: ą, ę, ó itp.)
- // Dla liczb i dat zwykłe odejmowanie da właściwą kolejność.
- if (typeof av === 'string') return flatSortDir * av.localeCompare(bv);
- return flatSortDir * (av - bv);
- });
- // Generujemy nagłówki tabeli. Aktywna kolumna dostaje klasę sort-asc lub sort-desc
- // (CSS dodaje strzałkę ▲ / ▼ przez pseudoelement ::after).
- const thHTML = cols.map(c => {
- const cls = flatSortCol === c.key
- ? (flatSortDir === 1 ? 'sort-asc' : 'sort-desc')
- : '';
- return `<th class="${cls}" onclick="flatSort('${c.key}')">${c.label}<span class="sort-icon"></span></th>`;
- }).join('');
- const rowsHTML = sorted.map(r => `<tr>
- <td class="map-name">${r.map}</td>
- <td class="mono">${r.dateStr}</td>
- <td class="mono">${fmtDuration(r.timeActive)}</td>
- <td class="num">${fmtNum(r.km, 2)}</td>
- <td class="num">${r.stopsPassedBy}</td>
- <td class="num">${r.stopsMissed > 0 ? `<span class="pill pill-danger">${r.stopsMissed}</span>` : '—'}</td>
- <td class="num">${r.stopsTooEarly > 0 ? `<span class="pill pill-warn">${r.stopsTooEarly}</span>` : '—'}</td>
- <td class="num">${r.stopsTooLate > 0 ? `<span class="pill pill-warn">${r.stopsTooLate}</span>` : '—'}</td>
- <td class="num">${r.passengers}</td>
- <td class="num">${fmtNum(r.revenue, 2)}</td>
- </tr>`).join('');
- document.getElementById('table-wrap').innerHTML = `
- <table id="flat-table" class="fade-in">
- <thead><tr>${thHTML}</tr></thead>
- <tbody>${rowsHTML}</tbody>
- </table>`;
- }
- // Obsługa kliknięcia w nagłówek kolumny.
- // Jeśli kliknięto tę samą kolumnę co poprzednio — odwróć kierunek (*= -1).
- // Jeśli nową — ustaw ją jako aktywną i zacznij od rosnącego.
- // window. jest tu wymagane, bo funkcja jest wywoływana z atrybutu onclick w HTML
- // (inline onclick ma dostęp tylko do globalnego zasięgu window).
- window.flatSort = function(col) {
- if (flatSortCol === col) flatSortDir *= -1;
- else { flatSortCol = col; flatSortDir = 1; }
- render();
- };
- // =============================================================================
- // SEKCJA 10: WIDOK SUMY PER MAPA (zagregowane statystyki dla każdej mapy)
- // =============================================================================
- let sumSortCol = 'sessions'; // domyślnie sortuj po liczbie sesji
- let sumSortDir = -1; // -1 = malejąco (najpopularniejsze mapy na górze)
- function renderSummary(sessions) {
- // Agregacja — "składamy" wszystkie sesje w jeden rekord per mapa.
- // Podobnie jak w renderGrouped, ale zamiast przechowywać sesje, sumujemy wartości.
- const byMap = {};
- sessions.forEach(s => {
- if (!byMap[s.map]) {
- // Inicjalizacja rekordu dla nowej mapy
- byMap[s.map] = { map: s.map, sessions: 0, km: 0, time: 0,
- passengers: 0, revenue: 0, missed: 0, early: 0, late: 0, stops: 0 };
- }
- const g = byMap[s.map];
- g.sessions++;
- g.km += s.km;
- g.time += s.timeActive;
- g.passengers += s.passengers;
- g.revenue += s.revenue;
- g.missed += s.stopsMissed;
- g.early += s.stopsTooEarly;
- g.late += s.stopsTooLate;
- g.stops += s.stopsPassedBy;
- });
- // Definicja kolumn z funkcją formatowania (fmt) — każda kolumna wie jak wyświetlić swoją wartość.
- // Takie podejście (dane + logika w jednym miejscu) nazywa się "data-driven rendering".
- const cols = [
- { key: 'map', label: 'Mapa', fmt: r => r.map },
- { key: 'sessions', label: 'Sesji', fmt: r => r.sessions },
- { key: 'km', label: 'Łączne km', fmt: r => fmtNum(r.km, 1) + ' km' },
- { key: 'time', label: 'Łączny czas', fmt: r => fmtDuration(r.time) },
- { key: 'passengers',label: 'Pasażerowie', fmt: r => r.passengers.toLocaleString('pl') },
- { key: 'revenue', label: 'Przychód', fmt: r => fmtNum(r.revenue, 2) },
- { key: 'stops', label: 'Przystanki', fmt: r => r.stops },
- { key: 'missed', label: 'Ominięte', fmt: r => r.missed > 0 ? `<span class="pill pill-danger">${r.missed}</span>` : '0' },
- { key: 'early', label: 'Za wcześnie', fmt: r => r.early > 0 ? `<span class="pill pill-warn">${r.early}</span>` : '0' },
- { key: 'late', label: 'Za późno', fmt: r => r.late > 0 ? `<span class="pill pill-warn">${r.late}</span>` : '0' },
- ];
- // Object.values() zwraca tablicę wartości ze słownika (same rekordy, bez kluczy).
- const rows = Object.values(byMap).sort((a, b) => {
- let av = a[sumSortCol], bv = b[sumSortCol];
- if (typeof av === 'string') return sumSortDir * av.localeCompare(bv);
- return sumSortDir * (av - bv);
- });
- const thHTML = cols.map(c => {
- const cls = sumSortCol === c.key
- ? (sumSortDir === 1 ? 'sort-asc' : 'sort-desc')
- : '';
- return `<th class="${cls}" onclick="sumSort('${c.key}')">${c.label}<span class="sort-icon"></span></th>`;
- }).join('');
- // cols.map((c, i) => ...) — i to indeks kolumny; pierwsza kolumna (mapa) dostaje
- // inną klasę CSS niż pozostałe (wyrównanie do lewej zamiast do prawej).
- const rowsHTML = rows.map(r => `<tr>
- ${cols.map((c, i) => `<td class="${i === 0 ? 'map-name' : 'num'}">${c.fmt(r)}</td>`).join('')}
- </tr>`).join('');
- document.getElementById('table-wrap').innerHTML = `
- <table class="fade-in">
- <thead><tr>${thHTML}</tr></thead>
- <tbody>${rowsHTML}</tbody>
- </table>`;
- }
- // Analogicznie do flatSort — obsługa kliknięcia w nagłówek tabeli sumarycznej.
- // Domyślny kierunek to malejący (-1), bo przy statystykach zwykle interesuje nas "top".
- window.sumSort = function(col) {
- if (sumSortCol === col) sumSortDir *= -1;
- else { sumSortCol = col; sumSortDir = -1; }
- render();
- };
- // Pierwsze wywołanie render() — nic jeszcze nie wyświetla (allSessions jest puste),
- // ale inicjalizuje karty i nagłówki w stanie "oczekiwania na plik".
- render();
- </script>
- </body>
- </html>
Advertisement
Add Comment
Please, Sign In to add comment