469 lines
24 KiB
JavaScript
469 lines
24 KiB
JavaScript
// ===== Dashboard Feature (Terintegrasi) =====
|
||
// Menghubungkan: webgis-poverty-pontianak, jalan, penduduk, spbu
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ── State ──────────────────────────────────────────────────────────────
|
||
let dashboardData = null;
|
||
let activeTab = 'kemiskinan';
|
||
|
||
// ── Modal Element ──────────────────────────────────────────────────────
|
||
const modal = document.getElementById('dashboardModal');
|
||
if (!modal) return;
|
||
|
||
// ── Open / Close ───────────────────────────────────────────────────────
|
||
window.openDashboard = function () {
|
||
modal.classList.add('show');
|
||
if (!dashboardData) loadDashboard();
|
||
else renderTab(activeTab);
|
||
};
|
||
|
||
window.closeDashboard = function () {
|
||
modal.classList.remove('show');
|
||
};
|
||
|
||
document.getElementById('closeDashboardModal')?.addEventListener('click', window.closeDashboard);
|
||
modal.addEventListener('click', function (e) {
|
||
if (e.target === modal) window.closeDashboard();
|
||
});
|
||
|
||
// ── Load Data ──────────────────────────────────────────────────────────
|
||
function loadDashboard() {
|
||
const body = document.getElementById('dashboardBody');
|
||
body.innerHTML = `
|
||
<div class="db-loading">
|
||
<div class="db-spinner"></div>
|
||
<span>Memuat data dashboard terintegrasi…</span>
|
||
</div>`;
|
||
|
||
fetch('api/dashboard/stats_extended.php')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status !== 'success') throw new Error(data.message || 'Gagal memuat');
|
||
dashboardData = data;
|
||
renderShell();
|
||
renderTab(activeTab);
|
||
})
|
||
.catch(err => {
|
||
body.innerHTML = `<div class="db-error">❌ Gagal memuat data: ${err.message}</div>`;
|
||
});
|
||
}
|
||
|
||
// ── Shell (tab bar + container) ────────────────────────────────────────
|
||
function renderShell() {
|
||
const body = document.getElementById('dashboardBody');
|
||
const tabs = [
|
||
{ id: 'kemiskinan', label: '🏠 Kemiskinan' },
|
||
{ id: 'jalan', label: '🛣️ Jalan' },
|
||
{ id: 'spbu', label: '⛽ SPBU' },
|
||
{ id: 'penduduk', label: '👥 Penduduk' },
|
||
];
|
||
|
||
body.innerHTML = `
|
||
<div class="db-tab-bar" id="dbTabBar">
|
||
${tabs.map(t => `
|
||
<button class="db-tab-btn${t.id === activeTab ? ' active' : ''}"
|
||
data-tab="${t.id}">${t.label}</button>
|
||
`).join('')}
|
||
</div>
|
||
<div id="dbTabContent" class="db-tab-content"></div>`;
|
||
|
||
document.getElementById('dbTabBar').addEventListener('click', function (e) {
|
||
const btn = e.target.closest('[data-tab]');
|
||
if (!btn) return;
|
||
activeTab = btn.dataset.tab;
|
||
document.querySelectorAll('.db-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === activeTab));
|
||
renderTab(activeTab);
|
||
});
|
||
}
|
||
|
||
// ── Route to tab ───────────────────────────────────────────────────────
|
||
function renderTab(tab) {
|
||
const content = document.getElementById('dbTabContent');
|
||
if (!content || !dashboardData) return;
|
||
if (tab === 'kemiskinan') renderKemiskinan(content, dashboardData.poverty);
|
||
else if (tab === 'jalan') renderJalan(content, dashboardData.jalan);
|
||
else if (tab === 'spbu') renderSpbu(content, dashboardData.spbu);
|
||
else if (tab === 'penduduk') renderPenduduk(content, dashboardData.penduduk);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// Tab 1: Kemiskinan
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
function renderKemiskinan(el, d) {
|
||
const pctColor = d.pct_terurus >= 70 ? '#22c55e' : d.pct_terurus >= 40 ? '#f59e0b' : '#ef4444';
|
||
|
||
el.innerHTML = `
|
||
<div class="db-kpi-grid">
|
||
<div class="db-kpi-card kpi-ibadah">
|
||
<div class="db-kpi-icon">🕌</div>
|
||
<div class="db-kpi-val">${d.total_ibadah}</div>
|
||
<div class="db-kpi-label">Rumah Ibadah</div>
|
||
</div>
|
||
<div class="db-kpi-card kpi-miskin">
|
||
<div class="db-kpi-icon">🏠</div>
|
||
<div class="db-kpi-val">${d.total_miskin}</div>
|
||
<div class="db-kpi-label">KK Miskin</div>
|
||
</div>
|
||
<div class="db-kpi-card kpi-jiwa">
|
||
<div class="db-kpi-icon">👥</div>
|
||
<div class="db-kpi-val">${d.total_jiwa}</div>
|
||
<div class="db-kpi-label">Total Jiwa</div>
|
||
</div>
|
||
<div class="db-kpi-card kpi-log">
|
||
<div class="db-kpi-icon">📋</div>
|
||
<div class="db-kpi-val">${d.total_log}</div>
|
||
<div class="db-kpi-label">Log Bantuan</div>
|
||
</div>
|
||
</div>
|
||
|
||
${d.total_miskin > 0 ? `
|
||
<div class="db-section">
|
||
<div class="db-section-title">📊 Cakupan Penanganan</div>
|
||
<div class="db-coverage-row">
|
||
<div class="db-coverage-labels">
|
||
<span style="color:#22c55e">✅ Terurus: <strong>${d.terurus}</strong></span>
|
||
<span style="color:#ef4444">⚠️ Tidak Terurus: <strong>${d.tidak_terurus}</strong></span>
|
||
<span style="color:${pctColor}; font-weight:700">${d.pct_terurus}%</span>
|
||
</div>
|
||
<div class="db-progress-bar">
|
||
<div class="db-progress-fill" style="width:${d.pct_terurus}%; background:${pctColor};"></div>
|
||
</div>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<div class="db-charts-grid">
|
||
<div class="db-chart-card">
|
||
<div class="db-chart-title">📦 Kategori Bantuan</div>
|
||
<canvas id="chartKategori" height="180"></canvas>
|
||
</div>
|
||
<div class="db-chart-card">
|
||
<div class="db-chart-title">🎁 Tipe Bantuan</div>
|
||
<canvas id="chartTipe" height="180"></canvas>
|
||
</div>
|
||
<div class="db-chart-card db-chart-wide">
|
||
<div class="db-chart-title">📅 Aktivitas Bantuan (6 Bulan)</div>
|
||
<canvas id="chartBulan" height="140"></canvas>
|
||
</div>
|
||
</div>`;
|
||
|
||
setTimeout(() => {
|
||
drawPieChart('chartKategori', d.distribusi.kategori, ['#6366f1','#f59e0b','#22c55e','#ef4444','#06b6d4']);
|
||
drawPieChart('chartTipe', d.distribusi.tipe_bantuan, ['#4f46e5','#16a34a','#dc2626','#d97706','#0891b2','#7c3aed']);
|
||
drawBarChart('chartBulan', d.distribusi.per_bulan);
|
||
}, 50);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// Tab 2: Jalan
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
function renderJalan(el, d) {
|
||
const panjangKm = (d.total_panjang_m / 1000).toFixed(2);
|
||
const luasHa = (d.total_luas_m2 / 10000).toFixed(2);
|
||
|
||
el.innerHTML = `
|
||
<div class="db-kpi-grid">
|
||
<div class="db-kpi-card" style="--kpi-color:#3b82f6">
|
||
<div class="db-kpi-icon">🛣️</div>
|
||
<div class="db-kpi-val">${d.total_jalan}</div>
|
||
<div class="db-kpi-label">Ruas Jalan</div>
|
||
</div>
|
||
<div class="db-kpi-card" style="--kpi-color:#0ea5e9">
|
||
<div class="db-kpi-icon">📏</div>
|
||
<div class="db-kpi-val">${panjangKm} km</div>
|
||
<div class="db-kpi-label">Total Panjang</div>
|
||
</div>
|
||
<div class="db-kpi-card" style="--kpi-color:#8b5cf6">
|
||
<div class="db-kpi-icon">🗂️</div>
|
||
<div class="db-kpi-val">${d.total_parsil}</div>
|
||
<div class="db-kpi-label">Persil Tanah</div>
|
||
</div>
|
||
<div class="db-kpi-card" style="--kpi-color:#6d28d9">
|
||
<div class="db-kpi-icon">📐</div>
|
||
<div class="db-kpi-val">${luasHa} ha</div>
|
||
<div class="db-kpi-label">Total Luas Persil</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="db-charts-grid">
|
||
<div class="db-chart-card">
|
||
<div class="db-chart-title">🛣️ Jalan per Status</div>
|
||
<canvas id="chartStatusJalan" height="180"></canvas>
|
||
</div>
|
||
<div class="db-chart-card">
|
||
<div class="db-chart-title">📋 Persil per Kepemilikan</div>
|
||
<canvas id="chartKepemilikan" height="180"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
${Object.keys(d.per_status).length > 0 ? `
|
||
<div class="db-section">
|
||
<div class="db-section-title">🛣️ Rincian Status Jalan</div>
|
||
<div class="db-table-wrap">
|
||
<table class="db-table">
|
||
<thead><tr><th>Status</th><th>Jumlah Ruas</th></tr></thead>
|
||
<tbody>
|
||
${Object.entries(d.per_status).map(([k,v]) => `
|
||
<tr><td>${esc(k)}</td><td><span class="db-badge">${v}</span></td></tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>` : '<div class="db-empty">Belum ada data jalan tersimpan di database.</div>'}`;
|
||
|
||
setTimeout(() => {
|
||
drawPieChart('chartStatusJalan', d.per_status, ['#3b82f6','#0ea5e9','#38bdf8']);
|
||
drawPieChart('chartKepemilikan', d.per_kepemilikan, ['#8b5cf6','#a78bfa','#c4b5fd','#7c3aed']);
|
||
}, 50);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// Tab 3: SPBU
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
function renderSpbu(el, d) {
|
||
el.innerHTML = `
|
||
<div class="db-kpi-grid">
|
||
<div class="db-kpi-card" style="--kpi-color:#f97316">
|
||
<div class="db-kpi-icon">⛽</div>
|
||
<div class="db-kpi-val">${d.total_spbu}</div>
|
||
<div class="db-kpi-label">Total SPBU</div>
|
||
</div>
|
||
<div class="db-kpi-card" style="--kpi-color:#22c55e">
|
||
<div class="db-kpi-icon">🕐</div>
|
||
<div class="db-kpi-val">${d.buka_24_jam}</div>
|
||
<div class="db-kpi-label">Buka 24 Jam</div>
|
||
</div>
|
||
<div class="db-kpi-card" style="--kpi-color:#ef4444">
|
||
<div class="db-kpi-icon">🕙</div>
|
||
<div class="db-kpi-val">${d.tidak_24_jam}</div>
|
||
<div class="db-kpi-label">Tidak 24 Jam</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="db-charts-grid">
|
||
<div class="db-chart-card">
|
||
<div class="db-chart-title">⏰ Status Operasional</div>
|
||
<canvas id="chartSpbuOps" height="180"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
${d.list.length > 0 ? `
|
||
<div class="db-section">
|
||
<div class="db-section-title">📍 Daftar SPBU</div>
|
||
<div class="db-table-wrap">
|
||
<table class="db-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Nama SPBU</th>
|
||
<th>Nomor WA</th>
|
||
<th>Operasional</th>
|
||
<th>Koordinat</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${d.list.map((s, i) => `
|
||
<tr>
|
||
<td>${i+1}</td>
|
||
<td><strong>${esc(s.nama_spbu)}</strong></td>
|
||
<td>${esc(s.nomor_wa)}</td>
|
||
<td>${s.buka_24_jam == 1
|
||
? '<span class="db-badge" style="background:#22c55e">24 Jam</span>'
|
||
: '<span class="db-badge" style="background:#f59e0b">Terbatas</span>'}</td>
|
||
<td style="font-size:11px;color:#888">${parseFloat(s.latitude).toFixed(5)}, ${parseFloat(s.longitude).toFixed(5)}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>` : '<div class="db-empty">Belum ada data SPBU tersimpan di database.</div>'}`;
|
||
|
||
setTimeout(() => {
|
||
drawPieChart('chartSpbuOps', { 'Buka 24 Jam': d.buka_24_jam, 'Tidak 24 Jam': d.tidak_24_jam }, ['#22c55e','#ef4444']);
|
||
}, 50);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// Tab 4: Penduduk (Choropleth Kecamatan)
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
function renderPenduduk(el, d) {
|
||
const maxDensity = Math.max(...d.kecamatan.map(k => k.density));
|
||
|
||
el.innerHTML = `
|
||
<div class="db-kpi-grid">
|
||
<div class="db-kpi-card" style="--kpi-color:#0891b2">
|
||
<div class="db-kpi-icon">🗺️</div>
|
||
<div class="db-kpi-val">${d.total_kecamatan}</div>
|
||
<div class="db-kpi-label">Kecamatan</div>
|
||
</div>
|
||
<div class="db-kpi-card" style="--kpi-color:#06b6d4">
|
||
<div class="db-kpi-icon">📊</div>
|
||
<div class="db-kpi-val">${d.rata_density.toLocaleString('id-ID')}</div>
|
||
<div class="db-kpi-label">Rata-rata Kepadatan (jiwa/km²)</div>
|
||
</div>
|
||
<div class="db-kpi-card" style="--kpi-color:#0e7490">
|
||
<div class="db-kpi-icon">🏆</div>
|
||
<div class="db-kpi-val" style="font-size:14px">${esc(d.terpadat)}</div>
|
||
<div class="db-kpi-label">Kecamatan Terpadat</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="db-section">
|
||
<div class="db-section-title">👥 Kepadatan Penduduk per Kecamatan (jiwa/km²)</div>
|
||
${d.kecamatan.map(k => {
|
||
const pct = ((k.density / maxDensity) * 100).toFixed(1);
|
||
const color = k.density >= 8 ? '#ef4444' : k.density >= 5 ? '#f59e0b' : '#22c55e';
|
||
return `
|
||
<div style="margin-bottom:10px">
|
||
<div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:3px">
|
||
<span>${esc(k.nama)}</span>
|
||
<strong style="color:${color}">${k.density.toLocaleString('id-ID')} jiwa/km²</strong>
|
||
</div>
|
||
<div class="db-progress-bar">
|
||
<div class="db-progress-fill" style="width:${pct}%; background:${color};"></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
<div style="margin-top:8px; font-size:11px; color:#888; display:flex; gap:16px">
|
||
<span>🟢 <5 Rendah</span>
|
||
<span>🟡 5–8 Sedang</span>
|
||
<span>🔴 ≥8 Tinggi</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="db-charts-grid">
|
||
<div class="db-chart-card db-chart-wide">
|
||
<div class="db-chart-title">📊 Kepadatan per Kecamatan</div>
|
||
<canvas id="chartDensity" height="160"></canvas>
|
||
</div>
|
||
</div>`;
|
||
|
||
setTimeout(() => {
|
||
drawDensityBar('chartDensity', d.kecamatan);
|
||
}, 50);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// Chart Helpers
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
function drawPieChart(canvasId, dataObj, colors) {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return;
|
||
const entries = Object.entries(dataObj || {}).filter(([,v]) => v > 0);
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
if (entries.length === 0) {
|
||
ctx.fillStyle = '#aaa'; ctx.font = '13px Inter'; ctx.textAlign = 'center';
|
||
ctx.fillText('Belum ada data', canvas.width/2, canvas.height/2);
|
||
return;
|
||
}
|
||
|
||
const labels = entries.map(([k]) => k);
|
||
const values = entries.map(([,v]) => v);
|
||
const total = values.reduce((a,b) => a+b, 0);
|
||
const cx = canvas.width/2, cy = canvas.height * 0.42;
|
||
const r = Math.min(cx, cy) * 0.72;
|
||
|
||
let startAngle = -Math.PI/2;
|
||
values.forEach((v,i) => {
|
||
const slice = (v/total)*2*Math.PI;
|
||
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||
ctx.arc(cx, cy, r, startAngle, startAngle+slice);
|
||
ctx.closePath();
|
||
ctx.fillStyle = colors[i % colors.length];
|
||
ctx.fill(); ctx.strokeStyle='#fff'; ctx.lineWidth=2; ctx.stroke();
|
||
startAngle += slice;
|
||
});
|
||
|
||
ctx.fillStyle='#333'; ctx.font='bold 15px Inter'; ctx.textAlign='center';
|
||
ctx.fillText(total, cx, cy+5);
|
||
|
||
const legY = cy+r+14, legItemW = 92, perRow = Math.floor(canvas.width/legItemW);
|
||
labels.forEach((lbl,i) => {
|
||
const row=Math.floor(i/perRow), col=i%perRow;
|
||
const lx=(canvas.width - Math.min(labels.length,perRow)*legItemW)/2 + col*legItemW;
|
||
const ly=legY+row*17;
|
||
ctx.fillStyle=colors[i%colors.length]; ctx.fillRect(lx,ly,10,10);
|
||
ctx.fillStyle='#555'; ctx.font='10px Inter'; ctx.textAlign='left';
|
||
const shortLbl = lbl.length>11 ? lbl.slice(0,10)+'…' : lbl;
|
||
ctx.fillText(`${shortLbl} (${values[i]})`, lx+13, ly+9);
|
||
});
|
||
}
|
||
|
||
function drawBarChart(canvasId, perBulan) {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
if (!perBulan || perBulan.length === 0) {
|
||
ctx.fillStyle='#aaa'; ctx.font='13px Inter'; ctx.textAlign='center';
|
||
ctx.fillText('Belum ada data bantuan', canvas.width/2, canvas.height/2);
|
||
return;
|
||
}
|
||
const padL=36, padR=12, padT=12, padB=28;
|
||
const W=canvas.width-padL-padR, H=canvas.height-padT-padB;
|
||
const values=perBulan.map(d=>parseInt(d.cnt)), maxVal=Math.max(...values,1);
|
||
const barW=W/perBulan.length*0.6, gap=W/perBulan.length;
|
||
ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=1;
|
||
for (let i=0;i<=4;i++){
|
||
const y=padT+H-(i/4)*H;
|
||
ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(padL+W,y); ctx.stroke();
|
||
ctx.fillStyle='#9ca3af'; ctx.font='9px Inter'; ctx.textAlign='right';
|
||
ctx.fillText(Math.round((i/4)*maxVal), padL-4, y+3);
|
||
}
|
||
perBulan.forEach((d,i)=>{
|
||
const bh=(parseInt(d.cnt)/maxVal)*H, bx=padL+i*gap+(gap-barW)/2, by=padT+H-bh;
|
||
const grad=ctx.createLinearGradient(0,by,0,padT+H);
|
||
grad.addColorStop(0,'#6366f1'); grad.addColorStop(1,'#a5b4fc');
|
||
ctx.fillStyle=grad;
|
||
ctx.beginPath();
|
||
if(ctx.roundRect) ctx.roundRect(bx,by,barW,bh,[4,4,0,0]); else ctx.rect(bx,by,barW,bh);
|
||
ctx.fill();
|
||
ctx.fillStyle='#4f46e5'; ctx.font='bold 10px Inter'; ctx.textAlign='center';
|
||
ctx.fillText(d.cnt, bx+barW/2, by-4);
|
||
const ml=d.bulan ? d.bulan.slice(5)+'/'+d.bulan.slice(2,4) : '';
|
||
ctx.fillStyle='#6b7280'; ctx.font='9px Inter';
|
||
ctx.fillText(ml, bx+barW/2, padT+H+14);
|
||
});
|
||
}
|
||
|
||
function drawDensityBar(canvasId, kecamatan) {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
const padL=130, padR=50, padT=12, padB=12;
|
||
const W=canvas.width-padL-padR, H=canvas.height-padT-padB;
|
||
const maxVal=Math.max(...kecamatan.map(k=>k.density),1);
|
||
const barH=Math.floor(H/kecamatan.length)-6;
|
||
|
||
kecamatan.forEach((k,i) => {
|
||
const bw=(k.density/maxVal)*W;
|
||
const by=padT+i*(barH+6);
|
||
const color=k.density>=8?'#ef4444':k.density>=5?'#f59e0b':'#22c55e';
|
||
// Label kecamatan
|
||
ctx.fillStyle='#374151'; ctx.font='11px Inter'; ctx.textAlign='right';
|
||
ctx.fillText(k.nama, padL-6, by+barH/2+4);
|
||
// Bar
|
||
ctx.fillStyle=color+'33';
|
||
ctx.fillRect(padL, by, W, barH);
|
||
const grad=ctx.createLinearGradient(padL,0,padL+bw,0);
|
||
grad.addColorStop(0,color); grad.addColorStop(1,color+'99');
|
||
ctx.fillStyle=grad;
|
||
if(ctx.roundRect) ctx.roundRect(padL,by,bw,barH,[0,4,4,0]); else ctx.fillRect(padL,by,bw,barH);
|
||
ctx.fill();
|
||
// Value
|
||
ctx.fillStyle=color; ctx.font='bold 11px Inter'; ctx.textAlign='left';
|
||
ctx.fillText(k.density.toFixed(3), padL+bw+5, by+barH/2+4);
|
||
});
|
||
}
|
||
|
||
// ── Utility ───────────────────────────────────────────────────────────
|
||
function esc(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
})();
|