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()
{
try {

View File

@@ -28,9 +28,6 @@ class DashboardService
public function summaryCards()
{
$programs = Surveillance::orderBy('id')->get();
$results = [];
$today = date('Y-m-d');
$currentFrom = date('Y-m-d', strtotime('-6 days'));
@@ -39,15 +36,23 @@ class DashboardService
$prevFrom = date('Y-m-d', strtotime('-13 days'));
$prevTo = date('Y-m-d', strtotime('-7 days'));
$results = [];
$programs = Surveillance::where('id', '!=', 6)
->orderBy('id')
->get();
foreach ($programs as $program) {
$current = SurveillanceCase::where('surveillance_id', $program->id)
->whereBetween('case_date', [$currentFrom, $currentTo])
->count();
->distinct('lab_code')
->count('lab_code');
$previous = SurveillanceCase::where('surveillance_id', $program->id)
->whereBetween('case_date', [$prevFrom, $prevTo])
->count();
->distinct('lab_code')
->count('lab_code');
$percentChange = $previous > 0
? 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;
}
@@ -101,8 +144,8 @@ class DashboardService
AND week_data >= ?
)
)",
[$startYear, $startYear, $startWeek]
)
[$startYear, $startYear, $startWeek]
)
->whereRaw(
"(year_data < ?

View File

@@ -13,7 +13,7 @@ return [
|
*/
'name' => env('APP_NAME', 'Laravel'),
'name' => env('APP_NAME', 'NRML Dashboard'),
'lookback_days' => [
'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
.getElementById('chartSelectorBtn')
?.addEventListener('click', openChartSelector);
const toggle = document.getElementById("exportToggle");
const items = document.getElementById("exportItems");
const close = document.getElementById("exportClose");
if (!toggle || !items) return;
toggle.addEventListener("click", () => {
@@ -20,12 +27,31 @@ document.addEventListener("DOMContentLoaded", () => {
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() {
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.");
return;
}
@@ -35,7 +61,7 @@ function openChartSelector() {
list.innerHTML = "";
Object.keys(charts).forEach(id => {
Object.keys(Charts.charts).forEach(id => {
list.innerHTML += `
<label style="display:block;margin-bottom:6px;">
<input type="checkbox" value="${id}" checked>
@@ -99,7 +125,7 @@ async function exportSelectedCharts() {
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgb(255, 255, 255)",
color: "#fff",
color: "#000000",
padding: "20px",
borderRadius: "10px",
zIndex: "10000"
@@ -141,7 +167,7 @@ async function exportSelectedCharts() {
});
}
} else {
const chart = charts[cb.value];
const chart = Charts.charts[cb.value];
if (!chart) continue;
items.push({
@@ -185,7 +211,7 @@ async function exportSelectedCharts() {
if (item.type === "map") {
img = item.img;
width = cardWidth - 50;
width = cardWidth - 60;
height = width * 0.65;
} else {
@@ -352,12 +378,7 @@ async function getMapImage() {
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 => {
if (!layer.toGeoJSON) return;
@@ -478,9 +499,7 @@ async function getMapImage() {
ctx.fillStyle = item.fillColor;
ctx.fill();
ctx.strokeStyle = item.strokeColor;
ctx.lineWidth = 2;
ctx.stroke();
return;
}

View File

@@ -63,7 +63,7 @@ Chart.register({
});
Chart.register(ChartDataLabels);
Chart.defaults.devicePixelRatio = 2;
const charts = {};
export const charts = {};
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);
if (!ctx) return;
@@ -160,7 +160,7 @@ function buildChart(id, type, labels, data) {
labels = [];
data = [];
}
const isHorizontal = id === 'sexChart';
const isHorizontal = id === 'sexChart' || id === 'influenzaSubtypeDistribution';
const isAgeChart = id === 'ageChart';
const isSentinelChart = id === 'sentinelChart';
const options = {
@@ -292,7 +292,7 @@ function buildChart(id, type, labels, data) {
});
charts[id].$totalTested = 0;
}
function buildMixedTrendChart(canvasId, labels, samples, lines) {
export function buildMixedTrendChart(canvasId, labels, samples, lines) {
const ctx = document.getElementById(canvasId);
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 influenzaSubtypeChart;
let covidDistributedByAgeChart;
let covidLineageFrequencyChart;
let influenzaSubtypeFrequencyChart;
@@ -31,38 +29,46 @@ function loadSummary() {
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="col-md-2 mb-3">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between">
<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>
<h6 class="fw-bold">
${item.code === 'H5N1'
? 'A/H5N1 Cases'
: `${item.code} Cases`}
</h6>
<h3 class="mb-1">${item.current_total}</h3>
<small class="text-muted">Last 7 days</small>
</div>
<small class="text-muted">
${item.previous_total ?? 0} last week
</small>
<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>
</div>
`;
`;
window._summaryData = data;
updateAlerts();
@@ -89,58 +95,15 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
.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 {
yearlyView: useYearlyView,
labels
} = Charts.buildPeriodLabels(
Object.values(data),
startYear,
endYear
);
const datasets = [];
@@ -151,17 +114,14 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
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);
.filter(row => String(row.year) === String(label))
.reduce((sum, row) => sum + Number(row.total || 0), 0);
}
@@ -176,8 +136,8 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
datasets.push({
label: code,
data: values,
borderColor: SURVEILLANCE_COLORS[code],
backgroundColor: SURVEILLANCE_COLORS[code],
borderColor: Charts.SURVEILLANCE_COLORS[code],
backgroundColor: Charts.SURVEILLANCE_COLORS[code],
borderWidth: 3,
pointRadius: 4,
maxBarThickness: 50,
@@ -201,6 +161,7 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
return `${year}-W${String(week).padStart(2, '0')}`;
});
trendChart = new Chart(document.getElementById('trendChart'), {
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(data => {
let displayLabels = data.map(item => item.subtype);
let dataset = data.map(item => item.total);
const displayData = data.map(item => ({
...item,
subtype: item.subtype === "B/VIC" ? "B/Vic" : item.subtype
}));
// 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;
Charts.buildDistributionChart(
'influenzaSubtypeDistribution',
'bar',
displayData,
'subtype',
'total',
label => Charts.SUBTYPE_COLORS[label] || '#9ca3af'
);
});
}
@@ -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}`)
.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;
Charts.buildDistributionChart('covidDistributedByAgeGroup', 'bar', data, 'age_group');
});
}
@@ -430,36 +246,23 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
.then(res => res.json())
.then(data => {
// const weeks = [...new Set(data.map(item => item.week))].sort();
const totalYears = endYear - startYear;
const useYearlyView = totalYears >= 5;
const {
yearlyView: useYearlyView,
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 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 => {
@@ -492,7 +295,7 @@ function loadCovidLineageFrequency(periodType, startYear, startWeek, endYear, en
// borderColor: 'transparent',
borderWidth: 0,
pointRadius: 0,
backgroundColor: hexToRGBA(colors[index % colors.length], 0.3),
backgroundColor: hexToRGBA(Charts.COLORS[index % Charts.COLORS.length], 0.3),
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');
legendContainer.innerHTML = '';
datasets.forEach((dataset, index) => {
@@ -602,31 +402,22 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
.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 {
yearlyView: useYearlyView,
periods
} = Charts.buildPeriods(
data,
'week',
startYear,
endYear
);
const lineages = [...new Set(
data.map(item => item.lineage)
data.map(item => item.lineage === 'B/VIC' ? 'B/Vic' : item.lineage)
)];
const lineageColors = lineages.map(
label => SUBTYPE_COLORS[label] || '#9ca3af'
label => Charts.SUBTYPE_COLORS[label] || '#9ca3af'
);
const datasets = lineages.map((lineage, index) => {
@@ -747,7 +538,7 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
});
charts['influenzaSubtypeFrequency'] =
Charts.charts['influenzaSubtypeFrequency'] =
influenzaSubtypeFrequencyChart;
/*
@@ -857,9 +648,9 @@ function hexToRGBA(hex, alpha) {
}
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);
renderAlerts(finalAlerts);
@@ -1088,6 +879,7 @@ function normalizeProvince(name, validSet) {
return match || null;
}
window.normalizeProvince = normalizeProvince;
function getRadius(total) {
if (!total) return 0;
const r = Math.sqrt(total);
@@ -1095,10 +887,10 @@ function getRadius(total) {
}
function getPositivityColor(subtype) {
return SUBTYPE_COLORS[subtype] || "#9ca3af";
return Charts.SUBTYPE_COLORS[subtype] || "#9ca3af";
}
const getColorByPathogen = name =>
SUBTYPE_COLORS[name] || '#9ca3af';
Charts.SUBTYPE_COLORS[name] || '#9ca3af';
function addPositivityLegend() {
@@ -1157,7 +949,7 @@ function addPositivityLegend() {
>
<span style="
background:${SUBTYPE_COLORS[item]};
background:${Charts.SUBTYPE_COLORS[item]};
width:10px;
height:10px;
display:inline-block;
@@ -1237,7 +1029,7 @@ function loadProvinceMap(
map = L.map('provinceMap')
.setView([12.7, 104.9], 7);
window.map = map;
L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
@@ -1258,7 +1050,7 @@ function loadProvinceMap(
])
.then(([geojson, data]) => {
window._provinceData = data;
window.latestProvinceData = data;
updateAlerts();
@@ -1268,7 +1060,7 @@ function loadProvinceMap(
)
);
Object.keys(SUBTYPE_COLORS)
Object.keys(Charts.SUBTYPE_COLORS)
.forEach(subtype => {
subtypeLayers[subtype] =
@@ -1331,7 +1123,7 @@ function loadProvinceMap(
">
<span>
${r.pathogen_name}
${r.pathogen_name === 'B/VIC' ? 'B/Vic' : r.pathogen_name}
</span>
<span style="font-weight:600">
@@ -1411,17 +1203,17 @@ function loadProvinceMap(
{
radius: getRadius(row.total),
fillColor: getColorByPathogen(
row.pathogen_name
row.pathogen_name === 'B/VIC' ? 'B/Vic' : row.pathogen_name
),
fillOpacity: 0.85,
stroke: false
}
);
if (subtypeLayers[row.pathogen_name]) {
if (subtypeLayers[row.pathogen_name === 'B/VIC' ? 'B/Vic' : row.pathogen_name]) {
marker.addTo(
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 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 = '') {
const valueEl = document.getElementById(valueId);
@@ -90,16 +63,7 @@ function renderTrend(valueId, changeId, current, previous, suffix = '') {
changeEl.className = "text-muted";
}
}
function getSubtypeColor(label, index = 0) {
const specialColors = {
'A/H5N1': '#dc2626',
'Influenza': '#b90c00'
};
return specialColors[label]
|| COLORS[index % COLORS.length];
}
function renderSummary(summary = {}) {
const mappings = [
@@ -127,41 +91,41 @@ function renderDashboard(data = {}) {
renderSummary(data.summary);
renderProvinceHeatmap(data.province_distribution || []);
buildDistributionChart(
Charts.buildDistributionChart(
'pathogenChart',
'doughnut',
(data.pathogen_distribution || [])
.sort((a, b) => b.total - a.total),
'pathogen',
'total',
getSubtypeColor
Charts.getSubtypeColor
);
buildDistributionChart(
Charts.buildDistributionChart(
'ageChart',
'doughnut',
data.age_distribution || [],
'age_group'
);
buildDistributionChart(
Charts.buildDistributionChart(
'sexChart',
'bar',
data.sex_distribution || [],
'patient_sex'
);
buildDistributionChart(
Charts.buildDistributionChart(
'subtypeChart',
'bar',
data.subtype_distribution || [],
'subtype',
'total',
getSubtypeColor
Charts.getSubtypeColor
);
buildDistributionChart(
Charts.buildDistributionChart(
'sentinelChart',
'pie',
data.sentinel_sites || [],
@@ -178,21 +142,21 @@ function renderAFIDashboard(data = {}) {
renderAFITrend(
data.afi_case_trend.section_1,
'afiSection1Trend',
COLORS,
Chart.COLORS,
'trend'
);
renderAFITrend(
data.afi_case_trend.section_2,
'afiSection2Trend',
COLORS,
Chart.COLORS,
'trend'
);
renderAFITrend(
data.afi_case_trend.section_3,
'afiSection3Trend',
COLORS,
Chart.COLORS,
'trend'
);
@@ -200,21 +164,21 @@ function renderAFIDashboard(data = {}) {
renderAFITrend(
data.afi_case_trend.section_1,
'afiPcrChart',
COLORS,
Chart.COLORS,
'donut'
);
renderAFITrend(
data.afi_case_trend.section_2,
'afiMultiplexChart',
COLORS,
Chart.COLORS,
'donut'
);
renderAFITrend(
data.afi_case_trend.section_3,
'afiElisaChart',
COLORS,
Chart.COLORS,
'donut'
);
@@ -466,7 +430,7 @@ function renderProgramTrend(rows = []) {
if (!rows.length) {
buildMixedTrendChart(
Charts.buildMixedTrendChart(
'trendChart',
[],
[],
@@ -720,7 +684,7 @@ function renderProgramTrend(rows = []) {
}
buildMixedTrendChart(
Charts.buildMixedTrendChart(
'trendChart',
labels,
samples,
@@ -758,7 +722,7 @@ function renderAFITrend(
if (type === 'donut') {
buildDistributionChart(
Charts.buildDistributionChart(
canvasId,
'doughnut',
[],
@@ -768,7 +732,7 @@ function renderAFITrend(
} else {
buildMixedTrendChart(
Charts.buildMixedTrendChart(
canvasId,
[],
[],
@@ -862,7 +826,7 @@ function renderAFITrend(
total
}));
buildDistributionChart(
Charts.buildDistributionChart(
canvasId,
'doughnut',
donutRows,
@@ -886,12 +850,12 @@ function renderAFITrend(
0
);
if (charts[canvasId]) {
if (Charts.charts[canvasId]) {
charts[canvasId].$afiTotalCases =
Charts.charts[canvasId].$afiTotalCases =
donutTotal;
charts[canvasId].update();
Charts.charts[canvasId].update();
}
@@ -941,8 +905,8 @@ function renderAFITrend(
}),
color:
COLORS[
i % COLORS.length
Charts.COLORS[
i % Charts.COLORS.length
]
})
@@ -954,7 +918,7 @@ function renderAFITrend(
|--------------------------------------------------------------------------
*/
buildMixedTrendChart(
Charts.buildMixedTrendChart(
canvasId,
labels,
totalCases,
@@ -965,7 +929,7 @@ function renderAFITrend(
function renderPathogenChart(rows = []) {
buildDistributionChart(
Charts.buildDistributionChart(
'pathogenChart',
'doughnut',
rows,
@@ -973,7 +937,7 @@ function renderPathogenChart(rows = []) {
);
}
function renderSentinel(rows = []) {
buildDistributionChart(
Charts.buildDistributionChart(
'sentinelChart',
'pie',
rows,
@@ -982,27 +946,26 @@ function renderSentinel(rows = []) {
}
function renderSubtypeChart(rows = []) {
console.log('renderSubtypeChart');
buildDistributionChart(
Charts.buildDistributionChart(
'subtypeChart',
'bar',
rows,
'subtype',
'total',
getSubtypeColor
Charts.getSubtypeColor
);
}
function renderDemographics(data = {}) {
buildDistributionChart(
Charts.buildDistributionChart(
'ageChart',
'doughnut',
data.age_distribution || [],
'age_group'
);
buildDistributionChart(
Charts.buildDistributionChart(
'sexChart',
'bar',
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 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',
borderWidth: 0,
pointRadius: 0,
backgroundColor: hexToRGBA(COLORS[index % COLORS.length], 0.3),
backgroundColor: hexToRGBA(Charts.COLORS[index % Charts.COLORS.length], 0.3),
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
@@ -284,7 +284,7 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
)];
const lineageColors = lineages.map(
label => SUBTYPE_COLORS[label] || '#9ca3af'
label => Charts.SUBTYPE_COLORS[label] || '#9ca3af'
);
const datasets = lineages.map((lineage, index) => {
@@ -405,7 +405,7 @@ function loadInfluenzaSubtypeFrequency(periodType, startYear, startWeek, endYear
});
charts['influenzaSubtypeFrequency'] =
Charts.charts['influenzaSubtypeFrequency'] =
influenzaSubtypeFrequencyChart;
/*

View File

@@ -1,5 +1,5 @@
<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.') }}
</div>

View File

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

View File

@@ -17,8 +17,8 @@
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="/js/dashboard/filter.js"></script>
<script src="/js/dashboard/charts.js"></script>
<script src="/js/dashboard/export.js"></script>
<script src="/js/dashboard/globals.js" type="module"></script>
<script src="/js/dashboard/export.js" type="module"></script>
<style>
@@ -278,8 +278,8 @@
</button>
<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" onclick="exportFullDashboard()">Screen</button>
<button class="btn btn-sm btn-light" id="btnCharts">Charts</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-outline-secondary" id="exportClose"></button>
</div>
@@ -290,8 +290,8 @@
<div id="chartList"></div>
<div class="mt-3 d-flex justify-content-end gap-2">
<button onclick="closeChartSelector()">Cancel</button>
<button onclick="exportSelectedCharts()">Download PDF</button>
<button id="chartCancel" class="btn btn-sm btn-secondary">Cancel</button>
<button id="chartDownload" class="btn btn-sm btn-primary">Download PDF</button>
</div>
</div>
</div>
@@ -328,7 +328,14 @@
<script>
document.addEventListener("DOMContentLoaded", () => {
updateLastUpdated();
const lastUpdated = localStorage.getItem('dashboardLastUpdated');
if (lastUpdated) {
document.getElementById('lastUpdated').innerHTML =
`Last update: ${lastUpdated}`;
}
});
window.addEventListener("click", (e) => {
const modal = document.getElementById("chartModal");
@@ -360,7 +367,21 @@
.then(res => res.json())
.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();