476 lines
14 KiB
JavaScript
476 lines
14 KiB
JavaScript
const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS'];
|
||
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
|
||
let map;
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
|
||
if (!standardPrograms.includes(programCode)) return;
|
||
|
||
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||
const elements = document.querySelectorAll(".report-period");
|
||
elements.forEach(el => {
|
||
el.textContent = 'Week ' + startWeek + ' of ' + startYear + ' to ' + 'Week ' + endWeek + ' of ' + endYear
|
||
});
|
||
|
||
fetch(`/api/dashboard/program?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||
.then(res => res.json())
|
||
.then(programCode === 'AFI' ? renderAFIDashboard : renderDashboard)
|
||
.catch(err => console.error("Dashboard API error:", err));
|
||
|
||
});
|
||
|
||
|
||
});
|
||
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 renderAFIDashboard(data) {
|
||
const pathogenRows = (data.pathogen_distribution || [])
|
||
.sort((a, b) => b.total - a.total);
|
||
const colors = [
|
||
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
|
||
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
|
||
];
|
||
|
||
const rows = data.afi_trend || [];
|
||
|
||
const pcr = rows.filter(r => r.test_type === 'PCR');
|
||
const serum = rows.filter(r => r.test_type === 'Serum');
|
||
|
||
renderAFITrend(pcr, 'trendChart', colors);
|
||
//renderAFITrend(serum, 'pathogenChart', colors);
|
||
|
||
renderSummary(data.summary);
|
||
renderProvinceHeatmap(data.province_distribution);
|
||
renderDemographics(data);
|
||
renderPathogenChart(data.pathogen_distribution || []);
|
||
renderSentinel(data.sentinel_sites || []);
|
||
renderSubtypeChart(data.subtype_distribution || []);
|
||
|
||
|
||
charts['ageChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['ageChart'].update();
|
||
charts['sexChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sexChart'].update();
|
||
charts['sentinelChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sentinelChart'].update();
|
||
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['subtypeChart'].update();
|
||
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['pathogenChart'].update();
|
||
}
|
||
function renderProvinceHeatmap(rows) {
|
||
window.latestProvinceData = rows;
|
||
|
||
if (map) map.remove();
|
||
|
||
map = L.map('provinceMap').setView([12.7, 104.9], 7);
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors'
|
||
}).addTo(map);
|
||
|
||
addProvinceLegend();
|
||
|
||
fetch('/geo/cambodia_provinces.geojson')
|
||
.then(r => r.json())
|
||
.then(geo => {
|
||
|
||
const validProvinces = new Set(
|
||
geo.features.map(f => f.properties.ADM1_EN)
|
||
);
|
||
window.validProvinces = validProvinces;
|
||
const totals = {};
|
||
|
||
rows.forEach(r => {
|
||
|
||
const province = normalizeProvince(r.patient_province, validProvinces);
|
||
if (!province) return;
|
||
|
||
if (!totals[province]) {
|
||
totals[province] = { total: 0, positive: 0 };
|
||
}
|
||
|
||
totals[province].total += Number(r.total);
|
||
totals[province].positive += Number(r.positive);
|
||
|
||
});
|
||
|
||
function getColor(value) {
|
||
if (value > 50) return "#b91c1c";
|
||
if (value >= 10) return "#ef4444";
|
||
if (value > 0) return "#fecaca";
|
||
return "#f3f4f600";
|
||
}
|
||
|
||
window.provinceLayer = L.geoJSON(geo, {
|
||
style: feature => {
|
||
|
||
const province = feature.properties.ADM1_EN;
|
||
const value = totals[province]?.total || 0;
|
||
|
||
return {
|
||
color: "#444",
|
||
weight: 1.5,
|
||
fillColor: getColor(value),
|
||
fillOpacity: 0.8
|
||
};
|
||
},
|
||
onEachFeature: (feature, layer) => {
|
||
|
||
const province = feature.properties.ADM1_EN;
|
||
const total = totals[province]?.total || 0;
|
||
const positive = totals[province]?.positive || 0;
|
||
|
||
const percent = total
|
||
? ((positive / total) * 100).toFixed(1)
|
||
: 0;
|
||
|
||
layer.bindTooltip(`
|
||
${province}<br>
|
||
Total: ${total}<br>
|
||
Positivity: ${percent}%
|
||
`);
|
||
}
|
||
}).addTo(map);
|
||
|
||
});
|
||
}
|
||
function addProvinceLegend() {
|
||
|
||
const legend = L.control({ position: "bottomright" });
|
||
|
||
legend.onAdd = function () {
|
||
|
||
const div = L.DomUtil.create("div", "map-legend");
|
||
|
||
div.innerHTML = `
|
||
<div style="background:white;padding:10px 12px;border-radius:6px;
|
||
box-shadow:0 2px 6px rgba(0,0,0,0.2);font-size:12px;">
|
||
<div style="font-weight:600;margin-bottom:6px;">Cases</div>
|
||
|
||
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span style="width:12px;height:12px;background:#b91c1c;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
> 50
|
||
</div>
|
||
|
||
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span style="width:12px;height:12px;background:#ef4444;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
10 – 50
|
||
</div>
|
||
|
||
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span style="width:12px;height:12px;background:#fecaca;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
1 – 9
|
||
</div>
|
||
|
||
<div style="display:flex;align-items:center;">
|
||
<span style="width:12px;height:12px;background:#f3f4f6;
|
||
display:inline-block;margin-right:6px;"></span>
|
||
0
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return div;
|
||
};
|
||
|
||
legend.addTo(map);
|
||
}
|
||
function renderTrend(valueId, changeId, current, previous, suffix = '') {
|
||
|
||
const valueEl = document.getElementById(valueId);
|
||
const changeEl = document.getElementById(changeId);
|
||
|
||
if (!valueEl || !changeEl) return;
|
||
|
||
valueEl.textContent = current + suffix;
|
||
|
||
if (!previous) {
|
||
changeEl.innerHTML = "— No previous data";
|
||
changeEl.className = "text-muted";
|
||
return;
|
||
}
|
||
|
||
const diff = current - previous;
|
||
const percent = ((diff / previous) * 100).toFixed(1);
|
||
|
||
if (diff > 0) {
|
||
changeEl.innerHTML = `↑ +${percent}% from previous week`;
|
||
changeEl.className = "text-success";
|
||
}
|
||
else if (diff < 0) {
|
||
changeEl.innerHTML = `↓ ${percent}% from previous week`;
|
||
changeEl.className = "text-danger";
|
||
}
|
||
else {
|
||
changeEl.innerHTML = "— No significant change";
|
||
changeEl.className = "text-muted";
|
||
}
|
||
}
|
||
function renderProgramTrend(rows) {
|
||
|
||
rows = rows || [];
|
||
|
||
const labels = rows.map(r => `W${r.period}`);
|
||
const samples = rows.map(r => r.total_samples || 0);
|
||
const fluRate = rows.map(r => r.influenza_rate || 0);
|
||
const covidRate = rows.map(r => r.covid_rate || 0);
|
||
|
||
const lines = [
|
||
{ label: 'Influenza %', data: fluRate, color: '#fa2929' },
|
||
{ label: 'COVID-19 %', data: covidRate, color: '#1976D2' }
|
||
];
|
||
|
||
// ✅ ONLY NDS gets EV + Mpox
|
||
if (programCode === 'NDS') {
|
||
const evRate = rows.map(r => r.ev_rate || 0);
|
||
const mpoxRate = rows.map(r => r.mpox_rate || 0);
|
||
|
||
lines.push(
|
||
{ label: 'EV %', data: evRate, color: '#f59e0b' },
|
||
{ label: 'Mpox %', data: mpoxRate, color: '#8b5cf6' }
|
||
);
|
||
}
|
||
|
||
buildMixedTrendChart(
|
||
'trendChart',
|
||
labels,
|
||
samples,
|
||
lines
|
||
);
|
||
}
|
||
function renderSummary(summary) {
|
||
|
||
summary = summary || {};
|
||
|
||
const cases = summary.cases || {};
|
||
const hospital = summary.hospital_rate || {};
|
||
const icu = summary.icu_rate || {};
|
||
const positivity = summary.positivity_rate || {};
|
||
|
||
renderTrend(
|
||
"totalCases",
|
||
"casesChange",
|
||
cases.current || 0,
|
||
cases.previous || 0
|
||
);
|
||
renderTrend(
|
||
"influenzaRate",
|
||
"influenzaChange",
|
||
summary.influenza_rate.current,
|
||
summary.influenza_rate.previous,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"covidRate",
|
||
"covidChange",
|
||
summary.covid_rate.current,
|
||
summary.covid_rate.previous,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"hospitalRate",
|
||
"hospitalChange",
|
||
hospital.current || 0,
|
||
hospital.previous || 0,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"icuRate",
|
||
"icuChange",
|
||
icu.current || 0,
|
||
icu.previous || 0,
|
||
"%"
|
||
);
|
||
|
||
renderTrend(
|
||
"positivityRate",
|
||
"positivityChange",
|
||
positivity.current || 0,
|
||
positivity.previous || 0,
|
||
"%"
|
||
);
|
||
}
|
||
function renderDashboard(data) {
|
||
|
||
data = data || {};
|
||
let virusRows = data.virus_trend || [];
|
||
if (!virusRows.length) {
|
||
virusRows = [
|
||
{ period: '', influenza: 0, covid: 0, total_samples: 0 }
|
||
];
|
||
}
|
||
renderProgramTrend(data.trend || []);
|
||
renderSummary(data.summary || {});
|
||
renderProvinceHeatmap(data.province_distribution || []);
|
||
|
||
|
||
const pathogenRows = (data.pathogen_distribution || [])
|
||
.sort((a, b) => b.total - a.total);
|
||
const colors = [
|
||
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
|
||
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
|
||
];
|
||
|
||
buildChart(
|
||
'pathogenChart',
|
||
'doughnut',
|
||
pathogenRows.map(r => r.pathogen),
|
||
pathogenRows.map(r => r.total)
|
||
);
|
||
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['pathogenChart'].update();
|
||
|
||
buildChart(
|
||
'ageChart',
|
||
'doughnut',
|
||
(data.age_distribution || []).map(r => r.age_group),
|
||
(data.age_distribution || []).map(r => r.total)
|
||
);
|
||
charts['ageChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['ageChart'].update();
|
||
|
||
buildChart(
|
||
'sexChart',
|
||
'bar',
|
||
(data.sex_distribution || []).map(r => r.patient_sex),
|
||
(data.sex_distribution || []).map(r => r.total)
|
||
);
|
||
charts['sexChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sexChart'].update();
|
||
|
||
buildChart(
|
||
'subtypeChart',
|
||
'bar',
|
||
(data.subtype_distribution || []).map(r => r.subtype),
|
||
(data.subtype_distribution || []).map(r => r.total)
|
||
);
|
||
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['subtypeChart'].update();
|
||
buildChart(
|
||
'sentinelChart',
|
||
'pie',
|
||
(data.sentinel_sites || []).map(r => r.name),
|
||
(data.sentinel_sites || []).map(r => r.total)
|
||
);
|
||
charts['sentinelChart'].data.datasets[0].backgroundColor = colors;
|
||
charts['sentinelChart'].update();
|
||
|
||
}
|
||
|
||
|
||
//AFI
|
||
function renderAFITrend(rows, canvasId, colors) {
|
||
|
||
if (!rows || !rows.length) {
|
||
buildStackedChart(canvasId, [], []);
|
||
return;
|
||
}
|
||
|
||
const cleanRows = rows.filter(r => r.pathogen);
|
||
|
||
const keyFn = r => `${r.year}-${r.period}`;
|
||
|
||
const map = {};
|
||
cleanRows.forEach(r => {
|
||
const key = keyFn(r);
|
||
if (!map[key]) map[key] = {};
|
||
map[key][r.pathogen] = Number(r.total_tests || r.total || 0);
|
||
});
|
||
|
||
const keys = Object.keys(map).sort((a, b) => {
|
||
const [y1, w1] = a.split('-').map(Number);
|
||
const [y2, w2] = b.split('-').map(Number);
|
||
return y1 !== y2 ? y1 - y2 : w1 - w2;
|
||
});
|
||
|
||
const labels = keys.map(k => `W${k.split('-')[1]}`);
|
||
|
||
const pathogens = [...new Set(cleanRows.map(r => r.pathogen))];
|
||
|
||
const datasets = pathogens.map((p, i) => ({
|
||
label: p,
|
||
data: keys.map(k => map[k][p] || 0),
|
||
backgroundColor: colors[i % colors.length]
|
||
}));
|
||
|
||
buildStackedChart(canvasId, labels, datasets);
|
||
}
|
||
|
||
function renderPathogenChart(rows) {
|
||
buildChart(
|
||
'pathogenChart',
|
||
'doughnut',
|
||
rows.map(r => r.pathogen),
|
||
rows.map(r => r.total)
|
||
);
|
||
}
|
||
function renderSentinel(rows) {
|
||
buildChart(
|
||
'sentinelChart',
|
||
'pie',
|
||
rows.map(r => r.name),
|
||
rows.map(r => r.total)
|
||
);
|
||
}
|
||
function renderDemographics(data) {
|
||
buildChart(
|
||
'ageChart',
|
||
'doughnut',
|
||
(data.age_distribution || []).map(r => r.age_group),
|
||
(data.age_distribution || []).map(r => r.total)
|
||
);
|
||
|
||
buildChart(
|
||
'sexChart',
|
||
'bar',
|
||
(data.sex_distribution || []).map(r => r.patient_sex),
|
||
(data.sex_distribution || []).map(r => r.total)
|
||
);
|
||
}
|
||
function renderSubtypeChart(rows) {
|
||
buildChart(
|
||
'subtypeChart',
|
||
'bar',
|
||
rows.map(r => r.subtype),
|
||
rows.map(r => r.total)
|
||
);
|
||
}
|
||
|
||
|