This commit is contained in:
2026-07-01 14:48:45 +07:00
parent d201b53442
commit 5d2c055912
12 changed files with 412 additions and 497 deletions

View File

@@ -234,27 +234,6 @@ class DashboardController extends Controller
} }
/*
|--------------------------------------------------------------------------
| Sentinel Map
|--------------------------------------------------------------------------
*/
// public function sentinelMap(Request $request)
// {
// $range = $this->getEpiRange($request);
// if (!$range) {
// return response()->json(['error' => 'Missing epiweek range'], 400);
// }
// return response()->json($data);
// }
public function fetchSourceData() public function fetchSourceData()
{ {
try { try {

View File

@@ -28,9 +28,6 @@ class DashboardService
public function summaryCards() public function summaryCards()
{ {
$programs = Surveillance::orderBy('id')->get();
$results = [];
$today = date('Y-m-d'); $today = date('Y-m-d');
$currentFrom = date('Y-m-d', strtotime('-6 days')); $currentFrom = date('Y-m-d', strtotime('-6 days'));
@@ -39,15 +36,23 @@ class DashboardService
$prevFrom = date('Y-m-d', strtotime('-13 days')); $prevFrom = date('Y-m-d', strtotime('-13 days'));
$prevTo = date('Y-m-d', strtotime('-7 days')); $prevTo = date('Y-m-d', strtotime('-7 days'));
$results = [];
$programs = Surveillance::where('id', '!=', 6)
->orderBy('id')
->get();
foreach ($programs as $program) { foreach ($programs as $program) {
$current = SurveillanceCase::where('surveillance_id', $program->id) $current = SurveillanceCase::where('surveillance_id', $program->id)
->whereBetween('case_date', [$currentFrom, $currentTo]) ->whereBetween('case_date', [$currentFrom, $currentTo])
->count(); ->distinct('lab_code')
->count('lab_code');
$previous = SurveillanceCase::where('surveillance_id', $program->id) $previous = SurveillanceCase::where('surveillance_id', $program->id)
->whereBetween('case_date', [$prevFrom, $prevTo]) ->whereBetween('case_date', [$prevFrom, $prevTo])
->count(); ->distinct('lab_code')
->count('lab_code');
$percentChange = $previous > 0 $percentChange = $previous > 0
? round((($current - $previous) / $previous) * 100, 1) ? round((($current - $previous) / $previous) * 100, 1)
@@ -62,6 +67,44 @@ class DashboardService
]; ];
} }
/*
|--------------------------------------------------------------------------
| A/H5N1 Summary Card
|--------------------------------------------------------------------------
*/
$h5n1Current = SurveillanceCase::join('case_lab_results', function ($join) {
$join->on('surveillance_cases.lab_code', '=', 'case_lab_results.lab_code')
->on('surveillance_cases.surveillance_id', '=', 'case_lab_results.surveillance_id');
})
->whereBetween('surveillance_cases.case_date', [$currentFrom, $currentTo])
->where('case_lab_results.subtype', 'A/H5N1')
->where('surveillance_cases.surveillance_id', '!=', 6)
->distinct('surveillance_cases.lab_code')
->count('surveillance_cases.lab_code');
$h5n1Previous = SurveillanceCase::join('case_lab_results', function ($join) {
$join->on('surveillance_cases.lab_code', '=', 'case_lab_results.lab_code')
->on('surveillance_cases.surveillance_id', '=', 'case_lab_results.surveillance_id');
})
->whereBetween('surveillance_cases.case_date', [$prevFrom, $prevTo])
->where('case_lab_results.subtype', 'A/H5N1')
->where('surveillance_cases.surveillance_id', '!=', 6)
->distinct('surveillance_cases.lab_code')
->count('surveillance_cases.lab_code');
$h5n1Percent = $h5n1Previous > 0
? round((($h5n1Current - $h5n1Previous) / $h5n1Previous) * 100, 1)
: ($h5n1Current > 0 ? 100 : 0);
$results[] = [
'surveillance_id' => null,
'code' => 'H5N1',
'current_total' => $h5n1Current,
'previous_total' => $h5n1Previous,
'percent_change' => $h5n1Percent
];
return $results; return $results;
} }

View File

@@ -13,7 +13,7 @@ return [
| |
*/ */
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'NRML Dashboard'),
'lookback_days' => [ 'lookback_days' => [
'SARI' => 30, 'SARI' => 30,
@@ -61,7 +61,7 @@ return [
| |
*/ */
'url' => env('APP_URL', 'http://localhost'), 'url' => env('APP_URL', 'http://localhost:8000'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -1,9 +1,16 @@
import * as Charts from "./globals.js";
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
document
.getElementById('chartSelectorBtn')
?.addEventListener('click', openChartSelector);
const toggle = document.getElementById("exportToggle"); const toggle = document.getElementById("exportToggle");
const items = document.getElementById("exportItems"); const items = document.getElementById("exportItems");
const close = document.getElementById("exportClose"); const close = document.getElementById("exportClose");
if (!toggle || !items) return; if (!toggle || !items) return;
toggle.addEventListener("click", () => { toggle.addEventListener("click", () => {
@@ -20,12 +27,31 @@ document.addEventListener("DOMContentLoaded", () => {
toggle.innerText = "Export ▸"; toggle.innerText = "Export ▸";
}); });
} }
document
.getElementById('btnCharts')
?.addEventListener('click', openChartSelector);
document
.getElementById('btnExport')
?.addEventListener('click', exportFullDashboard);
document
.querySelector('#chartCancel')
?.addEventListener('click', closeChartSelector);
document
.querySelector('#chartDownload')
?.addEventListener('click', exportSelectedCharts);
}); });
function openChartSelector() { function openChartSelector() {
if (typeof charts === "undefined" || Object.keys(charts).length === 0) { if (typeof Charts.charts === "undefined" || Object.keys(Charts.charts).length === 0) {
alert("Charts are still loading. Please try again."); alert("Charts are still loading. Please try again.");
return; return;
} }
@@ -35,7 +61,7 @@ function openChartSelector() {
list.innerHTML = ""; list.innerHTML = "";
Object.keys(charts).forEach(id => { Object.keys(Charts.charts).forEach(id => {
list.innerHTML += ` list.innerHTML += `
<label style="display:block;margin-bottom:6px;"> <label style="display:block;margin-bottom:6px;">
<input type="checkbox" value="${id}" checked> <input type="checkbox" value="${id}" checked>
@@ -99,7 +125,7 @@ async function exportSelectedCharts() {
left: "50%", left: "50%",
transform: "translate(-50%, -50%)", transform: "translate(-50%, -50%)",
background: "rgb(255, 255, 255)", background: "rgb(255, 255, 255)",
color: "#fff", color: "#000000",
padding: "20px", padding: "20px",
borderRadius: "10px", borderRadius: "10px",
zIndex: "10000" zIndex: "10000"
@@ -141,7 +167,7 @@ async function exportSelectedCharts() {
}); });
} }
} else { } else {
const chart = charts[cb.value]; const chart = Charts.charts[cb.value];
if (!chart) continue; if (!chart) continue;
items.push({ items.push({
@@ -185,7 +211,7 @@ async function exportSelectedCharts() {
if (item.type === "map") { if (item.type === "map") {
img = item.img; img = item.img;
width = cardWidth - 50; width = cardWidth - 60;
height = width * 0.65; height = width * 0.65;
} else { } else {
@@ -352,12 +378,7 @@ async function getMapImage() {
totals[province].positive += Number(r.positive); 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.map.eachLayer(layer => { window.map.eachLayer(layer => {
if (!layer.toGeoJSON) return; if (!layer.toGeoJSON) return;
@@ -478,9 +499,7 @@ async function getMapImage() {
ctx.fillStyle = item.fillColor; ctx.fillStyle = item.fillColor;
ctx.fill(); ctx.fill();
ctx.strokeStyle = item.strokeColor;
ctx.lineWidth = 2;
ctx.stroke();
return; return;
} }

View File

@@ -63,7 +63,7 @@ Chart.register({
}); });
Chart.register(ChartDataLabels); Chart.register(ChartDataLabels);
Chart.defaults.devicePixelRatio = 2; Chart.defaults.devicePixelRatio = 2;
const charts = {}; export const charts = {};
function buildStackedChart(canvasId, labels, data) { function buildStackedChart(canvasId, labels, data) {
@@ -147,7 +147,7 @@ function buildStackedChart(canvasId, labels, data) {
} }
}); });
} }
function buildChart(id, type, labels, data) { export function buildChart(id, type, labels, data) {
const ctx = document.getElementById(id); const ctx = document.getElementById(id);
if (!ctx) return; if (!ctx) return;
@@ -160,7 +160,7 @@ function buildChart(id, type, labels, data) {
labels = []; labels = [];
data = []; data = [];
} }
const isHorizontal = id === 'sexChart'; const isHorizontal = id === 'sexChart' || id === 'influenzaSubtypeDistribution';
const isAgeChart = id === 'ageChart'; const isAgeChart = id === 'ageChart';
const isSentinelChart = id === 'sentinelChart'; const isSentinelChart = id === 'sentinelChart';
const options = { const options = {
@@ -292,7 +292,7 @@ function buildChart(id, type, labels, data) {
}); });
charts[id].$totalTested = 0; charts[id].$totalTested = 0;
} }
function buildMixedTrendChart(canvasId, labels, samples, lines) { export function buildMixedTrendChart(canvasId, labels, samples, lines) {
const ctx = document.getElementById(canvasId); const ctx = document.getElementById(canvasId);
if (!ctx) return; if (!ctx) return;
@@ -346,3 +346,160 @@ function buildMixedTrendChart(canvasId, labels, samples, lines) {
} }
}); });
} }
export 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();
}
export function getSubtypeColor(label, index = 0) {
const specialColors = {
'A/H5N1': '#dc2626',
'Influenza': '#b90c00'
};
return specialColors[label]
|| COLORS[index % COLORS.length];
}
export const COLORS = [
'#ef4444', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#14b8a6', // teal
'#f97316', // orange
'#84cc16', // lime
'#e11dba', // fuchsia
'#f6f63b', // yellow
'#0ea5e9', // sky
'#22c55e', // green
'#a855f7', // purple
'#ec4899', // pink
'#06b6d4', // cyan
'#65a30d', // olive
'#dc2626', // dark red
'#1d4ed8', // strong blue
'#7c3aed', // deep violet
'#059669', // dark emerald
'#c2410c', // burnt orange
'#be123c', // rose
'#4338ca', // indigo
'#0f766e', // dark teal
'#9333ea', // bright purple
'#15803d', // forest green
'#ea580c', // deep orange
'#0284c7', // ocean blue
'#ca8a04', // mustard
'#db2777' // magenta
];
export const SUBTYPE_COLORS = {
'A/H1N1pdm': '#f0d401',
'A/H3N2': '#00ffff',
'A/H9N2': '#2563eb',
'A/H5N1': '#dc2626',
'A/Unsubtypable': '#f455d7',
'B/Yam': '#9333ea',
'B/Vic': '#086037',
'B/Unsubtypable': '#66ff00',
'B/Victoria': '#9333ea',
'H1N1pdm': '#f0d401',
'H3N2': '#00ffff',
'H9N2': '#2563eb',
'J.2.4': '#8c6060',
'K': '#55f49a',
};
export const SURVEILLANCE_COLORS = {
'LBM': '#f0d401',
'ILI': '#2563eb',
'SARI': '#dc2626',
'NDS': '#9333ea',
'AFI': '#086037',
'SEQ': '#66ff00'
};
export function buildPeriodLabels(data, startYear, endYear) {
const yearlyView = (endYear - startYear) >= 5;
if (yearlyView) {
const labels = [...new Set(
data.flatMap(program =>
program.map(row => row.year)
)
)].sort((a, b) => a - b);
return {
yearlyView,
labels
};
}
const labelsSet = new Set();
data.forEach(program => {
program.forEach(row => {
labelsSet.add(`${row.year}-${row.period}`);
});
});
return {
yearlyView,
labels: [...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;
})
};
}
export function buildPeriods(data, field, startYear, endYear) {
const yearlyView = (endYear - startYear) >= 5;
const periods = yearlyView
? [...new Set(
data.map(row => row[field].split('-')[0])
)].sort((a, b) => a - b)
: [...new Set(
data.map(row => row[field])
)].sort();
return {
yearlyView,
periods
};
}

