1
0
mirror of git://f0xx.org/ac/ac-be-graphs synced 2026-07-29 01:39:11 +03:00
Files
ac-be-graphs/public/assets/js/graphs.js
Anton Afanasyeu bff43ff5d1 initial
2026-06-23 12:29:36 +02:00

1094 lines
40 KiB
JavaScript

(function () {
const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8'];
const BRICK_DEFS = {
sessions: { type: 'line', field: 'sessions_per_day', title: 'Sessions / day', color: COLORS[0] },
devices: { type: 'line', field: 'unique_devices_per_day', title: 'Unique devices / day', color: COLORS[1] },
duration: { type: 'line', field: 'avg_duration_s_per_day', title: 'Avg duration (s) / day', color: COLORS[2] },
send: { type: 'line', field: 'send_sessions_per_day', title: 'Send sessions / day', color: COLORS[3] },
recv: { type: 'line', field: 'recv_sessions_per_day', title: 'Receive sessions / day', color: COLORS[4] },
bitrate: { type: 'line', field: 'avg_bitrate_kbps_per_day', title: 'Outbound kbps / day', color: COLORS[0] },
'recv-kbps': { type: 'line', field: 'avg_recv_kbps_per_day', title: 'Inbound kbps / day', color: COLORS[1] },
crashes: { type: 'line', field: 'crashes_per_day', title: 'Issues / day', color: '#f87171' },
tickets: { type: 'line', field: 'tickets_per_day', title: 'Tickets / day', color: '#c084fc' },
'install-pie': { type: 'pie', field: 'install_sources', title: 'Install sources' },
'ntp-pie': { type: 'pie', field: 'ntp_sources', title: 'NTP sources' },
transport: { type: 'breakdown', field: 'transport_mix', title: 'Transport mix' },
versions: { type: 'breakdown', field: 'app_versions', title: 'App versions' },
success: { type: 'kpi', field: 'success_ratio_pct', title: 'Success ratio', suffix: '%' },
'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' },
fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top issue fingerprints' },
'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Issues by app version' },
'live-casts': { type: 'line', field: 'casts_per_day', title: 'Live casts / day', color: COLORS[0] },
'live-avg-length': { type: 'line', field: 'avg_length_s_per_day', title: 'Avg cast length (s) / day', color: COLORS[2] },
'live-platform-pie': { type: 'pie', field: 'platform_mix', title: 'Cast platforms' },
'live-join-platform-pie': { type: 'pie', field: 'joins_platform_mix', title: 'Join platforms' },
'live-video-codec-pie': { type: 'pie', field: 'video_codec_mix', title: 'Video codecs' },
'live-audio-codec-pie': { type: 'pie', field: 'audio_codec_mix', title: 'Audio codecs' },
'live-bw-pie': { type: 'pie', field: 'bw_mode_mix', title: 'Bandwidth modes' },
'live-top-users': { type: 'toplist', field: 'top_users', title: 'Top casters' },
};
let graphScopes = { user: null, slug: null, platform: null };
let liveCastScope = null;
let detailState = null;
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function el(id) {
return document.getElementById(id);
}
function escapeHtml(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function graphPermalink(scopeKey, suffix, days) {
const url = new URL(window.location.href);
url.searchParams.set('graph', scopeKey + '-' + suffix);
if (days) {
url.searchParams.set('days', String(days));
}
return url.toString();
}
function syncGraphPermalink(scopeKey, suffix) {
const daysSel = el('graphs-days');
const days = daysSel && daysSel.value ? daysSel.value : '14';
const link = graphPermalink(scopeKey, suffix, days);
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', link);
}
const share = el('graph-detail-share');
if (share) {
share.href = link;
}
}
function clearGraphPermalink() {
const url = new URL(window.location.href);
url.searchParams.delete('graph');
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', url.toString());
}
}
function openGraphFromPermalink() {
const params = new URLSearchParams(window.location.search);
const token = params.get('graph');
if (!token) return;
const node = document.getElementById('graph-' + token);
if (!node) return;
const card = node.closest('.card.card--lift');
if (card) {
openGraphDetail(card);
}
}
function parseCanvasId(id) {
const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/);
if (m) {
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null };
}
const live = String(id || '').match(/^graph-live-(.+)$/);
if (live) {
const suffix = 'live-' + live[1];
return { scopeKey: 'live_cast', suffix: suffix, def: BRICK_DEFS[suffix] || null };
}
return null;
}
function scopeData(scopeKey) {
if (scopeKey === 'live_cast') return liveCastScope;
if (scopeKey === 'user') return graphScopes.user;
if (scopeKey === 'slug') return graphScopes.slug;
return graphScopes.platform;
}
const BRICK_ORDER_KEY = 'graphs_brick_order_v1';
function prepareCanvas(canvas, fallbackW, fallbackH) {
const rect = canvas.getBoundingClientRect();
const w = Math.max(280, Math.round(rect.width || fallbackW || canvas.width || 520));
const h = Math.max(120, Math.round(rect.height || fallbackH || canvas.height || 160));
const dpr = Math.min(2.5, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { w, h, dpr };
}
function drawLineChart(canvas, series, color, opts) {
if (!canvas) return null;
const interactive = opts && opts.interactive;
const sized = prepareCanvas(canvas, 520, interactive ? 380 : 160);
const w = sized.w;
const h = sized.h;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = 'rgba(90,110,140,0.12)';
ctx.fillRect(0, 0, w, h);
const points = Array.isArray(series) ? series : [];
if (!points.length) {
ctx.fillStyle = '#94a3b8';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('No data', 24, h / 2);
return { points: [], padL: 36, padT: 12, plotW: w - 48, plotH: h - 40, maxY: 1 };
}
let maxY = 0;
points.forEach((p) => {
const y = Number(p.y || 0);
if (y > maxY) maxY = y;
});
if (maxY <= 0) maxY = 1;
const padL = interactive ? 56 : 36;
const padR = interactive ? 148 : 12;
const padT = interactive ? 24 : 16;
const padB = interactive ? 40 : 32;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const lineColor = color || '#3d8bfd';
const title = (opts && opts.title) || '';
ctx.strokeStyle = 'rgba(120,140,170,0.45)';
ctx.lineWidth = 1;
ctx.fillStyle = '#94a3b8';
ctx.font = interactive ? '11px system-ui, sans-serif' : '10px system-ui, sans-serif';
for (let i = 0; i <= 4; i++) {
const y = padT + (plotH * i) / 4;
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(w - padR, y);
ctx.stroke();
if (interactive) {
const tickVal = Math.round((maxY * (4 - i)) / 4);
ctx.fillText(String(tickVal), 8, y + 4);
}
}
const plotPoints = points.map((p, idx) => {
const x = padL + (plotW * idx) / Math.max(1, points.length - 1);
const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY;
return { x, y, raw: p, idx };
});
ctx.strokeStyle = lineColor;
ctx.lineWidth = interactive ? 2.5 : 2;
ctx.beginPath();
plotPoints.forEach((pt, idx) => {
if (idx === 0) ctx.moveTo(pt.x, pt.y);
else ctx.lineTo(pt.x, pt.y);
});
ctx.stroke();
if (interactive) {
plotPoints.forEach((pt) => {
ctx.beginPath();
ctx.fillStyle = lineColor;
ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
ctx.fill();
});
if (title) {
const lx = w - padR + 12;
ctx.fillStyle = lineColor;
ctx.fillRect(lx, padT, 12, 12);
ctx.fillStyle = '#e2e8f0';
ctx.font = '12px system-ui, sans-serif';
const legend = title.length > 18 ? title.slice(0, 17) + '…' : title;
ctx.fillText(legend, lx + 18, padT + 10);
ctx.fillStyle = '#94a3b8';
ctx.font = '10px system-ui, sans-serif';
ctx.fillText('Y: value · X: date', lx, padT + 28);
}
const xTicks = Math.min(6, points.length);
for (let t = 0; t < xTicks; t++) {
const idx = xTicks === 1 ? 0 : Math.round((t * (points.length - 1)) / (xTicks - 1));
const pt = plotPoints[idx];
const label = String(points[idx].x || '');
ctx.fillStyle = '#94a3b8';
ctx.font = '10px system-ui, sans-serif';
ctx.fillText(label, Math.max(padL, pt.x - 20), h - 12);
}
} else {
ctx.fillStyle = '#94a3b8';
ctx.font = '10px system-ui, sans-serif';
if (points.length) {
ctx.fillText(String(points[0].x || ''), padL, h - 8);
const last = points[points.length - 1];
ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8);
}
}
const highlightIdx = opts && opts.highlightIdx;
if (highlightIdx != null && plotPoints[highlightIdx]) {
const hi = plotPoints[highlightIdx];
ctx.beginPath();
ctx.fillStyle = '#f8fafc';
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.arc(hi.x, hi.y, 7, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
return {
points: plotPoints,
padL,
padT,
padR,
plotW,
plotH,
maxY,
w,
h,
series: points,
color: lineColor,
title,
};
}
function detailCanvasSize() {
const wrap = el('graph-detail-canvas-wrap');
const maxW = wrap ? wrap.clientWidth - 8 : window.innerWidth - 48;
return {
w: Math.max(640, Math.min(1280, maxW)),
h: Math.max(380, window.innerHeight - 200),
};
}
function redrawDetailChart(highlightIdx) {
const state = detailState;
if (!state || state.type !== 'line' || !state.canvas) return;
const canvas = state.canvas;
const meta = drawLineChart(canvas, state.series, state.def.color, {
interactive: true,
title: state.def.title,
});
if (meta && highlightIdx != null && meta.points[highlightIdx]) {
const ctx = canvas.getContext('2d');
const pt = meta.points[highlightIdx];
ctx.strokeStyle = 'rgba(148,163,184,0.55)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(pt.x, meta.padT);
ctx.lineTo(pt.x, meta.h - meta.padT);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.fillStyle = state.def.color || '#3d8bfd';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.arc(pt.x, pt.y, 7, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
detailState.meta = meta;
}
function drawPie(canvas, rows, opts) {
if (!canvas) return null;
const interactive = opts && opts.interactive;
const highlightIdx = opts && opts.highlightIdx;
const sized = prepareCanvas(canvas, interactive ? 640 : 520, interactive ? 380 : 160);
const w = sized.w;
const h = sized.h;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.clearRect(0, 0, w, h);
if (interactive) {
ctx.fillStyle = 'rgba(90,110,140,0.12)';
ctx.fillRect(0, 0, w, h);
}
const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : [];
if (!data.length) {
ctx.fillStyle = '#94a3b8';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('No data', 24, h / 2);
return { slices: [], cx: 0, cy: 0, r: 0, data: [], total: 0, w, h };
}
const total = data.reduce((s, r) => s + Number(r.value || 0), 0);
const cx = w * (interactive ? 0.38 : 0.35);
const cy = h * 0.5;
const r = Math.min(w, h) * (interactive ? 0.36 : 0.32);
let angle = -Math.PI / 2;
const slices = [];
data.forEach((row, i) => {
const slice = (Number(row.value) / total) * Math.PI * 2;
const start = angle;
const end = angle + slice;
slices.push({ start, end, idx: i, row, color: COLORS[i % COLORS.length] });
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.fillStyle = COLORS[i % COLORS.length];
if (interactive && highlightIdx === i) {
ctx.shadowColor = 'rgba(0,0,0,0.35)';
ctx.shadowBlur = 10;
} else {
ctx.shadowBlur = 0;
}
ctx.arc(cx, cy, r, start, end);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
angle = end;
});
const legendX = w * (interactive ? 0.58 : 0.62);
let ly = interactive ? 28 : 16;
data.forEach((row, i) => {
const color = COLORS[i % COLORS.length];
ctx.fillStyle = color;
ctx.fillRect(legendX, ly, interactive ? 14 : 10, interactive ? 14 : 10);
ctx.fillStyle = highlightIdx === i && interactive ? '#f8fafc' : '#cbd5e1';
ctx.font = interactive ? '13px system-ui, sans-serif' : '11px system-ui, sans-serif';
const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0;
const label = String(row.label || '') + ' (' + pct + '%)';
ctx.fillText(label, legendX + (interactive ? 22 : 16), ly + (interactive ? 12 : 9));
ly += interactive ? 22 : 18;
});
return { slices, cx, cy, r, data, total, w, h };
}
function redrawDetailPie(highlightIdx) {
const state = detailState;
if (!state || state.type !== 'pie' || !state.canvas) return;
const meta = drawPie(state.canvas, state.series, { interactive: true, highlightIdx: highlightIdx });
detailState.meta = meta;
}
function pieSliceAt(meta, mx, my) {
if (!meta || !meta.slices.length || meta.total <= 0) return -1;
const dx = mx - meta.cx;
const dy = my - meta.cy;
if (Math.sqrt(dx * dx + dy * dy) > meta.r) return -1;
let rel = Math.atan2(dy, dx) + Math.PI / 2;
if (rel < 0) rel += Math.PI * 2;
if (rel >= Math.PI * 2) rel -= Math.PI * 2;
let acc = 0;
for (let i = 0; i < meta.data.length; i++) {
const slice = (Number(meta.data[i].value) / meta.total) * Math.PI * 2;
if (rel >= acc && rel < acc + slice) return i;
acc += slice;
}
return -1;
}
function renderBreakdownList(target, rows) {
if (!target) return;
if (!Array.isArray(rows) || !rows.length) {
target.textContent = 'No data';
return;
}
const total = rows.reduce((s, r) => s + Number(r.value || 0), 0) || 1;
target.innerHTML = rows
.map((r) => {
const pct = Math.round((100 * Number(r.value || 0)) / total);
return (
'<div class="graph-breakdown-row"><span>' +
escapeHtml(String(r.label || '')) +
'</span><strong>' +
Number(r.value || 0) +
' <span class="muted">(' +
pct +
'%)</span></strong></div>'
);
})
.join('');
}
function renderTopList(target, rows) {
if (!target) return;
if (!Array.isArray(rows) || !rows.length) {
target.textContent = 'No data';
return;
}
target.innerHTML = rows
.map((r) => {
const label = escapeHtml(String(r.label || ''));
const val = Number(r.value || 0);
if (r.href) {
return (
'<div class="graph-breakdown-row"><a href="' +
escapeHtml(r.href) +
'">' +
label +
'</a><strong>' +
val +
'</strong></div>'
);
}
return '<div class="graph-breakdown-row"><span>' + label + '</span><strong>' + val + '</strong></div>';
})
.join('');
}
function renderScope(prefix, scope) {
if (!scope) return;
drawLineChart(el('graph-' + prefix + '-sessions'), scope.sessions_per_day, COLORS[0]);
drawLineChart(el('graph-' + prefix + '-devices'), scope.unique_devices_per_day, COLORS[1]);
drawLineChart(el('graph-' + prefix + '-duration'), scope.avg_duration_s_per_day, COLORS[2]);
drawLineChart(el('graph-' + prefix + '-send'), scope.send_sessions_per_day, COLORS[3]);
drawLineChart(el('graph-' + prefix + '-recv'), scope.recv_sessions_per_day, COLORS[4]);
drawLineChart(el('graph-' + prefix + '-bitrate'), scope.avg_bitrate_kbps_per_day, COLORS[0]);
drawLineChart(el('graph-' + prefix + '-recv-kbps'), scope.avg_recv_kbps_per_day, COLORS[1]);
drawLineChart(el('graph-' + prefix + '-crashes'), scope.crashes_per_day, '#f87171');
if (scope.tickets_per_day) {
drawLineChart(el('graph-' + prefix + '-tickets'), scope.tickets_per_day, '#c084fc');
}
drawPie(el('graph-' + prefix + '-install-pie'), scope.install_sources);
drawPie(el('graph-' + prefix + '-ntp-pie'), scope.ntp_sources);
renderBreakdownList(el('graph-' + prefix + '-transport'), scope.transport_mix);
renderBreakdownList(el('graph-' + prefix + '-versions'), scope.app_versions);
const ratioEl = el('graph-' + prefix + '-success');
if (ratioEl) {
ratioEl.textContent =
typeof scope.success_ratio_pct === 'number' ? scope.success_ratio_pct + '%' : '—';
}
const ntpAvg = el('graph-' + prefix + '-ntp-avg');
if (ntpAvg && scope.ntp_correction_avg_s != null) {
ntpAvg.textContent = scope.ntp_correction_avg_s + ' s avg correction';
}
renderTopList(el('graph-' + prefix + '-fingerprints'), scope.top_crash_fingerprints);
drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version);
}
function renderLiveCast(scope) {
if (!scope) {
const section = document.getElementById('graphs-live-cast-section');
if (section) section.hidden = true;
return;
}
const section = document.getElementById('graphs-live-cast-section');
if (section) section.hidden = false;
drawLineChart(el('graph-live-casts'), scope.casts_per_day, COLORS[0]);
drawLineChart(el('graph-live-avg-length'), scope.avg_length_s_per_day, COLORS[2]);
drawPie(el('graph-live-platform-pie'), scope.platform_mix);
drawPie(el('graph-live-join-platform-pie'), scope.joins_platform_mix);
drawPie(el('graph-live-video-codec-pie'), scope.video_codec_mix);
drawPie(el('graph-live-audio-codec-pie'), scope.audio_codec_mix);
drawPie(el('graph-live-bw-pie'), scope.bw_mode_mix);
renderTopList(el('graph-live-top-users'), scope.top_users);
}
function setScopeVisibility(viewer) {
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
const need = section.getAttribute('data-graph-scope');
let show = false;
if (viewer === 'platform_admin') {
show = need === 'platform_admin';
} else if (viewer === 'slug_admin') {
show = need === 'slug_admin' || need === 'user';
} else {
show = need === 'user';
}
section.hidden = !show;
});
}
function applyColumns(cols) {
const app = el('graphs-app');
if (!app) return;
const n = Math.min(4, Math.max(1, Number(cols) || 2));
app.style.setProperty('--graphs-columns', String(n));
}
function markGraphBricks() {
document.querySelectorAll('.graphs-grid .card.card--lift').forEach((card) => {
const canvas = card.querySelector('canvas');
const breakdown = card.querySelector('.graph-breakdown');
const kpi = card.querySelector('.graph-kpi');
if (!canvas && !breakdown && !kpi) return;
card.classList.add('graph-brick--clickable');
card.setAttribute('tabindex', '0');
card.setAttribute('role', 'button');
const title = card.querySelector('h3');
if (title) card.setAttribute('aria-label', 'Open full view: ' + title.textContent);
if (canvas && canvas.id) {
card.setAttribute('data-brick-id', canvas.id.replace(/^graph-/, ''));
} else if (breakdown && breakdown.id) {
card.setAttribute('data-brick-id', breakdown.id.replace(/^graph-/, ''));
} else if (kpi && kpi.id) {
card.setAttribute('data-brick-id', kpi.id.replace(/^graph-/, ''));
}
if (title && !title.querySelector('.graph-brick-drag-handle')) {
title.classList.add('graph-brick-head');
const dragLabel =
(typeof window.t === 'function' && window.t('graphs.brick_drag')) || 'Drag to reorder';
const handle = document.createElement('button');
handle.type = 'button';
handle.className = 'graph-brick-drag-handle';
handle.setAttribute('draggable', 'true');
handle.setAttribute('aria-label', dragLabel);
handle.title = dragLabel;
handle.innerHTML = '<span class="graph-brick-drag-grip" aria-hidden="true">⋮⋮</span>';
title.insertBefore(handle, title.firstChild);
}
if (canvas && !card.querySelector('.graph-brick-tip')) {
const tip = document.createElement('p');
tip.className = 'graph-brick-tip';
tip.hidden = true;
card.style.position = 'relative';
card.appendChild(tip);
}
});
}
function fieldHasData(def, scope) {
if (!def || !scope) return false;
const field = scope[def.field];
if (def.type === 'line') {
return Array.isArray(field) && field.some((p) => Number(p.y || 0) > 0);
}
if (def.type === 'pie' || def.type === 'breakdown' || def.type === 'toplist') {
return Array.isArray(field) && field.some((r) => Number(r.value || 0) > 0);
}
if (def.type === 'kpi') {
return field != null && field !== '' && String(field) !== '—';
}
return false;
}
function hideEmptyBricks() {
document.querySelectorAll('.graphs-grid .card.card--lift').forEach((card) => {
const canvas = card.querySelector('canvas');
const breakdown = card.querySelector('.graph-breakdown');
const kpi = card.querySelector('.graph-kpi');
let empty = true;
const brickId = card.getAttribute('data-brick-id') || '';
const m = brickId.match(/^(user|slug|platform)-(.+)$/);
if (m) {
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
const def = BRICK_DEFS[m[2]];
empty = !fieldHasData(def, scopeData(scopeKey));
} else if (brickId.startsWith('live-')) {
const def = BRICK_DEFS[brickId];
empty = !fieldHasData(def, liveCastScope);
} else if (canvas && canvas.id) {
const parsed = parseCanvasId(canvas.id);
empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey));
} else if (breakdown) {
empty = breakdown.textContent.trim() === 'No data' || breakdown.innerHTML.trim() === '';
} else if (kpi) {
empty = (kpi.textContent || '').trim() === '—';
}
card.classList.toggle('graph-brick--empty', empty);
});
}
function sessionMetricsDeficit(scope) {
if (!scope) return true;
const sessionFields = [
'sessions_per_day',
'unique_devices_per_day',
'avg_duration_s_per_day',
'send_sessions_per_day',
'recv_sessions_per_day',
'avg_bitrate_kbps_per_day',
];
return !sessionFields.some((key) => {
const rows = scope[key];
return Array.isArray(rows) && rows.some((p) => Number(p.y || 0) > 0);
});
}
function readBrickOrder(scopeKey) {
try {
const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}');
return Array.isArray(all[scopeKey]) ? all[scopeKey] : [];
} catch {
return [];
}
}
function saveBrickOrder(scopeKey, ids) {
try {
const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}');
all[scopeKey] = ids;
localStorage.setItem(BRICK_ORDER_KEY, JSON.stringify(all));
} catch { /* ignore */ }
}
function applyBrickOrder() {
document.querySelectorAll('.graphs-scope').forEach((section) => {
if (section.hidden) return;
const scopeKey = section.getAttribute('data-graph-scope') || 'user';
const grid = section.querySelector('.graphs-grid');
if (!grid) return;
const order = readBrickOrder(scopeKey);
if (!order.length) return;
const cards = Array.from(grid.querySelectorAll('.card.card--lift'));
const byId = new Map();
cards.forEach((c) => {
const id = c.getAttribute('data-brick-id');
if (id) byId.set(id, c);
});
order.forEach((id) => {
const card = byId.get(id);
if (card) grid.appendChild(card);
});
});
}
let brickDragMoved = false;
function initBrickDragDrop() {
let dragCard = null;
document.querySelectorAll('.graphs-grid').forEach((grid) => {
grid.addEventListener('dragstart', (ev) => {
const handle = ev.target.closest('.graph-brick-drag-handle');
if (!handle) {
ev.preventDefault();
return;
}
const card = handle.closest('.card.card--lift');
if (!card || !grid.contains(card)) return;
dragCard = card;
brickDragMoved = false;
card.classList.add('graph-brick--dragging');
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', card.getAttribute('data-brick-id') || '');
}
});
grid.addEventListener('dragend', () => {
if (dragCard) dragCard.classList.remove('graph-brick--dragging');
dragCard = null;
const section = grid.closest('.graphs-scope');
const scopeKey = section ? section.getAttribute('data-graph-scope') || 'user' : 'user';
const ids = Array.from(grid.querySelectorAll('.card.card--lift'))
.map((c) => c.getAttribute('data-brick-id'))
.filter(Boolean);
saveBrickOrder(scopeKey, ids);
});
grid.addEventListener('dragover', (ev) => {
ev.preventDefault();
if (!dragCard) return;
brickDragMoved = true;
const over = ev.target.closest('.card.card--lift');
if (!over || over === dragCard || !grid.contains(over)) return;
const rect = over.getBoundingClientRect();
const before = ev.clientY < rect.top + rect.height / 2;
grid.insertBefore(dragCard, before ? over : over.nextSibling);
});
});
}
function attachBrickPreviewHover() {
document.querySelectorAll('.graphs-grid .card.card--lift canvas').forEach((canvas) => {
const card = canvas.closest('.card');
const tip = card ? card.querySelector('.graph-brick-tip') : null;
if (!card || !tip) return;
const parsed = parseCanvasId(canvas.id);
if (!parsed || !parsed.def || parsed.def.type !== 'line') return;
const scope = scopeData(parsed.scopeKey);
if (!scope || !Array.isArray(scope[parsed.def.field])) return;
const series = scope[parsed.def.field];
const meta = drawLineChart(canvas, series, parsed.def.color, { interactive: false });
if (!meta || !meta.points.length) return;
canvas.onmousemove = function (ev) {
const rect = canvas.getBoundingClientRect();
const scaleX = meta.w / rect.width;
const x = (ev.clientX - rect.left) * scaleX;
let best = null;
let bestD = Infinity;
meta.points.forEach((pt) => {
const d = Math.abs(pt.x - x);
if (d < bestD) {
bestD = d;
best = pt;
}
});
if (!best || bestD > 32) {
tip.hidden = true;
drawLineChart(canvas, series, parsed.def.color, { interactive: false });
return;
}
const raw = best.raw || {};
tip.hidden = false;
tip.textContent = parsed.def.title + ': ' + String(raw.x || '') + ' → ' + Number(raw.y || 0);
tip.style.left = Math.min(card.clientWidth - 12, ev.clientX - card.getBoundingClientRect().left + 10) + 'px';
tip.style.top = Math.max(8, ev.clientY - card.getBoundingClientRect().top - 28) + 'px';
drawLineChart(canvas, series, parsed.def.color, { interactive: false, highlightIdx: best.idx });
};
canvas.onmouseleave = function () {
tip.hidden = true;
drawLineChart(canvas, series, parsed.def.color, { interactive: false });
};
});
}
function detachDetailResize() {
if (detailState && detailState.resizeHandler) {
window.removeEventListener('resize', detailState.resizeHandler);
}
}
function attachDetailResize(canvas, redrawFn) {
detachDetailResize();
const handler = function () {
if (!detailState || detailState.canvas !== canvas) return;
const size = detailCanvasSize();
canvas.width = size.w;
canvas.height = size.h;
redrawFn(null);
};
if (detailState) detailState.resizeHandler = handler;
window.addEventListener('resize', handler, { passive: true });
}
function closeGraphDetail() {
detachDetailResize();
const overlay = el('graph-detail-overlay');
if (overlay) overlay.hidden = true;
detailState = null;
clearGraphPermalink();
const tip = el('graph-detail-tooltip');
if (tip) tip.hidden = true;
const statsEl = el('graph-detail-stats');
if (statsEl) statsEl.textContent = '';
}
function openGraphDetail(card) {
const canvas = card.querySelector('canvas');
const breakdown = card.querySelector('.graph-breakdown');
const kpi = card.querySelector('.graph-kpi');
let parsed = null;
if (canvas && canvas.id) parsed = parseCanvasId(canvas.id);
if (!parsed && breakdown && breakdown.id) parsed = parseCanvasId(breakdown.id);
if (!parsed && kpi && kpi.id) parsed = parseCanvasId(kpi.id);
if (!parsed || !parsed.def) return;
const scope = scopeData(parsed.scopeKey);
if (!scope) return;
const def = parsed.def;
syncGraphPermalink(parsed.scopeKey, parsed.suffix);
const fieldVal = scope[def.field];
const titleEl = el('graph-detail-title');
const canvasEl = el('graph-detail-canvas');
const bodyEl = el('graph-detail-body');
const overlay = el('graph-detail-overlay');
if (!overlay || !titleEl || !bodyEl) return;
titleEl.textContent = def.title;
bodyEl.innerHTML = '';
if (canvasEl) canvasEl.hidden = true;
if (def.type === 'line' && canvasEl) {
canvasEl.hidden = false;
const size = detailCanvasSize();
canvasEl.width = size.w;
canvasEl.height = size.h;
detailState = {
type: 'line',
def,
series: fieldVal,
canvas: canvasEl,
meta: null,
};
redrawDetailChart(null);
attachLineHover(canvasEl, def);
attachDetailResize(canvasEl, redrawDetailChart);
const statsEl = el('graph-detail-stats');
if (statsEl) {
statsEl.textContent =
(fieldVal && fieldVal.length ? fieldVal.length + ' points · ' : '') +
'Move pointer over chart for values';
}
} else if (def.type === 'pie' && canvasEl) {
canvasEl.hidden = false;
const size = detailCanvasSize();
canvasEl.width = size.w;
canvasEl.height = size.h;
detailState = {
type: 'pie',
def,
series: fieldVal,
canvas: canvasEl,
meta: null,
};
redrawDetailPie(null);
attachPieHover(canvasEl, def);
attachDetailResize(canvasEl, redrawDetailPie);
const statsEl = el('graph-detail-stats');
if (statsEl) {
const n = Array.isArray(fieldVal) ? fieldVal.filter((r) => Number(r.value) > 0).length : 0;
statsEl.textContent =
(n ? n + ' segments · ' : '') + 'Hover slices for value breakdown';
}
} else if (def.type === 'breakdown' || def.type === 'toplist') {
const wrap = document.createElement('div');
wrap.className = 'graph-detail-table graph-breakdown';
if (!Array.isArray(fieldVal) || !fieldVal.length) {
wrap.textContent = 'No data';
} else if (def.type === 'toplist') {
renderTopList(wrap, fieldVal);
} else {
renderBreakdownList(wrap, fieldVal);
}
bodyEl.appendChild(wrap);
detailState = { type: def.type };
} else if (def.type === 'kpi') {
const p = document.createElement('p');
p.className = 'graph-kpi graph-detail-kpi';
let text = '—';
if (typeof fieldVal === 'number') {
text = String(fieldVal) + (def.suffix || '');
} else if (fieldVal != null) {
text = String(fieldVal);
}
p.textContent = text;
bodyEl.appendChild(p);
detailState = { type: 'kpi' };
}
overlay.hidden = false;
}
function valueUnit(def) {
if (!def || !def.title) return '';
if (def.title.indexOf('kbps') >= 0) return ' kbps';
if (def.title.indexOf('duration') >= 0) return ' s';
return '';
}
function attachLineHover(canvas, def) {
const tip = el('graph-detail-tooltip');
const statsEl = el('graph-detail-stats');
const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap');
if (!wrap) return;
canvas.onmousemove = function (ev) {
const meta = detailState && detailState.meta;
if (!meta || !meta.points.length) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const x = (ev.clientX - rect.left) * scaleX;
let best = null;
let bestD = Infinity;
let bestIdx = -1;
meta.points.forEach((pt, idx) => {
const d = Math.abs(pt.x - x);
if (d < bestD) {
bestD = d;
best = pt;
bestIdx = idx;
}
});
if (!best || bestD > 32) {
if (tip) tip.hidden = true;
redrawDetailChart(null);
return;
}
redrawDetailChart(bestIdx);
const raw = best.raw || {};
const unit = valueUnit(def);
const label = String(raw.x || '') + ' → ' + Number(raw.y || 0) + unit;
if (tip) {
tip.hidden = false;
tip.textContent = label;
const wrapRect = wrap.getBoundingClientRect();
tip.style.left = ev.clientX - wrapRect.left + 14 + 'px';
tip.style.top = ev.clientY - wrapRect.top - 10 + 'px';
}
if (statsEl) {
statsEl.textContent = def.title + ' · ' + label;
}
};
canvas.onmouseleave = function () {
if (tip) tip.hidden = true;
redrawDetailChart(null);
if (statsEl && detailState && detailState.series) {
statsEl.textContent =
detailState.series.length + ' points · Move pointer over chart for values';
}
};
}
function attachPieHover(canvas, def) {
const tip = el('graph-detail-tooltip');
const statsEl = el('graph-detail-stats');
const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap');
if (!wrap) return;
canvas.onmousemove = function (ev) {
const meta = detailState && detailState.meta;
if (!meta || !meta.slices.length) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (ev.clientX - rect.left) * scaleX;
const my = (ev.clientY - rect.top) * scaleY;
const idx = pieSliceAt(meta, mx, my);
if (idx < 0) {
if (tip) tip.hidden = true;
redrawDetailPie(null);
if (statsEl) {
statsEl.textContent =
meta.data.length + ' segments · Hover slices for value breakdown';
}
return;
}
redrawDetailPie(idx);
const row = meta.data[idx] || {};
const pct = meta.total > 0 ? Math.round((100 * Number(row.value)) / meta.total) : 0;
const label =
String(row.label || '') + ': ' + Number(row.value || 0) + ' (' + pct + '%)';
if (tip) {
tip.hidden = false;
tip.textContent = label;
const wrapRect = wrap.getBoundingClientRect();
tip.style.left = ev.clientX - wrapRect.left + 14 + 'px';
tip.style.top = ev.clientY - wrapRect.top - 10 + 'px';
}
if (statsEl) {
statsEl.textContent = def.title + ' · ' + label;
}
};
canvas.onmouseleave = function () {
if (tip) tip.hidden = true;
redrawDetailPie(null);
if (statsEl && detailState && detailState.series) {
const n = detailState.series.filter((r) => Number(r.value) > 0).length;
statsEl.textContent = n + ' segments · Hover slices for value breakdown';
}
};
}
function bootGraphs() {
const app = el('graphs-app');
if (!app) return;
const status = el('graphs-status');
const daysSel = el('graphs-days');
const colsSel = el('graphs-columns');
const overlay = el('graph-detail-overlay');
if (overlay) {
overlay.addEventListener('click', (ev) => {
if (ev.target === overlay) closeGraphDetail();
});
const closeBtn = el('graph-detail-close');
if (closeBtn) closeBtn.addEventListener('click', closeGraphDetail);
const copyBtn = el('graph-detail-copy-link');
if (copyBtn) {
copyBtn.addEventListener('click', function () {
const share = el('graph-detail-share');
const link = (share && share.href && share.href !== '#') ? share.href : window.location.href;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(function () {
copyBtn.textContent = 'Copied';
setTimeout(function () { copyBtn.textContent = 'Copy link'; }, 1500);
}).catch(function () { window.prompt('Copy graph link:', link); });
} else {
window.prompt('Copy graph link:', link);
}
});
}
}
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') closeGraphDetail();
});
if (colsSel) {
applyColumns(colsSel.value);
colsSel.addEventListener('change', () => applyColumns(colsSel.value));
} else {
applyColumns(2);
}
initBrickDragDrop();
app.addEventListener('click', (ev) => {
if (ev.target.closest('.graph-brick-drag-handle') || brickDragMoved) {
brickDragMoved = false;
return;
}
const card = ev.target.closest('.graph-brick--clickable');
if (card && app.contains(card)) openGraphDetail(card);
});
app.addEventListener('keydown', (ev) => {
const card = ev.target.closest('.graph-brick--clickable');
if (card && (ev.key === 'Enter' || ev.key === ' ')) {
ev.preventDefault();
openGraphDetail(card);
}
});
function load() {
const params = new URLSearchParams(window.location.search);
const daysParam = params.get('days');
if (daysParam && daysSel) {
daysSel.value = daysParam;
}
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
status.textContent = 'Loading…';
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
xhr.onload = function () {
let payload = null;
try {
payload = JSON.parse(xhr.responseText);
} catch {
status.textContent = 'Invalid response';
return;
}
if (!payload || !payload.ok) {
status.textContent = (payload && payload.error) || 'Failed';
return;
}
const data = payload.data || {};
const viewer = data.viewer || 'user';
graphScopes.user = data.user || null;
graphScopes.slug = data.slug_admin || null;
graphScopes.platform = data.platform_admin || null;
liveCastScope = data.live_cast || null;
setScopeVisibility(viewer);
renderScope('user', graphScopes.user);
if (graphScopes.slug) renderScope('slug', graphScopes.slug);
if (graphScopes.platform) renderScope('platform', graphScopes.platform);
renderLiveCast(liveCastScope);
markGraphBricks();
hideEmptyBricks();
applyBrickOrder();
attachBrickPreviewHover();
const cols = colsSel && colsSel.value ? colsSel.value : '2';
let statusText = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols;
const visibleScope =
viewer === 'platform_admin' ? graphScopes.platform : viewer === 'slug_admin' ? graphScopes.slug : graphScopes.user;
const deficitNotice = el('graphs-session-notice');
const deficit = sessionMetricsDeficit(visibleScope);
if (deficitNotice) {
deficitNotice.hidden = !deficit;
}
status.textContent = statusText;
openGraphFromPermalink();
};
xhr.onerror = function () {
status.textContent = 'Network error';
};
xhr.send();
}
if (daysSel) {
daysSel.addEventListener('change', load);
}
load();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootGraphs);
} else {
bootGraphs();
}
})();