(function () { 'use strict'; // --- theme (dark/light), persisted per browser ------------------------- var THEME_KEY = 'ts3monolith-theme'; var root = document.documentElement; var toggle = document.querySelector('.theme-toggle'); function currentTheme() { return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; } function applyTheme(mode) { if (mode === 'light') root.setAttribute('data-theme', 'light'); else root.removeAttribute('data-theme'); // Charts read their colors straight from the CSS custom properties at // draw time, so a theme switch just needs a redraw of the data already // in hand -- no re-fetch, unlike the old per-theme PNG swap. renderAllCharts(); } try { var saved = window.localStorage.getItem(THEME_KEY); if (saved) applyTheme(saved); } catch (e) { /* ignore */ } if (toggle) { toggle.addEventListener('click', function () { var next = currentTheme() === 'light' ? 'dark' : 'light'; applyTheme(next); try { window.localStorage.setItem(THEME_KEY, next); } catch (e) { /* ignore */ } }); } // --- RRD charts ----------------------------------------------------------- // Each panel's time series comes from GET /stats/graph/{range} as plain // JSON (see rrd.Manager.Fetch / GraphData in internal/rrd/rrd.go) and is // drawn here in a , instead of asking the server to render a PNG // with rrdtool. That makes the chart a normal responsive element (it // resizes with its card, including on mobile, instead of being locked to // whatever pixel size rrdtool was told to render) and lets a theme toggle // or window resize just redraw the cached points rather than re-fetching // an image. var chartPanels = document.querySelectorAll('.js-chart'); var chartData = {}; function paletteColor(name, fallback) { var v = getComputedStyle(root).getPropertyValue(name); return v ? v.trim() : fallback; } function timeFormatter(key) { if (key === 'week' || key === 'month') { return function (t) { return new Date(t * 1000).toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit' }); }; } if (key === 'year') { return function (t) { return new Date(t * 1000).toLocaleDateString('pl-PL', { month: 'short', year: 'numeric' }); }; } return function (t) { return new Date(t * 1000).toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' }); }; } function fmtNum(v) { return v === null || v === undefined ? '—' : String(Math.round(v)); } function statsText(data) { return 'Aktualnie: ' + fmtNum(data.current) + ' Średnio: ' + fmtNum(data.average) + ' Maks.: ' + fmtNum(data.max); } // Draws one series into canvas, sized to the canvas's current CSS box // (so the caller controls size purely through layout/CSS). Returns false // when there is nothing to draw (e.g. a fresh RRD with no samples yet), // so the caller can show a text fallback instead of a blank box. function drawChart(canvas, data) { var points = (data && data.points) || []; var hasData = points.some(function (p) { return p.v !== null && p.v !== undefined; }); if (!hasData) return false; var cssW = canvas.clientWidth; var cssH = canvas.clientHeight; if (!cssW || !cssH) return false; var dpr = window.devicePixelRatio || 1; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); var ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); var lineColor = paletteColor('--line', '#313832'); var textColor = paletteColor('--muted', '#9da79f'); var accent = paletteColor('--accent', '#8fb9a4'); var padL = 28, padR = 6, padT = 8, padB = 16; var plotW = Math.max(1, cssW - padL - padR); var plotH = Math.max(1, cssH - padT - padB); var values = []; points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); }); var maxV = values.length ? Math.max.apply(null, values) : 1; var top = Math.max(1, Math.ceil(maxV * 1.2)); function xAt(i) { return padL + (points.length > 1 ? (i / (points.length - 1)) * plotW : plotW / 2); } function yAt(v) { return padT + plotH - (v / top) * plotH; } // horizontal grid + y labels (0 / mid / top) ctx.font = '10px ' + getComputedStyle(document.body).fontFamily; ctx.lineWidth = 1; [0, top / 2, top].forEach(function (t) { var y = Math.round(yAt(t)) + 0.5; ctx.strokeStyle = lineColor; ctx.globalAlpha = t === 0 ? 0.7 : 0.35; ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = textColor; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(String(Math.round(t)), padL - 6, yAt(t)); }); // x labels: first / middle / last sample if (points.length > 1) { var fmt = timeFormatter(data.key); ctx.fillStyle = textColor; ctx.textBaseline = 'alphabetic'; [0, Math.floor((points.length - 1) / 2), points.length - 1].forEach(function (i, idx) { ctx.textAlign = idx === 0 ? 'left' : (idx === 2 ? 'right' : 'center'); ctx.fillText(fmt(points[i].t), xAt(i), cssH - 3); }); } // area + line, broken into separate segments across data gaps (nulls) // instead of interpolating through them var seg = []; function flushSegment() { if (seg.length < 2) { seg = []; return; } ctx.beginPath(); ctx.moveTo(seg[0][0], padT + plotH); seg.forEach(function (pt) { ctx.lineTo(pt[0], pt[1]); }); ctx.lineTo(seg[seg.length - 1][0], padT + plotH); ctx.closePath(); ctx.fillStyle = accent + '38'; ctx.fill(); ctx.beginPath(); seg.forEach(function (pt, idx) { if (idx === 0) ctx.moveTo(pt[0], pt[1]); else ctx.lineTo(pt[0], pt[1]); }); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.lineWidth = 1.6; ctx.strokeStyle = accent; ctx.stroke(); seg = []; } points.forEach(function (p, i) { if (p.v === null || p.v === undefined) { flushSegment(); return; } seg.push([xAt(i), yAt(p.v)]); }); flushSegment(); return true; } function renderPanel(panel) { var canvas = panel.querySelector('.js-chart-canvas'); var empty = panel.querySelector('.graph-empty'); var statsEl = panel.querySelector('.js-graph-stats'); if (!canvas) return; var data = chartData[panel.dataset.range]; var ok = !!data && drawChart(canvas, data); canvas.hidden = !ok; if (empty) { empty.hidden = ok; empty.textContent = data ? 'Brak danych' : 'Wykres niedostępny'; } if (statsEl) statsEl.textContent = ok ? statsText(data) : ''; } function renderAllCharts() { chartPanels.forEach(renderPanel); } function loadCharts() { return Promise.all(Array.prototype.map.call(chartPanels, function (panel) { var range = panel.dataset.range; return fetch('/stats/graph/' + range, { cache: 'no-store' }) .then(function (r) { if (!r.ok) throw new Error('graph'); return r.json(); }) .then(function (data) { chartData[range] = data; }) .catch(function () { chartData[range] = null; }); })).then(renderAllCharts); } function debounce(fn, wait) { var t; return function () { clearTimeout(t); t = setTimeout(fn, wait); }; } if (chartPanels.length) { loadCharts(); window.addEventListener('resize', debounce(renderAllCharts, 150)); } // --- live stats + chart refresh -------------------------------------------- var count = document.getElementById('online-count'); var status = document.getElementById('refresh-status'); var progress = document.getElementById('route-progress'); function setBusy(busy) { if (progress) progress.hidden = !busy; } function refresh() { setBusy(true); if (status) status.textContent = 'Odświeżanie…'; var tasks = []; if (count) { tasks.push(fetch('/stats/live', { cache: 'no-store' }) .then(function (response) { if (!response.ok) throw new Error('stats'); return response.json(); }) .then(function (data) { if (typeof data.current === 'number') count.textContent = String(data.current); })); } if (chartPanels.length) tasks.push(loadCharts()); Promise.all(tasks) .then(function () { if (status) status.textContent = 'Dane aktualne'; }) .catch(function () { if (status) status.textContent = 'Nie udało się odświeżyć'; }) .then(function () { setBusy(false); }); } if (count || chartPanels.length) { window.setInterval(refresh, 60000); } // --- graph lightbox ------------------------------------------------------- var lightbox = document.getElementById('graph-lightbox'); var lightboxCanvas = document.getElementById('lightbox-canvas'); var lightboxTitle = document.getElementById('lightbox-title'); var lightboxStats = document.getElementById('lightbox-stats'); var openers = document.querySelectorAll('.js-graph-open'); var closeButtons = document.querySelectorAll('[data-lightbox-close]'); function openLightbox(range) { if (!lightbox || !lightboxCanvas) return; var data = chartData[range]; lightbox.hidden = false; lightbox.setAttribute('aria-hidden', 'false'); document.body.classList.add('lightbox-open'); if (lightboxTitle) lightboxTitle.textContent = data ? data.title : ''; if (lightboxStats) lightboxStats.textContent = data ? statsText(data) : ''; // Wait a frame so the now-visible dialog has its real layout size // before measuring clientWidth/clientHeight for the bigger redraw. requestAnimationFrame(function () { if (data) drawChart(lightboxCanvas, data); }); } function closeLightbox() { if (!lightbox) return; lightbox.hidden = true; lightbox.setAttribute('aria-hidden', 'true'); document.body.classList.remove('lightbox-open'); } openers.forEach(function (button) { button.addEventListener('click', function () { var panel = button.closest('.js-chart'); if (panel) openLightbox(panel.dataset.range); }); }); closeButtons.forEach(function (button) { button.addEventListener('click', closeLightbox); }); document.addEventListener('keydown', function (event) { if (event.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox(); }); })();