View File

@@ -1,59 +0,0 @@
export const COLORS = [
'#ef4444', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#14b8a6', // teal
'#f97316', // orange
'#84cc16', // lime
'#e11dba', // fuchsia
'#f6f63b', // yellow
'#0ea5e9', // sky
'#22c55e', // green
'#a855f7', // purple
'#ec4899', // pink
'#06b6d4', // cyan
'#65a30d', // olive
'#dc2626', // dark red
'#1d4ed8', // strong blue
'#7c3aed', // deep violet
'#059669', // dark emerald
'#c2410c', // burnt orange
'#be123c', // rose
'#4338ca', // indigo
'#0f766e', // dark teal
'#9333ea', // bright purple
'#15803d', // forest green
'#ea580c', // deep orange
'#0284c7', // ocean blue
'#ca8a04', // mustard
'#db2777' // magenta
];
export const SUBTYPE_COLORS = {
'A/H1N1pdm': '#f0d401',
'A/H3N2': '#00ffff',
'A/H9N2': '#2563eb',
'A/H5N1': '#dc2626',
'A/Unsubtypable': '#f455d7',
'B/Yam': '#9333ea',
'B/Vic': '#086037',
'B/Unsubtypable': '#66ff00',
'B/Victoria': '#9333ea',
'H1N1pdm': '#f0d401',
'H3N2': '#00ffff',
'H9N2': '#2563eb',
'J.2.4': '#8c6060',
'K': '#55f49a',
};
export const SURVEILLANCE_COLORS = {
'LBM': '#f0d401',
'ILI': '#2563eb',
'SARI': '#dc2626',
'NDS': '#9333ea',
'AFI': '#086037',
'SEQ': '#66ff00'
};

