let trendChart; let map; /* |-------------------------------------------------------------------------- | Load Summary Cards |-------------------------------------------------------------------------- */ function loadSummary() { fetch('/api/dashboard/summary') .then(res => res.json()) .then(data => { let html = ''; const alerts = []; data.forEach(item => { let trendColor = 'text-secondary'; if (item.percent_change > 0) trendColor = 'text-danger'; if (item.percent_change < 0) trendColor = 'text-success'; html += `
${item.code}

${item.current_total}

Last 7 days
${item.percent_change > 0 ? '▲' : item.percent_change < 0 ? '▼' : '–'} ${Math.abs(item.percent_change)}%
${item.previous_total ?? 0} last week
`; window._summaryData = data; updateAlerts(); }); document.getElementById('summary_cards').innerHTML = html; renderAlerts(alerts); }); } /* |-------------------------------------------------------------------------- | Load Trend Chart |-------------------------------------------------------------------------- */ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) { fetch(`/api/dashboard/trend?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`) .then(res => res.json()) .then(data => { if (trendChart) trendChart.destroy(); const labelsSet = new Set(); Object.values(data).forEach(program => { program.forEach(row => { labelsSet.add(`${row.year}-${row.period}`); }); }); const labels = Array.from(labelsSet).sort((a, b) => { const [yearA, weekA] = a.split('-').map(Number); const [yearB, weekB] = b.split('-').map(Number); if (yearA !== yearB) return yearA - yearB; return weekA - weekB; }); const colors = { SARI: '#2563eb', ILI: '#10b981', LBM: '#9333ea' }; const datasets = []; const allowedPrograms = ['SARI', 'ILI', 'LBM']; Object.keys(data).forEach(code => { if (!allowedPrograms.includes(code)) return; const values = labels.map(label => { const found = data[code].find(row => `${row.year}-${row.period}` === label); return found ? found.total : 0; }); datasets.push({ label: code, data: values, borderColor: colors[code], backgroundColor: colors[code], borderWidth: 3, pointRadius: 4, fill: false, tension: 0.3 }); }); const displayLabels = labels.map(l => { const [year, week] = l.split('-'); return `W${String(week).padStart(2, '0')}`; }); trendChart = new Chart(document.getElementById('trendChart'), { type: 'line', data: { labels: displayLabels, datasets: datasets }, options: { responsive: true, plugins: { legend: { position: 'bottom' } }, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } } }); }); } function updateAlerts() { if (!window._summaryData || !window._provinceData) return; const raw = buildAlerts(window._summaryData, window._provinceData); const finalAlerts = processAlerts(raw); renderAlerts(finalAlerts); } function generateAlerts(data) { const alerts = []; // ------------------------- // 1. Program-level alerts // ------------------------- const summary = data.summary || {}; const programs = [ { key: 'influenza_rate', label: 'Influenza' }, { key: 'covid_rate', label: 'COVID-19' }, { key: 'positivity_rate', label: 'Overall positivity' } ]; programs.forEach(p => { const current = summary[p.key]?.current || 0; const previous = summary[p.key]?.previous || 0; const diff = previous ? ((current - previous) / previous) * 100 : 0; if (current >= 15) { alerts.push(`🔴 High ${p.label} (${current}%)`); } else if (current >= 10) { alerts.push(`🟠 Moderate ${p.label} (${current}%)`); } if (diff >= 10) { alerts.push(`🟡 Increasing ${p.label} (+${diff.toFixed(1)}%)`); } }); // ------------------------- // 2. Province-level alerts // ------------------------- const provinces = data.province_distribution || []; const top = [...provinces] .sort((a, b) => b.total - a.total) .slice(0, 3); top.forEach(p => { const percent = p.total ? ((p.positive / p.total) * 100) : 0; if (percent >= 15) { alerts.push(`🔴 High positivity in ${p.patient_province} (${percent.toFixed(1)}%)`); } else if (percent >= 10) { alerts.push(`🟠 Moderate positivity in ${p.patient_province}`); } if (p.total >= 50) { alerts.push(`🟡 High case volume in ${p.patient_province} (${p.total})`); } }); // ------------------------- // fallback // ------------------------- if (!alerts.length) { alerts.push("🟢 No unusual activity detected"); } return alerts; } function createAlert(type, message, priority) { return { type, message, priority }; } function buildAlerts(summaryData, provinceData) { const alerts = []; // ------------------------- // 1. Program alerts // ------------------------- summaryData.forEach(item => { // 🔴 High activity if (item.current_total >= 80) { alerts.push(createAlert( 'high', `High ${item.code} activity (${item.current_total} cases)`, 1 )); } // 🟠 Moderate else if (item.current_total >= 40) { alerts.push(createAlert( 'moderate', `${item.code} activity elevated (${item.current_total})`, 2 )); } // 🟡 Increasing trend if (item.percent_change >= 10) { alerts.push(createAlert( 'trend', `Increasing ${item.code} (+${item.percent_change}%)`, 3 )); } }); // ------------------------- // 2. Province alerts // ------------------------- const top = [...provinceData] .sort((a, b) => b.total - a.total) .slice(0, 5); top.forEach(p => { const percent = p.total ? ((p.positive / p.total) * 100) : 0; // 🔴 High positivity if (percent >= 15) { alerts.push(createAlert( 'high', `High positivity in ${p.patient_province} (${percent.toFixed(1)}%)`, 1 )); } // 🟠 Moderate positivity else if (percent >= 10) { alerts.push(createAlert( 'moderate', `Moderate positivity in ${p.patient_province}`, 2 )); } // 🟡 High volume if (p.total >= 50) { alerts.push(createAlert( 'volume', `High case volume in ${p.patient_province} (${p.total})`, 3 )); } }); return alerts; } function processAlerts(alerts) { const seen = new Set(); const unique = alerts.filter(a => { if (seen.has(a.message)) return false; seen.add(a.message); return true; }); // sort by priority unique.sort((a, b) => a.priority - b.priority); // limit to top 5 return unique.slice(0, 5); } function renderAlerts(alerts) { const container = document.getElementById('alertsList'); if (!container) return; if (!alerts.length) { container.innerHTML = `
  • 🟢 No unusual activity detected
  • `; return; } const colorMap = { high: 'text-danger', moderate: 'text-warning', trend: 'text-primary', volume: 'text-secondary' }; container.innerHTML = alerts.map(a => `
  • ${a.type === 'high' ? '🔴' : a.type === 'moderate' ? '🟠' : a.type === 'trend' ? '🟡' : '🔵'} ${a.message}
  • `).join(''); } /* |-------------------------------------------------------------------------- | Province Map Helpers |-------------------------------------------------------------------------- */ function getPositivityColor(p) { if (p > 20) return "#b91c1c"; if (p > 10) return "#ef4444"; if (p > 5) return "#f59e0b"; if (p > 0) return "#84cc16"; return "#9ca3af"; } function normalizeProvince(name, validSet) { if (!name || !validSet) return null; const clean = str => str.toLowerCase().replace(/\s+/g, ''); const raw = name.trim(); const map = { "kepville": "Kep", "sihanoukville": "Preah Sihanouk", "sihanoukvillecity": "Preah Sihanouk", "krongpailin": "Pailin", "mondulkiri": "Mondulkiri", "odormeanchey": "Oddar Meanchey", "tbongkhmom": "Tboung Khmum", "tboungkhmum": "Tboung Khmum", "rattanakiri": "Ratanak Kiri" }; const key = clean(raw); if (map[key] && validSet.has(map[key])) { return map[key]; } const match = [...validSet].find(p => clean(p) === key); return match || null; } function getRadius(total) { if (!total) return 0; const r = Math.sqrt(total); return Math.max(4, Math.min(r * 2, 22)); } function addPositivityLegend() { const legend = L.control({ position: "bottomleft" }); legend.onAdd = function () { const div = L.DomUtil.create("div", "map-legend"); div.innerHTML = `
    Positivity
    > 20%
    10–20%
    5–10%
    0–5%
    0%
    `; return div; }; legend.addTo(map); } /* |-------------------------------------------------------------------------- | Province Map |-------------------------------------------------------------------------- */ function loadProvinceMap(startYear, startWeek, endYear, endWeek) { if (map) map.remove(); map = L.map('provinceMap').setView([12.7, 104.9], 7); addPositivityLegend(); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(map); Promise.all([ fetch('/geo/cambodia_provinces.geojson').then(r => r.json()), fetch(`/api/dashboard/province-circles?start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`).then(r => r.json()) ]) .then(([geojson, data]) => { window._provinceData = data; updateAlerts(); const validProvinces = new Set( geojson.features.map(f => f.properties.ADM1_EN) ); L.geoJSON(geojson, { style: { fillOpacity: 0, color: '#ccc', weight: 1, interactive: false }, onEachFeature: function (feature, layer) { const province = feature.properties.ADM1_EN; const center = layer.getBounds().getCenter(); const rows = data.filter(d => { if (![1, 2, 3].includes(d.surveillance_id)) return false; const name = normalizeProvince(d.patient_province, validProvinces); return name === province; }); const offsets = { 1: -0.15, 2: 0, 3: 0.15 }; const colors = { 1: '#2563eb', 2: '#10b981', 3: '#9333ea' }; rows.forEach(row => { const percent = row.total ? ((row.positive / row.total) * 100).toFixed(1) : 0; const offset = offsets[row.surveillance_id] ?? 0; const lat = center.lat; const lng = center.lng + offset; const programName = row.surveillance_id === 1 ? 'SARI' : row.surveillance_id === 2 ? 'ILI' : 'LBM'; L.circleMarker([lat, lng], { radius: getRadius(row.total), fillColor: colors[row.surveillance_id], color: getPositivityColor(percent), weight: 2, fillOpacity: 0.9 }) .bindTooltip(` ${province}
    ${programName}
    Cases: ${row.total}
    Positivity: ${percent}% `) .addTo(map); }); } }).addTo(map); }); } /* |-------------------------------------------------------------------------- | Initialize Dashboard |-------------------------------------------------------------------------- */ document.addEventListener("DOMContentLoaded", () => { loadSummary(); new DashboardFilter((startYear, startWeek, endYear, endWeek) => { loadTrend('week', startYear, startWeek, endYear, endWeek); loadProvinceMap(startYear, startWeek, endYear, endWeek); }); });