mirror of
git://f0xx.org/ac/ac-be-graphs
synced 2026-07-29 03:38:27 +03:00
1094 lines
40 KiB
JavaScript
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, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
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();
|
|
}
|
|
})();
|