/* ============================================================ SENTINEL โ€” Analytics View ============================================================ */ Views.analytics = async function(container) { container.innerHTML = Utils.loading(); try { const [summary, weekly, trend, compliance] = await Promise.all([ API.get('/analytics/summary'), API.get('/analytics/weekly'), API.get('/analytics/trend', { hours: 24 }), API.get('/analytics/compliance'), ]); const resRate = summary.resolution_rate || 0; const zones = await API.get('/zones'); container.innerHTML = `
Incidents (30 days)
${summary.total}
${summary.resolved} resolved
Resolution Rate
${resRate}%
${summary.avg_response_min || 'โ€”'}m avg response
Avg Response Time
${summary.avg_response_min || 'โ€”'}m
Last 30 days
Active Zones
${zones.length}
${zones.filter(z=>z.risk_score>=60).length} high risk
24-Hour Incident Trend
Weekly Incident Volume
Incident Type Breakdown
${(summary.by_type || []).map(t => { const pct = summary.total > 0 ? Math.round(t.count / summary.total * 100) : 0; return `
${t.name} ${Utils.scoreBar(pct, 'var(--warn)')} ${t.count}
`; }).join('') || Utils.empty('No data')}
Patrol Compliance (7 days)
${(compliance || []).map(c => { const pct = Math.min(100, c.total_checkpoints * 4); // 25 checkpoints = 100% const color = pct >= 80 ? 'var(--accent2)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)'; return `
${c.name}
${c.unit_name}
${Utils.scoreBar(pct, color)} ${c.total_checkpoints}
`; }).join('') || Utils.empty('No patrol data')}
Zone Risk Summary
${zones.sort((a,b)=>b.risk_score-a.risk_score).map(z => `
${z.name}
${z.incidents_24h||0} incidents 24h ยท ${z.active_incidents||0} active
${Utils.riskGaugeSVG(z.risk_score, 52)}
`).join('')}
`; // Draw charts drawTrendChart(trend); drawWeeklyChart(weekly); } catch(e) { container.innerHTML = `
Analytics load failed: ${e.message}
`; } }; function drawTrendChart(trend) { const canvas = document.getElementById('chart-trend'); if (!canvas) return; const ctx = canvas.getContext('2d'); const data = trend.incidents || []; const w = canvas.offsetWidth || 400; const h = 160; canvas.width = w; canvas.height = h; const pad = { t: 10, r: 10, b: 30, l: 30 }; const chartW = w - pad.l - pad.r; const chartH = h - pad.t - pad.b; const maxVal = Math.max(...data.map(d => parseInt(d.incidents) || 0), 1); ctx.clearRect(0, 0, w, h); // Grid ctx.strokeStyle = 'rgba(28,32,48,1)'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const y = pad.t + (chartH / 4) * i; ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke(); ctx.fillStyle = '#5a6480'; ctx.font = '9px Share Tech Mono'; ctx.fillText(Math.round(maxVal - (maxVal/4)*i), 2, y + 3); } if (data.length < 2) { ctx.fillStyle = '#5a6480'; ctx.font = '11px Share Tech Mono'; ctx.fillText('No data yet', w/2-30, h/2); return; } // Gradient fill const grad = ctx.createLinearGradient(0, pad.t, 0, h - pad.b); grad.addColorStop(0, 'rgba(255,59,59,0.3)'); grad.addColorStop(1, 'rgba(255,59,59,0)'); const points = data.map((d, i) => ({ x: pad.l + (i / (data.length - 1)) * chartW, y: pad.t + chartH - ((parseInt(d.incidents)||0) / maxVal) * chartH, })); ctx.beginPath(); ctx.moveTo(points[0].x, h - pad.b); points.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(points[points.length-1].x, h - pad.b); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)); ctx.strokeStyle = '#ff3b3b'; ctx.lineWidth = 2; ctx.stroke(); // X labels ctx.fillStyle = '#5a6480'; ctx.font = '9px Share Tech Mono'; data.forEach((d, i) => { if (i % Math.ceil(data.length / 6) === 0) { const x = pad.l + (i / (data.length-1)) * chartW; ctx.fillText((d.hour_slot || '').slice(11, 16), x - 10, h - 8); } }); } function drawWeeklyChart(weekly) { const canvas = document.getElementById('chart-weekly'); if (!canvas) return; const ctx = canvas.getContext('2d'); const data = weekly || []; const w = canvas.offsetWidth || 400; const h = 160; canvas.width = w; canvas.height = h; const pad = { t: 10, r: 10, b: 30, l: 30 }; const chartW = w - pad.l - pad.r; const chartH = h - pad.t - pad.b; const maxVal = Math.max(...data.map(d => parseInt(d.incidents)||0), 1); const barW = (chartW / (data.length || 1)) * 0.35; ctx.clearRect(0, 0, w, h); ctx.strokeStyle = 'rgba(28,32,48,1)'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const y = pad.t + (chartH / 4) * i; ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke(); ctx.fillStyle = '#5a6480'; ctx.font = '9px Share Tech Mono'; ctx.fillText(Math.round(maxVal - (maxVal/4)*i), 2, y+3); } data.forEach((d, i) => { const x = pad.l + (i / data.length) * chartW + (chartW / data.length) * 0.15; const incH = ((parseInt(d.incidents)||0) / maxVal) * chartH; const resH = ((parseInt(d.resolved)||0) / maxVal) * chartH; // Incidents bar ctx.fillStyle = 'rgba(255,59,59,0.75)'; ctx.fillRect(x, pad.t + chartH - incH, barW, incH); // Resolved bar ctx.fillStyle = 'rgba(0,201,122,0.75)'; ctx.fillRect(x + barW + 2, pad.t + chartH - resH, barW, resH); // Label ctx.fillStyle = '#5a6480'; ctx.font = '9px Share Tech Mono'; ctx.fillText((d.day_name || '').slice(0,3).toUpperCase(), x, h - 8); }); // Legend ctx.fillStyle = 'rgba(255,59,59,0.75)'; ctx.fillRect(w - 120, 10, 10, 8); ctx.fillStyle = '#8896b0'; ctx.font = '9px Share Tech Mono'; ctx.fillText('Incidents', w - 107, 18); ctx.fillStyle = 'rgba(0,201,122,0.75)'; ctx.fillRect(w - 120, 22, 10, 8); ctx.fillStyle = '#8896b0'; ctx.fillText('Resolved', w - 107, 30); }