Files

1531 lines
45 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, 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);
});