From b22457f344eb4dd60b418c24f5411bf76bf07bee Mon Sep 17 00:00:00 2001 From: Khantey Long Date: Tue, 24 Mar 2026 09:45:13 +0700 Subject: [PATCH] finshed afi --- dashboard/public/js/overview.js | 348 +++++++++++++++--- .../views/dashboard/overview.blade.php | 11 +- 2 files changed, 299 insertions(+), 60 deletions(-) diff --git a/dashboard/public/js/overview.js b/dashboard/public/js/overview.js index 9fd5c7e..984559e 100644 --- a/dashboard/public/js/overview.js +++ b/dashboard/public/js/overview.js @@ -13,6 +13,7 @@ function loadSummary() { .then(data => { let html = ''; + const alerts = []; data.forEach(item => { @@ -53,14 +54,19 @@ function loadSummary() { `; + + window._summaryData = data; + updateAlerts(); + }); document.getElementById('summary_cards').innerHTML = html; + renderAlerts(alerts); + }); } - /* |-------------------------------------------------------------------------- | Load Trend Chart @@ -157,6 +163,215 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) { }); } +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(''); +} /* @@ -164,7 +379,13 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) { | 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; @@ -200,8 +421,32 @@ function getRadius(total) { 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 @@ -213,6 +458,7 @@ 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' @@ -222,77 +468,79 @@ function loadProvinceMap(startYear, startWeek, endYear, endWeek) { 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]) => { + .then(([geojson, data]) => { + window._provinceData = data; + updateAlerts(); - const validProvinces = new Set( - geojson.features.map(f => f.properties.ADM1_EN) - ); + const validProvinces = new Set( + geojson.features.map(f => f.properties.ADM1_EN) + ); - L.geoJSON(geojson, { - style: { - fillOpacity: 0, - color: '#ccc', - weight: 1, - interactive: false - }, + L.geoJSON(geojson, { + style: { + fillOpacity: 0, + color: '#ccc', + weight: 1, + interactive: false + }, - onEachFeature: function (feature, layer) { + onEachFeature: function (feature, layer) { - const province = feature.properties.ADM1_EN; - const center = layer.getBounds().getCenter(); + 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 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 name = normalizeProvince(d.patient_province, validProvinces); + return name === province; + }); - const offsets = { 1: -0.15, 2: 0, 3: 0.15 }; + const offsets = { 1: -0.15, 2: 0, 3: 0.15 }; - const colors = { - 1: '#2563eb', - 2: '#10b981', - 3: '#9333ea' - }; + const colors = { + 1: '#2563eb', + 2: '#10b981', + 3: '#9333ea' + }; - rows.forEach(row => { + rows.forEach(row => { - const percent = row.total - ? ((row.positive / row.total) * 100).toFixed(1) - : 0; + const percent = row.total + ? ((row.positive / row.total) * 100).toFixed(1) + : 0; - const offset = offsets[row.surveillance_id] ?? 0; + const offset = offsets[row.surveillance_id] ?? 0; - const lat = center.lat; - const lng = center.lng + offset; + const lat = center.lat; + const lng = center.lng + offset; - const programName = - row.surveillance_id === 1 ? 'SARI' : - row.surveillance_id === 2 ? 'ILI' : 'LBM'; + 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: '#fff', - weight: 1, - fillOpacity: 0.9 - }) - .bindTooltip(` + 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); - }); + }); - } + } - }).addTo(map); + }).addTo(map); - }); + }); } diff --git a/dashboard/resources/views/dashboard/overview.blade.php b/dashboard/resources/views/dashboard/overview.blade.php index f722c40..38536d2 100644 --- a/dashboard/resources/views/dashboard/overview.blade.php +++ b/dashboard/resources/views/dashboard/overview.blade.php @@ -66,16 +66,7 @@
    Recent Alerts & Notifications
    - - +