finalized overview page

This commit is contained in:
2026-03-19 09:20:42 +07:00
parent c2b820fc6d
commit e80cb128bf
9 changed files with 822 additions and 227 deletions

View File

@@ -74,6 +74,7 @@ class DashboardController extends Controller
} }
$data = $this->service->aggregateAllPrograms( $data = $this->service->aggregateAllPrograms(
$range['startYear'], $range['startYear'],
$range['startWeek'], $range['startWeek'],
$range['endYear'], $range['endYear'],

View File

@@ -8,6 +8,16 @@ use App\Models\CaseLabResult;
class DashboardService class DashboardService
{ {
private function totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
return SurveillanceCase::where('surveillance_id', $surveillanceId)
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
})
->distinct('lab_code')
->count('lab_code');
}
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -56,9 +66,8 @@ class DashboardService
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function sariSummaryFast($surveillanceId, $year, $week) public function programSummaryFast($surveillanceId, $year, $week)
{ {
$row = SurveillanceCase::leftJoin( $row = SurveillanceCase::leftJoin(
'case_lab_results', 'case_lab_results',
'surveillance_cases.lab_code', 'surveillance_cases.lab_code',
@@ -79,14 +88,20 @@ class DashboardService
END) as overall_positive, END) as overall_positive,
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN case_lab_results.indicator = 'SARI Influenza Test' WHEN case_lab_results.is_positive = 1
AND case_lab_results.is_positive = 1 AND (
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
)
THEN surveillance_cases.lab_code THEN surveillance_cases.lab_code
END) as influenza_positive, END) as influenza_positive,
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN case_lab_results.indicator = 'SARI Covid Test' WHEN case_lab_results.is_positive = 1
AND case_lab_results.is_positive = 1 AND (
LOWER(case_lab_results.pathogen_name) LIKE '%covid%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%sars%'
)
THEN surveillance_cases.lab_code THEN surveillance_cases.lab_code
END) as covid_positive END) as covid_positive
") ")
@@ -103,29 +118,14 @@ class DashboardService
]; ];
} }
return [ return [
'cases' => $row->total_cases, 'cases' => $row->total_cases,
'overall_rate' => round( 'overall_rate' => round(($row->overall_positive / $row->total_cases) * 100, 1),
($row->overall_positive / $row->total_cases) * 100
,
1
),
'influenza_rate' => round( 'influenza_rate' => round(($row->influenza_positive / $row->total_cases) * 100, 1),
($row->influenza_positive / $row->total_cases) * 100
,
1
),
'covid_rate' => round(
($row->covid_positive / $row->total_cases) * 100
,
1
),
'covid_rate' => round(($row->covid_positive / $row->total_cases) * 100, 1),
]; ];
} }
@@ -148,9 +148,25 @@ class DashboardService
$prevYear--; $prevYear--;
} }
$current = $this->sariSummaryFast($surveillanceId, $endYear, $endWeek); $latest = SurveillanceCase::where('surveillance_id', $surveillanceId)
$previous = $this->sariSummaryFast($surveillanceId, $prevYear, $prevWeek); ->selectRaw("year_data, week_data")
->orderByDesc('year_data')
->orderByDesc('week_data')
->first();
$year = $latest->year_data;
$week = $latest->week_data;
$current = $this->programSummaryFast($surveillanceId, $year, $week);
$previous = $this->programSummaryFast($surveillanceId, $prevYear, $prevWeek);
$prevWeek = $week - 1;
$prevYear = $year;
if ($prevWeek <= 0) {
$prevWeek = 52;
$prevYear--;
}
$previous = $this->programSummaryFast($surveillanceId, $prevYear, $prevWeek);
return [ return [
@@ -195,7 +211,7 @@ class DashboardService
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function aggregateAllPrograms($periodType, $startYear, $startWeek, $endYear, $endWeek) public function aggregateAllPrograms($startYear, $startWeek, $endYear, $endWeek)
{ {
$data = SurveillanceCase::selectRaw(" $data = SurveillanceCase::selectRaw("
@@ -293,13 +309,34 @@ class DashboardService
$endWeek $endWeek
), ),
'province_distribution' => $this->provinceCirclesProgram( 'province_distribution' => $this->provinceProgram(
$surveillanceId, $surveillanceId,
$startYear, $startYear,
$startWeek, $startWeek,
$endYear, $endYear,
$endWeek $endWeek
) ),
'virus_trend' => $this->virusTrend(
$surveillanceId,
$startYear,
$startWeek,
$endYear,
$endWeek
),
'subtype_distribution' => $this->subtypeDistribution(
$surveillanceId,
$startYear,
$startWeek,
$endYear,
$endWeek
),
'sentinel_sites' => $this->sentinelSites(
$surveillanceId,
$startYear,
$startWeek,
$endYear,
$endWeek
),
]; ];
@@ -340,14 +377,46 @@ class DashboardService
}) })
->selectRaw(" ->selectRaw("
surveillance_cases.year_data as year, surveillance_cases.year_data as year,
surveillance_cases.week_data as period, surveillance_cases.week_data as period,
COUNT(DISTINCT surveillance_cases.lab_code) as total_samples,
ROUND( COUNT(DISTINCT surveillance_cases.lab_code) as total_samples,
SUM(CASE WHEN case_lab_results.is_positive = 1 THEN 1 ELSE 0 END)
/ NULLIF(COUNT(*),0) * 100,1 -- Overall positivity rate
) as positivity_rate ROUND(
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
THEN surveillance_cases.lab_code
END)
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as positivity_rate,
-- Influenza positivity rate
ROUND(
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND (
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
)
THEN surveillance_cases.lab_code
END)
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as influenza_rate,
-- COVID positivity rate
ROUND(
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND (
case_lab_results.pathogen_name = 'Positive'
OR case_lab_results.pathogen_name = 'SARS-CoV-2'
)
AND case_lab_results.indicator LIKE '%Covid%'
THEN surveillance_cases.lab_code
END)
/ NULLIF(COUNT(DISTINCT surveillance_cases.lab_code), 0) * 100
,1) as covid_rate
") ")
->groupBy( ->groupBy(
@@ -399,7 +468,49 @@ class DashboardService
return $results; return $results;
} }
public function virusTrend($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
return CaseLabResult::join(
'surveillance_cases',
'case_lab_results.lab_code',
'=',
'surveillance_cases.lab_code'
)
->where('surveillance_cases.surveillance_id', $surveillanceId)
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
})
->selectRaw("
surveillance_cases.week_data as period,
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND (
LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
)
THEN surveillance_cases.lab_code
END) as influenza,
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
AND (
case_lab_results.pathogen_name = 'Positive'
OR case_lab_results.pathogen_name = 'SARS-CoV-2'
)
AND case_lab_results.indicator LIKE '%Covid%'
THEN surveillance_cases.lab_code
END) as covid
")
->groupBy('surveillance_cases.week_data')
->orderBy('surveillance_cases.week_data')
->get();
}
/* /*
@@ -407,14 +518,24 @@ class DashboardService
| Province Distribution (Program) | Province Distribution (Program)
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function provinceCircles($startYear, $startWeek, $endYear, $endWeek)
public function provinceCirclesProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) {
return SurveillanceCase::selectRaw(" surveillance_cases.site_province_name, surveillance_cases.surveillance_id, COUNT(*) as total ")->join('case_lab_results', 'surveillance_cases.lab_code', '=', 'case_lab_results.lab_code')->where('case_lab_results.is_positive', 1)->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw("(surveillance_cases.year_data > ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data >= ?))", [$startYear, $startYear, $startWeek])->whereRaw("(surveillance_cases.year_data < ? OR (surveillance_cases.year_data = ? AND surveillance_cases.week_data <= ?))", [$endYear, $endYear, $endWeek]);
})->groupBy('surveillance_cases.site_province_name', 'surveillance_cases.surveillance_id')->get();
}
public function provinceProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
return SurveillanceCase::selectRaw(" return SurveillanceCase::selectRaw("
surveillance_cases.site_province_name, surveillance_cases.site_province_name,
COUNT(DISTINCT surveillance_cases.lab_code) as total
") COUNT(DISTINCT surveillance_cases.lab_code) as total,
COUNT(DISTINCT CASE
WHEN case_lab_results.is_positive = 1
THEN surveillance_cases.lab_code
END) as positive
")
->join( ->join(
'case_lab_results', 'case_lab_results',
@@ -424,7 +545,6 @@ class DashboardService
) )
->where('surveillance_cases.surveillance_id', $surveillanceId) ->where('surveillance_cases.surveillance_id', $surveillanceId)
->where('case_lab_results.is_positive', 1)
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
@@ -442,10 +562,30 @@ class DashboardService
->groupBy('surveillance_cases.site_province_name') ->groupBy('surveillance_cases.site_province_name')
->get(); ->get();
} }
/*
|--------------------------------------------------------------------------
| sentinel sites
|--------------------------------------------------------------------------
*/
public function sentinelSites($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
return SurveillanceCase::selectRaw("
sentinel_site_name as name,
COUNT(DISTINCT lab_code) as total
")
->where('surveillance_id', $surveillanceId)
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
})
->groupBy('sentinel_site_name')
->orderByDesc('total') // nice for chart
->get();
}
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -455,44 +595,84 @@ class DashboardService
public function pathogenDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) public function pathogenDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
return CaseLabResult::selectRaw(" $rows = CaseLabResult::join(
pathogen_name, 'surveillance_cases',
COUNT(*) as total 'case_lab_results.lab_code',
") '=',
'surveillance_cases.lab_code'
)
->where('surveillance_cases.surveillance_id', $surveillanceId)
->join( ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
'surveillance_cases', $q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
'case_lab_results.lab_code', ->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
'=', })
'surveillance_cases.lab_code'
)
->where('case_lab_results.is_positive', 1)
->selectRaw("
CASE
WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
THEN 'Influenza'
WHEN case_lab_results.pathogen_name = 'Positive'
AND case_lab_results.indicator LIKE '%Covid%'
THEN 'SARS-CoV-2'
ELSE case_lab_results.pathogen_name
END as pathogen,
COUNT(DISTINCT surveillance_cases.lab_code) as total
")
->groupBy('pathogen')
->havingRaw("pathogen IS NOT NULL AND pathogen != ''")
->orderByDesc('total')
->get();
return $rows->map(function ($r) use ($total) {
$r->rate = $total > 0 ? round(($r->total / $total) * 100, 1) : 0;
return $r;
});
}
public function subtypeDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
$rows = CaseLabResult::join(
'surveillance_cases',
'case_lab_results.lab_code',
'=',
'surveillance_cases.lab_code'
)
->where('surveillance_cases.surveillance_id', $surveillanceId) ->where('surveillance_cases.surveillance_id', $surveillanceId)
->where('case_lab_results.is_positive', 1) ->where('case_lab_results.is_positive', 1)
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { ->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
$q->whereRaw( ->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
"(year_data > ? OR (year_data = ? AND week_data >= ?))",
[$startYear, $startYear, $startWeek]
)
->whereRaw(
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
[$endYear, $endYear, $endWeek]
);
}) })
->groupBy('pathogen_name') ->selectRaw("
->orderByDesc('total') subtype,
->get(); COUNT(DISTINCT surveillance_cases.lab_code) as total
")
->groupBy('subtype')
->havingRaw("subtype IS NOT NULL AND subtype != 'Positive' AND subtype != ''")
->orderByDesc('total')
->get();
return $rows->map(function ($r) use ($total) {
$r->rate = $total > 0 ? round(($r->total / $total) * 100, 1) : 0;
return $r;
});
} }
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Age Distribution | Age Distribution

