Files
WEBGIS_Project/frontend/poverty-map.html
T
2026-06-10 23:55:59 +07:00

1293 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 Pontianak</title>
<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;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<style>
:root {
--bg-dark: #0f172a;
--bg-panel: #111827;
--border: rgba(148, 163, 184, 0.22);
--text: #f8fafc;
--muted: #94a3b8;
--accent: #7c3aed;
--accent-l: #a78bfa;
--shadow: 0 18px 45px rgba(15, 23, 42, 0.28);
--sangat: #dc2626;
--miskin: #f97316;
--rentan: #eab308;
--sudah: #16a34a;
--belum: #dc2626;
--outside: #2563eb;
--gold: #f4a261;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
overflow: hidden;
color: var(--text);
background: #eef2f7;
font-family: "Inter", system-ui, sans-serif;
}
button,
input,
select {
font: inherit;
}
.app {
display: grid;
grid-template-columns: 370px minmax(0, 1fr);
height: 100vh;
}
.sidebar {
position: relative;
z-index: 900;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
background:
radial-gradient(circle at top left, rgba(124, 58, 237, 0.22), transparent 38%),
linear-gradient(180deg, var(--bg-dark), var(--bg-panel));
box-shadow: var(--shadow);
overflow-y: auto;
}
.brand {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.brand h1 {
margin: 0;
font-size: 1.3rem;
line-height: 1.15;
letter-spacing: -0.03em;
}
.brand p {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.85rem;
line-height: 1.4;
}
.brand-links {
display: flex;
flex-shrink: 0;
gap: 6px;
}
.icon-btn {
display: inline-grid;
width: 40px;
height: 40px;
place-items: center;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
text-decoration: none;
transition: 0.2s;
}
.icon-btn:hover {
border-color: rgba(167, 139, 250, 0.75);
background: rgba(124, 58, 237, 0.3);
}
.summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.s-card {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(15, 23, 42, 0.62);
}
.s-card span {
display: block;
color: var(--muted);
font-size: 0.68rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.s-card strong {
display: block;
margin-top: 3px;
font-size: 1.5rem;
line-height: 1;
}
.s-card.sangat strong {
color: var(--sangat);
}
.s-card.miskin strong {
color: var(--miskin);
}
.s-card.rentan strong {
color: var(--rentan);
}
.panel {
padding: 14px;
border: 1px solid var(--border);
border-radius: 18px;
background: rgba(17, 24, 39, 0.76);
backdrop-filter: blur(14px);
}
.panel h2 {
margin: 0 0 10px;
color: #ddd6fe;
font-size: 0.82rem;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 10px;
}
.field:last-child {
margin-bottom: 0;
}
label {
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
}
input[type="text"],
select {
width: 100%;
min-height: 40px;
border: 1px solid var(--border);
border-radius: 11px;
padding: 8px 10px;
color: var(--text);
background: rgba(15, 23, 42, 0.88);
outline: none;
}
input[type="text"]:focus,
select:focus {
border-color: var(--accent-l);
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.18);
}
.ibadah-list,
.family-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
}
.ibadah-item,
.family-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(15, 23, 42, 0.5);
cursor: pointer;
transition: 0.15s;
}
.family-item {
align-items: flex-start;
cursor: default;
}
.ibadah-item:hover {
border-color: rgba(167, 139, 250, 0.5);
background: rgba(124, 58, 237, 0.18);
}
.ibadah-item.active {
border-color: var(--accent-l);
background: rgba(124, 58, 237, 0.28);
}
.ibadah-icon {
flex-shrink: 0;
font-size: 1.3rem;
}
.ibadah-info,
.family-info {
min-width: 0;
flex: 1;
}
.ibadah-info strong,
.family-info strong {
display: block;
overflow: hidden;
font-size: 0.86rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.ibadah-info small,
.family-info small {
color: var(--muted);
font-size: 0.74rem;
}
.radius-row {
display: flex;
align-items: center;
gap: 10px;
}
.radius-row input[type="range"] {
flex: 1;
accent-color: var(--accent-l);
}
.radius-val {
min-width: 62px;
color: var(--accent-l);
font-size: 0.88rem;
font-weight: 700;
text-align: right;
white-space: nowrap;
}
.primary-btn {
width: 100%;
min-height: 38px;
margin-top: 10px;
border: 0;
border-radius: 11px;
color: #fff;
background: var(--accent);
cursor: pointer;
font-weight: 700;
}
.primary-btn:hover {
background: #6d28d9;
}
.hint {
margin: 8px 0 0;
color: var(--muted);
font-size: 0.75rem;
line-height: 1.4;
}
.legend-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.8rem;
}
.leg-dot {
width: 12px;
height: 12px;
flex-shrink: 0;
border-radius: 50%;
}
.leg-rect {
width: 18px;
height: 10px;
flex-shrink: 0;
border-radius: 3px;
opacity: 0.75;
}
.mini-stats {
position: fixed;
z-index: 850;
top: 18px;
right: 18px;
width: min(360px, calc(100vw - 36px));
padding: 14px;
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 18px;
color: var(--text);
background: rgba(17, 24, 39, 0.9);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
.mini-stats h2 {
margin: 0 0 10px;
color: #ddd6fe;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mini-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.mini-card {
padding: 9px 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 12px;
background: rgba(15, 23, 42, 0.64);
}
.mini-card span {
display: block;
color: var(--muted);
font-size: 0.68rem;
text-transform: uppercase;
}
.mini-card strong {
display: block;
margin-top: 3px;
font-size: 1.1rem;
}
.kk-panel,
.list-panel {
position: fixed;
z-index: 1100;
right: 24px;
width: min(380px, calc(100vw - 48px));
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 20px;
color: var(--text);
background: rgba(17, 24, 39, 0.96);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
.kk-panel {
display: none;
bottom: 24px;
padding: 18px;
}
.kk-panel.show {
display: block;
}
.list-panel {
top: 112px;
display: none;
max-height: calc(100vh - 136px);
padding: 16px;
overflow: auto;
}
.list-panel.show {
display: block;
}
.panel-header,
.kk-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.panel-header h3,
.kk-panel-header h3 {
margin: 0;
font-size: 0.95rem;
}
.panel-header p {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.78rem;
}
.close-kk,
.close-list {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
color: var(--text);
background: rgba(255, 255, 255, 0.1);
cursor: pointer;
}
.kk-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
font-size: 0.83rem;
}
.kk-row:last-child {
border-bottom: 0;
}
.kk-row span {
color: var(--muted);
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 3px 9px;
color: #fff;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.badge.sangat {
background: var(--sangat);
}
.badge.miskin {
background: var(--miskin);
}
.badge.rentan {
color: #0f172a;
background: var(--rentan);
}
.badge.sudah {
background: var(--sudah);
}
.badge.belum {
background: var(--belum);
}
.popup-btn {
width: 100%;
min-height: 36px;
margin-top: 10px;
border: 0;
border-radius: 10px;
color: #fff;
background: var(--accent);
cursor: pointer;
font-weight: 800;
}
.toast {
position: fixed;
z-index: 1300;
top: 16px;
left: 50%;
padding: 11px 18px;
border-radius: 999px;
color: #0f172a;
background: rgba(255, 255, 255, 0.93);
box-shadow: var(--shadow);
font-weight: 800;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -16px);
transition: 0.25s ease;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.loading {
position: absolute;
inset: 0;
z-index: 800;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
}
.loading-box {
padding: 18px 28px;
border-radius: 14px;
background: var(--bg-panel);
font-size: 0.9rem;
font-weight: 600;
}
.worship-marker {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 3px solid #fff;
border-radius: 50% 50% 50% 10px;
background: var(--gold);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.3);
transform: rotate(-45deg);
}
.worship-marker span {
font-size: 20px;
transform: rotate(45deg);
}
.family-dot {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.72);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.family-dot.inside {
background: var(--belum);
border-color: #fff;
transform: scale(1.12);
}
.family-dot.outside {
background: var(--outside);
}
.family-dot.sudah {
outline: 3px solid var(--sudah);
outline-offset: 1px;
}
.leaflet-popup-content-wrapper {
border-radius: 16px;
}
.leaflet-popup-content h3 {
margin: 0 0 5px;
color: #4c1d95;
}
.leaflet-popup-content p {
margin: 0 0 7px;
}
#map {
position: relative;
width: 100%;
height: 100vh;
}
@media (max-width: 860px) {
.app {
display: block;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: min(88vw, 360px);
transform: translateX(-101%);
transition: transform 0.24s;
}
.sidebar.is-open {
transform: translateX(0);
}
.mobile-toggle {
position: fixed;
z-index: 1200;
top: 14px;
left: 14px;
display: inline-grid !important;
}
.mini-stats {
top: 66px;
right: 12px;
left: 12px;
width: auto;
}
.kk-panel,
.list-panel {
right: 12px;
left: 12px;
width: auto;
}
.list-panel {
top: auto;
bottom: 12px;
max-height: 50vh;
}
#map {
height: 100vh;
}
}
</style>
</head>
<body>
<button class="icon-btn mobile-toggle" id="mobileToggle" style="display: none" aria-label="Menu"></button>
<div class="toast" id="toast"></div>
<main class="app">
<aside class="sidebar" id="sidebar">
<div class="brand">
<div>
<h1>Poverty Mapping</h1>
<p>Pemetaan keluarga berdasarkan tingkat kemiskinan per zona tempat ibadah.</p>
</div>
<div class="brand-links">
<a class="icon-btn" href="/" title="Peta SPBU"></a>
<a class="icon-btn" href="/admin-poverty.html" title="Kelola Data">✏️</a>
</div>
</div>
<section class="summary">
<div class="s-card sangat"><span>Sangat Miskin</span><strong id="cntSangat"></strong></div>
<div class="s-card miskin"><span>Miskin</span><strong id="cntMiskin"></strong></div>
<div class="s-card rentan"><span>Rentan Miskin</span><strong id="cntRentan"></strong></div>
</section>
<section class="panel">
<h2>Filter</h2>
<div class="field">
<label for="filterKategori">Kategori Kemiskinan</label>
<select id="filterKategori">
<option value="all">Semua Kategori</option>
<option value="Sangat Miskin">Sangat Miskin</option>
<option value="Miskin">Miskin</option>
<option value="Rentan Miskin">Rentan Miskin</option>
</select>
</div>
<div class="field">
<label for="filterBantuan">Status Bantuan</label>
<select id="filterBantuan">
<option value="all">Semua Status</option>
<option value="Belum Menerima">Belum Menerima</option>
<option value="Sudah Menerima">Sudah Menerima</option>
</select>
</div>
<div class="field">
<label for="filterRumah">Kondisi Rumah</label>
<select id="filterRumah">
<option value="all">Semua Kondisi</option>
<option value="Tidak Permanen">Tidak Permanen</option>
<option value="Semi Permanen">Semi Permanen</option>
<option value="Permanen">Permanen</option>
</select>
</div>
</section>
<section class="panel">
<h2>Tempat Ibadah <span id="ibadahCount" style="font-weight: 400; color: var(--muted)"></span></h2>
<div class="ibadah-list" id="ibadahList">
<div style="color: var(--muted); font-size: 0.82rem; padding: 8px 0">Memuat...</div>
</div>
</section>
<section class="panel" id="radiusPanel" style="display: none">
<h2>Radius Zona: <span id="radiusLabel" style="color: var(--accent-l)">500 m</span></h2>
<div class="radius-row">
<input type="range" id="radiusSlider" min="100" max="2000" step="50" value="500" />
<span class="radius-val" id="radiusVal">500 m</span>
</div>
<p class="hint">Geser untuk mengatur radius zona. Radius tersimpan di browser untuk prototype.</p>
<button class="primary-btn" id="saveRadiusBtn" type="button">Simpan Radius</button>
<button class="primary-btn" id="openFamilyListBtn" type="button">Lihat Daftar Keluarga</button>
</section>
<section class="panel">
<h2>Legenda</h2>
<div class="legend-grid">
<div class="legend-item"><div class="leg-dot" style="background: var(--sangat)"></div> Sangat Miskin</div>
<div class="legend-item"><div class="leg-dot" style="background: var(--miskin)"></div> Miskin</div>
<div class="legend-item"><div class="leg-dot" style="background: var(--rentan)"></div> Rentan Miskin</div>
<div class="legend-item"><div class="leg-dot" style="background: var(--belum); border: 2px solid #fff"></div> Dalam Radius</div>
<div class="legend-item"><div class="leg-dot" style="background: var(--outside); border: 2px solid #fff"></div> Luar Radius</div>
<div class="legend-item"><div class="leg-dot" style="background: var(--sudah); border: 2px solid #fff"></div> Sudah Bantuan</div>
<div class="legend-item"><div class="leg-rect" style="background: var(--accent)"></div> Zona Ibadah</div>
</div>
</section>
</aside>
<section id="map">
<div class="loading" id="loadingOverlay">
<div class="loading-box">⏳ Memuat data peta kemiskinan...</div>
</div>
</section>
</main>
<section class="mini-stats">
<h2>Dashboard Ringkas</h2>
<div class="mini-grid">
<div class="mini-card"><span>Terdampak</span><strong id="totalAffected">0</strong></div>
<div class="mini-card"><span>Cakupan</span><strong id="coveragePercent">0%</strong></div>
<div class="mini-card"><span>Sangat Miskin</span><strong id="veryPoorCount">0</strong></div>
<div class="mini-card"><span>Miskin</span><strong id="poorCount">0</strong></div>
<div class="mini-card"><span>Rentan</span><strong id="vulnerableCount">0</strong></div>
<div class="mini-card"><span>Dipilih</span><strong id="selectedName">Semua</strong></div>
</div>
</section>
<aside class="list-panel" id="familyListPanel">
<div class="panel-header">
<div>
<h3>Daftar Keluarga Dalam Radius</h3>
<p id="familyPanelSubtitle">Pilih tempat ibadah untuk melihat daftar.</p>
</div>
<button class="close-list" id="closeFamilyList" type="button">×</button>
</div>
<div class="family-list" id="familyList"></div>
</aside>
<div class="kk-panel" id="kkPanel">
<div class="kk-panel-header">
<h3 id="kkNama"></h3>
<button class="close-kk" id="closeKk" type="button">×</button>
</div>
<div id="kkRows"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
const API_BASE = "/api/poverty";
const KAT_COLOR = {
"Sangat Miskin": "#dc2626",
"Miskin": "#f97316",
"Rentan Miskin": "#eab308"
};
const IBADAH_ICON = {
masjid: "🕌",
gereja: "⛪",
pura: "🛕",
vihara: "☸️",
kelenteng: "🏮",
lainnya: "⭐"
};
const kategoriLabel = {
sangat_miskin: "Sangat Miskin",
miskin: "Miskin",
rentan_miskin: "Rentan Miskin"
};
const fallbackTempatIbadah = [
{ id: 1, nama: "Masjid Jami Sultan Syarif Abdurrahman", jenis: "masjid", alamat: "Jl. Tanjungpura", kelurahan: "Dalam Bugis", lat: -0.0226, lng: 109.3316 },
{ id: 2, nama: "Gereja Bethel Indonesia", jenis: "gereja", alamat: "Jl. Gajahmada", kelurahan: "Sungai Bangkong", lat: -0.0334, lng: 109.3253 },
{ id: 3, nama: "Vihara Bodhisattva", jenis: "vihara", alamat: "Jl. Diponegoro", kelurahan: "Mariana", lat: -0.0289, lng: 109.3401 }
];
const fallbackKeluarga = [
{ id: 1, nama_kk: "Ahmad Fauzi", jumlah_anggota: 5, kategori: "sangat_miskin", alamat: "Dalam Bugis", kelurahan: "Dalam Bugis", keterangan: "Prioritas bantuan pangan.", lat: -0.0231, lng: 109.3321 },
{ id: 2, nama_kk: "Siti Rahmah", jumlah_anggota: 3, kategori: "miskin", alamat: "Dalam Bugis", kelurahan: "Dalam Bugis", keterangan: "Membutuhkan dukungan pendidikan.", lat: -0.0219, lng: 109.3308 },
{ id: 3, nama_kk: "Budi Santoso", jumlah_anggota: 6, kategori: "rentan_miskin", alamat: "Sungai Bangkong", kelurahan: "Sungai Bangkong", keterangan: "Pendapatan tidak tetap.", lat: -0.0341, lng: 109.3261 },
{ id: 4, nama_kk: "Maria Goretti", jumlah_anggota: 4, kategori: "miskin", alamat: "Mariana", kelurahan: "Mariana", keterangan: "Butuh verifikasi lanjutan.", lat: -0.0295, lng: 109.3412 },
{ id: 5, nama_kk: "Lim Ah Kow", jumlah_anggota: 2, kategori: "sangat_miskin", alamat: "Mariana", kelurahan: "Mariana", keterangan: "Lansia tinggal sendiri.", lat: -0.0501, lng: 109.36 }
];
const map = L.map("map").setView([-0.0263, 109.3425], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap contributors"
}).addTo(map);
const ibadahLayer = L.layerGroup().addTo(map);
const kkLayer = L.layerGroup().addTo(map);
let allIbadah = [];
let allKeluarga = [];
let activeIbadah = null;
let radiusCircle = null;
let toastTimer;
const ibadahMarkers = new Map();
const kkMarkers = new Map();
const elements = {
sidebar: document.getElementById("sidebar"),
mobileToggle: document.getElementById("mobileToggle"),
toast: document.getElementById("toast"),
loadingOverlay: document.getElementById("loadingOverlay"),
loadingBox: document.querySelector(".loading-box"),
filterKategori: document.getElementById("filterKategori"),
filterBantuan: document.getElementById("filterBantuan"),
filterRumah: document.getElementById("filterRumah"),
ibadahList: document.getElementById("ibadahList"),
ibadahCount: document.getElementById("ibadahCount"),
radiusPanel: document.getElementById("radiusPanel"),
radiusSlider: document.getElementById("radiusSlider"),
radiusVal: document.getElementById("radiusVal"),
radiusLabel: document.getElementById("radiusLabel"),
saveRadiusBtn: document.getElementById("saveRadiusBtn"),
openFamilyListBtn: document.getElementById("openFamilyListBtn"),
cntSangat: document.getElementById("cntSangat"),
cntMiskin: document.getElementById("cntMiskin"),
cntRentan: document.getElementById("cntRentan"),
totalAffected: document.getElementById("totalAffected"),
coveragePercent: document.getElementById("coveragePercent"),
veryPoorCount: document.getElementById("veryPoorCount"),
poorCount: document.getElementById("poorCount"),
vulnerableCount: document.getElementById("vulnerableCount"),
selectedName: document.getElementById("selectedName"),
familyListPanel: document.getElementById("familyListPanel"),
familyPanelSubtitle: document.getElementById("familyPanelSubtitle"),
familyList: document.getElementById("familyList"),
closeFamilyList: document.getElementById("closeFamilyList"),
kkPanel: document.getElementById("kkPanel"),
kkNama: document.getElementById("kkNama"),
kkRows: document.getElementById("kkRows"),
closeKk: document.getElementById("closeKk")
};
function geoJsonToRows(payload) {
if (payload?.type === "FeatureCollection") {
return payload.features.map((feature) => ({
...feature.properties,
lng: Number(feature.geometry.coordinates[0]),
lat: Number(feature.geometry.coordinates[1])
}));
}
if (Array.isArray(payload?.data)) return payload.data;
if (Array.isArray(payload)) return payload;
return [];
}
async function fetchRows(url, fallback) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const rows = geoJsonToRows(await response.json());
return rows.length ? rows : fallback;
} catch {
return fallback;
}
}
function storedRadius(id) {
return Number(localStorage.getItem(`poverty-radius-${id}`)) || 500;
}
function normalizeIbadah(item) {
return {
...item,
id: Number(item.id),
jenis: String(item.jenis || "lainnya").toLowerCase(),
lat: Number(item.lat),
lng: Number(item.lng),
radius: Number(item.radius) || storedRadius(item.id)
};
}
function normalizeKeluarga(item, index) {
const kategori = item.kategori || item.kategori_kemiskinan || "miskin";
const kategoriKemiskinan = kategoriLabel[kategori] || kategori;
const bantuan = item.status_bantuan || (index % 3 === 0 ? "Sudah Menerima" : "Belum Menerima");
const kondisi = item.kondisi_rumah || ["Tidak Permanen", "Semi Permanen", "Permanen"][index % 3];
return {
...item,
id: Number(item.id),
lat: Number(item.lat),
lng: Number(item.lng),
kategori,
kategori_kemiskinan: kategoriKemiskinan,
status_bantuan: bantuan,
kondisi_rumah: kondisi,
penghasilan: Number(item.penghasilan) || (kategori === "sangat_miskin" ? 600000 : kategori === "miskin" ? 1000000 : 1500000)
};
}
function ibadahIcon(jenis) {
return IBADAH_ICON[jenis] || IBADAH_ICON.lainnya;
}
function slugKat(label) {
if (label === "Sangat Miskin") return "sangat";
if (label === "Miskin") return "miskin";
return "rentan";
}
function formatRupiah(value) {
return `Rp ${Number(value || 0).toLocaleString("id-ID")}`;
}
function formatMeters(value) {
if (value == null || Number.isNaN(value)) return "-";
return `${Math.round(value).toLocaleString("id-ID")} m`;
}
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 4000);
}
function distanceBetween(a, b) {
return L.latLng(a.lat, a.lng).distanceTo(L.latLng(b.lat, b.lng));
}
function nearestWorshipDistance(family) {
if (!allIbadah.length) return null;
return Math.min(...allIbadah.map((ibadah) => distanceBetween(family, ibadah)));
}
function getFiltered(keluarga) {
const kat = elements.filterKategori.value;
const bantuan = elements.filterBantuan.value;
const rumah = elements.filterRumah.value;
return keluarga.filter((item) =>
(kat === "all" || item.kategori_kemiskinan === kat) &&
(bantuan === "all" || item.status_bantuan === bantuan) &&
(rumah === "all" || item.kondisi_rumah === rumah)
);
}
function familiesInRadius() {
if (!activeIbadah) return [];
return getFiltered(allKeluarga)
.map((item) => ({ ...item, distance: distanceBetween(item, activeIbadah) }))
.filter((item) => item.distance <= activeIbadah.radius)
.sort((a, b) => a.distance - b.distance);
}
function updateSummary(keluarga) {
elements.cntSangat.textContent = keluarga.filter((item) => item.kategori_kemiskinan === "Sangat Miskin").length;
elements.cntMiskin.textContent = keluarga.filter((item) => item.kategori_kemiskinan === "Miskin").length;
elements.cntRentan.textContent = keluarga.filter((item) => item.kategori_kemiskinan === "Rentan Miskin").length;
}
function updateMiniStats(rows) {
const counts = rows.reduce((acc, item) => {
acc[item.kategori_kemiskinan] = (acc[item.kategori_kemiskinan] || 0) + 1;
return acc;
}, {});
const denominator = getFiltered(allKeluarga).length || allKeluarga.length;
elements.totalAffected.textContent = rows.length;
elements.coveragePercent.textContent = denominator ? `${Math.round((rows.length / denominator) * 100)}%` : "0%";
elements.veryPoorCount.textContent = counts["Sangat Miskin"] || 0;
elements.poorCount.textContent = counts.Miskin || 0;
elements.vulnerableCount.textContent = counts["Rentan Miskin"] || 0;
elements.selectedName.textContent = activeIbadah ? activeIbadah.nama.split(" ").slice(0, 2).join(" ") : "Semua";
}
function makeWorshipIcon(jenis) {
return L.divIcon({
className: "",
html: `<div class="worship-marker"><span>${ibadahIcon(jenis)}</span></div>`,
iconSize: [38, 38],
iconAnchor: [19, 36],
popupAnchor: [0, -34]
});
}
function makeFamilyIcon(item, isInside) {
const color = isInside ? "#dc2626" : "#2563eb";
const bantuanClass = item.status_bantuan === "Sudah Menerima" ? "sudah" : "";
return L.divIcon({
className: "",
html: `<div class="family-dot ${isInside ? "inside" : "outside"} ${bantuanClass}" style="background:${color}"></div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -10]
});
}
function badgeHtml(label) {
return `<span class="badge ${slugKat(label)}">${label}</span>`;
}
function familyPopup(item) {
const nearest = nearestWorshipDistance(item);
return `
<h3>${item.nama_kk}</h3>
<p><strong>Anggota:</strong> ${item.jumlah_anggota} orang</p>
<p><strong>Kategori:</strong> ${badgeHtml(item.kategori_kemiskinan)}</p>
<p><strong>Status Bantuan:</strong> ${item.status_bantuan}</p>
<p><strong>Kondisi Rumah:</strong> ${item.kondisi_rumah}</p>
<p><strong>Catatan:</strong> ${item.keterangan || "-"}</p>
<p><strong>Jarak tempat ibadah terdekat:</strong> ${formatMeters(nearest)}</p>
`;
}
function worshipPopup(item) {
const count = allKeluarga.filter((family) => distanceBetween(family, item) <= item.radius).length;
return `
<h3>${item.nama}</h3>
<p><strong>Jenis:</strong> ${item.jenis}</p>
<p><strong>Alamat:</strong> ${item.alamat || "-"}</p>
<p><strong>Radius:</strong> ${item.radius} m</p>
<p><strong>Keluarga dalam radius:</strong> ${count}</p>
<button class="popup-btn" onclick="window.selectIbadahFromPopup(${item.id})">Pilih Zona</button>
<button class="popup-btn" onclick="window.openFamilyPanel()">Lihat Daftar Keluarga</button>
`;
}
function renderIbadahMarkers() {
ibadahLayer.clearLayers();
ibadahMarkers.clear();
allIbadah.forEach((item) => {
const marker = L.marker([item.lat, item.lng], {
icon: makeWorshipIcon(item.jenis),
title: item.nama
})
.bindPopup(() => worshipPopup(item))
.on("click", () => selectIbadah(item));
marker.addTo(ibadahLayer);
ibadahMarkers.set(item.id, marker);
});
}
function renderIbadahList() {
elements.ibadahCount.textContent = `(${allIbadah.length})`;
if (!allIbadah.length) {
elements.ibadahList.innerHTML = `<div style="color:var(--muted);font-size:.82rem;padding:8px 0">Belum ada tempat ibadah.</div>`;
return;
}
elements.ibadahList.innerHTML = [
`<div class="ibadah-item ${!activeIbadah ? "active" : ""}" data-id="all">
<span class="ibadah-icon">🗺️</span>
<div class="ibadah-info"><strong>Semua Tempat Ibadah</strong><small>${allKeluarga.length} keluarga total</small></div>
</div>`
].concat(allIbadah.map((item) => {
const count = allKeluarga.filter((family) => distanceBetween(family, item) <= item.radius).length;
return `<div class="ibadah-item ${activeIbadah?.id === item.id ? "active" : ""}" data-id="${item.id}">
<span class="ibadah-icon">${ibadahIcon(item.jenis)}</span>
<div class="ibadah-info">
<strong>${item.nama}</strong>
<small>${item.jenis} · ${count} keluarga · r=${item.radius}m</small>
</div>
</div>`;
})).join("");
elements.ibadahList.querySelectorAll(".ibadah-item").forEach((item) => {
item.addEventListener("click", () => {
const id = item.dataset.id;
if (id === "all") {
selectAllIbadah();
} else {
const ibadah = allIbadah.find((row) => row.id === Number(id));
if (ibadah) selectIbadah(ibadah);
}
});
});
}
function renderKkMarkers() {
kkLayer.clearLayers();
kkMarkers.clear();
const filtered = getFiltered(allKeluarga);
const insideIds = new Set(familiesInRadius().map((item) => item.id));
filtered.forEach((item) => {
const marker = L.marker([item.lat, item.lng], {
icon: makeFamilyIcon(item, activeIbadah ? insideIds.has(item.id) : false),
title: item.nama_kk
})
.bindPopup(() => familyPopup(item))
.on("click", () => showKkPanel(item));
marker.addTo(kkLayer);
kkMarkers.set(item.id, marker);
});
updateSummary(filtered);
}
function clearRadiusCircle() {
if (radiusCircle) {
map.removeLayer(radiusCircle);
radiusCircle = null;
}
}
function drawRadiusCircle() {
clearRadiusCircle();
if (!activeIbadah) return;
radiusCircle = L.circle([activeIbadah.lat, activeIbadah.lng], {
radius: activeIbadah.radius,
color: "#7c3aed",
weight: 2,
opacity: 0.7,
fillColor: "#7c3aed",
fillOpacity: 0.08,
dashArray: "6,6"
}).addTo(map);
}
function selectIbadah(item) {
activeIbadah = item;
elements.radiusPanel.style.display = "";
elements.radiusSlider.value = item.radius;
elements.radiusVal.textContent = `${item.radius} m`;
elements.radiusLabel.textContent = `${item.radius} m`;
drawRadiusCircle();
map.setView([item.lat, item.lng], 15);
renderIbadahList();
updateCoverage();
}
function selectAllIbadah() {
activeIbadah = null;
clearRadiusCircle();
elements.radiusPanel.style.display = "none";
elements.familyListPanel.classList.remove("show");
map.setView([-0.0263, 109.3425], 13);
renderIbadahList();
updateCoverage();
}
function updateCoverage() {
const rows = familiesInRadius();
if (activeIbadah) {
drawRadiusCircle();
}
renderKkMarkers();
updateMiniStats(activeIbadah ? rows : getFiltered(allKeluarga));
renderFamilyPanel();
if (activeIbadah) {
const marker = ibadahMarkers.get(activeIbadah.id);
if (marker?.isPopupOpen()) marker.setPopupContent(worshipPopup(activeIbadah));
}
}
function renderFamilyPanel() {
const rows = familiesInRadius();
elements.familyPanelSubtitle.textContent = activeIbadah
? `${activeIbadah.nama} • radius ${activeIbadah.radius} meter`
: "Pilih tempat ibadah untuk melihat daftar.";
elements.familyList.innerHTML = rows.length
? rows.map((item) => `
<article class="family-item">
<div class="ibadah-icon">👥</div>
<div class="family-info">
<strong>${item.nama_kk}</strong>
<small>${formatMeters(item.distance)}${item.jumlah_anggota} anggota • ${item.kelurahan || "-"}</small><br>
<span class="badge ${slugKat(item.kategori_kemiskinan)}">${item.kategori_kemiskinan}</span>
</div>
</article>
`).join("")
: `<p style="color:var(--muted);font-size:.82rem">Tidak ada keluarga dalam radius dan filter saat ini.</p>`;
}
function showKkPanel(item) {
elements.kkNama.textContent = item.nama_kk;
elements.kkPanel.classList.add("show");
elements.kkRows.innerHTML = `
<div class="kk-row"><span>Alamat</span><strong style="text-align:right;max-width:200px">${item.alamat || "-"}</strong></div>
<div class="kk-row"><span>Jumlah Anggota</span><strong>${item.jumlah_anggota} orang</strong></div>
<div class="kk-row"><span>Penghasilan/bln</span><strong>${formatRupiah(item.penghasilan)}</strong></div>
<div class="kk-row"><span>Kondisi Rumah</span><strong>${item.kondisi_rumah}</strong></div>
<div class="kk-row"><span>Kategori</span><span class="badge ${slugKat(item.kategori_kemiskinan)}">${item.kategori_kemiskinan}</span></div>
<div class="kk-row"><span>Status Bantuan</span><span class="badge ${item.status_bantuan === "Sudah Menerima" ? "sudah" : "belum"}">${item.status_bantuan}</span></div>
<div class="kk-row"><span>Jarak Terdekat</span><strong>${formatMeters(nearestWorshipDistance(item))}</strong></div>
${item.keterangan ? `<div class="kk-row"><span>Keterangan</span><strong style="text-align:right;max-width:200px">${item.keterangan}</strong></div>` : ""}
`;
}
window.openFamilyPanel = function () {
if (!activeIbadah && allIbadah.length) {
selectIbadah(allIbadah[0]);
}
renderFamilyPanel();
elements.familyListPanel.classList.add("show");
};
window.selectIbadahFromPopup = function (id) {
const ibadah = allIbadah.find((item) => item.id === Number(id));
if (ibadah) selectIbadah(ibadah);
};
elements.radiusSlider.addEventListener("input", (event) => {
if (!activeIbadah) return;
activeIbadah.radius = Number(event.target.value);
elements.radiusVal.textContent = `${activeIbadah.radius} m`;
elements.radiusLabel.textContent = `${activeIbadah.radius} m`;
updateCoverage();
});
elements.saveRadiusBtn.addEventListener("click", () => {
if (!activeIbadah) return;
localStorage.setItem(`poverty-radius-${activeIbadah.id}`, String(activeIbadah.radius));
showToast(`Radius ${activeIbadah.nama} disimpan: ${activeIbadah.radius} m`);
renderIbadahList();
});
elements.openFamilyListBtn.addEventListener("click", () => window.openFamilyPanel());
elements.closeFamilyList.addEventListener("click", () => elements.familyListPanel.classList.remove("show"));
elements.closeKk.addEventListener("click", () => elements.kkPanel.classList.remove("show"));
elements.mobileToggle.addEventListener("click", () => elements.sidebar.classList.toggle("is-open"));
["filterKategori", "filterBantuan", "filterRumah"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => {
updateCoverage();
renderIbadahList();
});
});
window.addEventListener("resize", () => setTimeout(() => map.invalidateSize(), 100));
async function loadAll() {
try {
const [ibadahRows, keluargaRows] = await Promise.all([
fetchRows(`${API_BASE}/tempat-ibadah`, fallbackTempatIbadah),
fetchRows(`${API_BASE}/keluarga`, fallbackKeluarga)
]);
allIbadah = ibadahRows.map(normalizeIbadah);
allKeluarga = keluargaRows.map(normalizeKeluarga);
elements.loadingOverlay.style.display = "none";
renderIbadahMarkers();
renderIbadahList();
updateCoverage();
showToast(`Data dimuat: ${allIbadah.length} tempat ibadah, ${allKeluarga.length} keluarga`);
} catch (error) {
elements.loadingBox.textContent = `❌ Gagal memuat: ${error.message}`;
}
}
loadAll();
</script>
</body>
</html>