946 lines
43 KiB
HTML
946 lines
43 KiB
HTML
<!doctype html>
|
||
<html lang="id">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>WebGIS Poverty Mapping Gereja</title>
|
||
<meta name="description" content="Peta distribusi bantuan sosial gereja berbasis WebGIS — analisis kemiskinan SAW, buffer, routing, dan pemetaan lokasi." />
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
|
||
<style>
|
||
:root {
|
||
--bg-dark: #0f172a;
|
||
--bg-panel: #111827;
|
||
--text-main: #f8fafc;
|
||
--text-muted: #94a3b8;
|
||
--border: rgba(148, 163, 184, 0.22);
|
||
--accent: #7c3aed;
|
||
--accent-2: #06b6d4;
|
||
--red: #dc2626;
|
||
--orange: #f97316;
|
||
--yellow: #facc15;
|
||
--green: #22c55e;
|
||
--radius: 20px;
|
||
--shadow: 0 22px 60px rgba(15, 23, 42, 0.32);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
overflow: hidden;
|
||
font-family: "Inter", system-ui, sans-serif;
|
||
color: var(--text-main);
|
||
background: linear-gradient(135deg, #130f2e 0%, #111827 54%, #312e81 100%);
|
||
}
|
||
button, input, select { font: inherit; }
|
||
a { color: inherit; text-decoration: none; }
|
||
.app { display: grid; grid-template-columns: 360px minmax(0, 1fr); min-height: 100vh; }
|
||
|
||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||
.sidebar {
|
||
position: relative;
|
||
z-index: 900;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
padding: 20px 16px;
|
||
background:
|
||
radial-gradient(circle at 20% 0%, rgba(124, 58, 237, .28), transparent 30%),
|
||
linear-gradient(180deg, var(--bg-dark), var(--bg-panel));
|
||
box-shadow: var(--shadow);
|
||
border-right: 1px solid rgba(255,255,255,.08);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(124,58,237,.3) transparent;
|
||
}
|
||
.brand { margin-bottom: 16px; }
|
||
.brand small { color: #a5b4fc; font-weight: 800; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; }
|
||
.brand h1 { margin: 6px 0 6px; font-size: 1.45rem; line-height: 1.12; }
|
||
.brand p { margin: 0; color: #cbd5e1; line-height: 1.45; font-size: .86rem; }
|
||
|
||
.panel {
|
||
border: 1px solid rgba(255,255,255,.11);
|
||
background: rgba(255,255,255,.06);
|
||
border-radius: var(--radius);
|
||
padding: 14px;
|
||
margin-bottom: 12px;
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
.panel h2 {
|
||
margin: 0 0 11px;
|
||
font-size: .88rem;
|
||
font-weight: 800;
|
||
letter-spacing: .05em;
|
||
text-transform: uppercase;
|
||
color: #a5b4fc;
|
||
}
|
||
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.stat { background: rgba(15,23,42,.5); border: 1px solid var(--border); border-radius: 16px; padding: 11px; }
|
||
.stat span { display: block; color: var(--text-muted); font-size: .7rem; font-weight: 800; text-transform: uppercase; }
|
||
.stat strong { display: block; margin-top: 5px; font-size: 1.28rem; }
|
||
|
||
.field { display: grid; gap: 6px; margin-bottom: 10px; color: #e5e7eb; font-size: .8rem; font-weight: 800; }
|
||
.field:last-child { margin-bottom: 0; }
|
||
input[type="text"], input[type="number"], select, textarea {
|
||
width: 100%;
|
||
border: 1px solid var(--border);
|
||
border-radius: 13px;
|
||
padding: 10px 11px;
|
||
outline: none;
|
||
color: #f8fafc;
|
||
background: rgba(15,23,42,.65);
|
||
transition: border-color .15s, box-shadow .15s;
|
||
}
|
||
input:focus, select:focus, textarea:focus {
|
||
border-color: #a78bfa;
|
||
box-shadow: 0 0 0 3px rgba(124,58,237,.18);
|
||
}
|
||
.switch {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
border: 1px solid var(--border);
|
||
background: rgba(15,23,42,.38);
|
||
border-radius: 14px;
|
||
padding: 10px 11px;
|
||
margin-top: 7px;
|
||
font-size: .82rem;
|
||
font-weight: 700;
|
||
}
|
||
.switch input { width: auto; }
|
||
|
||
.legend { display: grid; gap: 8px; color: #e5e7eb; font-size: .82rem; }
|
||
.legend span { display: flex; align-items: center; gap: 8px; }
|
||
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0; box-shadow: 0 0 0 3px rgba(255,255,255,.08); }
|
||
|
||
.list { display: grid; gap: 8px; max-height: 280px; overflow: auto; padding-right: 2px; }
|
||
.family-item {
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
background: rgba(15,23,42,.42);
|
||
padding: 10px;
|
||
cursor: pointer;
|
||
transition: .15s;
|
||
}
|
||
.family-item:hover { border-color: rgba(167,139,250,.7); background: rgba(124,58,237,.18); }
|
||
.family-item strong { display: block; font-size: .88rem; }
|
||
.family-item small { color: #cbd5e1; line-height: 1.5; font-size: .76rem; }
|
||
|
||
.badge { display: inline-flex; border-radius: 999px; padding: 3px 9px; font-size: .69rem; font-weight: 900; margin-top: 6px; }
|
||
.sangat { background: #fee2e2; color: #7f1d1d; }
|
||
.butuh { background: #ffedd5; color: #7c2d12; }
|
||
.rentan { background: #fef9c3; color: #713f12; }
|
||
.mampu { background: #dcfce7; color: #14532d; }
|
||
.status-menunggu { background: #fef3c7; color: #92400e; }
|
||
.status-diverifikasi{ background: #dbeafe; color: #1e40af; }
|
||
.status-disetujui { background: #dcfce7; color: #14532d; }
|
||
.status-ditolak { background: #fee2e2; color: #7f1d1d; }
|
||
.status-selesai { background: #e0e7ff; color: #3730a3; }
|
||
|
||
/* Buffer panel buttons */
|
||
.radius-btns { display: flex; gap: 7px; margin-top: 8px; flex-wrap: wrap; }
|
||
.radius-btn {
|
||
flex: 1;
|
||
min-width: 56px;
|
||
border: 1px solid rgba(167,139,250,.5);
|
||
border-radius: 12px;
|
||
padding: 9px 6px;
|
||
background: rgba(124,58,237,.18);
|
||
color: #c4b5fd;
|
||
font-size: .78rem;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
transition: .15s;
|
||
text-align: center;
|
||
}
|
||
.radius-btn:hover, .radius-btn.active {
|
||
background: rgba(124,58,237,.5);
|
||
border-color: #a78bfa;
|
||
color: #fff;
|
||
}
|
||
.radius-btn.clear-btn {
|
||
background: rgba(220,38,38,.15);
|
||
border-color: rgba(220,38,38,.4);
|
||
color: #fca5a5;
|
||
}
|
||
.radius-btn.clear-btn:hover { background: rgba(220,38,38,.35); border-color: #f87171; color: #fff; }
|
||
|
||
.church-select { margin-bottom: 8px; }
|
||
.buffer-result {
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
border-radius: 12px;
|
||
background: rgba(124,58,237,.18);
|
||
border: 1px solid rgba(167,139,250,.3);
|
||
font-size: .82rem;
|
||
color: #e5e7eb;
|
||
display: none;
|
||
}
|
||
.buffer-result.show { display: block; }
|
||
.buffer-result strong { color: #c4b5fd; }
|
||
|
||
/* Nearest church panel */
|
||
.nearest-result {
|
||
padding: 10px;
|
||
border-radius: 12px;
|
||
background: rgba(6,182,212,.1);
|
||
border: 1px solid rgba(6,182,212,.28);
|
||
color: #e5e7eb;
|
||
font-size: .82rem;
|
||
margin-top: 8px;
|
||
display: none;
|
||
}
|
||
.nearest-result.show { display: block; }
|
||
.nearest-result h4 { margin: 0 0 5px; font-size: .88rem; color: #67e8f9; }
|
||
.nearest-result p { margin: 3px 0; }
|
||
|
||
/* Map */
|
||
#map { height: 100vh; width: 100%; background: #eef2f7; }
|
||
.map-top {
|
||
position: absolute;
|
||
z-index: 800;
|
||
top: 16px;
|
||
right: 16px;
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
.map-card {
|
||
border: 1px solid rgba(15,23,42,.12);
|
||
background: rgba(255,255,255,.93);
|
||
color: #0f172a;
|
||
border-radius: 16px;
|
||
box-shadow: var(--shadow);
|
||
padding: 10px 14px;
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
.map-card strong { display: block; font-size: 1rem; }
|
||
.map-card span { color: #64748b; font-size: .75rem; font-weight: 700; }
|
||
|
||
/* Mobile */
|
||
.mobile-toggle {
|
||
position: fixed;
|
||
display: none;
|
||
z-index: 1200;
|
||
top: 14px;
|
||
left: 14px;
|
||
width: 44px;
|
||
height: 44px;
|
||
border: 0;
|
||
border-radius: 14px;
|
||
background: #0f172a;
|
||
color: white;
|
||
box-shadow: var(--shadow);
|
||
cursor: pointer;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
/* Popup */
|
||
.leaflet-popup-content-wrapper { border-radius: 20px; overflow: hidden; padding: 0; }
|
||
.leaflet-popup-content { margin: 0; width: 310px !important; }
|
||
.popup { color: #0f172a; padding: 16px; }
|
||
.popup h3 { margin: 0 0 5px; font-size: 1rem; }
|
||
.popup p { margin: 0 0 8px; color: #475569; line-height: 1.4; font-size: .86rem; }
|
||
.popup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin: 8px 0; }
|
||
.popup-grid div { border-radius: 11px; background: #f8fafc; padding: 8px; }
|
||
.popup-grid span { display: block; color: #64748b; font-size: .68rem; font-weight: 800; text-transform: uppercase; }
|
||
.popup-grid strong { display: block; margin-top: 2px; font-size: .84rem; }
|
||
.history { border-top: 1px solid #e5e7eb; margin-top: 8px; padding-top: 8px; }
|
||
.history ul { margin: 6px 0 0 16px; padding: 0; color: #334155; font-size: .82rem; }
|
||
.nearest-info { margin-top: 8px; padding: 8px; border-radius: 11px; background: #eff6ff; border: 1px solid #bfdbfe; }
|
||
.nearest-info p { margin: 2px 0; color: #1e3a8a; font-size: .8rem; }
|
||
.popup-btn {
|
||
display: block;
|
||
width: 100%;
|
||
margin-top: 8px;
|
||
border: 0;
|
||
border-radius: 11px;
|
||
padding: 9px 12px;
|
||
font-weight: 800;
|
||
font-size: .82rem;
|
||
cursor: pointer;
|
||
text-align: center;
|
||
transition: .15s;
|
||
}
|
||
.popup-btn.primary { background: #7c3aed; color: #fff; }
|
||
.popup-btn.primary:hover { background: #6d28d9; }
|
||
.popup-btn.route { background: #0891b2; color: #fff; }
|
||
.popup-btn.route:hover { background: #0e7490; }
|
||
|
||
/* Routing control */
|
||
.leaflet-routing-container { display: none; } /* hide default panel */
|
||
|
||
/* Toast */
|
||
.toast {
|
||
position: fixed;
|
||
right: 20px;
|
||
bottom: 20px;
|
||
background: #0f172a;
|
||
color: white;
|
||
padding: 12px 16px;
|
||
border-radius: 13px;
|
||
box-shadow: var(--shadow);
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
pointer-events: none;
|
||
transition: .22s ease;
|
||
z-index: 2000;
|
||
font-size: .9rem;
|
||
max-width: 340px;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateY(0); }
|
||
|
||
/* Ibadah legend */
|
||
.ibadah-legend { display: none; }
|
||
.ibadah-legend.active { display: block; }
|
||
|
||
/* Map markers */
|
||
.ibadah-map-marker {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 999px;
|
||
color: white;
|
||
border: 3px solid white;
|
||
box-shadow: 0 8px 22px rgba(15,23,42,.28);
|
||
font-size: 14px;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
body { overflow: hidden; }
|
||
.app { grid-template-columns: 1fr; }
|
||
.sidebar {
|
||
position: fixed;
|
||
inset: 0 auto 0 0;
|
||
width: min(90vw, 360px);
|
||
transform: translateX(-105%);
|
||
transition: transform .24s ease;
|
||
z-index: 1100;
|
||
}
|
||
.sidebar.open { transform: translateX(0); }
|
||
.mobile-toggle { display: block; }
|
||
.map-top { top: 68px; left: 12px; right: 12px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<button class="mobile-toggle" id="mobileToggle" aria-label="Buka Menu">☰</button>
|
||
<div class="toast" id="toast"></div>
|
||
<div class="app">
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="brand">
|
||
<small>Gereja Peduli</small>
|
||
<h1>WebGIS Poverty Mapping</h1>
|
||
<p>Peta prioritas keluarga penerima bantuan berbasis SAW, buffer analysis, dan routing OSM.</p>
|
||
</div>
|
||
|
||
<!-- Stats -->
|
||
<section class="panel">
|
||
<h2>Statistik</h2>
|
||
<div class="stats">
|
||
<div class="stat"><span>Total Keluarga</span><strong id="statTotal">0</strong></div>
|
||
<div class="stat"><span>Prioritas Tinggi</span><strong id="statHigh">0</strong></div>
|
||
<div class="stat"><span>Tersalurkan</span><strong id="statAid">Rp0</strong></div>
|
||
<div class="stat"><span>Wilayah</span><strong id="statArea">0</strong></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Filter -->
|
||
<section class="panel">
|
||
<h2>Filter Peta</h2>
|
||
<label class="field">Kategori Kemiskinan
|
||
<select id="categoryFilter">
|
||
<option value="">Semua Kategori</option>
|
||
<option value="Sangat Membutuhkan">Sangat Membutuhkan</option>
|
||
<option value="Membutuhkan">Membutuhkan</option>
|
||
<option value="Rentan">Rentan</option>
|
||
<option value="Relatif Mampu">Relatif Mampu</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">Cari Keluarga
|
||
<input id="searchInput" type="text" placeholder="Nama, alamat, atau wilayah…" />
|
||
</label>
|
||
<label class="switch"><span>🌡️ Heatmap Persebaran</span><input id="heatToggle" type="checkbox" checked /></label>
|
||
<label class="switch"><span>⛪ Tampilkan Gereja</span><input id="ibadahToggle" type="checkbox" /></label>
|
||
</section>
|
||
|
||
<!-- ═══ Buffer Analysis ═══════════════════════════════════════ -->
|
||
<section class="panel" id="bufferPanel">
|
||
<h2>📍 Buffer Analysis</h2>
|
||
<label class="field church-select">Pilih Gereja
|
||
<select id="bufferChurchSelect">
|
||
<option value="">— Memuat gereja… —</option>
|
||
</select>
|
||
</label>
|
||
<div class="radius-btns">
|
||
<button class="radius-btn" data-radius="1000" id="btn1km">1 km</button>
|
||
<button class="radius-btn" data-radius="3000" id="btn3km">3 km</button>
|
||
<button class="radius-btn" data-radius="5000" id="btn5km">5 km</button>
|
||
<button class="radius-btn clear-btn" id="btnClearBuffer">Hapus</button>
|
||
</div>
|
||
<div class="buffer-result" id="bufferResult">
|
||
Gereja: <strong id="bufferChurchName">—</strong><br/>
|
||
Radius: <strong id="bufferRadiusLabel">—</strong><br/>
|
||
Keluarga dalam radius: <strong id="bufferCount">0</strong><br/>
|
||
Prioritas tinggi: <strong id="bufferHighCount">0</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══ Nearest Church Analysis ══════════════════════════════ -->
|
||
<section class="panel">
|
||
<h2>🔍 Nearest Church Analysis</h2>
|
||
<p style="font-size:.8rem;color:#94a3b8;margin:0 0 8px">Klik marker keluarga di peta untuk melihat gereja terdekat.</p>
|
||
<div class="nearest-result" id="nearestResult">
|
||
<h4 id="nearestName">—</h4>
|
||
<p>📍 <span id="nearestAlamat">—</span></p>
|
||
<p>📏 Jarak: <strong id="nearestJarak">—</strong></p>
|
||
<p>⛪ Jenis: <span id="nearestJenis">—</span></p>
|
||
<p>🏠 Kapasitas: <span id="nearestKapasitas">—</span> keluarga</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══ Legenda ═══════════════════════════════════════════════ -->
|
||
<section class="panel">
|
||
<h2>Legenda Keluarga</h2>
|
||
<div class="legend">
|
||
<span><i class="dot" style="background:var(--red)"></i>Sangat Membutuhkan</span>
|
||
<span><i class="dot" style="background:var(--orange)"></i>Membutuhkan</span>
|
||
<span><i class="dot" style="background:var(--yellow)"></i>Rentan</span>
|
||
<span><i class="dot" style="background:var(--green)"></i>Relatif Mampu</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel ibadah-legend" id="ibadahLegendPanel">
|
||
<h2>Legenda Gereja</h2>
|
||
<div class="legend">
|
||
<span><i class="dot" style="background:#7c3aed"></i>Gereja Katolik</span>
|
||
<span><i class="dot" style="background:#2563eb"></i>GPIB</span>
|
||
<span><i class="dot" style="background:#0891b2"></i>GKI</span>
|
||
<span><i class="dot" style="background:#16a34a"></i>HKBP</span>
|
||
<span><i class="dot" style="background:#ea580c"></i>Pentakosta</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Statistik Wilayah -->
|
||
<section class="panel">
|
||
<h2>Distribusi Wilayah</h2>
|
||
<div id="areaStats"><p style="color:#94a3b8;font-size:.82rem">Memuat…</p></div>
|
||
</section>
|
||
|
||
<!-- Daftar Keluarga -->
|
||
<section class="panel">
|
||
<h2>Daftar Keluarga</h2>
|
||
<div class="list" id="familyList"></div>
|
||
</section>
|
||
|
||
<!-- Link -->
|
||
<section class="panel" style="display:flex;gap:10px;flex-wrap:wrap">
|
||
<a href="/admin-poverty.html" style="flex:1;text-align:center;padding:9px;border-radius:12px;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.3);font-size:.82rem;font-weight:700">⚙️ Admin</a>
|
||
<a href="/pengajuan.html" style="flex:1;text-align:center;padding:9px;border-radius:12px;background:rgba(6,182,212,.18);border:1px solid rgba(6,182,212,.3);font-size:.82rem;font-weight:700">📋 Ajukan Bantuan</a>
|
||
<a href="/" style="flex:1;text-align:center;padding:9px;border-radius:12px;background:rgba(255,255,255,.07);border:1px solid var(--border);font-size:.82rem;font-weight:700">⛽ SPBU</a>
|
||
</section>
|
||
</aside>
|
||
|
||
<main style="position:relative">
|
||
<div class="map-top">
|
||
<div class="map-card"><span>Ranking #1</span><strong id="topFamily">-</strong></div>
|
||
<div class="map-card"><span>Rata-rata Skor SAW</span><strong id="avgScore">0.000</strong></div>
|
||
</div>
|
||
<div id="map"></div>
|
||
</main>
|
||
</div>
|
||
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.min.js"></script>
|
||
<script>
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
STATE & MAP INIT
|
||
══════════════════════════════════════════════════════════════════ */
|
||
const API = "/api/poverty";
|
||
const state = {
|
||
rows: [],
|
||
dashboard: null,
|
||
markers: new Map(),
|
||
ibadah: [],
|
||
ibadahLoaded: false,
|
||
bufferCircle: null,
|
||
bufferHighlights: [],
|
||
activeRadius: null,
|
||
routingControl: null,
|
||
};
|
||
|
||
const map = L.map("map", { zoomControl: true }).setView([-0.0263, 109.3425], 12);
|
||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||
maxZoom: 19,
|
||
attribution: "© <a href='https://openstreetmap.org'>OpenStreetMap</a>",
|
||
}).addTo(map);
|
||
|
||
const markerLayer = L.layerGroup().addTo(map);
|
||
const heatLayer = L.layerGroup().addTo(map);
|
||
const ibadahLayer = L.layerGroup();
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
HELPERS
|
||
══════════════════════════════════════════════════════════════════ */
|
||
const el = (id) => document.getElementById(id);
|
||
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) =>
|
||
({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
||
const rp = (v) => v == null ? "Disembunyikan"
|
||
: new Intl.NumberFormat("id-ID", { style:"currency", currency:"IDR", maximumFractionDigits:0 }).format(Number(v||0));
|
||
const fmt = (v) => Number(v||0).toFixed(3);
|
||
const fmtM = (m) => m >= 1000 ? `${(m/1000).toFixed(1)} km` : `${m} m`;
|
||
|
||
const badgeClass = (k) =>
|
||
k === "Sangat Membutuhkan" ? "sangat" : k === "Membutuhkan" ? "butuh" : k === "Rentan" ? "rentan" : "mampu";
|
||
const markerColor = (k) =>
|
||
k === "Sangat Membutuhkan" ? "#dc2626" : k === "Membutuhkan" ? "#f97316" : k === "Rentan" ? "#facc15" : "#22c55e";
|
||
const ibadahColors = {
|
||
"Gereja Katolik":"#7c3aed", GPIB:"#2563eb", GKI:"#0891b2", HKBP:"#16a34a", Pentakosta:"#ea580c",
|
||
};
|
||
|
||
let toastTimer;
|
||
function toast(msg, duration = 2800) {
|
||
el("toast").textContent = msg;
|
||
el("toast").classList.add("show");
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => el("toast").classList.remove("show"), duration);
|
||
}
|
||
|
||
async function fetchJson(path) {
|
||
const r = await fetch(`${API}${path}`);
|
||
const payload = await r.json();
|
||
if (!r.ok || payload.success === false) throw new Error(payload.message || "Gagal memuat data");
|
||
return payload;
|
||
}
|
||
|
||
function params() {
|
||
const k = encodeURIComponent(el("categoryFilter").value);
|
||
const s = encodeURIComponent(el("searchInput").value.trim());
|
||
return `kategori=${k}&search=${s}`;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
LOAD DATA
|
||
══════════════════════════════════════════════════════════════════ */
|
||
async function loadData() {
|
||
const [mapPay, dashPay] = await Promise.all([
|
||
fetchJson(`/map?${params()}`),
|
||
fetchJson("/dashboard"),
|
||
]);
|
||
state.rows = mapPay.data || [];
|
||
state.dashboard = dashPay.data || {};
|
||
renderAll();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
RENDER STATS
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function renderStats() {
|
||
const d = state.dashboard || {};
|
||
el("statTotal").textContent = d.total_keluarga || 0;
|
||
el("statHigh").textContent = d.prioritas_tinggi || 0;
|
||
el("statAid").textContent = rp(d.total_bantuan_tersalurkan || 0);
|
||
el("statArea").textContent = Object.keys(d.wilayah || {}).length;
|
||
const sorted = [...state.rows].sort((a,b) => Number(a.ranking||99) - Number(b.ranking||99));
|
||
el("topFamily").textContent = sorted[0]?.nama_kepala_keluarga || "-";
|
||
const avg = state.rows.length
|
||
? state.rows.reduce((s,r)=>s+Number(r.skor_saw||0),0)/state.rows.length : 0;
|
||
el("avgScore").textContent = fmt(avg);
|
||
}
|
||
|
||
function renderAreaStats() {
|
||
const area = state.rows.reduce((acc, r) => { acc[r.wilayah]=(acc[r.wilayah]||0)+1; return acc; }, {});
|
||
const max = Math.max(1, ...Object.values(area));
|
||
el("areaStats").innerHTML = Object.entries(area).map(([name,count]) => `
|
||
<div style="margin:8px 0;font-size:.8rem;color:#e5e7eb">
|
||
<div style="display:flex;justify-content:space-between;margin-bottom:3px"><span>${esc(name)}</span><strong>${count}</strong></div>
|
||
<div style="height:7px;border-radius:99px;background:rgba(255,255,255,.1);overflow:hidden">
|
||
<div style="height:100%;width:${(count/max)*100}%;background:linear-gradient(90deg,#7c3aed,#06b6d4)"></div>
|
||
</div>
|
||
</div>`).join("") || `<p style="color:#94a3b8;font-size:.82rem">Belum ada data.</p>`;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
POPUP HTML – KELUARGA
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function popupHtml(item, nearestChurch) {
|
||
const hist = (item.riwayat_bantuan || []).length
|
||
? `<ul>${item.riwayat_bantuan.map(r=>`<li>${esc(r.jenis_bantuan)} – ${rp(r.jumlah_bantuan)} (${String(r.tanggal_diterima||"").slice(0,10)})</li>`).join("")}</ul>`
|
||
: "<p>Belum ada riwayat bantuan.</p>";
|
||
|
||
const nearestHtml = nearestChurch ? `
|
||
<div class="nearest-info">
|
||
<p>⛪ <strong>Gereja terdekat:</strong> ${esc(nearestChurch.nama)}</p>
|
||
<p>📏 Jarak: <strong>${fmtM(nearestChurch.jarak_meter)}</strong></p>
|
||
<p>🏠 Kapasitas: ${nearestChurch.kapasitas_bantuan || 0} keluarga</p>
|
||
</div>` : "";
|
||
|
||
return `
|
||
<div class="popup">
|
||
<h3>${esc(item.nama_kepala_keluarga)}</h3>
|
||
<p>${esc(item.alamat)}<br/>${esc(item.wilayah)}</p>
|
||
<span class="badge ${badgeClass(item.kategori)}">${esc(item.kategori)}</span>
|
||
<div class="popup-grid">
|
||
<div><span>Tanggungan</span><strong>${item.jumlah_tanggungan}</strong></div>
|
||
<div><span>Pendapatan</span><strong>${rp(item.pendapatan_bulanan)}</strong></div>
|
||
<div><span>Skor SAW</span><strong>${fmt(item.skor_saw)}</strong></div>
|
||
<div><span>Ranking</span><strong>#${item.ranking||"-"}</strong></div>
|
||
</div>
|
||
${nearestHtml}
|
||
<div class="history"><strong>Riwayat Bantuan</strong>${hist}</div>
|
||
${nearestChurch ? `<button class="popup-btn route" onclick="showRoute(${item.latitude},${item.longitude},${nearestChurch.lat},${nearestChurch.lng},'${esc(nearestChurch.nama)}')">🗺️ Tampilkan Rute ke Gereja Terdekat</button>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
POPUP HTML – IBADAH (GEREJA)
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function ibadahPopupHtml(item) {
|
||
const mc = ibadahColors[item.jenis] || "#64748b";
|
||
return `
|
||
<div class="popup">
|
||
<h3>${esc(item.nama)}</h3>
|
||
<span class="badge" style="background:${mc}22;color:${mc};border:1px solid ${mc}44">${esc(item.jenis)}</span>
|
||
<p style="margin-top:9px">${esc(item.alamat)}<br/>${esc(item.kelurahan)}</p>
|
||
<div class="popup-grid">
|
||
<div><span>Kapasitas Bantuan</span><strong>${item.kapasitas_bantuan || 0} KK</strong></div>
|
||
<div><span>Koordinat</span><strong>${Number(item.lat).toFixed(5)}, ${Number(item.lng).toFixed(5)}</strong></div>
|
||
</div>
|
||
${item.keterangan ? `<p style="margin-top:8px;font-size:.8rem;color:#475569"><em>${esc(item.keterangan)}</em></p>` : ""}
|
||
<button class="popup-btn primary" onclick="runBufferFromChurch(${item.id}, 1000)">📍 Buffer 1 km dari sini</button>
|
||
</div>`;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
MARKERS RENDER
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function renderMarkers() {
|
||
markerLayer.clearLayers();
|
||
heatLayer.clearLayers();
|
||
state.markers.clear();
|
||
|
||
state.rows.forEach((item) => {
|
||
const ll = [Number(item.latitude), Number(item.longitude)];
|
||
if (!Number.isFinite(ll[0]) || !Number.isFinite(ll[1])) return;
|
||
|
||
if (el("heatToggle").checked) {
|
||
L.circle(ll, {
|
||
radius: 280 + Number(item.skor_saw||0)*800,
|
||
stroke: false,
|
||
fillColor: markerColor(item.kategori),
|
||
fillOpacity: item.kategori === "Sangat Membutuhkan" ? .22 : .12,
|
||
}).addTo(heatLayer);
|
||
}
|
||
|
||
// Find nearest church from loaded ibadah list (client-side)
|
||
let nearest = null;
|
||
if (state.ibadah.length) {
|
||
let minDist = Infinity;
|
||
state.ibadah.forEach((church) => {
|
||
const d = L.latLng(ll).distanceTo(L.latLng([Number(church.lat), Number(church.lng)]));
|
||
if (d < minDist) { minDist = d; nearest = { ...church, jarak_meter: Math.round(d) }; }
|
||
});
|
||
}
|
||
|
||
const marker = L.circleMarker(ll, {
|
||
radius: 9,
|
||
color: "#ffffff",
|
||
weight: 2,
|
||
fillColor: markerColor(item.kategori),
|
||
fillOpacity: .92,
|
||
}).addTo(markerLayer);
|
||
|
||
marker.bindPopup(popupHtml(item, nearest), { maxWidth: 320 });
|
||
|
||
// On popup open: update nearest panel and show in sidebar
|
||
marker.on("popupopen", () => {
|
||
if (nearest) {
|
||
el("nearestResult").classList.add("show");
|
||
el("nearestName").textContent = nearest.nama;
|
||
el("nearestAlamat").textContent = nearest.alamat + ", " + nearest.kelurahan;
|
||
el("nearestJarak").textContent = fmtM(nearest.jarak_meter);
|
||
el("nearestJenis").textContent = nearest.jenis;
|
||
el("nearestKapasitas").textContent = nearest.kapasitas_bantuan || 0;
|
||
}
|
||
});
|
||
|
||
state.markers.set(String(item.id_keluarga), marker);
|
||
});
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
IBADAH (GEREJA) LAYER
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function ibadahIcon(jenis) {
|
||
const c = ibadahColors[jenis] || "#64748b";
|
||
return L.divIcon({
|
||
className: "",
|
||
html: `<div class="ibadah-map-marker" style="background:${c}">⛪</div>`,
|
||
iconSize: [30,30], iconAnchor: [15,30], popupAnchor: [0,-28],
|
||
});
|
||
}
|
||
|
||
function renderIbadahMarkers() {
|
||
ibadahLayer.clearLayers();
|
||
state.ibadah.forEach((item) => {
|
||
const ll = [Number(item.lat), Number(item.lng)];
|
||
if (!Number.isFinite(ll[0]) || !Number.isFinite(ll[1])) return;
|
||
L.marker(ll, { icon: ibadahIcon(item.jenis) })
|
||
.bindPopup(ibadahPopupHtml(item))
|
||
.addTo(ibadahLayer);
|
||
});
|
||
// Populate buffer church select
|
||
populateChurchSelect();
|
||
// Re-render markers to update nearest info
|
||
renderMarkers();
|
||
}
|
||
|
||
async function loadIbadahLayer(force = false) {
|
||
if (state.ibadahLoaded && !force) return;
|
||
const payload = await fetchJson("/ibadah");
|
||
state.ibadah = payload.data || [];
|
||
state.ibadahLoaded = true;
|
||
renderIbadahMarkers();
|
||
}
|
||
|
||
function populateChurchSelect() {
|
||
const sel = el("bufferChurchSelect");
|
||
sel.innerHTML = `<option value="">— Pilih Gereja —</option>` +
|
||
state.ibadah.map(c => `<option value="${c.id}" data-lat="${c.lat}" data-lng="${c.lng}">${esc(c.nama)} (${esc(c.jenis)})</option>`).join("");
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
BUFFER ANALYSIS
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function clearBuffer() {
|
||
if (state.bufferCircle) { map.removeLayer(state.bufferCircle); state.bufferCircle = null; }
|
||
state.bufferHighlights.forEach((c) => map.removeLayer(c));
|
||
state.bufferHighlights = [];
|
||
state.activeRadius = null;
|
||
el("bufferResult").classList.remove("show");
|
||
document.querySelectorAll(".radius-btn[data-radius]").forEach(b => b.classList.remove("active"));
|
||
}
|
||
|
||
function runBuffer(churchId, radiusM) {
|
||
clearBuffer();
|
||
const option = el("bufferChurchSelect").querySelector(`option[value="${churchId}"]`);
|
||
if (!option || !churchId) { toast("Pilih gereja terlebih dahulu."); return; }
|
||
const church = state.ibadah.find(c => String(c.id) === String(churchId));
|
||
if (!church) return;
|
||
|
||
const lat = Number(church.lat);
|
||
const lng = Number(church.lng);
|
||
state.activeRadius = radiusM;
|
||
state.bufferCircle = L.circle([lat, lng], {
|
||
radius: radiusM,
|
||
color: "#7c3aed",
|
||
fillColor: "#7c3aed",
|
||
fillOpacity: .1,
|
||
weight: 2,
|
||
dashArray: "6 4",
|
||
}).addTo(map);
|
||
|
||
const inRadius = [];
|
||
state.rows.forEach((item) => {
|
||
const dist = L.latLng([lat, lng]).distanceTo(L.latLng([Number(item.latitude), Number(item.longitude)]));
|
||
if (dist <= radiusM) {
|
||
inRadius.push(item);
|
||
const hl = L.circleMarker([Number(item.latitude), Number(item.longitude)], {
|
||
radius: 11,
|
||
color: "#fff",
|
||
weight: 2,
|
||
fillColor: "#7c3aed",
|
||
fillOpacity: .85,
|
||
});
|
||
state.bufferHighlights.push(hl);
|
||
hl.addTo(map);
|
||
}
|
||
});
|
||
|
||
map.fitBounds(state.bufferCircle.getBounds(), { padding: [30,30] });
|
||
|
||
const highCount = inRadius.filter(r => r.kategori === "Sangat Membutuhkan").length;
|
||
el("bufferChurchName").textContent = church.nama;
|
||
el("bufferRadiusLabel").textContent = fmtM(radiusM);
|
||
el("bufferCount").textContent = inRadius.length;
|
||
el("bufferHighCount").textContent = highCount;
|
||
el("bufferResult").classList.add("show");
|
||
toast(`Buffer ${fmtM(radiusM)}: ditemukan ${inRadius.length} keluarga, ${highCount} prioritas tinggi.`);
|
||
}
|
||
|
||
// Called from popup button on ibadah marker
|
||
window.runBufferFromChurch = (churchId, radiusM) => {
|
||
// Select the church in the select
|
||
el("bufferChurchSelect").value = churchId;
|
||
// Activate button
|
||
document.querySelectorAll(".radius-btn[data-radius]").forEach(b => {
|
||
b.classList.toggle("active", Number(b.dataset.radius) === radiusM);
|
||
});
|
||
runBuffer(churchId, radiusM);
|
||
map.closePopup();
|
||
};
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
ROUTING (Leaflet Routing Machine + OSRM)
|
||
══════════════════════════════════════════════════════════════════ */
|
||
window.showRoute = (fromLat, fromLng, toLat, toLng, churchName) => {
|
||
if (state.routingControl) {
|
||
map.removeControl(state.routingControl);
|
||
state.routingControl = null;
|
||
}
|
||
toast(`🗺️ Memuat rute ke ${churchName}…`, 5000);
|
||
state.routingControl = L.Routing.control({
|
||
waypoints: [
|
||
L.latLng(fromLat, fromLng),
|
||
L.latLng(toLat, toLng),
|
||
],
|
||
router: L.Routing.osrmv1({
|
||
serviceUrl: "https://router.project-osrm.org/route/v1",
|
||
}),
|
||
routeWhileDragging: false,
|
||
addWaypoints: false,
|
||
fitSelectedRoutes: true,
|
||
lineOptions: {
|
||
styles: [{ color: "#7c3aed", weight: 5, opacity: .85 }],
|
||
},
|
||
createMarker: () => null, // keep original markers
|
||
}).addTo(map);
|
||
|
||
state.routingControl.on("routesfound", (e) => {
|
||
const r = e.routes[0];
|
||
const dist = (r.summary.totalDistance / 1000).toFixed(2);
|
||
const dur = Math.round(r.summary.totalTime / 60);
|
||
toast(`✅ Rute ke ${churchName}: ${dist} km, ±${dur} menit.`, 5000);
|
||
});
|
||
state.routingControl.on("routingerror", () => {
|
||
toast("⚠️ Gagal memuat rute. Periksa koneksi internet.");
|
||
});
|
||
map.closePopup();
|
||
};
|
||
|
||
// Expose clear route globally
|
||
window.clearRoute = () => {
|
||
if (state.routingControl) { map.removeControl(state.routingControl); state.routingControl = null; }
|
||
};
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
FAMILY LIST
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function renderList() {
|
||
el("familyList").innerHTML = state.rows.map((item) => `
|
||
<article class="family-item" data-id="${item.id_keluarga}">
|
||
<strong>#${item.ranking||"-"} ${esc(item.nama_kepala_keluarga)}</strong>
|
||
<small>${esc(item.wilayah)}<br>Skor ${fmt(item.skor_saw)} · ${item.jumlah_tanggungan} tanggungan</small>
|
||
<br><span class="badge ${badgeClass(item.kategori)}">${esc(item.kategori)}</span>
|
||
</article>`).join("") || `<p style='color:#94a3b8;font-size:.82rem'>Tidak ada keluarga sesuai filter.</p>`;
|
||
|
||
document.querySelectorAll(".family-item").forEach((item) => {
|
||
item.addEventListener("click", () => {
|
||
const marker = state.markers.get(item.dataset.id);
|
||
if (!marker) return;
|
||
map.setView(marker.getLatLng(), 17);
|
||
marker.openPopup();
|
||
if (window.innerWidth <= 900) el("sidebar").classList.remove("open");
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
RENDER ALL
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function renderAll() {
|
||
renderStats();
|
||
renderAreaStats();
|
||
renderMarkers();
|
||
renderList();
|
||
if (state.rows.length) {
|
||
const bounds = L.latLngBounds(
|
||
state.rows.filter(r => Number.isFinite(Number(r.latitude))).map(r => [r.latitude, r.longitude])
|
||
);
|
||
if (bounds.isValid()) map.fitBounds(bounds, { padding:[40,40], maxZoom:15 });
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
EVENT LISTENERS
|
||
══════════════════════════════════════════════════════════════════ */
|
||
// Filter / search
|
||
["categoryFilter","heatToggle"].forEach(id =>
|
||
el(id).addEventListener("change", () => loadData().catch(e => toast(e.message))));
|
||
|
||
let debounce;
|
||
el("searchInput").addEventListener("input", () => {
|
||
clearTimeout(debounce);
|
||
debounce = setTimeout(() => loadData().catch(e => toast(e.message)), 280);
|
||
});
|
||
|
||
// Ibadah toggle
|
||
el("ibadahToggle").addEventListener("change", async () => {
|
||
try {
|
||
if (el("ibadahToggle").checked) {
|
||
await loadIbadahLayer(true);
|
||
ibadahLayer.addTo(map);
|
||
el("ibadahLegendPanel").classList.add("active");
|
||
el("bufferPanel").style.opacity = "1";
|
||
} else {
|
||
map.removeLayer(ibadahLayer);
|
||
el("ibadahLegendPanel").classList.remove("active");
|
||
clearBuffer();
|
||
}
|
||
} catch (e) {
|
||
el("ibadahToggle").checked = false;
|
||
el("ibadahLegendPanel").classList.remove("active");
|
||
toast(e.message);
|
||
}
|
||
});
|
||
|
||
// Buffer radius buttons
|
||
document.querySelectorAll(".radius-btn[data-radius]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const churchId = el("bufferChurchSelect").value;
|
||
const radiusM = Number(btn.dataset.radius);
|
||
document.querySelectorAll(".radius-btn[data-radius]").forEach(b => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
// Auto-load ibadah if needed
|
||
if (!state.ibadahLoaded) {
|
||
loadIbadahLayer(true).then(() => {
|
||
ibadahLayer.addTo(map);
|
||
el("ibadahToggle").checked = true;
|
||
el("ibadahLegendPanel").classList.add("active");
|
||
runBuffer(churchId, radiusM);
|
||
}).catch(e => toast(e.message));
|
||
} else {
|
||
runBuffer(churchId, radiusM);
|
||
}
|
||
});
|
||
});
|
||
|
||
el("btnClearBuffer").addEventListener("click", clearBuffer);
|
||
|
||
// Church select change clears buffer
|
||
el("bufferChurchSelect").addEventListener("change", () => {
|
||
clearBuffer();
|
||
if (state.activeRadius) {
|
||
const btn = document.querySelector(`.radius-btn[data-radius="${state.activeRadius}"]`);
|
||
if (btn) btn.classList.remove("active");
|
||
}
|
||
});
|
||
|
||
// Mobile
|
||
el("mobileToggle").addEventListener("click", () => el("sidebar").classList.toggle("open"));
|
||
window.addEventListener("resize", () => map.invalidateSize());
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
INIT
|
||
══════════════════════════════════════════════════════════════════ */
|
||
loadData().catch(e => toast(e.message));
|
||
|
||
// Pre-load ibadah layer quietly so nearest-church info is available from start
|
||
loadIbadahLayer(true).catch(() => {/* ignore silent preload errors */});
|
||
</script>
|
||
</body>
|
||
</html>
|