View File

@@ -1,6 +1,83 @@
Chart.register(ChartDataLabels);
const charts = {}; const charts = {};
function buildChart(id, type, labels, data, label = 'Cases') {
function buildStackedChart(canvasId, labels, datasets) {
const ctx = document.getElementById(canvasId);
if (!ctx) return;
if (charts[canvasId]) {
charts[canvasId].destroy();
}
charts[canvasId] = new Chart(ctx, {
type: "bar",
data: {
labels: labels,
datasets: datasets,
datalabels: {
display: true
}
},
plugins: [ChartDataLabels],
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 20,
bottom: 30
}
},
plugins: {
legend: {
position: 'bottom',
align: 'center',
labels: {
padding: 20,
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
pointStyle: 'circle'
}
},
datalabels: {
color: "#000",
anchor: "end",
align: "top",
clamp: true,
clip: false,
font: {
weight: "bold",
size: 10
},
formatter: function (value) {
return value > 0 ? value : null;
}
}
},
scales: {
x: {
stacked: true
},
y: {
stacked: true,
beginAtZero: true
}
}
}
});
}
function buildChart(id, type, labels, data) {
const ctx = document.getElementById(id); const ctx = document.getElementById(id);
@@ -13,96 +90,276 @@ function buildChart(id, type, labels, data, label = 'Cases') {
data: { data: {
labels: labels, labels: labels,
datasets: [{ datasets: [{
label: label,
data: data, data: data,
borderWidth: 2, borderWidth: 2,
tension: 0.3 tension: 0.3,
barPercentage: 0.8,
categoryPercentage: 0.6,
maxBarThickness: 50
}] }]
}, },
options: {
responsive: true,
maintainAspectRatio: false
}
});
}
function buildMixedTrendChart(canvasId, labels, samples, positivity) {
const ctx = document.getElementById(canvasId);
if (!ctx) return;
if (charts[canvasId]) charts[canvasId].destroy();
charts[canvasId] = new Chart(ctx, {
data: {
labels: labels,
datasets: [
{
type: 'line',
label: '% Positive',
data: positivity,
borderColor: '#1e6ef2',
borderWidth: 2,
tension: 0.4,
fill: false,
pointRadius: 4,
pointStyle: 'line',
yAxisID: 'y1'
},
{
type: 'bar',
label: 'Total sample',
data: samples,
backgroundColor: '#2ecc71',
borderRadius: 6,
barPercentage: 0.6,
pointStyle: 'rect',
categoryPercentage: 0.7,
yAxisID: 'y',
},
]
},
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
layout: {
padding: {
top: 30,
bottom: 30
}
},
plugins: { plugins: {
legend: { legend: {
position: 'bottom', position: 'bottom',
align: 'center', align: 'center',
display: type === 'pie' || type === 'doughnut',
labels: {
padding: 10,
boxWidth: 14,
boxHeight: 14,
usePointStyle: true,
pointStyle: 'circle'
}
},
datalabels: {
color: "#282626",
anchor: type === "bar" ? "end" : "center",
align: type === "bar" ? "top" : "center",
font: {
size: 10
},
formatter: function(value, ctx) {
const data = ctx.chart.data.datasets[0].data;
const total = data.reduce((a, b) => a + b, 0);
const percent = total ? (value / total * 100).toFixed(1) : 0;
return percent + '%';
}
}
}
}
});
}
function buildMixedTrendChart(canvasId, labels, samples, fluRate, covidRate) {
const ctx = document.getElementById(canvasId);
if (!ctx) return;
if (charts[canvasId]) charts[canvasId].destroy();
charts[canvasId] = new Chart(ctx, {
data: {
labels: labels,
datasets: [
{
type: 'line',
label: 'Influenza %',
data: fluRate,
borderColor: '#fa2929',
backgroundColor: '#fa2929',
tension: 0.4,
yAxisID: 'y1',
pointStyle: 'line',
},
{
type: 'line',
label: 'COVID-19 %',
data: covidRate,
borderColor: '#1976D2',
backgroundColor: '#1976D2',
tension: 0.4,
yAxisID: 'y1',
pointStyle: 'line',
},
{
type: 'bar',
label: 'Total Cases',
data: samples,
backgroundColor: '#0B8F3C',
borderRadius: 2,
barPercentage: 0.8,
categoryPercentage: 0.7,
yAxisID: 'y',
}
]
},
plugins: [ChartDataLabels],
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { labels: {
usePointStyle: true, usePointStyle: true,
padding: 20, padding: 20
boxWidth: 30, }
font: { },
size: 12 datalabels: {
align: "top",
anchor: "end",
color: "#555",
font: {
size: 10
},
formatter: function (value, context) {
if (Number(value) === 0) return null;
if (context.dataset.type === 'line') {
return value + '%';
} }
return value;
} }
} }
}, },
layout: { layout: {
padding: { padding: {
bottom: 50 top: 30,
bottom: 20
} }
}, },
scales: { scales: {
y: { y: {
position: 'left', position: 'left',
title: { title: {
display: true, display: true,
text: 'Total sample' text: 'Cases'
} }
}, },
y1: { y1: {
position: 'right', position: 'right',
grid: { grid: { drawOnChartArea: false },
drawOnChartArea: false
},
title: { title: {
display: true, display: true,
text: '% Positive' text: '% Positivity'
}, },
ticks: {
callback: value => value + '%'
}
} }
} }
} }
}); });
} }
// function buildMixedTrendChart(canvasId, labels, samples, positivity) {
// const ctx = document.getElementById(canvasId);
// if (!ctx) return;
// if (charts[canvasId]) charts[canvasId].destroy();
// charts[canvasId] = new Chart(ctx, {
// data: {
// labels: labels,
// datasets: [
// {
// type: 'line',
// label: '% Positive',
// data: positivity,
// borderColor: '#1e6ef2',
// borderWidth: 2,
// tension: 0.4,
// fill: false,
// pointRadius: 2,
// pointStyle: 'line',
// yAxisID: 'y1',
// },
// {
// type: 'bar',
// label: 'Total sample ',
// data: samples,
// backgroundColor: '#2ecc71',
// borderRadius: 2,
// barPercentage: 0.8,
// categoryPercentage: 0.7,
// yAxisID: 'y',
// }
// ]
// },
// plugins: [ChartDataLabels],
// options: {
// responsive: true,
// maintainAspectRatio: false,
// plugins: {
// legend: {
// position: 'bottom',
// align: 'center',
// labels: {
// usePointStyle: true,
// padding: 20,
// boxWidth: 30,
// font: { size: 12 }
// }
// },
// datalabels: {
// align: "top",
// anchor: "end",
// color: "#555",
// font: {
// size: 10
// },
// formatter: function (value, context) {
// if (Number(value) === 0) return null;
// if (context.dataset.type === 'line') {
// console.log(value);
// return value + '%';
// }
// return value;
// }
// }
// },
// layout: {
// padding: {
// top: 20,
// bottom: 50
// }
// },
// scales: {
// y: {
// position: 'left',
// title: {
// display: true,
// text: 'Total sample'
// }
// },
// y1: {
// position: 'right',
// grid: {
// drawOnChartArea: false
// },
// title: {
// display: true,
// text: '% Positive'
// },
// ticks: {
// callback: value => value + '%'
// }
// }
// }
// }
// });
// }

