Files
2026-06-22 08:47:13 +07:00

1011 lines
23 KiB
JavaScript
Raw Permalink 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.
import { COLORS, SUBTYPE_COLORS } from "./globals.js";
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) => {
document.querySelectorAll(".report-period")
.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 buildDistributionChart(
id,
type,
rows,
labelKey,
valueKey = 'total',
colorResolver = null
) {
const labels = rows.map(r => r[labelKey]);
buildChart(
id,
type,
labels,
rows.map(r => r[valueKey])
);
if (!charts[id]) return;
charts[id].data.datasets[0].backgroundColor = labels.map(
(label, index) =>
colorResolver
? colorResolver(label, index)
: COLORS[index % COLORS.length]
);
charts[id].update();
}
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 getSubtypeColor(label, index = 0) {
const specialColors = {
'A/H5N1': '#dc2626',
'Influenza': '#b90c00'
};
return specialColors[label]
|| COLORS[index % COLORS.length];
}
function renderSummary(summary = {}) {
const mappings = [
['totalCases', 'casesChange', summary.cases],
['influenzaRate', 'influenzaChange', summary.influenza_rate, '%'],
['covidRate', 'covidChange', summary.covid_rate, '%'],
['hospitalRate', 'hospitalChange', summary.hospital_rate, '%'],
['icuRate', 'icuChange', summary.icu_rate, '%'],
['positivityRate', 'positivityChange', summary.positivity_rate, '%']
];
mappings.forEach(([valueId, changeId, data = {}, suffix = '']) => {
renderTrend(
valueId,
changeId,
data.current || 0,
data.previous || 0,
suffix
);
});
}
function renderDashboard(data = {}) {
renderProgramTrend(data.trend || []);
renderSummary(data.summary);
renderProvinceHeatmap(data.province_distribution || []);
buildDistributionChart(
'pathogenChart',
'doughnut',
(data.pathogen_distribution || [])
.sort((a, b) => b.total - a.total),
'pathogen',
'total',
getSubtypeColor
);
buildDistributionChart(
'ageChart',
'doughnut',
data.age_distribution || [],
'age_group'
);
buildDistributionChart(
'sexChart',
'bar',
data.sex_distribution || [],
'patient_sex'
);
buildDistributionChart(
'subtypeChart',
'bar',
data.subtype_distribution || [],
'subtype',
'total',
getSubtypeColor
);
buildDistributionChart(
'sentinelChart',
'pie',
data.sentinel_sites || [],
'name'
);
}
function renderAFIDashboard(data = {}) {
const trend = data.afi_trend || {};
const positivity = data.afi_case_trend || {};
renderAFITrend(
data.afi_case_trend.section_1,
'afiSection1Trend',
COLORS,
'trend'
);
renderAFITrend(
data.afi_case_trend.section_2,
'afiSection2Trend',
COLORS,
'trend'
);
renderAFITrend(
data.afi_case_trend.section_3,
'afiSection3Trend',
COLORS,
'trend'
);
// DOUGHNUTS
renderAFITrend(
data.afi_case_trend.section_1,
'afiPcrChart',
COLORS,
'donut'
);
renderAFITrend(
data.afi_case_trend.section_2,
'afiMultiplexChart',
COLORS,
'donut'
);
renderAFITrend(
data.afi_case_trend.section_3,
'afiElisaChart',
COLORS,
'donut'
);
renderSummary(data.summary);
renderProvinceHeatmap(data.province_distribution);
renderDemographics(data);
renderPathogenChart(data.pathogen_distribution || []);
renderSentinel(data.sentinel_sites || []);
renderSubtypeChart(data.subtype_distribution || []);
}
function normalizeProvince(name, validSet) {
if (!name || !validSet) return null;
const clean = str =>
str.toLowerCase().replace(/\s+/g, '');
const raw = name.trim();
const provinceMap = {
"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 (provinceMap[key] && validSet.has(provinceMap[key])) {
return provinceMap[key];
}
const match = [...validSet]
.find(p => clean(p) === key);
return match || null;
}
window.normalizeProvince = normalizeProvince;
function renderProvinceHeatmap(rows = []) {
window.latestProvinceData = rows;
if (map) {
map.remove();
}
map = L.map('provinceMap')
.setView([12.7, 104.9], 7);
window.map = map;
L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{
attribution: '&copy; 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;
totals[province] ??= {
total: 0,
positive: 0
};
totals[province].total += Number(r.total || 0);
totals[province].positive += Number(r.positive || 0);
});
const 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 renderProgramTrend(rows = []) {
if (!rows.length) {
buildMixedTrendChart(
'trendChart',
[],
[],
[]
);
return;
}
/*
|--------------------------------------------------------------------------
| YEARLY VIEW
|--------------------------------------------------------------------------
*/
const years = [...new Set(
rows.map(r => Number(r.year))
)];
const totalYears =
Math.max(...years) - Math.min(...years);
const useYearlyView =
totalYears >= 5;
/*
|--------------------------------------------------------------------------
| LABELS
|--------------------------------------------------------------------------
*/
let labels;
if (useYearlyView) {
labels = [...new Set(
rows.map(r => String(r.year))
)].sort();
} else {
labels = [...new Set(
rows.map(r =>
`${r.year}-W${r.period}`
)
)].sort((a, b) => {
const [yearA, weekA] =
a.split('-W').map(Number);
const [yearB, weekB] =
b.split('-W').map(Number);
if (yearA !== yearB) {
return yearA - yearB;
}
return weekA - weekB;
});
}
/*
|--------------------------------------------------------------------------
| TOTAL SAMPLES
|--------------------------------------------------------------------------
*/
const samples = labels.map(label => {
if (useYearlyView) {
return rows
.filter(r =>
String(r.year) === label
)
.reduce(
(sum, r) =>
sum + Number(r.total_samples || 0),
0
);
}
const row = rows.find(r =>
`${r.year}-W${r.period}` === label
);
return row?.total_samples || 0;
});
/*
|--------------------------------------------------------------------------
| LINES
|--------------------------------------------------------------------------
*/
const lines = [
{
label: 'Influenza %',
data: labels.map(label => {
if (useYearlyView) {
const filtered = rows.filter(r =>
String(r.year) === label
);
const total =
filtered.reduce(
(sum, r) =>
sum + Number(r.influenza_rate || 0),
0
);
return filtered.length
? total / filtered.length
: 0;
}
const row = rows.find(r =>
`${r.year}-W${r.period}` === label
);
return row?.influenza_rate || 0;
}),
color: '#d21919'
},
{
label: 'COVID-19 %',
data: labels.map(label => {
if (useYearlyView) {
const filtered = rows.filter(r =>
String(r.year) === label
);
const total =
filtered.reduce(
(sum, r) =>
sum + Number(r.covid_rate || 0),
0
);
return filtered.length
? total / filtered.length
: 0;
}
const row = rows.find(r =>
`${r.year}-W${r.period}` === label
);
return row?.covid_rate || 0;
}),
color: '#10b981'
}
];
/*
|--------------------------------------------------------------------------
| NDS EXTRA LINES
|--------------------------------------------------------------------------
*/
if (programCode === 'NDS') {
lines.push(
{
label: 'EV %',
data: labels.map(label => {
if (useYearlyView) {
const filtered = rows.filter(r =>
String(r.year) === label
);
const total =
filtered.reduce(
(sum, r) =>
sum + Number(r.ev_rate || 0),
0
);
return filtered.length
? total / filtered.length
: 0;
}
const row = rows.find(r =>
`${r.year}-W${r.period}` === label
);
return row?.ev_rate || 0;
}),
color: '#f59e0b'
},
{
label: 'Mpox %',
data: labels.map(label => {
if (useYearlyView) {
const filtered = rows.filter(r =>
String(r.year) === label
);
const total =
filtered.reduce(
(sum, r) =>
sum + Number(r.mpox_rate || 0),
0
);
return filtered.length
? total / filtered.length
: 0;
}
const row = rows.find(r =>
`${r.year}-W${r.period}` === label
);
return row?.mpox_rate || 0;
}),
color: '#8b5cf6'
}
);
}
buildMixedTrendChart(
'trendChart',
labels,
samples,
lines
);
}
function renderAFITrend(
data = {},
canvasId,
COLORS,
type = 'trend'
) {
/*
|--------------------------------------------------------------------------
| DATA
|--------------------------------------------------------------------------
*/
const rows =
data.rows || [];
const totals =
data.totals || [];
/*
|--------------------------------------------------------------------------
| EMPTY
|--------------------------------------------------------------------------
*/
if (!totals.length) {
if (type === 'donut') {
buildDistributionChart(
canvasId,
'doughnut',
[],
'pathogen',
'total'
);
} else {
buildMixedTrendChart(
canvasId,
[],
[],
[]
);
}
return;
}
/*
|--------------------------------------------------------------------------
| LABELS
|--------------------------------------------------------------------------
*/
const labels = [...new Set(
totals.map(r =>
`${r.year}-W${r.period}`
)
)].sort((a, b) => {
const [yearA, weekA] =
a.split('-W').map(Number);
const [yearB, weekB] =
b.split('-W').map(Number);
if (yearA !== yearB) {
return yearA - yearB;
}
return weekA - weekB;
});
/*
|--------------------------------------------------------------------------
| TOTAL CASES (BLUE BARS)
|--------------------------------------------------------------------------
*/
const totalCases = labels.map(label => {
const row = totals.find(r =>
`${r.year}-W${r.period}`
=== label
);
return Number(
row?.total_cases || 0
);
});
/*
|--------------------------------------------------------------------------
| DONUT
|--------------------------------------------------------------------------
*/
if (type === 'donut') {
const grouped = {};
rows.forEach(r => {
const pathogen =
r.pathogen || 'Unknown';
grouped[pathogen] ??= 0;
grouped[pathogen] +=
Number(r.total_positive || 0);
});
const donutRows =
Object.entries(grouped)
.map(([pathogen, total]) => ({
pathogen,
total
}));
buildDistributionChart(
canvasId,
'doughnut',
donutRows,
'pathogen',
'total'
);
/*
|--------------------------------------------------------------------------
| DONUT CENTER TOTAL
|--------------------------------------------------------------------------
|
| MUST MATCH SUM OF BLUE BARS
|
*/
const donutTotal =
totalCases.reduce(
(a, b) => a + b,
0
);
if (charts[canvasId]) {
charts[canvasId].$afiTotalCases =
donutTotal;
charts[canvasId].update();
}
return;
}
/*
|--------------------------------------------------------------------------
| PATHOGENS
|--------------------------------------------------------------------------
*/
const pathogens = [
...new Set(
rows.map(r => r.pathogen)
)
];
/*
|--------------------------------------------------------------------------
| LINES
|--------------------------------------------------------------------------
*/
const lines = pathogens.map(
(pathogen, i) => ({
label: `${pathogen} %`,
data: labels.map(label => {
const row = rows.find(r =>
`${r.year}-W${r.period}`
=== label
&& r.pathogen === pathogen
);
return Number(
row?.positivity_rate || 0
);
}),
color:
COLORS[
i % COLORS.length
]
})
);
/*
|--------------------------------------------------------------------------
| BUILD
|--------------------------------------------------------------------------
*/
buildMixedTrendChart(
canvasId,
labels,
totalCases,
lines
);
}
function renderPathogenChart(rows = []) {
buildDistributionChart(
'pathogenChart',
'doughnut',
rows,
'pathogen'
);
}
function renderSentinel(rows = []) {
buildDistributionChart(
'sentinelChart',
'pie',
rows,
'name'
);
}
function renderSubtypeChart(rows = []) {
console.log('renderSubtypeChart');
buildDistributionChart(
'subtypeChart',
'bar',
rows,
'subtype',
'total',
getSubtypeColor
);
}
function renderDemographics(data = {}) {
buildDistributionChart(
'ageChart',
'doughnut',
data.age_distribution || [],
'age_group'
);
buildDistributionChart(
'sexChart',
'bar',
data.sex_distribution || [],
'patient_sex'
);
}