1531 lines
45 KiB
JavaScript
1531 lines
45 KiB
JavaScript
import { COLORS, SUBTYPE_COLORS, SURVEILLANCE_COLORS } from "./globals.js";
|
||
|
||
let trendChart;
|
||
let influenzaSubtypeChart;
|
||
let covidDistributedByAgeChart;
|
||
let covidLineageFrequencyChart;
|
||
let influenzaSubtypeFrequencyChart;
|
||
let map;
|
||
let subtypeLayers = {};
|
||
let hiddenSubtypes = new Set();
|
||
|
||
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| Load Summary Cards
|
||
|--------------------------------------------------------------------------
|
||
*/
|
||
|
||
function loadSummary() {
|
||
fetch('/api/dashboard/summary')
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
|
||
let html = '';
|
||
const alerts = [];
|
||
|
||
data.forEach(item => {
|
||
|
||
let trendColor = 'text-secondary';
|
||
|
||
if (item.percent_change > 0) trendColor = 'text-danger';
|
||
if (item.percent_change < 0) trendColor = 'text-success';
|
||
|
||
html += `
|
||
<div class="col-md-2 mb-3">
|
||
<div class="card shadow-sm h-100">
|
||
<div class="card-body">
|
||
|
||
<div class="d-flex justify-content-between">
|
||
|
||
<div>
|
||
<h6 class="fw-bold">${item.code} Cases</h6>
|
||
<h3 class="mb-1">${item.current_total}</h3>
|
||
<small class="text-muted">Last 7 days</small>
|
||
</div>
|
||
|
||
<div class="text-end">
|
||
|
||
<div class="${trendColor} fw-bold">
|
||
${item.percent_change > 0 ? '▲' : item.percent_change < 0 ? '▼' : '–'}
|
||
${Math.abs(item.percent_change)}%
|
||
</div>
|
||
|
||
<small class="text-muted">
|
||
${item.previous_total ?? 0} last week
|
||
</small>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
window._summaryData = data;
|
||
updateAlerts();
|
||
|
||
});
|
||
|
||
document.getElementById('summary_cards').innerHTML = html;
|
||
|
||
renderAlerts(alerts);
|
||
|
||
});
|
||
}
|
||
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| Load Trend Chart
|
||
|--------------------------------------------------------------------------
|
||
*/
|
||
|
||
function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
|
||
|
||
fetch(`/api/dashboard/trend?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
|
||
if (trendChart) trendChart.destroy();
|
||
const totalYears = endYear - startYear;
|
||
const useYearlyView = totalYears >= 5;
|
||
|
||
// const labelsSet = new Set();
|
||
|
||
// Object.values(data).forEach(program => {
|
||
// program.forEach(row => {
|
||
// labelsSet.add(`${row.year}-${row.period}`);
|
||
// });
|
||
// });
|
||
|
||
// const labels = Array.from(labelsSet).sort((a, b) => {
|
||
|
||
// const [yearA, weekA] = a.split('-').map(Number);
|
||
// const [yearB, weekB] = b.split('-').map(Number);
|
||
|
||
// if (yearA !== yearB) return yearA - yearB;
|
||
// return weekA - weekB;
|
||
|
||
// });
|
||
let labels = [];
|
||
|
||
if (useYearlyView) {
|
||
|
||
labels = [...new Set(
|
||
Object.values(data)
|
||
.flat()
|
||
.map(row => row.year)
|
||
)].sort((a, b) => a - b);
|
||
|
||
} else {
|
||
|
||
const labelsSet = new Set();
|
||
|
||
Object.values(data).forEach(program => {
|
||
program.forEach(row => {
|
||
labelsSet.add(`${row.year}-${row.period}`);
|
||
});
|
||
});
|
||
|
||
labels = Array.from(labelsSet).sort((a, b) => {
|
||
|
||
const [yearA, weekA] = a.split('-').map(Number);
|
||
const [yearB, weekB] = b.split('-').map(Number);
|
||
|
||
if (yearA !== yearB) return yearA - yearB;
|
||
|
||
return weekA - weekB;
|
||
|
||
});
|
||
|
||
}
|
||
|
||
|
||
const datasets = [];
|
||
|
||
const allowedPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS'];
|
||
|
||
Object.keys(data).forEach(code => {
|
||
|
||
if (!allowedPrograms.includes(code)) return;
|
||
|
||
// const values = labels.map(label => {
|
||
// const found = data[code].find(row => `${row.year}-${row.period}` === label);
|
||
// return found ? found.total : 0;
|
||
// });
|
||
const values = labels.map(label => {
|
||
|
||
if (useYearlyView) {
|
||
|
||
return data[code]
|
||
.filter(row => row.year == label)
|
||
.reduce((sum, row) => sum + row.total, 0);
|
||
|
||
}
|
||
|
||
const found = data[code].find(
|
||
row => `${row.year}-${row.period}` === label
|
||
);
|
||
|
||
return found ? found.total : 0;
|
||
|
||
});
|
||
|
||
datasets.push({
|
||
label: code,
|
||
data: values,
|
||
borderColor: SURVEILLANCE_COLORS[code],
|
||
backgroundColor: SURVEILLANCE_COLORS[code],
|
||
borderWidth: 3,
|
||
pointRadius: 4,
|
||
maxBarThickness: 50,
|
||
fill: false,
|
||
tension: 0.3
|
||
});
|
||
|
||
});
|
||
|
||
|
||
|
||
// const displayLabels = labels.map(l => {
|
||
// const [year, week] = l.split('-');
|
||
// return `${year}-W${String(week).padStart(2, '0')}`;
|
||
// });
|
||
|
||
const displayLabels = useYearlyView
|
||
? labels
|
||
: labels.map(l => {
|
||
const [year, week] = l.split('-');
|
||
return `${year}-W${String(week).padStart(2, '0')}`;
|
||
});
|
||
|
||
trendChart = new Chart(document.getElementById('trendChart'), {
|
||
|
||
type: 'line',
|
||
data: {
|
||
labels: displayLabels,
|
||
datasets: datasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
|
||
plugins: {
|
||
legend: { position: 'bottom' },
|
||
datalabels: {
|
||
display: false
|
||
}
|
||
},
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
|
||
title: {
|
||
display: true,
|
||
text: 'Number of Cases'
|
||
},
|
||
},
|
||
x: {
|
||
|
||
grid: {
|
||
display: false
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: 'Surveillance'
|
||
},
|
||
}
|
||
}
|
||
}
|
||
});
|
||
charts['trendChart'] = trendChart;
|
||
|
||
});
|
||
}
|
||
function loadInfluenzaSubtypeDistribution(periodType, startYear, startWeek, endYear, endWeek) {
|
||
|
||
fetch(`/api/dashboard/influenza-subtype-distribution?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
|
||
let displayLabels = data.map(item => item.subtype);
|
||
let dataset = data.map(item => item.total);
|
||
|
||
// const colors = displayLabels.map(
|
||
// label => SUBTYPE_COLORS[label] || '#9ca3af'
|
||
// );
|
||
|
||
if (influenzaSubtypeChart) influenzaSubtypeChart.destroy();
|
||
influenzaSubtypeChart = new Chart(document.getElementById('influenzaSubtypeDistribution'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: displayLabels,
|
||
datasets: [{
|
||
data: dataset,
|
||
backgroundColor: displayLabels.map(
|
||
label => SUBTYPE_COLORS[label] || '#9ca3af'
|
||
),
|
||
}]
|
||
},
|
||
options: {
|
||
layout: {
|
||
padding: {
|
||
top: 20,
|
||
right: 30,
|
||
bottom: 20
|
||
}
|
||
},
|
||
|
||
indexAxis: 'y',
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
position: 'right'
|
||
},
|
||
datalabels: {
|
||
color: '#000000',
|
||
borderRadius: 6,
|
||
z: 1000,
|
||
padding: {
|
||
top: 6,
|
||
bottom: 6,
|
||
},
|
||
|
||
font: {
|
||
weight: 'bold',
|
||
size: 12
|
||
},
|
||
formatter: (value) => value,
|
||
anchor: 'end',
|
||
align: 'end',
|
||
offset: 4,
|
||
clamp: true,
|
||
clip: false
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Number of Positive Influenza Subtypes'
|
||
}
|
||
},
|
||
y: {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Influenza Subtypes'
|
||
},
|
||
grid: {
|
||
display: false
|
||
}
|
||
}
|
||
}
|
||
},
|
||
plugins: [ChartDataLabels]
|
||
});
|
||
charts['influenzaSubtypeDistribution'] = influenzaSubtypeChart;
|
||
|
||
});
|
||
}
|
||
function loadCovidDistributedByAgeGroup(periodType, startYear, startWeek, endYear, endWeek) {
|
||
|
||
fetch(`/api/dashboard/covid-distributed-by-age-group?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
|
||
let displayLabels = data.map(item => item.age_group);
|
||
let dataset = data.map(item => item.total);
|
||
|
||
|
||
if (covidDistributedByAgeChart) covidDistributedByAgeChart.destroy();
|
||
covidDistributedByAgeChart = new Chart(document.getElementById('covidDistributedByAgeGroup'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: displayLabels,
|
||
datasets: [{
|
||
label: 'Total Covid-19 Detected',
|
||
data: dataset,
|
||
backgroundColor: COLORS,
|
||
maxBarThickness: 50
|
||
}]
|
||
},
|
||
options: {
|
||
layout: {
|
||
padding: {
|
||
top: 50,
|
||
bottom: 10,
|
||
}
|
||
},
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
position: 'bottom'
|
||
},
|
||
datalabels: {
|
||
color: '#000',
|
||
borderRadius: 6,
|
||
z: 1000,
|
||
padding: {
|
||
top: 6,
|
||
bottom: 6,
|
||
left: 10,
|
||
right: 10
|
||
},
|
||
font: {
|
||
weight: 'bold',
|
||
size: 12
|
||
},
|
||
formatter: (value) => value,
|
||
anchor: 'end',
|
||
align: 'end',
|
||
offset: 4,
|
||
clamp: true,
|
||
clip: false
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Patient Age Group'
|
||
},
|
||
grid: {
|
||
display: false
|
||
}
|
||
},
|
||
y: {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Number of Positive SARS-CoV-2'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
charts['covidDistributedByAgeGroup'] = covidDistributedByAgeChart;
|
||
|
||
});
|
||
}
|
||
function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, endWeek) {
|
||
|
||
fetch(`/api/dashboard/covid-lineage-frequency?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
|
||
// const weeks = [...new Set(data.map(item => item.week))].sort();
|
||
const totalYears = endYear - startYear;
|
||
const useYearlyView = totalYears >= 5;
|
||
|
||
let periods;
|
||
|
||
if (useYearlyView) {
|
||
|
||
periods = [...new Set(
|
||
data.map(item => item.week.split('-')[0])
|
||
)].sort((a, b) => a - b);
|
||
|
||
} else {
|
||
|
||
periods = [...new Set(
|
||
data.map(item => item.week)
|
||
)].sort();
|
||
|
||
}
|
||
const lineages = [...new Set(data.map(item => item.lineage))];
|
||
|
||
|
||
|
||
const datasets = lineages.map((lineage, index) => {
|
||
// const lineageData = weeks.map(week => {
|
||
// const found = data.find(
|
||
// item => item.week === week && item.lineage === lineage
|
||
// );
|
||
// return found ? found.total : 0;
|
||
// });
|
||
|
||
const lineageData = periods.map(period => {
|
||
|
||
if (useYearlyView) {
|
||
|
||
return data
|
||
.filter(item =>
|
||
item.week.split('-')[0] == period &&
|
||
item.lineage === lineage
|
||
)
|
||
.reduce((sum, item) => sum + item.total, 0);
|
||
|
||
}
|
||
|
||
const found = data.find(
|
||
item =>
|
||
item.week === period &&
|
||
item.lineage === lineage
|
||
);
|
||
|
||
return found ? found.total : 0;
|
||
|
||
});
|
||
|
||
return {
|
||
label: lineage,
|
||
data: lineageData,
|
||
fill: true,
|
||
tension: 0.4,
|
||
// borderColor: 'transparent',
|
||
borderWidth: 0,
|
||
pointRadius: 0,
|
||
backgroundColor: hexToRGBA(colors[index % colors.length], 0.3),
|
||
stack: 'total'
|
||
};
|
||
});
|
||
|
||
if (covidLineageFrequencyChart) covidLineageFrequencyChart.destroy();
|
||
|
||
const ctx = document.getElementById('covidLineageFrequency').getContext('2d');
|
||
|
||
covidLineageFrequencyChart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: periods,
|
||
datasets: datasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
datalabels: {
|
||
display: false
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
|
||
grid: {
|
||
display: false
|
||
}
|
||
},
|
||
y: {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Relative Frequency'
|
||
},
|
||
}
|
||
}
|
||
}
|
||
});
|
||
charts['covidLineageFrequency'] = covidLineageFrequencyChart;
|
||
|
||
// -------------------------
|
||
// Custom right-side scrollable legend
|
||
// -------------------------
|
||
const legendContainer = document.getElementById('legendContainer');
|
||
legendContainer.innerHTML = '';
|
||
datasets.forEach((dataset, index) => {
|
||
const item = document.createElement('div');
|
||
item.style.display = 'flex';
|
||
item.style.alignItems = 'center';
|
||
item.style.marginBottom = '4px';
|
||
item.style.fontSize = '11px';
|
||
item.style.cursor = 'pointer';
|
||
item.innerHTML = `
|
||
<span style="width:15px;height:15px;background:${dataset.backgroundColor};display:inline-block;margin-right:8px;"></span>
|
||
${dataset.label}
|
||
`;
|
||
|
||
item.addEventListener('click', () => {
|
||
const meta = covidLineageFrequencyChart.getDatasetMeta(index);
|
||
|
||
const allHidden = datasets.every((d, i) => covidLineageFrequencyChart.getDatasetMeta(i).hidden || i === index);
|
||
if (!allHidden) {
|
||
datasets.forEach((d, i) => {
|
||
covidLineageFrequencyChart.getDatasetMeta(i).hidden = true;
|
||
});
|
||
meta.hidden = false;
|
||
} else {
|
||
datasets.forEach((d, i) => {
|
||
covidLineageFrequencyChart.getDatasetMeta(i).hidden = false;
|
||
});
|
||
}
|
||
|
||
covidLineageFrequencyChart.update();
|
||
|
||
Array.from(legendContainer.children).forEach((child, i) => {
|
||
const metaItem = covidLineageFrequencyChart.getDatasetMeta(i);
|
||
child.style.opacity = metaItem.hidden ? 0.5 : 1;
|
||
});
|
||
});
|
||
|
||
legendContainer.appendChild(item);
|
||
});
|
||
|
||
legendContainer.style.maxHeight = '375px';
|
||
legendContainer.style.overflowY = 'auto';
|
||
legendContainer.style.padding = '8px';
|
||
legendContainer.style.borderRadius = '0px';
|
||
});
|
||
}
|
||
function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear, endWeek) {
|
||
|
||
fetch(`/api/dashboard/influenza-relative-frequency?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
|
||
const totalYears = endYear - startYear;
|
||
const useYearlyView = totalYears >= 5;
|
||
|
||
let periods;
|
||
|
||
if (useYearlyView) {
|
||
|
||
periods = [...new Set(
|
||
data.map(item => item.week.split('-')[0])
|
||
)].sort((a, b) => a - b);
|
||
|
||
} else {
|
||
|
||
periods = [...new Set(
|
||
data.map(item => item.week)
|
||
)].sort();
|
||
|
||
}
|
||
|
||
const lineages = [...new Set(
|
||
data.map(item => item.lineage)
|
||
)];
|
||
|
||
const lineageColors = lineages.map(
|
||
label => SUBTYPE_COLORS[label] || '#9ca3af'
|
||
);
|
||
|
||
const datasets = lineages.map((lineage, index) => {
|
||
|
||
const lineageData = periods.map(period => {
|
||
|
||
if (useYearlyView) {
|
||
|
||
return data
|
||
.filter(item =>
|
||
item.week.split('-')[0] == period &&
|
||
item.lineage === lineage
|
||
)
|
||
.reduce((sum, item) => sum + item.total, 0);
|
||
|
||
}
|
||
|
||
const found = data.find(
|
||
item =>
|
||
item.week === period &&
|
||
item.lineage === lineage
|
||
);
|
||
|
||
return found ? found.total : 0;
|
||
|
||
});
|
||
|
||
return {
|
||
label: lineage,
|
||
data: lineageData,
|
||
fill: true,
|
||
tension: 0.4,
|
||
borderWidth: 0,
|
||
pointRadius: 0,
|
||
backgroundColor: hexToRGBA(
|
||
lineageColors[index % lineageColors.length],
|
||
0.6
|
||
),
|
||
stack: 'total'
|
||
};
|
||
|
||
});
|
||
|
||
if (influenzaSubtypeFrequencyChart) {
|
||
influenzaSubtypeFrequencyChart.destroy();
|
||
}
|
||
|
||
const ctx = document
|
||
.getElementById('influenzaSubtypeFrequency')
|
||
.getContext('2d');
|
||
|
||
influenzaSubtypeFrequencyChart = new Chart(ctx, {
|
||
|
||
type: 'line',
|
||
|
||
data: {
|
||
labels: periods,
|
||
datasets: datasets
|
||
},
|
||
|
||
options: {
|
||
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
|
||
plugins: {
|
||
|
||
legend: {
|
||
display: false
|
||
},
|
||
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
|
||
datalabels: {
|
||
display: false
|
||
}
|
||
|
||
},
|
||
|
||
scales: {
|
||
|
||
x: {
|
||
|
||
stacked: true,
|
||
|
||
|
||
|
||
grid: {
|
||
display: false
|
||
}
|
||
|
||
},
|
||
|
||
y: {
|
||
|
||
stacked: true,
|
||
|
||
beginAtZero: true,
|
||
|
||
title: {
|
||
display: true,
|
||
text: 'Relative Frequency'
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
});
|
||
|
||
charts['influenzaSubtypeFrequency'] =
|
||
influenzaSubtypeFrequencyChart;
|
||
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| CUSTOM LEGEND
|
||
|--------------------------------------------------------------------------
|
||
*/
|
||
|
||
const legendContainer =
|
||
document.getElementById(
|
||
'legendContainerInfluenzaSubtypeFrequency'
|
||
);
|
||
|
||
legendContainer.innerHTML = '';
|
||
|
||
datasets.forEach((dataset, index) => {
|
||
|
||
const item = document.createElement('div');
|
||
|
||
item.style.display = 'flex';
|
||
item.style.alignItems = 'center';
|
||
item.style.marginBottom = '4px';
|
||
item.style.fontSize = '11px';
|
||
item.style.cursor = 'pointer';
|
||
|
||
item.innerHTML = `
|
||
<span style="
|
||
width:15px;
|
||
height:15px;
|
||
background:${dataset.backgroundColor};
|
||
display:inline-block;
|
||
margin-right:8px;
|
||
"></span>
|
||
${dataset.label}
|
||
`;
|
||
|
||
item.addEventListener('click', () => {
|
||
|
||
const meta =
|
||
influenzaSubtypeFrequencyChart
|
||
.getDatasetMeta(index);
|
||
|
||
const allHidden = datasets.every(
|
||
(d, i) =>
|
||
influenzaSubtypeFrequencyChart
|
||
.getDatasetMeta(i).hidden ||
|
||
i === index
|
||
);
|
||
|
||
if (!allHidden) {
|
||
|
||
datasets.forEach((d, i) => {
|
||
|
||
influenzaSubtypeFrequencyChart
|
||
.getDatasetMeta(i)
|
||
.hidden = true;
|
||
|
||
});
|
||
|
||
meta.hidden = false;
|
||
|
||
} else {
|
||
|
||
datasets.forEach((d, i) => {
|
||
|
||
influenzaSubtypeFrequencyChart
|
||
.getDatasetMeta(i)
|
||
.hidden = false;
|
||
|
||
});
|
||
|
||
}
|
||
|
||
influenzaSubtypeFrequencyChart.update();
|
||
|
||
Array.from(legendContainer.children)
|
||
.forEach((child, i) => {
|
||
|
||
const metaItem =
|
||
influenzaSubtypeFrequencyChart
|
||
.getDatasetMeta(i);
|
||
|
||
child.style.opacity =
|
||
metaItem.hidden ? 0.5 : 1;
|
||
|
||
});
|
||
|
||
});
|
||
|
||
legendContainer.appendChild(item);
|
||
|
||
});
|
||
|
||
legendContainer.style.maxHeight = '375px';
|
||
legendContainer.style.overflowY = 'auto';
|
||
legendContainer.style.padding = '8px';
|
||
legendContainer.style.borderRadius = '0px';
|
||
|
||
});
|
||
|
||
}
|
||
function hexToRGBA(hex, alpha) {
|
||
const r = parseInt(hex.slice(1, 3), 16);
|
||
const g = parseInt(hex.slice(3, 5), 16);
|
||
const b = parseInt(hex.slice(5, 7), 16);
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
}
|
||
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 = [];
|
||
|
||
|
||
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 = [];
|
||
|
||
|
||
summaryData.forEach(item => {
|
||
|
||
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 = `
|
||
<li class="list-group-item text-success">
|
||
🟢 No unusual activity detected
|
||
</li>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const colorMap = {
|
||
high: 'text-danger',
|
||
moderate: 'text-warning',
|
||
trend: 'text-primary',
|
||
volume: 'text-secondary'
|
||
};
|
||
|
||
container.innerHTML = alerts.map(a => `
|
||
<li class="list-group-item ${colorMap[a.type] || ''}">
|
||
${a.type === 'high' ? '🔴' :
|
||
a.type === 'moderate' ? '🟠' :
|
||
a.type === 'trend' ? '🟡' :
|
||
'🔵'}
|
||
${a.message}
|
||
</li>
|
||
`).join('');
|
||
}
|
||
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 getRadius(total) {
|
||
if (!total) return 0;
|
||
const r = Math.sqrt(total);
|
||
return Math.max(4, Math.min(r * 2, 22));
|
||
}
|
||
|
||
function getPositivityColor(subtype) {
|
||
return SUBTYPE_COLORS[subtype] || "#9ca3af";
|
||
}
|
||
const getColorByPathogen = name =>
|
||
SUBTYPE_COLORS[name] || '#9ca3af';
|
||
|
||
function addPositivityLegend() {
|
||
|
||
const legend = L.control({
|
||
position: "bottomleft"
|
||
});
|
||
|
||
legend.onAdd = function () {
|
||
|
||
const div = L.DomUtil.create(
|
||
"div",
|
||
"map-legend"
|
||
);
|
||
|
||
const items = Object.keys(subtypeLayers)
|
||
.filter(subtype => {
|
||
|
||
const layer = subtypeLayers[subtype];
|
||
|
||
return layer &&
|
||
layer.getLayers().length > 0;
|
||
|
||
});
|
||
div.innerHTML = `
|
||
<div style="
|
||
background:white;
|
||
padding:8px 10px;
|
||
border-radius:8px;
|
||
box-shadow:0 2px 8px rgba(0,0,0,0.15);
|
||
font-size:11px;
|
||
max-width:160px;
|
||
max-height:220px;
|
||
overflow-y:auto;
|
||
">
|
||
|
||
<div style="
|
||
font-weight:600;
|
||
margin-bottom:8px;
|
||
font-size:12px;
|
||
">
|
||
Influenza Subtypes
|
||
</div>
|
||
|
||
${items.map(item => `
|
||
|
||
<div
|
||
class="legend-item"
|
||
data-subtype="${item}"
|
||
style="
|
||
display:flex;
|
||
align-items:center;
|
||
margin-bottom:6px;
|
||
cursor:pointer;
|
||
transition:0.2s;
|
||
"
|
||
>
|
||
|
||
<span style="
|
||
background:${SUBTYPE_COLORS[item]};
|
||
width:10px;
|
||
height:10px;
|
||
display:inline-block;
|
||
margin-right:6px;
|
||
border-radius:50%;
|
||
flex-shrink:0;
|
||
"></span>
|
||
|
||
<span>
|
||
${item}
|
||
</span>
|
||
|
||
</div>
|
||
|
||
`).join('')}
|
||
|
||
</div>
|
||
`;
|
||
|
||
setTimeout(() => {
|
||
|
||
div.querySelectorAll('.legend-item')
|
||
.forEach(el => {
|
||
|
||
el.addEventListener('click', () => {
|
||
|
||
const subtype =
|
||
el.dataset.subtype;
|
||
|
||
const layer =
|
||
subtypeLayers[subtype];
|
||
|
||
if (!layer) return;
|
||
|
||
if (hiddenSubtypes.has(subtype)) {
|
||
|
||
hiddenSubtypes.delete(subtype);
|
||
|
||
map.addLayer(layer);
|
||
|
||
el.style.opacity = 1;
|
||
|
||
} else {
|
||
|
||
hiddenSubtypes.add(subtype);
|
||
|
||
map.removeLayer(layer);
|
||
|
||
el.style.opacity = 0.35;
|
||
|
||
}
|
||
|
||
});
|
||
|
||
});
|
||
|
||
}, 0);
|
||
|
||
return div;
|
||
};
|
||
|
||
legend.addTo(map);
|
||
|
||
}
|
||
function loadProvinceMap(
|
||
startYear,
|
||
startWeek,
|
||
endYear,
|
||
endWeek
|
||
) {
|
||
|
||
if (map) map.remove();
|
||
|
||
subtypeLayers = {};
|
||
hiddenSubtypes = new Set();
|
||
|
||
map = L.map('provinceMap')
|
||
.setView([12.7, 104.9], 7);
|
||
|
||
|
||
|
||
L.tileLayer(
|
||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||
{
|
||
attribution: '© OpenStreetMap'
|
||
}
|
||
).addTo(map);
|
||
|
||
Promise.all([
|
||
|
||
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]) => {
|
||
|
||
window._provinceData = data;
|
||
|
||
updateAlerts();
|
||
|
||
const validProvinces = new Set(
|
||
geojson.features.map(
|
||
f => f.properties.ADM1_EN
|
||
)
|
||
);
|
||
|
||
Object.keys(SUBTYPE_COLORS)
|
||
.forEach(subtype => {
|
||
|
||
subtypeLayers[subtype] =
|
||
L.layerGroup().addTo(map);
|
||
|
||
});
|
||
|
||
|
||
L.geoJSON(geojson, {
|
||
|
||
style: {
|
||
fillOpacity: 0.05,
|
||
fillColor: '#2563eb',
|
||
color: '#bbb',
|
||
weight: 1
|
||
},
|
||
|
||
onEachFeature: function (
|
||
feature,
|
||
layer
|
||
) {
|
||
|
||
const province =
|
||
feature.properties.ADM1_EN;
|
||
|
||
const center =
|
||
layer.getBounds().getCenter();
|
||
|
||
const rows = data.filter(d => {
|
||
|
||
const name =
|
||
normalizeProvince(
|
||
d.patient_province,
|
||
validProvinces
|
||
);
|
||
|
||
return name === province;
|
||
|
||
});
|
||
|
||
if (!rows.length) return;
|
||
const tooltipHTML = `
|
||
<div style="min-width:170px">
|
||
|
||
<div style="
|
||
font-weight:600;
|
||
margin-bottom:6px;
|
||
font-size:13px;
|
||
">
|
||
${province}
|
||
</div>
|
||
|
||
${rows.map(r => `
|
||
|
||
<div style="
|
||
display:flex;
|
||
justify-content:space-between;
|
||
gap:12px;
|
||
margin-bottom:2px;
|
||
">
|
||
|
||
<span>
|
||
${r.pathogen_name}
|
||
</span>
|
||
|
||
<span style="font-weight:600">
|
||
${r.total}
|
||
</span>
|
||
|
||
</div>
|
||
|
||
`).join('')}
|
||
|
||
</div>
|
||
`;
|
||
layer.bindTooltip(
|
||
tooltipHTML,
|
||
{
|
||
sticky: true,
|
||
direction: 'top'
|
||
}
|
||
);
|
||
layer.on({
|
||
|
||
mouseover: function (e) {
|
||
|
||
e.target.setStyle({
|
||
fillOpacity: 0.15,
|
||
weight: 2,
|
||
color: '#666'
|
||
});
|
||
|
||
},
|
||
|
||
mouseout: function (e) {
|
||
|
||
e.target.setStyle({
|
||
fillOpacity: 0.05,
|
||
weight: 1,
|
||
color: '#bbb'
|
||
});
|
||
|
||
}
|
||
|
||
});
|
||
const pathogens = [
|
||
...new Set(
|
||
rows.map(
|
||
r => r.pathogen_name
|
||
)
|
||
)
|
||
];
|
||
|
||
const spacing = 0.12;
|
||
|
||
rows.forEach(row => {
|
||
|
||
const index =
|
||
pathogens.indexOf(row.pathogen_name);
|
||
|
||
const cols = 3;
|
||
|
||
const col = index % cols;
|
||
const rowIndex = Math.floor(index / cols);
|
||
|
||
const xOffset =
|
||
(col - (cols - 1) / 2) * spacing;
|
||
|
||
const yOffset =
|
||
rowIndex * spacing;
|
||
|
||
const lat =
|
||
center.lat + yOffset;
|
||
|
||
const lng =
|
||
center.lng + xOffset;
|
||
|
||
const marker = L.circleMarker(
|
||
[lat, lng],
|
||
{
|
||
radius: getRadius(row.total),
|
||
fillColor: getColorByPathogen(
|
||
row.pathogen_name
|
||
),
|
||
fillOpacity: 0.85,
|
||
stroke: false
|
||
}
|
||
);
|
||
|
||
if (subtypeLayers[row.pathogen_name]) {
|
||
marker.addTo(
|
||
subtypeLayers[
|
||
row.pathogen_name
|
||
]
|
||
);
|
||
|
||
}
|
||
|
||
});
|
||
|
||
}
|
||
|
||
}).addTo(map);
|
||
addPositivityLegend();
|
||
|
||
});
|
||
|
||
}
|
||
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
|
||
loadSummary();
|
||
|
||
new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
|
||
|
||
loadTrend('week', startYear, startWeek, endYear, endWeek);
|
||
loadInfluenzaSubtypeDistribution('week', startYear, startWeek, endYear, endWeek);
|
||
loadCovidDistributedByAgeGroup('week', startYear, startWeek, endYear, endWeek);
|
||
loadInfluenzaSubtypeFrequency('week', startYear, startWeek, endYear, endWeek);
|
||
loadCovidLineageFrequency('week', startYear, startWeek, endYear, endWeek);
|
||
loadProvinceMap(startYear, startWeek, endYear, endWeek);
|
||
|
||
const elements = document.querySelectorAll(".report-period");
|
||
|
||
elements.forEach(el => {
|
||
el.textContent =
|
||
'Week ' + startWeek + ' of ' + startYear +
|
||
' to Week ' + endWeek + ' of ' + endYear;
|
||
});
|
||
|
||
});
|
||
|
||
|
||
let currentSlide = 0;
|
||
|
||
const slides = document.querySelectorAll('.slide');
|
||
const nextBtn = document.querySelector('.next-btn');
|
||
const prevBtn = document.querySelector('.prev-btn');
|
||
|
||
function showSlide(index) {
|
||
|
||
slides.forEach((slide, i) => {
|
||
|
||
slide.classList.remove('active', 'prev');
|
||
|
||
if (i === index) {
|
||
|
||
slide.classList.add('active');
|
||
|
||
setTimeout(() => {
|
||
|
||
if (typeof map !== 'undefined' && map) {
|
||
map.invalidateSize();
|
||
}
|
||
|
||
}, 300);
|
||
|
||
} else if (i === index - 1) {
|
||
|
||
slide.classList.add('prev');
|
||
|
||
}
|
||
|
||
});
|
||
|
||
}
|
||
|
||
function nextSlide() {
|
||
|
||
currentSlide = (currentSlide + 1) % slides.length;
|
||
showSlide(currentSlide);
|
||
|
||
}
|
||
|
||
function prevSlide() {
|
||
|
||
currentSlide =
|
||
(currentSlide - 1 + slides.length) % slides.length;
|
||
|
||
showSlide(currentSlide);
|
||
|
||
}
|
||
|
||
if (nextBtn) {
|
||
nextBtn.addEventListener('click', nextSlide);
|
||
}
|
||
|
||
if (prevBtn) {
|
||
prevBtn.addEventListener('click', prevSlide);
|
||
}
|
||
|
||
const slideInterval = 2 * (15 * 1000);
|
||
|
||
setInterval(nextSlide, slideInterval);
|
||
|
||
showSlide(currentSlide);
|
||
|
||
});
|