Files
nrml_dashboard/dashboard/public/js/program.js
2026-04-29 09:06:54 +07:00

476 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
);
}