View File

@@ -1,7 +1,5 @@
import { COLORS, SUBTYPE_COLORS, SURVEILLANCE_COLORS } from "./globals.js"; import * as Charts from "./dashboard/globals.js";
let trendChart; let trendChart;
let influenzaSubtypeChart;
let covidDistributedByAgeChart; let covidDistributedByAgeChart;
let covidLineageFrequencyChart; let covidLineageFrequencyChart;
let influenzaSubtypeFrequencyChart; let influenzaSubtypeFrequencyChart;
@@ -31,6 +29,8 @@ function loadSummary() {
if (item.percent_change > 0) trendColor = 'text-danger'; if (item.percent_change > 0) trendColor = 'text-danger';
if (item.percent_change < 0) trendColor = 'text-success'; if (item.percent_change < 0) trendColor = 'text-success';
html += ` html += `
<div class="col-md-2 mb-3"> <div class="col-md-2 mb-3">
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
@@ -39,7 +39,11 @@ function loadSummary() {
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<h6 class="fw-bold">${item.code} Cases</h6> <h6 class="fw-bold">
${item.code === 'H5N1'
? 'A/H5N1 Cases'
: `${item.code} Cases`}
</h6>
<h3 class="mb-1">${item.current_total}</h3> <h3 class="mb-1">${item.current_total}</h3>
<small class="text-muted">Last 7 days</small> <small class="text-muted">Last 7 days</small>
</div> </div>
@@ -61,9 +65,11 @@ function loadSummary() {
</div> </div>
</div> </div>
</div> </div>
`; `;
window._summaryData = data; window._summaryData = data;
updateAlerts(); updateAlerts();
@@ -89,58 +95,15 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
.then(data => { .then(data => {
if (trendChart) trendChart.destroy(); if (trendChart) trendChart.destroy();
const totalYears = endYear - startYear;
const useYearlyView = totalYears >= 5;
// const labelsSet = new Set(); const {
yearlyView: useYearlyView,
// Object.values(data).forEach(program => { labels
// program.forEach(row => { } = Charts.buildPeriodLabels(
// labelsSet.add(`${row.year}-${row.period}`); Object.values(data),
// }); startYear,
// }); endYear
);
// 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 datasets = [];
@@ -151,17 +114,14 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
if (!allowedPrograms.includes(code)) return; 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 => { const values = labels.map(label => {
if (useYearlyView) { if (useYearlyView) {
return data[code] return data[code]
.filter(row => row.year == label) .filter(row => String(row.year) === String(label))
.reduce((sum, row) => sum + row.total, 0); .reduce((sum, row) => sum + Number(row.total || 0), 0);
} }
@@ -176,8 +136,8 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
datasets.push({ datasets.push({
label: code, label: code,
data: values, data: values,
borderColor: SURVEILLANCE_COLORS[code], borderColor: Charts.SURVEILLANCE_COLORS[code],
backgroundColor: SURVEILLANCE_COLORS[code], backgroundColor: Charts.SURVEILLANCE_COLORS[code],
borderWidth: 3, borderWidth: 3,
pointRadius: 4, pointRadius: 4,
maxBarThickness: 50, maxBarThickness: 50,
@@ -201,6 +161,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
return `${year}-W${String(week).padStart(2, '0')}`; return `${year}-W${String(week).padStart(2, '0')}`;
}); });
trendChart = new Chart(document.getElementById('trendChart'), { trendChart = new Chart(document.getElementById('trendChart'), {
type: 'line', type: 'line',
@@ -244,7 +205,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
} }
} }
}); });
charts['trendChart'] = trendChart; Charts.charts['trendChart'] = trendChart;
}); });
} }
@@ -254,88 +215,19 @@ function loadInfluenzaSubtypeDistribution(periodType, startYear, startWeek, endY
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
let displayLabels = data.map(item => item.subtype); const displayData = data.map(item => ({
let dataset = data.map(item => item.total); ...item,
subtype: item.subtype === "B/VIC" ? "B/Vic" : item.subtype
}));
// const colors = displayLabels.map( Charts.buildDistributionChart(
// label => SUBTYPE_COLORS[label] || '#9ca3af' 'influenzaSubtypeDistribution',
// ); 'bar',
displayData,
if (influenzaSubtypeChart) influenzaSubtypeChart.destroy(); 'subtype',
influenzaSubtypeChart = new Chart(document.getElementById('influenzaSubtypeDistribution'), { 'total',
type: 'bar', label => Charts.SUBTYPE_COLORS[label] || '#9ca3af'
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;
}); });
} }
@@ -344,83 +236,7 @@ function loadCovidDistributedByAgeGroup(periodType, startYear, startWeek, endYea
fetch(`/api/dashboard/covid-distributed-by-age-group?period_type=${periodType}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${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(res => res.json())
.then(data => { .then(data => {
Charts.buildDistributionChart('covidDistributedByAgeGroup', 'bar', data, 'age_group');
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;
}); });
} }
@@ -430,36 +246,23 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
// const weeks = [...new Set(data.map(item => item.week))].sort(); const {
const totalYears = endYear - startYear; yearlyView: useYearlyView,
const useYearlyView = totalYears >= 5; periods
} = Charts.buildPeriods(
data,
'week',
startYear,
endYear
);
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 lineages = [...new Set(data.map(item => item.lineage))];
const datasets = lineages.map((lineage, index) => { 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 => { const lineageData = periods.map(period => {
@@ -492,7 +295,7 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
// borderColor: 'transparent', // borderColor: 'transparent',
borderWidth: 0, borderWidth: 0,
pointRadius: 0, pointRadius: 0,
backgroundColor: hexToRGBA(colors[index % colors.length], 0.3), backgroundColor: hexToRGBA(Charts.COLORS[index % Charts.COLORS.length], 0.3),
stack: 'total' stack: 'total'
}; };
}); });
@@ -545,11 +348,8 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
} }
} }
}); });
charts['covidLineageFrequency'] = covidLineageFrequencyChart; Charts.charts['covidLineageFrequency'] = covidLineageFrequencyChart;
// -------------------------
// Custom right-side scrollable legend
// -------------------------
const legendContainer = document.getElementById('legendContainer'); const legendContainer = document.getElementById('legendContainer');
legendContainer.innerHTML = ''; legendContainer.innerHTML = '';
datasets.forEach((dataset, index) => { datasets.forEach((dataset, index) => {
@@ -602,31 +402,22 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
const totalYears = endYear - startYear; const {
const useYearlyView = totalYears >= 5; yearlyView: useYearlyView,
periods
let periods; } = Charts.buildPeriods(
data,
if (useYearlyView) { 'week',
startYear,
periods = [...new Set( endYear
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( const lineages = [...new Set(
data.map(item => item.lineage) data.map(item => item.lineage === 'B/VIC' ? 'B/Vic' : item.lineage)
)]; )];
const lineageColors = lineages.map( const lineageColors = lineages.map(
label => SUBTYPE_COLORS[label] || '#9ca3af' label => Charts.SUBTYPE_COLORS[label] || '#9ca3af'
); );
const datasets = lineages.map((lineage, index) => { const datasets = lineages.map((lineage, index) => {
@@ -747,7 +538,7 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
}); });
charts['influenzaSubtypeFrequency'] = Charts.charts['influenzaSubtypeFrequency'] =
influenzaSubtypeFrequencyChart; influenzaSubtypeFrequencyChart;
/* /*
@@ -857,9 +648,9 @@ function hexToRGBA(hex, alpha) {
} }
function updateAlerts() { function updateAlerts() {
if (!window._summaryData || !window._provinceData) return; if (!window._summaryData || !window.latestProvinceData) return;
const raw = buildAlerts(window._summaryData, window._provinceData); const raw = buildAlerts(window._summaryData, window.latestProvinceData);
const finalAlerts = processAlerts(raw); const finalAlerts = processAlerts(raw);
renderAlerts(finalAlerts); renderAlerts(finalAlerts);
@@ -1088,6 +879,7 @@ function normalizeProvince(name, validSet) {
return match || null; return match || null;
} }
window.normalizeProvince = normalizeProvince;
function getRadius(total) { function getRadius(total) {
if (!total) return 0; if (!total) return 0;
const r = Math.sqrt(total); const r = Math.sqrt(total);
@@ -1095,10 +887,10 @@ function getRadius(total) {
} }
function getPositivityColor(subtype) { function getPositivityColor(subtype) {
return SUBTYPE_COLORS[subtype] || "#9ca3af"; return Charts.SUBTYPE_COLORS[subtype] || "#9ca3af";
} }
const getColorByPathogen = name => const getColorByPathogen = name =>
SUBTYPE_COLORS[name] || '#9ca3af'; Charts.SUBTYPE_COLORS[name] || '#9ca3af';
function addPositivityLegend() { function addPositivityLegend() {
@@ -1157,7 +949,7 @@ function addPositivityLegend() {
> >
<span style=" <span style="
background:${SUBTYPE_COLORS[item]}; background:${Charts.SUBTYPE_COLORS[item]};
width:10px; width:10px;
height:10px; height:10px;
display:inline-block; display:inline-block;
@@ -1237,7 +1029,7 @@ function loadProvinceMap(
map = L.map('provinceMap') map = L.map('provinceMap')
.setView([12.7, 104.9], 7); .setView([12.7, 104.9], 7);
window.map = map;
L.tileLayer( L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
@@ -1258,7 +1050,7 @@ function loadProvinceMap(
]) ])
.then(([geojson, data]) => { .then(([geojson, data]) => {
window._provinceData = data; window.latestProvinceData = data;
updateAlerts(); updateAlerts();
@@ -1268,7 +1060,7 @@ function loadProvinceMap(
) )
); );
Object.keys(SUBTYPE_COLORS) Object.keys(Charts.SUBTYPE_COLORS)
.forEach(subtype => { .forEach(subtype => {
subtypeLayers[subtype] = subtypeLayers[subtype] =
@@ -1331,7 +1123,7 @@ function loadProvinceMap(
"> ">
<span> <span>
${r.pathogen_name} ${r.pathogen_name === 'B/VIC' ? 'B/Vic' : r.pathogen_name}
</span> </span>
<span style="font-weight:600"> <span style="font-weight:600">
@@ -1411,17 +1203,17 @@ function loadProvinceMap(
{ {
radius: getRadius(row.total), radius: getRadius(row.total),
fillColor: getColorByPathogen( fillColor: getColorByPathogen(
row.pathogen_name row.pathogen_name === 'B/VIC' ? 'B/Vic' : row.pathogen_name
), ),
fillOpacity: 0.85, fillOpacity: 0.85,
stroke: false stroke: false
} }
); );
if (subtypeLayers[row.pathogen_name]) { if (subtypeLayers[row.pathogen_name === 'B/VIC' ? 'B/Vic' : row.pathogen_name]) {
marker.addTo( marker.addTo(
subtypeLayers[ subtypeLayers[
row.pathogen_name row.pathogen_name === 'B/VIC' ? 'B/Vic' : row.pathogen_name
] ]
); );

View File

@@ -1,4 +1,4 @@
import { COLORS, SUBTYPE_COLORS } from "./globals.js"; import * as Charts from "./dashboard/globals.js";
const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS']; const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI', 'NDS'];
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase(); const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
@@ -31,34 +31,7 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
}); });
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 = '') { function renderTrend(valueId, changeId, current, previous, suffix = '') {
const valueEl = document.getElementById(valueId); const valueEl = document.getElementById(valueId);
@@ -90,16 +63,7 @@ function renderTrend(valueId, changeId, current, previous, suffix = '') {
changeEl.className = "text-muted"; 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 = {}) { function renderSummary(summary = {}) {
const mappings = [ const mappings = [
@@ -127,41 +91,41 @@ function renderDashboard(data = {}) {
renderSummary(data.summary); renderSummary(data.summary);
renderProvinceHeatmap(data.province_distribution || []); renderProvinceHeatmap(data.province_distribution || []);
buildDistributionChart( Charts.buildDistributionChart(
'pathogenChart', 'pathogenChart',
'doughnut', 'doughnut',
(data.pathogen_distribution || []) (data.pathogen_distribution || [])
.sort((a, b) => b.total - a.total), .sort((a, b) => b.total - a.total),
'pathogen', 'pathogen',
'total', 'total',
getSubtypeColor Charts.getSubtypeColor
); );
buildDistributionChart( Charts.buildDistributionChart(
'ageChart', 'ageChart',
'doughnut', 'doughnut',
data.age_distribution || [], data.age_distribution || [],
'age_group' 'age_group'
); );
buildDistributionChart( Charts.buildDistributionChart(
'sexChart', 'sexChart',
'bar', 'bar',
data.sex_distribution || [], data.sex_distribution || [],
'patient_sex' 'patient_sex'
); );
buildDistributionChart( Charts.buildDistributionChart(
'subtypeChart', 'subtypeChart',
'bar', 'bar',
data.subtype_distribution || [], data.subtype_distribution || [],
'subtype', 'subtype',
'total', 'total',
getSubtypeColor Charts.getSubtypeColor
); );
buildDistributionChart( Charts.buildDistributionChart(
'sentinelChart', 'sentinelChart',
'pie', 'pie',
data.sentinel_sites || [], data.sentinel_sites || [],
@@ -178,21 +142,21 @@ function renderAFIDashboard(data = {}) {
renderAFITrend( renderAFITrend(
data.afi_case_trend.section_1, data.afi_case_trend.section_1,
'afiSection1Trend', 'afiSection1Trend',
COLORS, Chart.COLORS,
'trend' 'trend'
); );
renderAFITrend( renderAFITrend(
data.afi_case_trend.section_2, data.afi_case_trend.section_2,
'afiSection2Trend', 'afiSection2Trend',
COLORS, Chart.COLORS,
'trend' 'trend'
); );
renderAFITrend( renderAFITrend(
data.afi_case_trend.section_3, data.afi_case_trend.section_3,
'afiSection3Trend', 'afiSection3Trend',
COLORS, Chart.COLORS,
'trend' 'trend'
); );
@@ -200,21 +164,21 @@ function renderAFIDashboard(data = {}) {
renderAFITrend( renderAFITrend(
data.afi_case_trend.section_1, data.afi_case_trend.section_1,
'afiPcrChart', 'afiPcrChart',
COLORS, Chart.COLORS,
'donut' 'donut'
); );
renderAFITrend( renderAFITrend(
data.afi_case_trend.section_2, data.afi_case_trend.section_2,
'afiMultiplexChart', 'afiMultiplexChart',
COLORS, Chart.COLORS,
'donut' 'donut'
); );
renderAFITrend( renderAFITrend(
data.afi_case_trend.section_3, data.afi_case_trend.section_3,
'afiElisaChart', 'afiElisaChart',
COLORS, Chart.COLORS,
'donut' 'donut'
); );
@@ -466,7 +430,7 @@ function renderProgramTrend(rows = []) {
if (!rows.length) { if (!rows.length) {
buildMixedTrendChart( Charts.buildMixedTrendChart(
'trendChart', 'trendChart',
[], [],
[], [],
@@ -720,7 +684,7 @@ function renderProgramTrend(rows = []) {
} }
buildMixedTrendChart( Charts.buildMixedTrendChart(
'trendChart', 'trendChart',
labels, labels,
samples, samples,
@@ -758,7 +722,7 @@ function renderAFITrend(
if (type === 'donut') { if (type === 'donut') {
buildDistributionChart( Charts.buildDistributionChart(
canvasId, canvasId,
'doughnut', 'doughnut',
[], [],
@@ -768,7 +732,7 @@ function renderAFITrend(
} else { } else {
buildMixedTrendChart( Charts.buildMixedTrendChart(
canvasId, canvasId,
[], [],
[], [],
@@ -862,7 +826,7 @@ function renderAFITrend(
total total
})); }));
buildDistributionChart( Charts.buildDistributionChart(
canvasId, canvasId,
'doughnut', 'doughnut',
donutRows, donutRows,
@@ -886,12 +850,12 @@ function renderAFITrend(
0 0
); );
if (charts[canvasId]) { if (Charts.charts[canvasId]) {
charts[canvasId].$afiTotalCases = Charts.charts[canvasId].$afiTotalCases =
donutTotal; donutTotal;
charts[canvasId].update(); Charts.charts[canvasId].update();
} }
@@ -941,8 +905,8 @@ function renderAFITrend(
}), }),
color: color:
COLORS[ Charts.COLORS[
i % COLORS.length i % Charts.COLORS.length
] ]
}) })
@@ -954,7 +918,7 @@ function renderAFITrend(
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
buildMixedTrendChart( Charts.buildMixedTrendChart(
canvasId, canvasId,
labels, labels,
totalCases, totalCases,
@@ -965,7 +929,7 @@ function renderAFITrend(
function renderPathogenChart(rows = []) { function renderPathogenChart(rows = []) {
buildDistributionChart( Charts.buildDistributionChart(
'pathogenChart', 'pathogenChart',
'doughnut', 'doughnut',
rows, rows,
@@ -973,7 +937,7 @@ function renderPathogenChart(rows = []) {
); );
} }
function renderSentinel(rows = []) { function renderSentinel(rows = []) {
buildDistributionChart( Charts.buildDistributionChart(
'sentinelChart', 'sentinelChart',
'pie', 'pie',
rows, rows,
@@ -982,27 +946,26 @@ function renderSentinel(rows = []) {
} }
function renderSubtypeChart(rows = []) { function renderSubtypeChart(rows = []) {
console.log('renderSubtypeChart');
buildDistributionChart( Charts.buildDistributionChart(
'subtypeChart', 'subtypeChart',
'bar', 'bar',
rows, rows,
'subtype', 'subtype',
'total', 'total',
getSubtypeColor Charts.getSubtypeColor
); );
} }
function renderDemographics(data = {}) { function renderDemographics(data = {}) {
buildDistributionChart( Charts.buildDistributionChart(
'ageChart', 'ageChart',
'doughnut', 'doughnut',
data.age_distribution || [], data.age_distribution || [],
'age_group' 'age_group'
); );
buildDistributionChart( Charts.buildDistributionChart(
'sexChart', 'sexChart',
'bar', 'bar',
data.sex_distribution || [], data.sex_distribution || [],

View File

@@ -1,4 +1,4 @@
import { COLORS, SUBTYPE_COLORS } from "./globals.js"; import * as Charts from "./dashboard/globals.js";
let sequencingTotalChart; let sequencingTotalChart;
let covidLineageFrequencyChart; let covidLineageFrequencyChart;
@@ -70,7 +70,7 @@ function renderSequencingTotalChart(rows) {
} }
}); });
charts['sequencingTotalChart'] = sequencingTotalChart; Charts.charts['sequencingTotalChart'] = sequencingTotalChart;
} }
@@ -150,7 +150,7 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
// borderColor: 'transparent', // borderColor: 'transparent',
borderWidth: 0, borderWidth: 0,
pointRadius: 0, pointRadius: 0,
backgroundColor: hexToRGBA(COLORS[index % COLORS.length], 0.3), backgroundColor: hexToRGBA(Charts.COLORS[index % Charts.COLORS.length], 0.3),
stack: 'total' stack: 'total'
}; };
}); });
@@ -203,7 +203,7 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
} }
} }
}); });
charts['covidLineageFrequency'] = covidLineageFrequencyChart; Charts.charts['covidLineageFrequency'] = covidLineageFrequencyChart;
// ------------------------- // -------------------------
// Custom right-side scrollable legend // Custom right-side scrollable legend
@@ -284,7 +284,7 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
)]; )];
const lineageColors = lineages.map( const lineageColors = lineages.map(
label => SUBTYPE_COLORS[label] || '#9ca3af' label => Charts.SUBTYPE_COLORS[label] || '#9ca3af'
); );
const datasets = lineages.map((lineage, index) => { const datasets = lineages.map((lineage, index) => {
@@ -405,7 +405,7 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
}); });
charts['influenzaSubtypeFrequency'] = Charts.charts['influenzaSubtypeFrequency'] =
influenzaSubtypeFrequencyChart; influenzaSubtypeFrequencyChart;
/* /*

View File

@@ -1,5 +1,5 @@
<x-guest-layout> <x-guest-layout>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <div class="mb-4 text-sm text-gray-600 dark:text-gray-400 p-4">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
</div> </div>

View File

@@ -60,14 +60,14 @@
</span> </span>
</label> </label>
@if (Route::has('password.request')) <!-- @if (Route::has('password.request'))
<a <a
href="{{ route('password.request') }}" href="{{ route('password.request') }}"
class="text-sm text-green-600 hover:text-green-700" class="text-sm text-green-600 hover:text-green-700"
> >
Forgot password? Forgot password?
</a> </a>
@endif @endif -->
</div> </div>

View File

@@ -17,8 +17,8 @@
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="/js/dashboard/filter.js"></script> <script src="/js/dashboard/filter.js"></script>
<script src="/js/dashboard/charts.js"></script> <script src="/js/dashboard/globals.js" type="module"></script>
<script src="/js/dashboard/export.js"></script> <script src="/js/dashboard/export.js" type="module"></script>
<style> <style>
@@ -278,8 +278,8 @@
</button> </button>
<div id="exportItems" class="align-items-center gap-2"> <div id="exportItems" class="align-items-center gap-2">
<button class="btn btn-sm btn-light" onclick="openChartSelector()">Charts</button> <button class="btn btn-sm btn-light" id="btnCharts">Charts</button>
<button class="btn btn-sm btn-light" onclick="exportFullDashboard()">Screen</button> <button class="btn btn-sm btn-light" id="btnExport">Screen</button>
<button class="btn btn-sm btn-light" onclick="window.print()">Print</button> <button class="btn btn-sm btn-light" onclick="window.print()">Print</button>
<button class="btn btn-sm btn-outline-secondary" id="exportClose"></button> <button class="btn btn-sm btn-outline-secondary" id="exportClose"></button>
</div> </div>
@@ -290,8 +290,8 @@
<div id="chartList"></div> <div id="chartList"></div>
<div class="mt-3 d-flex justify-content-end gap-2"> <div class="mt-3 d-flex justify-content-end gap-2">
<button onclick="closeChartSelector()">Cancel</button> <button id="chartCancel" class="btn btn-sm btn-secondary">Cancel</button>
<button onclick="exportSelectedCharts()">Download PDF</button> <button id="chartDownload" class="btn btn-sm btn-primary">Download PDF</button>
</div> </div>
</div> </div>
</div> </div>
@@ -328,7 +328,14 @@
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
updateLastUpdated();
const lastUpdated = localStorage.getItem('dashboardLastUpdated');
if (lastUpdated) {
document.getElementById('lastUpdated').innerHTML =
`Last update: ${lastUpdated}`;
}
}); });
window.addEventListener("click", (e) => { window.addEventListener("click", (e) => {
const modal = document.getElementById("chartModal"); const modal = document.getElementById("chartModal");
@@ -360,7 +367,21 @@
.then(res => res.json()) .then(res => res.json())
.then(() => { .then(() => {
updateLastUpdated(); const now = new Date();
const time = now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
const date = now.toISOString().split('T')[0];
const timestamp = `${time} | ${date}`;
localStorage.setItem(
'dashboardLastUpdated',
timestamp
);
location.reload(); location.reload();