Merge pull request #5 from khantey1998/master

dev
This commit is contained in:
2026-03-24 10:04:44 +07:00
committed by GitHub
10 changed files with 212900 additions and 105424 deletions

217138
dashboard.sql

File diff suppressed because it is too large Load Diff

View File

@@ -50,12 +50,7 @@ class DashboardController extends Controller
public function summary() public function summary()
{ {
$dateFrom = Carbon::now()->subDays(7)->toDateString(); return response()->json($this->service->summaryCards());
$dateTo = Carbon::now()->toDateString();
$data = $this->service->summaryCards($dateFrom, $dateTo);
return response()->json($data);
} }
@@ -148,21 +143,22 @@ class DashboardController extends Controller
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function sentinelMap(Request $request) // public function sentinelMap(Request $request)
{
$range = $this->getEpiRange($request);
if (!$range) { // {
return response()->json(['error' => 'Missing epiweek range'], 400); // $range = $this->getEpiRange($request);
}
$data = $this->service->sentinelMap( // if (!$range) {
$range['startYear'], // return response()->json(['error' => 'Missing epiweek range'], 400);
$range['startWeek'], // }
$range['endYear'],
$range['endWeek']
);
return response()->json($data); // $data = $this->service->sentinelSites(
} // $range['startYear'],
// $range['startWeek'],
// $range['endYear'],
// $range['endWeek']
// );
// return response()->json($data);
// }
} }

View File

