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
-
- -
- âš Monitoring influenza increase in selected provinces.
-
-
- -
- 🔔 SARS-CoV-2 positivity rate under review.
-
-
-
+