View File

@@ -1,7 +1,6 @@
class DashboardFilter { class DashboardFilter {
constructor(onChange) { constructor(onChange) {
this.onChange = onChange; this.onChange = onChange;
this.rangeSelect = document.getElementById("trend_range"); this.rangeSelect = document.getElementById("trend_range");

View File

@@ -238,7 +238,7 @@ function loadProvinceMap(startYear, startWeek, endYear, endWeek) {
}) })
.bindTooltip(` .bindTooltip(`
<strong>${province}</strong><br> <strong>${province}</strong><br>
${programName}<br> ${programName}<br>
Total: ${row.total} Total: ${row.total}
`) `)
.addTo(map); .addTo(map);

View File

@@ -1,6 +1,7 @@
const standardPrograms = ['SARI', 'ILI', 'LBM']; const standardPrograms = ['SARI', 'ILI', 'LBM'];
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase(); const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
let map;
let provinceLayer;
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
@@ -15,6 +16,115 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
}); });
function renderProvinceHeatmap(rows) {
const totals = {};
rows.forEach(r => {
totals[r.site_province_name] = {
total: Number(r.total),
positive: Number(r.positive)
};
});
if (map) {
map.remove();
}
map = L.map('provinceMap').setView([12.7, 104.9], 7);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
addProvinceLegend();
fetch('/geo/cambodia_provinces.geojson')
.then(r => r.json())
.then(geo => {
function getColor(value) {
if (value > 50) return "#b91c1c";
if (value >= 10) return "#ef4444";
if (value > 0) return "#fecaca";
return "#f3f4f600";
}
provinceLayer = L.geoJSON(geo, {
style: feature => {
const province = feature.properties.ADM1_EN;
const value = totals[province]?.total || 0;
return {
color: "#444",
weight: 1,
fillColor: getColor(value),
fillOpacity: 0.7
};
},
onEachFeature: (feature, layer) => {
const province = feature.properties.ADM1_EN;
const total = totals[province]?.total || 0;
const positive = totals[province]?.positive || 0;
const percent = total ? ((positive / total) * 100).toFixed(1) : 0;
console.log(province, total, positive, percent);
layer.bindTooltip(`
${province}<br>
Total: ${total}<br>
Positivity: ${percent}%
`);
}
}).addTo(map);
});
}
function addProvinceLegend() {
const legend = L.control({ position: "bottomright" });
legend.onAdd = function () {
const div = L.DomUtil.create("div", "map-legend");
div.innerHTML = `
<div style="background:white;padding:10px 12px;border-radius:6px;
box-shadow:0 2px 6px rgba(0,0,0,0.2);font-size:12px;">
<div style="font-weight:600;margin-bottom:6px;">Cases</div>
<div style="display:flex;align-items:center;margin-bottom:4px;">
<span style="width:12px;height:12px;background:#b91c1c;
display:inline-block;margin-right:6px;"></span>
> 50
</div>
<div style="display:flex;align-items:center;margin-bottom:4px;">
<span style="width:12px;height:12px;background:#ef4444;
display:inline-block;margin-right:6px;"></span>
10 50
</div>
<div style="display:flex;align-items:center;margin-bottom:4px;">
<span style="width:12px;height:12px;background:#fecaca;
display:inline-block;margin-right:6px;"></span>
1 9
</div>
<div style="display:flex;align-items:center;">
<span style="width:12px;height:12px;background:#f3f4f6;
display:inline-block;margin-right:6px;"></span>
0
</div>
</div>
`;
return div;
};
legend.addTo(map);
}
function renderTrend(valueId, changeId, current, previous, suffix = '') { function renderTrend(valueId, changeId, current, previous, suffix = '') {
const valueEl = document.getElementById(valueId); const valueEl = document.getElementById(valueId);
@@ -49,16 +159,17 @@ function renderTrend(valueId, changeId, current, previous, suffix = '') {
function renderProgramTrend(rows) { function renderProgramTrend(rows) {
rows = rows || []; rows = rows || [];
const labels = rows.map(r => `W${r.period}`); const labels = rows.map(r => `W${r.period}`);
const samples = rows.map(r => r.total_samples || 0); const samples = rows.map(r => r.total_samples || 0);
const positivity = rows.map(r => r.positivity_rate || 0); const fluRate = rows.map(r => r.influenza_rate || 0);
const covidRate = rows.map(r => r.covid_rate || 0);
buildMixedTrendChart( buildMixedTrendChart(
'trendChart', 'trendChart',
labels, labels,
samples, samples,
positivity fluRate,
covidRate
); );
} }
function renderSummary(summary) { function renderSummary(summary) {
@@ -117,26 +228,46 @@ function renderSummary(summary) {
); );
} }
function renderDashboard(data) { function renderDashboard(data) {
console.log("SUMMARY:", data.summary);
data = data || {}; data = data || {};
renderProgramTrend(data.trend || []); renderProgramTrend(data.trend || []);
renderSummary(data.summary || {}); renderSummary(data.summary || {});
buildChart( renderProvinceHeatmap(data.province_distribution || []);
'provinceChart', // buildStackedChart(
'bar', // "pathogenChart",
(data.province_distribution || []).map(r => r.site_province_name), // labels,
(data.province_distribution || []).map(r => r.total) // [
); // {
// label: "Influenza",
// data: influenza,
// backgroundColor: "#2E7D32"
// },
// {
// label: "SARS-CoV-2",
// data: covid,
// backgroundColor: "#A5D6A7"
// }
// ]
// );
const pathogenRows = (data.pathogen_distribution || [])
.sort((a, b) => b.total - a.total);
const colors = [
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16'
];
buildChart( buildChart(
'pathogenChart', 'pathogenChart',
'bar', 'doughnut',
(data.pathogen_distribution || []).map(r => r.pathogen_name), pathogenRows.map(r => r.pathogen),
(data.pathogen_distribution || []).map(r => r.total), pathogenRows.map(r => r.total)
'Positive'
); );
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
charts['pathogenChart'].update();
buildChart( buildChart(
'ageChart', 'ageChart',
@@ -144,6 +275,8 @@ function renderDashboard(data) {
(data.age_distribution || []).map(r => r.age_group), (data.age_distribution || []).map(r => r.age_group),
(data.age_distribution || []).map(r => r.total) (data.age_distribution || []).map(r => r.total)
); );
charts['ageChart'].data.datasets[0].backgroundColor = colors;
charts['ageChart'].update();
buildChart( buildChart(
'sexChart', 'sexChart',
@@ -151,5 +284,24 @@ function renderDashboard(data) {
(data.sex_distribution || []).map(r => r.patient_sex), (data.sex_distribution || []).map(r => r.patient_sex),
(data.sex_distribution || []).map(r => r.total) (data.sex_distribution || []).map(r => r.total)
); );
charts['sexChart'].data.datasets[0].backgroundColor = colors;
charts['sexChart'].update();
buildChart(
'subtypeChart',
'bar',
(data.subtype_distribution || []).map(r => r.subtype),
(data.subtype_distribution || []).map(r => r.total)
);
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
charts['subtypeChart'].update();
buildChart(
'sentinelChart',
'pie',
(data.sentinel_sites || []).map(r => r.name),
(data.sentinel_sites || []).map(r => r.total)
);
charts['sentinelChart'].data.datasets[0].backgroundColor = colors;
charts['sentinelChart'].update();
} }

View File

@@ -50,14 +50,9 @@
<div class="col-md-3"> <div class="col-md-3">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<small>Total Cases Reported (latest epiweek)</small>
<small>Total Cases Reported (Last 7 Days)</small>
<h3 id="totalCases">0</h3> <h3 id="totalCases">0</h3>
<small id="casesChange" class="text-muted"> No change</small>
<small id="casesChange" class="text-muted">
No change
</small>
</div> </div>
</div> </div>
</div> </div>
@@ -65,14 +60,9 @@
<div class="col-md-3"> <div class="col-md-3">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<small>Overall Positivity Rate</small> <small>Overall Positivity Rate</small>
<h3 id="positivityRate">0%</h3> <h3 id="positivityRate">0%</h3>
<small id="positivityChange" class="text-muted"> No change</small>
<small id="positivityChange" class="text-muted">
No change
</small>
</div> </div>
</div> </div>
</div> </div>
@@ -80,139 +70,152 @@
<div class="col-md-3"> <div class="col-md-3">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<small>Influenza Rate</small> <small>Influenza Rate</small>
<h3 id="influenzaRate">0%</h3> <h3 id="influenzaRate">0%</h3>
<small id="influenzaChange" class="text-muted"> No change</small>
<small id="influenzaChange" class="text-muted">
No change
</small>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<small>SARS-CoV-2 Rate</small>
<small>SARS-Cov-2 Rate</small>
<h3 id="covidRate">0%</h3> <h3 id="covidRate">0%</h3>
<small id="covidChange" class="text-muted"> No change</small>
</div>
</div>
</div>
<small id="covidChange" class="text-muted"> </div>
No change <!-- TREND CHART (PRIMARY) -->
</small> <div class="row g-3 mb-4">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body" style="height:520px;">
<h6 class="fw-bold mb-3">
Case Trends & Positivity Rate by Epiweek
</h6>
<div style="height:460px; position:relative;">
<canvas id="trendChart"></canvas>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- PATHOGEN DISTRIBUTION -->
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body" style="height:520px">
<h6 class="fw-bold mb-3">Pathogen Distribution</h6>
<div style="height:460px; position:relative;">
<canvas id="pathogenChart"></canvas>
</div>
</div> </div>
<!-- TREND CHART -->
<div class="card shadow-sm mb-4">
<div class="card-body" style="height:500px;">
<div class="d-flex justify-content-between mb-2">
<h6 class="fw-bold">Case Trends and Positivity Rate by Epiweek</h6>
</div> </div>
<canvas id="trendChart"></canvas>
</div> </div>
</div> </div>
<!-- MAP + SITE+subtype -->
<!-- PROVINCE + PATHOGEN -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-md-6"> <!-- MAP -->
<div class="col-md-4">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:300px"> <div class="card-body" style="height:520px">
<h6 class="fw-bold mb-3"> <h6 class="fw-bold mb-3">Cases by Province</h6>
Cases by Province
</h6>
<canvas id="provinceChart"></canvas> <div id="provinceMap" style="height:450px;"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-8">
<div class="col-md-6">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:300px"> <div class="card-body" style="height:520px">
<h6 class="fw-bold mb-3"> <h6 class="fw-bold mb-3">Sentinel Sites & Influenza Subtypes</h6>
Pathogen Distribution
</h6>
<canvas id="pathogenChart"></canvas> <div class="row ">
<!-- SENTINEL PIE -->
<div class="col-md-6 d-flex flex-column">
<small class="text-muted mb-2">Cases by Sentinel Site</small>
<div style="height: 460px; position:relative;">
<canvas id="sentinelChart"></canvas>
</div>
</div>
<!-- SUBTYPE -->
<div class="col-md-6 d-flex flex-column">
<small class="text-muted mb-2">Influenza Subtypes</small>
<div style="height: 460px; position:relative;">
<canvas id="subtypeChart"></canvas>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- DEMOGRAPHICS -->
<!-- DEMOGRAPHIC -->
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:300px"> <div class="card-body" style="height:400px">
<div class="row h-100">
<div class="col-md-6 d-flex flex-column">
<h6 class="fw-bold mb-3">Age Distribution</h6>
<h6 class="fw-bold mb-3">Age Distribution</h6> <div style="height:360px; position:relative;">
<canvas id="ageChart"></canvas>
<canvas id="ageChart"></canvas> </div>
</div>
<div class="col-md-6 d-flex flex-column">
<h6 class="fw-bold mb-3">Sex Distribution</h6>
<div style="height:360px; position:relative;">
<canvas id="sexChart"></canvas>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body" style="height:300px"> <div class="card-body" style="height:400px">
<h6 class="fw-bold mb-3">Sex Distribution</h6>
<canvas id="sexChart"></canvas>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script>
window.SURVEILLANCE_ID = {{ $selected->id }}; window.SURVEILLANCE_ID = {{ $selected->id }};
window.PROGRAM_CODE = "{{ $selected->code }}"; window.PROGRAM_CODE = "{{ $selected->code }}";
</script> </script>
<script src="/js/program.js"></script> <script src="/js/program.js"></script>
@endsection @endsection

View File

@@ -51,7 +51,7 @@
<div class="mb-3"> <div class="mb-3">
<h5 class="fw-bold mb-1">Epidemic Trend</h5> <h5 class="fw-bold mb-1">Epidemic Trend</h5>
<p class="text-muted small mb-0"> <p class="text-muted small mb-0">
Weekly reported cases by surveillance program (based on selected epiweek range)
</p> </p>
</div> </div>

View File

@@ -4,13 +4,16 @@
<head> <head>
<title>NRML Dashboard</title> <title>NRML Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<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/charts.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<style> <style>
body { body {
@@ -139,7 +142,7 @@
</div> </div>
@yield('scripts') @yield('scripts')
</body> </body>
</html> </html>