Files
WEBGIS_Project/frontend/poverty-map.html
T
2026-06-10 22:46:14 +07:00

684 lines
20 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>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 {
--green: #1b4332;
--green-2: #2d6a4f;
--gold: #f4a261;
--gold-soft: rgba(244, 162, 97, 0.2);
--blue: #2563eb;
--red: #dc2626;
--dark-red: #7f1d1d;
--orange: #ea580c;
--yellow: #eab308;
--text: #0f172a;
--muted: #64748b;
--panel: rgba(255, 255, 255, 0.9);
--border: rgba(15, 23, 42, 0.12);
--shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
height: 100vh;
overflow: hidden;
font-family: "Inter", system-ui, sans-serif;
color: var(--text);
background: #eef2f7;
}
button,
input {
font: inherit;
}
#map {
width: 100vw;
height: 100vh;
}
.topbar {
position: fixed;
z-index: 900;
top: 14px;
left: 14px;
right: 14px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 14px;
align-items: stretch;
pointer-events: none;
}
.brand,
.radius-panel,
.stats-panel,
.family-panel {
border: 1px solid var(--border);
border-radius: 20px;
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
pointer-events: auto;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
min-height: 74px;
padding: 14px 18px;
color: #fff;
background: linear-gradient(135deg, rgba(27, 67, 50, 0.96), rgba(45, 106, 79, 0.92));
}
.brand-icon {
display: grid;
width: 46px;
height: 46px;
flex: 0 0 auto;
place-items: center;
border-radius: 16px;
background: rgba(244, 162, 97, 0.22);
font-size: 1.45rem;
}
.brand h1 {
margin: 0;
font-size: 1.1rem;
letter-spacing: -0.03em;
}
.brand p {
margin: 4px 0 0;
color: rgba(255, 255, 255, 0.78);
font-size: 0.86rem;
}
.radius-panel {
padding: 14px 16px;
}
.radius-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.radius-row strong {
color: var(--green);
}
input[type="range"] {
width: 100%;
accent-color: var(--gold);
}
.stats-panel {
position: fixed;
z-index: 850;
top: 112px;
left: 14px;
width: 340px;
padding: 16px;
}
.stats-panel h2,
.family-panel h2 {
margin: 0 0 12px;
color: var(--green);
font-size: 0.92rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.stat-card {
padding: 12px;
border: 1px solid var(--border);
border-radius: 15px;
background: #fff;
}
.stat-card span {
display: block;
color: var(--muted);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
}
.stat-card strong {
display: block;
margin-top: 5px;
color: var(--text);
font-size: 1.35rem;
}
.family-panel {
position: fixed;
z-index: 920;
top: 112px;
right: 14px;
width: min(410px, calc(100vw - 28px));
max-height: calc(100vh - 132px);
padding: 16px;
overflow: auto;
transform: translateX(calc(100% + 24px));
transition: transform 0.25s ease;
}
.family-panel.open {
transform: translateX(0);
}
.panel-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.close-btn {
width: 34px;
height: 34px;
border: 0;
border-radius: 11px;
color: #fff;
background: var(--green);
cursor: pointer;
font-weight: 900;
}
.family-list {
display: grid;
gap: 10px;
}
.family-item {
padding: 12px;
border: 1px solid var(--border);
border-radius: 16px;
background: #fff;
}
.family-item strong {
display: block;
margin-bottom: 5px;
}
.family-item small {
color: var(--muted);
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
color: #fff;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.badge.sangat_miskin {
background: var(--dark-red);
}
.badge.miskin {
background: var(--orange);
}
.badge.rentan_miskin {
color: #422006;
background: var(--yellow);
}
.popup-btn {
width: 100%;
min-height: 36px;
margin-top: 10px;
border: 0;
border-radius: 11px;
color: #fff;
background: var(--green);
cursor: pointer;
font-weight: 800;
}
.worship-marker {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 3px solid #fff;
border-radius: 50% 50% 50% 12px;
background: var(--gold);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
transform: rotate(-45deg);
}
.worship-marker span {
transform: rotate(45deg);
font-size: 21px;
}
.family-marker {
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 3px solid #fff;
border-radius: 50%;
color: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
transition: background-color 0.2s ease, transform 0.2s ease;
}
.family-marker.inside {
background: var(--red);
transform: scale(1.08);
}
.family-marker.outside {
background: var(--blue);
}
.leaflet-popup-content-wrapper {
border-radius: 16px;
}
.leaflet-popup-content h3 {
margin: 0 0 5px;
color: var(--green);
}
.leaflet-popup-content p {
margin: 0 0 7px;
}
.muted {
color: var(--muted);
}
@media (max-width: 760px) {
.topbar {
grid-template-columns: 1fr;
}
.brand {
min-height: unset;
}
.stats-panel {
top: 190px;
width: calc(100vw - 28px);
}
.family-panel {
top: auto;
right: 10px;
bottom: 10px;
left: 10px;
width: auto;
max-height: 55vh;
}
}
</style>
</head>
<body>
<div id="map"></div>
<header class="topbar">
<section class="brand">
<div class="brand-icon">🤝</div>
<div>
<h1>Poverty Mapping Pontianak</h1>
<p>Pemetaan keluarga miskin di sekitar tempat ibadah untuk dukungan komunitas.</p>
</div>
</section>
<section class="radius-panel">
<div class="radius-row">
<span>Radius analisis</span>
<strong id="radiusLabel">500 meter</strong>
</div>
<input id="radiusSlider" type="range" min="100" max="2000" step="50" value="500" />
</section>
</header>
<section class="stats-panel">
<h2>Dashboard Ringkas</h2>
<div class="stats-grid">
<div class="stat-card">
<span>Terdampak</span>
<strong id="totalAffected">0</strong>
</div>
<div class="stat-card">
<span>Cakupan</span>
<strong id="coveragePercent">0%</strong>
</div>
<div class="stat-card">
<span>Sangat Miskin</span>
<strong id="veryPoorCount">0</strong>
</div>
<div class="stat-card">
<span>Miskin</span>
<strong id="poorCount">0</strong>
</div>
<div class="stat-card">
<span>Rentan Miskin</span>
<strong id="vulnerableCount">0</strong>
</div>
<div class="stat-card">
<span>Tempat Dipilih</span>
<strong id="selectedName">-</strong>
</div>
</div>
</section>
<aside class="family-panel" id="familyPanel">
<div class="panel-header">
<div>
<h2>Daftar Keluarga</h2>
<p class="muted" id="familyPanelSubtitle">Pilih tempat ibadah untuk melihat daftar.</p>
</div>
<button class="close-btn" id="closePanel" type="button">×</button>
</div>
<div class="family-list" id="familyList"></div>
</aside>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
const API_BASE = "/api/poverty";
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", kelurahan: "Dalam Bugis", keterangan: "Prioritas bantuan pangan.", lat: -0.0231, lng: 109.3321 },
{ id: 2, nama_kk: "Siti Rahmah", jumlah_anggota: 3, kategori: "miskin", 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", kelurahan: "Sungai Bangkong", keterangan: "Pendapatan tidak tetap.", lat: -0.0341, lng: 109.3261 },
{ id: 4, nama_kk: "Maria Goretti", jumlah_anggota: 4, kategori: "miskin", 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", kelurahan: "Mariana", keterangan: "Lansia tinggal sendiri.", lat: -0.0501, lng: 109.36 }
];
const kategoriLabel = {
sangat_miskin: "Sangat Miskin",
miskin: "Miskin",
rentan_miskin: "Rentan Miskin"
};
const worshipIcon = {
masjid: "🕌",
gereja: "⛪",
pura: "🛕",
vihara: "☸️",
kelenteng: "🏮",
lainnya: "⭐"
};
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
const worshipLayer = L.layerGroup().addTo(map);
const familyLayer = L.layerGroup().addTo(map);
let radiusCircle = null;
let selectedIbadah = null;
let currentRadius = 500;
let tempatIbadah = [];
let keluarga = [];
const familyMarkers = new Map();
const worshipMarkers = new Map();
const elements = {
radiusSlider: document.getElementById("radiusSlider"),
radiusLabel: document.getElementById("radiusLabel"),
totalAffected: document.getElementById("totalAffected"),
coveragePercent: document.getElementById("coveragePercent"),
veryPoorCount: document.getElementById("veryPoorCount"),
poorCount: document.getElementById("poorCount"),
vulnerableCount: document.getElementById("vulnerableCount"),
selectedName: document.getElementById("selectedName"),
familyPanel: document.getElementById("familyPanel"),
familyPanelSubtitle: document.getElementById("familyPanelSubtitle"),
familyList: document.getElementById("familyList"),
closePanel: document.getElementById("closePanel")
};
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 makeWorshipIcon(jenis) {
return L.divIcon({
className: "",
html: `<div class="worship-marker"><span>${worshipIcon[jenis] || worshipIcon.lainnya}</span></div>`,
iconSize: [42, 42],
iconAnchor: [21, 40],
popupAnchor: [0, -36]
});
}
function makeFamilyIcon(isInside) {
return L.divIcon({
className: "",
html: `<div class="family-marker ${isInside ? "inside" : "outside"}">👥</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
popupAnchor: [0, -12]
});
}
function formatMeters(value) {
if (value == null || Number.isNaN(value)) return "-";
return `${Math.round(value).toLocaleString("id-ID")} m`;
}
function distanceBetween(a, b) {
return L.latLng(a.lat, a.lng).distanceTo(L.latLng(b.lat, b.lng));
}
function nearestWorshipDistance(family) {
if (!tempatIbadah.length) return null;
return Math.min(...tempatIbadah.map((ibadah) => distanceBetween(family, ibadah)));
}
function familiesInRadius() {
if (!selectedIbadah) return [];
return keluarga
.map((item) => ({ ...item, distance: distanceBetween(item, selectedIbadah) }))
.filter((item) => item.distance <= currentRadius)
.sort((a, b) => a.distance - b.distance);
}
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> <span class="badge ${item.kategori}">${kategoriLabel[item.kategori]}</span></p>
<p><strong>Catatan:</strong> ${item.keterangan || "-"}</p>
<p><strong>Jarak tempat ibadah terdekat:</strong> ${formatMeters(nearest)}</p>
`;
}
function worshipPopup(item) {
const count = selectedIbadah?.id === item.id ? familiesInRadius().length : keluarga.filter((family) => distanceBetween(family, item) <= currentRadius).length;
return `
<h3>${item.nama}</h3>
<p><strong>Jenis:</strong> ${item.jenis}</p>
<p><strong>Alamat:</strong> ${item.alamat || "-"}</p>
<p><strong>Keluarga dalam radius:</strong> ${count}</p>
<button class="popup-btn" onclick="window.openFamilyPanel()">Lihat Daftar Keluarga</button>
`;
}
function selectIbadah(item) {
selectedIbadah = item;
updateCoverage();
elements.familyPanel.classList.remove("open");
}
function renderMarkers() {
worshipLayer.clearLayers();
familyLayer.clearLayers();
worshipMarkers.clear();
familyMarkers.clear();
tempatIbadah.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(worshipLayer);
worshipMarkers.set(item.id, marker);
});
keluarga.forEach((item) => {
const marker = L.marker([item.lat, item.lng], { icon: makeFamilyIcon(false), title: item.nama_kk })
.bindPopup(() => familyPopup(item));
marker.addTo(familyLayer);
familyMarkers.set(item.id, marker);
});
}
function renderFamilyPanel() {
const rows = familiesInRadius();
elements.familyPanelSubtitle.textContent = selectedIbadah
? `${selectedIbadah.nama} • radius ${currentRadius} meter`
: "Pilih tempat ibadah untuk melihat daftar.";
elements.familyList.innerHTML = rows.length
? rows.map((item) => `
<article class="family-item">
<strong>${item.nama_kk}</strong>
<span class="badge ${item.kategori}">${kategoriLabel[item.kategori]}</span>
<p><small>Jarak ${formatMeters(item.distance)}${item.jumlah_anggota} anggota • ${item.kelurahan || "-"}</small></p>
</article>
`).join("")
: `<p class="muted">Tidak ada keluarga dalam radius saat ini.</p>`;
}
function updateStats(rows) {
const total = rows.length;
const counts = rows.reduce((acc, item) => {
acc[item.kategori] = (acc[item.kategori] || 0) + 1;
return acc;
}, {});
elements.totalAffected.textContent = total;
elements.coveragePercent.textContent = keluarga.length ? `${Math.round((total / keluarga.length) * 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 = selectedIbadah ? selectedIbadah.nama.split(" ").slice(0, 2).join(" ") : "-";
}
function updateCoverage() {
elements.radiusLabel.textContent = `${currentRadius} meter`;
if (radiusCircle) {
radiusCircle.remove();
radiusCircle = null;
}
const rows = familiesInRadius();
const insideIds = new Set(rows.map((item) => item.id));
if (selectedIbadah) {
radiusCircle = L.circle([selectedIbadah.lat, selectedIbadah.lng], {
radius: currentRadius,
color: "#f4a261",
weight: 2,
fillColor: "#f4a261",
fillOpacity: 0.18
}).addTo(map);
}
keluarga.forEach((item) => {
const marker = familyMarkers.get(item.id);
if (!marker) return;
marker.setIcon(makeFamilyIcon(insideIds.has(item.id)));
if (marker.isPopupOpen()) marker.setPopupContent(familyPopup(item));
});
if (selectedIbadah) {
const marker = worshipMarkers.get(selectedIbadah.id);
if (marker?.isPopupOpen()) marker.setPopupContent(worshipPopup(selectedIbadah));
}
updateStats(rows);
renderFamilyPanel();
}
window.openFamilyPanel = function () {
renderFamilyPanel();
elements.familyPanel.classList.add("open");
};
elements.radiusSlider.addEventListener("input", (event) => {
currentRadius = Number(event.target.value);
updateCoverage();
});
elements.closePanel.addEventListener("click", () => {
elements.familyPanel.classList.remove("open");
});
async function init() {
[tempatIbadah, keluarga] = await Promise.all([
fetchRows(`${API_BASE}/tempat-ibadah`, fallbackTempatIbadah),
fetchRows(`${API_BASE}/keluarga`, fallbackKeluarga)
]);
renderMarkers();
selectedIbadah = tempatIbadah[0] || null;
updateCoverage();
}
init();
</script>
</body>
</html>