Files
nrml_dashboard/dashboard/public/js/program.js
2026-04-08 14:48:42 +07:00

485 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'];
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
let map;
let provinceLayer;
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;
}
document.addEventListener("DOMContentLoaded", () => {
if (!standardPrograms.includes(programCode)) return;
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
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 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'
];
renderSummary(data.summary);
renderAFITrend(data.afi_trend);
renderProvinceHeatmap(data.province_distribution);
renderPathogenChart(data.pathogen_distribution);
renderDemographics(data);
renderSentinel(data.sentinel_sites || []);
renderSubtypeChart(data.subtype_distribution || []);
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
charts['pathogenChart'].update();
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();
}
function renderProvinceHeatmap(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)
);
const totals = {};
rows.forEach(r => {
// ✅ FIX: use patient_province + validSet
const province = normalizeProvince(r.patient_province, validProvinces);
if (!province) return;
// ✅ FIX: accumulate instead of overwrite
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";
}
provinceLayer = L.geoJSON(geo, {
style: feature => {
const province = feature.properties.ADM1_EN;
const value = totals[province]?.total || 0;
return {
color: "#444",
weight: 1,
fillColor: getColor(value),
fillOpacity: 0.7
};
},
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);
buildMixedTrendChart(
'trendChart',
labels,
samples,
fluRate,
covidRate
);
}
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) {
if (!rows.length) {
buildStackedChart('trendChart', [], []);
return;
}
const { labels, datasets } = transformAFIData(rows);
buildStackedChart('trendChart', labels, datasets);
}
function transformAFIData(rows) {
const grouped = {};
const pathogensSet = new Set();
rows.forEach(r => {
const key = `${r.year}-W${r.period}`;
if (!grouped[key]) {
grouped[key] = {};
}
grouped[key][r.pathogen] = r.total;
pathogensSet.add(r.pathogen);
});
const labels = Object.keys(grouped).sort((a, b) => {
const [yA, wA] = a.split('-W').map(Number);
const [yB, wB] = b.split('-W').map(Number);
return yA === yB ? wA - wB : yA - yB;
});
const pathogenTotals = {};
rows.forEach(r => {
pathogenTotals[r.pathogen] = (pathogenTotals[r.pathogen] || 0) + r.total;
});
const pathogens = Object.keys(pathogenTotals)
.sort((a, b) => pathogenTotals[b] - pathogenTotals[a]);
const datasets = pathogens.map(p => ({
label: p,
data: labels.map(l => grouped[l][p] || 0),
backgroundColor: getColorForPathogen(p)
}));
return { labels: labels.map(l => l.split('-')[1]), datasets };
}
function getColorForPathogen(name) {
const colors = {
Dengue: '#2563eb',
Chikungunya: '#10b981',
Zika: '#f59e0b',
Leptospira: '#ef4444',
Rickettsia: '#8b5cf6',
Salmonella: '#f97316',
Plasmodium: '#14b8a6',
Influenza: '#84cc16'
};
if (colors[name]) return colors[name];
// fallback random color (for future pathogens)
return `hsl(${Math.floor(Math.random() * 360)}, 70%, 60%)`;
}
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)
);
}
//Seq