(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, '"'); } 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 ( '
' + escapeHtml(String(r.label || '')) + '' + Number(r.value || 0) + ' (' + pct + '%)
' ); }) .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 ( '
' + label + '' + val + '
' ); } return '
' + label + '' + val + '
'; }) .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 = ''; 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(); } })();