@@ -25,27 +25,32 @@ class DashboardService
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function summaryCards($dateFrom, $dateTo) public function summaryCards()
{ {
$programs = Surveillance::orderBy('id')->get(); $programs = Surveillance::orderBy('id')->get();
$results = []; $results = [];
$today = date('Y-m-d');
$currentFrom = date('Y-m-d', strtotime('-6 days'));
$currentTo = $today;
$prevFrom = date('Y-m-d', strtotime('-13 days'));
$prevTo = date('Y-m-d', strtotime('-7 days'));
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', [$dateFrom, $dateTo]) ->whereBetween('case_date', [$currentFrom, $currentTo])
->count(); ->count();
$previous = SurveillanceCase::where('surveillance_id', $program->id) $previous = SurveillanceCase::where('surveillance_id', $program->id)
->whereBetween('case_date', [ ->whereBetween('case_date', [$prevFrom, $prevTo])
date('Y-m-d', strtotime($dateFrom . ' -7 days')),
date('Y-m-d', strtotime($dateFrom . ' -1 day'))
])
->count(); ->count();
$percentChange = $previous > 0 $percentChange = $previous > 0
? round((($current - $previous) / $previous) * 100, 1) ? round((($current - $previous) / $previous) * 100, 1)
: 0; : ($current > 0 ? 100 : 0);
$results[] = [ $results[] = [
'surveillance_id' => $program->id, 'surveillance_id' => $program->id,
@@ -59,7 +64,6 @@ class DashboardService
return $results; return $results;
} }
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Fast SARI Summary (single query) | Fast SARI Summary (single query)
@@ -136,7 +140,51 @@ class DashboardService
| Program Summary | Program Summary
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
public function afiTrend($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]);
})
->where('case_lab_results.is_positive', 1)
->whereNotNull('case_lab_results.pathogen_name')
->where('case_lab_results.pathogen_name', '!=', '')
->where('case_lab_results.pathogen_name', '!=', 'Positive')
->selectRaw("
surveillance_cases.year_data as year,
surveillance_cases.week_data as period,
CASE
WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' THEN 'Influenza'
ELSE case_lab_results.pathogen_name
END as pathogen,
COUNT(DISTINCT surveillance_cases.lab_code) as total
")
->groupBy(
'surveillance_cases.year_data',
'surveillance_cases.week_data',
'pathogen'
)
->orderBy('surveillance_cases.year_data')
->orderBy('surveillance_cases.week_data')
->get();
}
public function programSummary($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) public function programSummary($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
@@ -308,6 +356,13 @@ class DashboardService
$endYear, $endYear,
$endWeek $endWeek
), ),
'afi_trend' => $this->afiTrend(
$surveillanceId,
$startYear,
$startWeek,
$endYear,
$endWeek
),
'province_distribution' => $this->provinceProgram( 'province_distribution' => $this->provinceProgram(
$surveillanceId, $surveillanceId,
@@ -520,14 +575,47 @@ class DashboardService
*/ */
public function provinceCircles($startYear, $startWeek, $endYear, $endWeek) public function provinceCircles($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) { return SurveillanceCase::leftJoin(
$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]); 'case_lab_results',
})->groupBy('surveillance_cases.site_province_name', 'surveillance_cases.surveillance_id')->get(); 'surveillance_cases.lab_code',
'=',
'case_lab_results.lab_code'
)
->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]
);
})
->selectRaw("
surveillance_cases.patient_province,
surveillance_cases.surveillance_id,
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
")
->groupBy(
'surveillance_cases.patient_province',
'surveillance_cases.surveillance_id'
)
->get();
} }
public function provinceProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) public function provinceProgram($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
return SurveillanceCase::selectRaw(" return SurveillanceCase::selectRaw("
surveillance_cases.site_province_name, surveillance_cases.patient_province,
COUNT(DISTINCT surveillance_cases.lab_code) as total, COUNT(DISTINCT surveillance_cases.lab_code) as total,
@@ -560,7 +648,7 @@ class DashboardService
}) })
->groupBy('surveillance_cases.site_province_name') ->groupBy('surveillance_cases.patient_province')
->get(); ->get();
} }
@@ -583,7 +671,7 @@ class DashboardService
}) })
->groupBy('sentinel_site_name') ->groupBy('sentinel_site_name')
->orderByDesc('total') // nice for chart ->orderByDesc('total')
->get(); ->get();
} }
@@ -597,16 +685,13 @@ class DashboardService
{ {
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek); $total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
$rows = CaseLabResult::join( $rows = CaseLabResult::where('case_lab_results.surveillance_id', $surveillanceId)
'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) { ->whereIn('lab_code', function ($q) use ($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek]) $q->select('lab_code')
->from('surveillance_cases')
->where('surveillance_id', $surveillanceId)
->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek])
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]); ->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]);
}) })
@@ -615,17 +700,11 @@ class DashboardService
->selectRaw(" ->selectRaw("
CASE CASE
WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%' WHEN LOWER(case_lab_results.pathogen_name) LIKE '%influenza%'
OR LOWER(case_lab_results.pathogen_name) LIKE '%influzena%'
THEN 'Influenza' 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 ELSE case_lab_results.pathogen_name
END as pathogen, END as pathogen,
COUNT(DISTINCT surveillance_cases.lab_code) as total COUNT(DISTINCT case_lab_results.lab_code) as total
") ")
->groupBy('pathogen') ->groupBy('pathogen')
@@ -642,27 +721,34 @@ class DashboardService
{ {
$total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek); $total = $this->totalTested($surveillanceId, $startYear, $startWeek, $endYear, $endWeek);
$rows = CaseLabResult::join( $rows = CaseLabResult::where('case_lab_results.surveillance_id', $surveillanceId)
'surveillance_cases',
'case_lab_results.lab_code',
'=',
'surveillance_cases.lab_code'
)
->where('surveillance_cases.surveillance_id', $surveillanceId)
->where('case_lab_results.is_positive', 1)
->where(function ($q) use ($startYear, $startWeek, $endYear, $endWeek) { ->whereIn('case_lab_results.lab_code', function ($q) use ($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) {
$q->whereRaw("(year_data > ? OR (year_data = ? AND week_data >= ?))", [$startYear, $startYear, $startWeek]) $q->select('lab_code')
->whereRaw("(year_data < ? OR (year_data = ? AND week_data <= ?))", [$endYear, $endYear, $endWeek]); ->from('surveillance_cases')
->where('surveillance_id', $surveillanceId)
->whereRaw(
"(year_data > ? OR (year_data = ? AND week_data >= ?))",
[$startYear, $startYear, $startWeek]
)
->whereRaw(
"(year_data < ? OR (year_data = ? AND week_data <= ?))",
[$endYear, $endYear, $endWeek]
);
}) })
->where('case_lab_results.is_positive', 1)
->selectRaw(" ->selectRaw("
subtype, case_lab_results.subtype,
COUNT(DISTINCT surveillance_cases.lab_code) as total COUNT(DISTINCT case_lab_results.lab_code) as total
") ")
->groupBy('subtype') ->whereNotNull('case_lab_results.subtype')
->havingRaw("subtype IS NOT NULL AND subtype != 'Positive' AND subtype != ''") ->where('case_lab_results.subtype', '!=', '')
->where('case_lab_results.subtype', '!=', 'Positive')
->groupBy('case_lab_results.subtype')
->orderByDesc('total') ->orderByDesc('total')
->get(); ->get();
@@ -672,7 +758,6 @@ class DashboardService
}); });
} }
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Age Distribution | Age Distribution
@@ -681,15 +766,29 @@ class DashboardService
public function ageDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek) public function ageDistribution($surveillanceId, $startYear, $startWeek, $endYear, $endWeek)
{ {
return SurveillanceCase::selectRaw(" return SurveillanceCase::selectRaw("
CASE CASE
WHEN patient_age_inday < 365 THEN '0-1y' WHEN patient_age_inday <= 28 THEN '028 days'
WHEN patient_age_inday < 1825 THEN '1-5y' WHEN patient_age_inday <= 364 THEN '29 days11 months'
WHEN patient_age_inday < 6570 THEN '5-18y' WHEN patient_age_inday <= 1460 THEN '14 years'
WHEN patient_age_inday < 21900 THEN '18-60y' WHEN patient_age_inday <= 5110 THEN '514 years'
ELSE '60+y' WHEN patient_age_inday <= 8765 THEN '1524 years'
WHEN patient_age_inday <= 18250 THEN '2549 years'
WHEN patient_age_inday <= 23725 THEN '5064 years'
ELSE '65+ years'
END as age_group, END as age_group,
CASE
WHEN patient_age_inday <= 28 THEN 1
WHEN patient_age_inday <= 364 THEN 2
WHEN patient_age_inday <= 1460 THEN 3
WHEN patient_age_inday <= 5110 THEN 4
WHEN patient_age_inday <= 8765 THEN 5
WHEN patient_age_inday <= 18250 THEN 6
WHEN patient_age_inday <= 23725 THEN 7
ELSE 8
END as age_order,
COUNT(*) as total COUNT(*) as total
") ")
@@ -701,7 +800,6 @@ class DashboardService
"(year_data > ? OR (year_data = ? AND week_data >= ?))", "(year_data > ? OR (year_data = ? AND week_data >= ?))",
[$startYear, $startYear, $startWeek] [$startYear, $startYear, $startWeek]
) )
->whereRaw( ->whereRaw(
"(year_data < ? OR (year_data = ? AND week_data <= ?))", "(year_data < ? OR (year_data = ? AND week_data <= ?))",
[$endYear, $endYear, $endWeek] [$endYear, $endYear, $endWeek]
@@ -709,13 +807,11 @@ class DashboardService
}) })
->groupBy('age_group') ->groupBy('age_group', 'age_order')
->orderBy('age_order')
->get(); ->get();
} }
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Sex Distribution | Sex Distribution

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,58 @@
Chart.register({
id: 'noDataText',
afterDraw(chart) {
const datasets = chart.data.datasets || [];
const hasData = datasets.some(ds =>
(ds.data || []).some(v => Number(v) > 0)
);
chart.$noData = !hasData;
if (hasData) return;
const { ctx, width, height } = chart;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '14px sans-serif';
ctx.fillStyle = '#9ca3af';
ctx.fillText('No data available', width / 2, height / 2);
ctx.restore();
}
});
Chart.register({
id: 'centerText',
afterDraw(chart) {
if (chart.config.type !== 'doughnut') return;
if (chart.$noData) return;
const { ctx, width, height } = chart;
const data = chart.data.datasets[0].data;
const total = data.reduce((a, b) => a + b, 0);
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 18px sans-serif';
ctx.fillStyle = '#111827';
ctx.fillText(total, width / 2, height / 2 - 8);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#6b7280';
ctx.fillText('Total cases', width / 2, height / 2 + 12);
ctx.restore();
}
});
Chart.register(ChartDataLabels); Chart.register(ChartDataLabels);
const charts = {}; const charts = {};
@@ -80,10 +135,126 @@ function buildStackedChart(canvasId, labels, datasets) {
function buildChart(id, type, labels, data) { function buildChart(id, type, labels, data) {
const ctx = document.getElementById(id); const ctx = document.getElementById(id);
if (!ctx) return; if (!ctx) return;
if (charts[id]) charts[id].destroy(); Chart.getChart(id)?.destroy();
const hasData = data && data.some(v => Number(v) > 0);
if (!hasData) {
labels = [];
data = [];
}
const isHorizontal = id === 'sexChart';
const isAgeChart = id === 'ageChart';
const options = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: 30
},
indexAxis: isHorizontal ? 'y' : 'x',
plugins: {
legend: {
position: isAgeChart ? 'left' : 'bottom',
align: 'center',
display: (ctx) => {
const chart = ctx.chart;
if (!(chart.config.type === 'pie' || chart.config.type === 'doughnut')) {
return false;
}
return !chart.$noData;
},
labels: {
padding: 14,
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
pointStyle: 'circle',
font: {
size: 11
}
}
},
datalabels: {
color: "#282626",
offset: 6,
clip: false,
display: (ctx) => {
const chart = ctx.chart;
if (chart.$noData) return false;
if (chart.config.type === 'bar') return true;
return !chart.$noData;
},
anchor: (ctx) => {
const type = ctx.chart.config.type;
if (type === 'doughnut' || type === 'pie') {
return 'center';
}
return 'end';
},
align: (ctx) => {
const type = ctx.chart.config.type;
if (type === 'doughnut' || type === 'pie') {
return 'center';
}
if (type === 'bar') {
return ctx.chart.options.indexAxis === 'y' ? 'right' : 'end';
}
return 'center';
},
font: {
size: 10,
weight: '600'
},
formatter: (value, ctx) => {
if (ctx.chart.$noData) return '';
const data = ctx.chart.data.datasets[0].data;
const total = data.reduce((a, b) => a + b, 0);
if (!total) return '';
return ((value / total) * 100).toFixed(1) + '%';
}
}
}
};
if (type === 'bar') {
options.scales = {
x: {
beginAtZero: true,
grid: {
display: false // cleaner look
}
},
y: {
grid: {
color: '#f3f4f6' // subtle grid
}
}
};
}
if (type === 'doughnut') {
options.cutout = '70%';
options.elements = {
arc: {
borderWidth: (ctx) => ctx.chart.$noData ? 0 : 1
}
};
}
charts[id] = new Chart(ctx, { charts[id] = new Chart(ctx, {
type: type, type: type,
@@ -98,54 +269,18 @@ function buildChart(id, type, labels, data) {
maxBarThickness: 50 maxBarThickness: 50
}] }]
}, },
options: { options: options
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 30,
bottom: 30
}
},
plugins: {
legend: {
position: 'bottom',
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) { function buildMixedTrendChart(canvasId, labels, samples, fluRate, covidRate) {
const ctx = document.getElementById(canvasId); const ctx = document.getElementById(canvasId);
if (!ctx) return; if (!ctx) return;
if (!labels.length) labels = [''];
if (!fluRate.length) fluRate = [0];
if (!covidRate.length) covidRate = [0];
if (!samples.length) samples = [0];
if (charts[canvasId]) charts[canvasId].destroy(); if (charts[canvasId]) charts[canvasId].destroy();
charts[canvasId] = new Chart(ctx, { charts[canvasId] = new Chart(ctx, {

View File

@@ -13,6 +13,7 @@ function loadSummary() {
.then(data => { .then(data => {
let html = ''; let html = '';
const alerts = [];
data.forEach(item => { data.forEach(item => {
@@ -53,14 +54,19 @@ function loadSummary() {
</div> </div>
</div> </div>
`; `;
window._summaryData = data;
updateAlerts();
}); });
document.getElementById('summary_cards').innerHTML = html; document.getElementById('summary_cards').innerHTML = html;
renderAlerts(alerts);
}); });
} }
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Load Trend Chart | Load Trend Chart
@@ -89,7 +95,6 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
const [yearB, weekB] = b.split('-').map(Number); const [yearB, weekB] = b.split('-').map(Number);
if (yearA !== yearB) return yearA - yearB; if (yearA !== yearB) return yearA - yearB;
return weekA - weekB; return weekA - weekB;
}); });
@@ -109,11 +114,8 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
if (!allowedPrograms.includes(code)) return; if (!allowedPrograms.includes(code)) return;
const values = labels.map(label => { const values = labels.map(label => {
const found = data[code].find(row => `${row.year}-${row.period}` === label); const found = data[code].find(row => `${row.year}-${row.period}` === label);
return found ? found.total : 0; return found ? found.total : 0;
}); });
datasets.push({ datasets.push({
@@ -135,14 +137,11 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
}); });
trendChart = new Chart(document.getElementById('trendChart'), { trendChart = new Chart(document.getElementById('trendChart'), {
type: 'line', type: 'line',
data: { data: {
labels: displayLabels, labels: displayLabels,
datasets: datasets datasets: datasets
}, },
options: { options: {
responsive: true, responsive: true,
plugins: { plugins: {
@@ -160,14 +159,294 @@ function loadTrend(periodType, startYear, startWeek, endYear, endWeek) {
x: { grid: { display: false } } x: { grid: { display: false } }
} }
} }
}); });
}); });
}
function updateAlerts() {
if (!window._summaryData || !window._provinceData) return;
const raw = buildAlerts(window._summaryData, window._provinceData);
const finalAlerts = processAlerts(raw);
renderAlerts(finalAlerts);
}
function generateAlerts(data) {
const alerts = [];
// -------------------------
// 1. Program-level alerts
// -------------------------
const summary = data.summary || {};
const programs = [
{ key: 'influenza_rate', label: 'Influenza' },
{ key: 'covid_rate', label: 'COVID-19' },
{ key: 'positivity_rate', label: 'Overall positivity' }
];
programs.forEach(p => {
const current = summary[p.key]?.current || 0;
const previous = summary[p.key]?.previous || 0;
const diff = previous ? ((current - previous) / previous) * 100 : 0;
if (current >= 15) {
alerts.push(`🔴 High ${p.label} (${current}%)`);
} else if (current >= 10) {
alerts.push(`🟠 Moderate ${p.label} (${current}%)`);
}
if (diff >= 10) {
alerts.push(`🟡 Increasing ${p.label} (+${diff.toFixed(1)}%)`);
}
});
// -------------------------
// 2. Province-level alerts
// -------------------------
const provinces = data.province_distribution || [];
const top = [...provinces]
.sort((a, b) => b.total - a.total)
.slice(0, 3);
top.forEach(p => {
const percent = p.total
? ((p.positive / p.total) * 100)
: 0;
if (percent >= 15) {
alerts.push(`🔴 High positivity in ${p.patient_province} (${percent.toFixed(1)}%)`);
} else if (percent >= 10) {
alerts.push(`🟠 Moderate positivity in ${p.patient_province}`);
}
if (p.total >= 50) {
alerts.push(`🟡 High case volume in ${p.patient_province} (${p.total})`);
}
});
// -------------------------
// fallback
// -------------------------
if (!alerts.length) {
alerts.push("🟢 No unusual activity detected");
}
return alerts;
}
function createAlert(type, message, priority) {
return { type, message, priority };
}
function buildAlerts(summaryData, provinceData) {
const alerts = [];
// -------------------------
// 1. Program alerts
// -------------------------
summaryData.forEach(item => {
// 🔴 High activity
if (item.current_total >= 80) {
alerts.push(createAlert(
'high',
`High ${item.code} activity (${item.current_total} cases)`,
1
));
}
// 🟠 Moderate
else if (item.current_total >= 40) {
alerts.push(createAlert(
'moderate',
`${item.code} activity elevated (${item.current_total})`,
2
));
}
// 🟡 Increasing trend
if (item.percent_change >= 10) {
alerts.push(createAlert(
'trend',
`Increasing ${item.code} (+${item.percent_change}%)`,
3
));
}
});
// -------------------------
// 2. Province alerts
// -------------------------
const top = [...provinceData]
.sort((a, b) => b.total - a.total)
.slice(0, 5);
top.forEach(p => {
const percent = p.total
? ((p.positive / p.total) * 100)
: 0;
// 🔴 High positivity
if (percent >= 15) {
alerts.push(createAlert(
'high',
`High positivity in ${p.patient_province} (${percent.toFixed(1)}%)`,
1
));
}
// 🟠 Moderate positivity
else if (percent >= 10) {
alerts.push(createAlert(
'moderate',
`Moderate positivity in ${p.patient_province}`,
2
));
}
// 🟡 High volume
if (p.total >= 50) {
alerts.push(createAlert(
'volume',
`High case volume in ${p.patient_province} (${p.total})`,
3
));
}
});
return alerts;
}
function processAlerts(alerts) {
const seen = new Set();
const unique = alerts.filter(a => {
if (seen.has(a.message)) return false;
seen.add(a.message);
return true;
});
// sort by priority
unique.sort((a, b) => a.priority - b.priority);
// limit to top 5
return unique.slice(0, 5);
}
function renderAlerts(alerts) {
const container = document.getElementById('alertsList');
if (!container) return;
if (!alerts.length) {
container.innerHTML = `
<li class="list-group-item text-success">
🟢 No unusual activity detected
</li>
`;
return;
}
const colorMap = {
high: 'text-danger',
moderate: 'text-warning',
trend: 'text-primary',
volume: 'text-secondary'
};
container.innerHTML = alerts.map(a => `
<li class="list-group-item ${colorMap[a.type] || ''}">
${a.type === 'high' ? '🔴' :
a.type === 'moderate' ? '🟠' :
a.type === 'trend' ? '🟡' :
'🔵'}
${a.message}
</li>
`).join('');
} }
/*
|--------------------------------------------------------------------------
| Province Map Helpers
|--------------------------------------------------------------------------
*/
function getPositivityColor(p) {
if (p > 20) return "#b91c1c";
if (p > 10) return "#ef4444";
if (p > 5) return "#f59e0b";
if (p > 0) return "#84cc16";
return "#9ca3af";
}
function normalizeProvince(name, validSet) {
if (!name || !validSet) return null;
const clean = str =>
str.toLowerCase().replace(/\s+/g, '');
const raw = name.trim();
const map = {
"kepville": "Kep",
"sihanoukville": "Preah Sihanouk",
"sihanoukvillecity": "Preah Sihanouk",
"krongpailin": "Pailin",
"mondulkiri": "Mondulkiri",
"odormeanchey": "Oddar Meanchey",
"tbongkhmom": "Tboung Khmum",
"tboungkhmum": "Tboung Khmum",
"rattanakiri": "Ratanak Kiri"
};
const key = clean(raw);
if (map[key] && validSet.has(map[key])) {
return map[key];
}
const match = [...validSet].find(p => clean(p) === key);
return match || null;
}
function getRadius(total) {
if (!total) return 0;
const r = Math.sqrt(total);
return Math.max(4, Math.min(r * 2, 22));
}
function addPositivityLegend() {
const legend = L.control({ position: "bottomleft" });
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;">Positivity</div>
<div><span style="border:3px solid #b91c1c;width:12px;height:12px;display:inline-block;margin-right:6px;"></span>> 20%</div>
<div><span style="border:3px solid #ef4444;width:12px;height:12px;display:inline-block;margin-right:6px;"></span>1020%</div>
<div><span style="border:3px solid #f59e0b;width:12px;height:12px;display:inline-block;margin-right:6px;"></span>510%</div>
<div><span style="border:3px solid #84cc16;width:12px;height:12px;display:inline-block;margin-right:6px;"></span>05%</div>
<div><span style="border:3px solid #9ca3af;width:12px;height:12px;display:inline-block;margin-right:6px;"></span>0%</div>
</div>
`;
return div;
};
legend.addTo(map);
}
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Province Map | Province Map
@@ -179,17 +458,23 @@ function loadProvinceMap(startYear, startWeek, endYear, endWeek) {
if (map) map.remove(); if (map) map.remove();
map = L.map('provinceMap').setView([12.7, 104.9], 7); map = L.map('provinceMap').setView([12.7, 104.9], 7);
addPositivityLegend();
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap' attribution: '© OpenStreetMap'
}).addTo(map); }).addTo(map);
Promise.all([ Promise.all([
fetch('/geo/cambodia_provinces.geojson').then(r => r.json()), fetch('/geo/cambodia_provinces.geojson').then(r => r.json()),
fetch(`/api/dashboard/province-circles?start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`).then(r => r.json()) fetch(`/api/dashboard/province-circles?start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`).then(r => r.json())
]) ])
.then(([geojson, data]) => { .then(([geojson, data]) => {
window._provinceData = data;
updateAlerts();
const validProvinces = new Set(
geojson.features.map(f => f.properties.ADM1_EN)
);
L.geoJSON(geojson, { L.geoJSON(geojson, {
style: { style: {
@@ -204,22 +489,14 @@ function loadProvinceMap(startYear, startWeek, endYear, endWeek) {
const province = feature.properties.ADM1_EN; const province = feature.properties.ADM1_EN;
const center = layer.getBounds().getCenter(); const center = layer.getBounds().getCenter();
const rows = data.filter(d => d.site_province_name === province); const rows = data.filter(d => {
if (![1, 2, 3].includes(d.surveillance_id)) return false;
const offsets = { const name = normalizeProvince(d.patient_province, validProvinces);
1: -0.15, return name === province;
2: 0, });
3: 0.15
};
rows.forEach(row => { const offsets = { 1: -0.15, 2: 0, 3: 0.15 };
const lat = center.lat;
const lng = center.lng + offsets[row.surveillance_id];
const programName =
row.surveillance_id === 1 ? 'SARI' :
row.surveillance_id === 2 ? 'ILI' : 'LBM';
const colors = { const colors = {
1: '#2563eb', 1: '#2563eb',
@@ -227,19 +504,33 @@ function loadProvinceMap(startYear, startWeek, endYear, endWeek) {
3: '#9333ea' 3: '#9333ea'
}; };
rows.forEach(row => {
const percent = row.total
? ((row.positive / row.total) * 100).toFixed(1)
: 0;
const offset = offsets[row.surveillance_id] ?? 0;
const lat = center.lat;
const lng = center.lng + offset;
const programName =
row.surveillance_id === 1 ? 'SARI' :
row.surveillance_id === 2 ? 'ILI' : 'LBM';
L.circleMarker([lat, lng], { L.circleMarker([lat, lng], {
radius: getRadius(row.total),
radius: 9,
fillColor: colors[row.surveillance_id], fillColor: colors[row.surveillance_id],
color: '#fff', color: getPositivityColor(percent),
weight: 1, weight: 2,
fillOpacity: 0.9 fillOpacity: 0.9
}) })
.bindTooltip(` .bindTooltip(`
<strong>${province}</strong><br> <strong>${province}</strong><br>
${programName}<br> ${programName}<br>
Total: ${row.total} Cases: ${row.total}<br>
Positivity: ${percent}%
`) `)
.addTo(map); .addTo(map);
@@ -267,7 +558,6 @@ document.addEventListener("DOMContentLoaded", () => {
new DashboardFilter((startYear, startWeek, endYear, endWeek) => { new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
loadTrend('week', startYear, startWeek, endYear, endWeek); loadTrend('week', startYear, startWeek, endYear, endWeek);
loadProvinceMap(startYear, startWeek, endYear, endWeek); loadProvinceMap(startYear, startWeek, endYear, endWeek);
}); });

View File

@@ -1,36 +1,84 @@
const standardPrograms = ['SARI', 'ILI', 'LBM']; const standardPrograms = ['SARI', 'ILI', 'LBM', 'AFI'];
const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase(); const programCode = (window.PROGRAM_CODE || '').trim().toUpperCase();
let map; let map;
let provinceLayer; let provinceLayer;
function normalizeProvince(name, validSet) {
if (!name || !validSet) return null;
const clean = str =>
str.toLowerCase().replace(/\s+/g, '');
const raw = name.trim();
const map = {
"kepville": "Kep",
"sihanoukville": "Preah Sihanouk",
"sihanoukvillecity": "Preah Sihanouk",
"krongpailin": "Pailin",
"mondulkiri": "Mondulkiri",
"odormeanchey": "Oddar Meanchey",
"tbongkhmom": "Tboung Khmum",
"tboungkhmum": "Tboung Khmum",
"rattanakiri": "Ratanak Kiri"
};
const key = clean(raw);
if (map[key] && validSet.has(map[key])) {
return map[key];
}
const match = [...validSet].find(p => clean(p) === key);
return match || null;
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
if (!standardPrograms.includes(programCode)) return; if (!standardPrograms.includes(programCode)) return;
new DashboardFilter((startYear, startWeek, endYear, endWeek) => { new DashboardFilter((startYear, startWeek, endYear, endWeek) => {
fetch(`/api/dashboard/program?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`) fetch(`/api/dashboard/program?surveillance_id=${window.SURVEILLANCE_ID}&start_year=${startYear}&start_week=${startWeek}&end_year=${endYear}&end_week=${endWeek}`)
.then(res => res.json()) .then(res => res.json())
.then(renderDashboard) .then(programCode === 'AFI' ? renderAFIDashboard : renderDashboard)
.catch(err => console.error("Dashboard API error:", err)); .catch(err => console.error("Dashboard API error:", err));
}); });
}); });
function renderAFIDashboard(data) {
const pathogenRows = (data.pathogen_distribution || [])
.sort((a, b) => b.total - a.total);
const colors = [
'#2563eb', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
];
renderSummary(data.summary);
renderAFITrend(data.afi_trend);
renderProvinceHeatmap(data.province_distribution);
renderPathogenChart(data.pathogen_distribution);
renderDemographics(data);
renderSentinel(data.sentinel_sites || []);
renderSubtypeChart(data.subtype_distribution || []);
charts['pathogenChart'].data.datasets[0].backgroundColor = colors;
charts['pathogenChart'].update();
charts['ageChart'].data.datasets[0].backgroundColor = colors;
charts['ageChart'].update();
charts['sexChart'].data.datasets[0].backgroundColor = colors;
charts['sexChart'].update();
charts['sentinelChart'].data.datasets[0].backgroundColor = colors;
charts['sentinelChart'].update();
charts['subtypeChart'].data.datasets[0].backgroundColor = colors;
charts['subtypeChart'].update();
}
function renderProvinceHeatmap(rows) { function renderProvinceHeatmap(rows) {
const totals = {}; if (map) map.remove();
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); map = L.map('provinceMap').setView([12.7, 104.9], 7);
@@ -44,6 +92,28 @@ function renderProvinceHeatmap(rows) {
.then(r => r.json()) .then(r => r.json())
.then(geo => { .then(geo => {
const validProvinces = new Set(
geo.features.map(f => f.properties.ADM1_EN)
);
const totals = {};
rows.forEach(r => {
// ✅ FIX: use patient_province + validSet
const province = normalizeProvince(r.patient_province, validProvinces);
if (!province) return;
// ✅ FIX: accumulate instead of overwrite
if (!totals[province]) {
totals[province] = { total: 0, positive: 0 };
}
totals[province].total += Number(r.total);
totals[province].positive += Number(r.positive);
});
function getColor(value) { function getColor(value) {
if (value > 50) return "#b91c1c"; if (value > 50) return "#b91c1c";
if (value >= 10) return "#ef4444"; if (value >= 10) return "#ef4444";
@@ -65,11 +135,15 @@ function renderProvinceHeatmap(rows) {
}; };
}, },
onEachFeature: (feature, layer) => { onEachFeature: (feature, layer) => {
const province = feature.properties.ADM1_EN; const province = feature.properties.ADM1_EN;
const total = totals[province]?.total || 0; const total = totals[province]?.total || 0;
const positive = totals[province]?.positive || 0; const positive = totals[province]?.positive || 0;
const percent = total ? ((positive / total) * 100).toFixed(1) : 0;
console.log(province, total, positive, percent); // ✅ positivity kept
const percent = total
? ((positive / total) * 100).toFixed(1)
: 0;
layer.bindTooltip(` layer.bindTooltip(`
${province}<br> ${province}<br>
@@ -159,6 +233,7 @@ 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 fluRate = rows.map(r => r.influenza_rate || 0); const fluRate = rows.map(r => r.influenza_rate || 0);
@@ -230,32 +305,22 @@ function renderSummary(summary) {
function renderDashboard(data) { function renderDashboard(data) {
data = data || {}; data = data || {};
let virusRows = data.virus_trend || [];
if (!virusRows.length) {
virusRows = [
{ period: '', influenza: 0, covid: 0, total_samples: 0 }
];
}
renderProgramTrend(data.trend || []); renderProgramTrend(data.trend || []);
renderSummary(data.summary || {}); renderSummary(data.summary || {});
renderProvinceHeatmap(data.province_distribution || []); renderProvinceHeatmap(data.province_distribution || []);
// buildStackedChart(
// "pathogenChart",
// labels,
// [
// {
// label: "Influenza",
// data: influenza,
// backgroundColor: "#2E7D32"
// },
// {
// label: "SARS-CoV-2",
// data: covid,
// backgroundColor: "#A5D6A7"
// }
// ]
// );
const pathogenRows = (data.pathogen_distribution || []) const pathogenRows = (data.pathogen_distribution || [])
.sort((a, b) => b.total - a.total); .sort((a, b) => b.total - a.total);
const colors = [ const colors = [
'#2563eb', '#10b981', '#f59e0b', '#ef4444', '#2563eb', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#14b8a6', '#f97316', '#84cc16' '#8b5cf6', '#14b8a6', '#f97316', '#84cc16', '#e11dba', '#f6f63b'
]; ];
@@ -305,3 +370,114 @@ function renderDashboard(data) {
charts['sentinelChart'].update(); charts['sentinelChart'].update();
} }
//AFI
function renderAFITrend(rows) {
if (!rows.length) {
buildStackedChart('trendChart', [], []);
return;
}
const { labels, datasets } = transformAFIData(rows);
buildStackedChart('trendChart', labels, datasets);
}
function transformAFIData(rows) {
const grouped = {};
const pathogensSet = new Set();
rows.forEach(r => {
const key = `${r.year}-W${r.period}`;
if (!grouped[key]) {
grouped[key] = {};
}
grouped[key][r.pathogen] = r.total;
pathogensSet.add(r.pathogen);
});
const labels = Object.keys(grouped).sort((a, b) => {
const [yA, wA] = a.split('-W').map(Number);
const [yB, wB] = b.split('-W').map(Number);
return yA === yB ? wA - wB : yA - yB;
});
const pathogenTotals = {};
rows.forEach(r => {
pathogenTotals[r.pathogen] = (pathogenTotals[r.pathogen] || 0) + r.total;
});
const pathogens = Object.keys(pathogenTotals)
.sort((a, b) => pathogenTotals[b] - pathogenTotals[a]);
const datasets = pathogens.map(p => ({
label: p,
data: labels.map(l => grouped[l][p] || 0),
backgroundColor: getColorForPathogen(p)
}));
return { labels: labels.map(l => l.split('-')[1]), datasets };
}
function getColorForPathogen(name) {
const colors = {
Dengue: '#2563eb',
Chikungunya: '#10b981',
Zika: '#f59e0b',
Leptospira: '#ef4444',
Rickettsia: '#8b5cf6',
Salmonella: '#f97316',
Plasmodium: '#14b8a6',
Influenza: '#84cc16'
};
if (colors[name]) return colors[name];
// fallback random color (for future pathogens)
return `hsl(${Math.floor(Math.random() * 360)}, 70%, 60%)`;
}
function renderPathogenChart(rows) {
buildChart(
'pathogenChart',
'doughnut',
rows.map(r => r.pathogen),
rows.map(r => r.total)
);
}
function renderSentinel(rows) {
buildChart(
'sentinelChart',
'pie',
rows.map(r => r.name),
rows.map(r => r.total)
);
}
function renderDemographics(data) {
buildChart(
'ageChart',
'doughnut',
(data.age_distribution || []).map(r => r.age_group),
(data.age_distribution || []).map(r => r.total)
);
buildChart(
'sexChart',
'bar',
(data.sex_distribution || []).map(r => r.patient_sex),
(data.sex_distribution || []).map(r => r.total)
);
}
function renderSubtypeChart(rows) {
buildChart(
'subtypeChart',
'bar',
rows.map(r => r.subtype),
rows.map(r => r.total)
);
}

View File

@@ -172,39 +172,25 @@
<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:400px"> <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>
<div style="height:360px; position:relative;">
<canvas id="ageChart"></canvas>
</div>
</div>
<div class="col-md-6 d-flex flex-column">
<h6 class="fw-bold mb-3">Sex Distribution</h6> <h6 class="fw-bold mb-3">Sex Distribution</h6>
<div style="height:360px; position:relative;"> <div style="height:360px; position:relative;">
<canvas id="sexChart"></canvas> <canvas id="sexChart"></canvas>
</div> </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:400px"> <div class="card-body" style="height:400px">
<h6 class="fw-bold mb-3">Age Distribution</h6>
<div style="height:360px; position:relative;">
<canvas id="ageChart"></canvas>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@endsection @endsection

View File

@@ -66,16 +66,7 @@
<h5 class="fw-bold">Recent Alerts & Notifications</h5> <h5 class="fw-bold">Recent Alerts & Notifications</h5>
<ul class="list-group list-group-flush mt-3"> <ul id="alertsList" class="list-group list-group-flush mt-3"></ul>
<li class="list-group-item">
Monitoring influenza increase in selected provinces.
</li>
<li class="list-group-item">
🔔 SARS-CoV-2 positivity rate under review.
</li>
</ul>
</div> </div>
</div> </div>

View File

@@ -41,6 +41,11 @@
background: white; background: white;
border-bottom: 1px solid #dcdcdc; border-bottom: 1px solid #dcdcdc;
padding-left: 15px; padding-left: 15px;
position: sticky;
top: 0;
z-index: 1000;
background: white;
border-bottom: 1px solid #dcdcdc;
} }
/* NAV ITEMS */ /* NAV ITEMS */
@@ -57,14 +62,12 @@
background: #cce0d4; background: #cce0d4;
} }
/* ACTIVE TAB */
.active-tab { .active-tab {
color: #0B8F3C; color: #0B8F3C;
border-bottom: 3px solid #0B8F3C; border-bottom: 3px solid #0B8F3C;
background: #e5efe8; background: #e5efe8;
} }
/* CONTENT */
.content-area { .content-area {
padding: 25px; padding: 25px;
} }