menambahkan jalan dan parsil di portal

This commit is contained in:
z0rayy
2026-06-11 00:38:46 +07:00
parent 4928f53982
commit b027f02cf0
5 changed files with 627 additions and 8 deletions
+18 -5
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Portal - SPBU & Kemiskinan</title>
<title>WebGIS Portal - SPBU, Kemiskinan, Jalan & Parsil</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: #0f172a; color: #f1f5f9; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow: hidden; }
body { font-family: 'Inter', sans-serif; background: #0f172a; color: #f1f5f9; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow-x: hidden; overflow-y: auto; padding: 48px 0 80px; }
/* Background decorative */
.bg-pattern { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: radial-gradient(#334155 1px, transparent 1px); background-size: 40px 40px; opacity: 0.3; z-index: -1; }
@@ -18,9 +18,9 @@
header h1 { font-size: 42px; font-weight: 800; background: linear-gradient(135deg, #60a5fa, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 16px; }
header p { font-size: 18px; color: #cbd5e1; max-width: 600px; margin: 0 auto; line-height: 1.6; }
.cards-container { display: flex; gap: 40px; max-width: 1000px; width: 100%; padding: 0 20px; z-index: 10; flex-wrap: wrap; justify-content: center; }
.cards-container { display: flex; gap: 28px; max-width: 1160px; width: 100%; padding: 0 20px; z-index: 10; flex-wrap: wrap; justify-content: center; }
.choice-card { background: rgba(30, 41, 59, 0.7); border: 1px solid #334155; border-radius: 20px; padding: 40px 30px; width: calc(50% - 20px); min-width: 300px; text-align: center; cursor: pointer; transition: all 0.3s ease; backdrop-filter: blur(10px); display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; }
.choice-card { background: rgba(30, 41, 59, 0.7); border: 1px solid #334155; border-radius: 20px; padding: 36px 28px; width: calc(33.333% - 20px); min-width: 300px; max-width: 360px; text-align: center; cursor: pointer; transition: all 0.3s ease; backdrop-filter: blur(10px); display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; }
.choice-card:hover { transform: translateY(-10px); border-color: #3b82f6; box-shadow: 0 20px 40px rgba(0,0,0,0.4), 0 0 20px rgba(59,130,246,0.2); }
.choice-card .icon-box { width: 80px; height: 80px; border-radius: 20px; display: flex; align-items: center; justify-content: center; font-size: 36px; margin-bottom: 24px; transition: all 0.3s ease; }
@@ -33,6 +33,10 @@
.card-poverty:hover .icon-box { background: rgba(239, 68, 68, 0.2); color: #f87171; box-shadow: 0 0 15px rgba(239,68,68,0.5); }
.card-poverty .icon-box { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
/* Jalan & Parsil Card Styles */
.card-land:hover .icon-box { background: rgba(20, 184, 166, 0.2); color: #5eead4; box-shadow: 0 0 15px rgba(20,184,166,0.5); }
.card-land .icon-box { background: rgba(20, 184, 166, 0.1); color: #14b8a6; }
.choice-card h2 { font-size: 24px; font-weight: 700; margin-bottom: 12px; color: #f8fafc; }
.choice-card p { font-size: 15px; color: #94a3b8; line-height: 1.6; margin-bottom: 24px; flex-grow: 1; }
@@ -40,10 +44,11 @@
.badge-public { background: rgba(34, 197, 94, 0.1); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.2); }
.badge-private { background: rgba(245, 158, 11, 0.1); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.2); }
.admin-link { position: absolute; bottom: 30px; color: #64748b; text-decoration: none; font-size: 14px; transition: color 0.2s; z-index: 10; }
.admin-link { position: fixed; bottom: 30px; color: #64748b; text-decoration: none; font-size: 14px; transition: color 0.2s; z-index: 10; }
.admin-link:hover { color: #f8fafc; }
@media (max-width: 768px) {
body { justify-content: flex-start; padding-top: 32px; }
.choice-card { width: 100%; }
header h1 { font-size: 32px; }
}
@@ -75,6 +80,14 @@
<p>Sistem analisis spasial untuk mendeteksi rumah penduduk miskin dalam radius yang dapat diatur dari fasilitas ibadah.</p>
<span class="badge badge-public"><i class="fa-solid fa-globe mr-1"></i> Akses Publik</span>
</a>
<!-- Opsi 3: Jalan & Parsil -->
<a href="jalan-parsil.html" class="choice-card card-land">
<div class="icon-box"><i class="fa-solid fa-road"></i></div>
<h2>Peta Jalan & Parsil</h2>
<p>Analisis jaringan jalan, status bidang tanah, pencarian pemilik parsil, dan estimasi akses parsil ke jalan terdekat.</p>
<span class="badge badge-public"><i class="fa-solid fa-globe mr-1"></i> Akses Publik</span>
</a>
</div>
<a href="login.html?redirect=admin.html" class="admin-link">Masuk sebagai Administrator</a>
+589
View File
@@ -0,0 +1,589 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Peta Jalan & Parsil - WebGIS</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--clr-bg: #0f172a; --clr-surface: #1e293b; --clr-surface-light: #334155;
--clr-border: #334155; --clr-text: #f1f5f9; --clr-muted: #94a3b8;
--clr-primary: #14b8a6; --clr-primary-dark: #0f766e;
--clr-warning: #f59e0b; --clr-danger: #ef4444;
--radius: 10px; --shadow: 0 4px 24px rgba(0,0,0,.45);
--transition: .2s ease; --sidebar-width: 260px; --sidebar-collapsed: 70px;
}
html, body { height: 100%; font-family: 'Inter', sans-serif; background: var(--clr-bg); color: var(--clr-text); overflow: hidden; }
button, input, select { font: inherit; }
.app-wrapper { display: flex; height: 100vh; }
.sidebar {
width: var(--sidebar-width); background: linear-gradient(135deg, #123838 0%, #0f172a 100%);
border-right: 1px solid var(--clr-border); display: flex; flex-direction: column;
transition: width var(--transition); z-index: 100; box-shadow: 2px 0 8px rgba(0,0,0,.3); overflow: hidden;
}
.sidebar.collapsed { width: var(--sidebar-collapsed); }
.sidebar-header { padding: 20px; border-bottom: 1px solid var(--clr-border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.sidebar-logo { display: flex; align-items: center; gap: 10px; flex: 1; overflow: hidden; }
.sidebar-logo-icon {
width: 40px; height: 40px; flex-shrink: 0; background: linear-gradient(135deg, #14b8a6, #f59e0b);
border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700;
box-shadow: 0 0 12px rgba(20,184,166,.4);
}
.sidebar-logo-text { overflow: hidden; }
.sidebar-logo-text h2 { font-size: 14px; font-weight: 700; white-space: nowrap; }
.sidebar-logo-text p { font-size: 11px; color: var(--clr-muted); white-space: nowrap; }
.sidebar.collapsed .sidebar-logo-text { display: none; }
.sidebar-toggle { background: transparent; border: none; color: var(--clr-muted); cursor: pointer; font-size: 20px; padding: 4px; flex-shrink: 0; }
.sidebar-toggle:hover { color: var(--clr-text); }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 16px 0; }
.sidebar-section { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,.05); }
.sidebar-section-title { padding: 10px 16px; font-size: 10px; font-weight: 700; color: var(--clr-muted); text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; overflow: hidden; }
.sidebar.collapsed .sidebar-section-title { opacity: 0; }
.sidebar-menu { display: flex; flex-direction: column; }
.sidebar-item {
display: flex; align-items: center; gap: 12px; padding: 13px 16px; color: var(--clr-muted);
cursor: pointer; text-decoration: none; transition: all var(--transition); border-left: 3px solid transparent;
}
.sidebar-item:hover { color: var(--clr-text); background: rgba(255,255,255,.05); border-left-color: var(--clr-primary); }
.sidebar-item.active { color: #5eead4; background: rgba(20,184,166,.1); border-left-color: var(--clr-primary); }
.sidebar-item-icon { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.sidebar-item-text { flex: 1; font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; }
.sidebar.collapsed .sidebar-item-text { display: none; }
.main-wrapper { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
header {
flex-shrink: 0; min-height: 60px; background: linear-gradient(135deg, #123838 0%, #0f172a 100%);
border-bottom: 1px solid var(--clr-border); display: flex; align-items: center; justify-content: space-between;
padding: 0 20px; gap: 14px; box-shadow: 0 2px 12px rgba(0,0,0,.5);
}
.header-title h1 { font-size: 18px; font-weight: 700; color: #99f6e4; }
.header-breadcrumb { font-size: 13px; color: var(--clr-muted); margin-top: 2px; }
.header-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.count-pill { font-size: 13px; color: #cbd5e1; background: rgba(255,255,255,.06); border: 1px solid #334155; padding: 5px 12px; border-radius: 7px; }
.refresh-btn { background: rgba(20,184,166,.15); border: 1px solid rgba(20,184,166,.3); color: #5eead4; border-radius: 7px; padding: 6px 12px; cursor: pointer; font-size: 13px; }
.refresh-btn:hover { background: rgba(20,184,166,.25); }
.content-area { flex: 1; position: relative; overflow: hidden; }
#map { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; }
.panel {
position: absolute; background: rgba(30, 41, 59, .95); backdrop-filter: blur(8px);
border: 1px solid var(--clr-border); border-radius: var(--radius); box-shadow: var(--shadow); z-index: 1000;
}
.search-panel { top: 16px; left: 60px; width: 320px; padding: 12px; display: flex; flex-direction: column; }
.search-panel h3, .filter-panel h3, .stats-panel h3, .legend-panel h3 {
font-size: 12px; font-weight: 700; color: var(--clr-text); margin-bottom: 10px; text-transform: uppercase; letter-spacing: .5px;
}
.ctrl-input, .ctrl-select {
width: 100%; padding: 9px 12px; background: var(--clr-bg); border: 1px solid var(--clr-border);
border-radius: 6px; color: var(--clr-text); font-size: 13px; outline: none;
}
.ctrl-input:focus, .ctrl-select:focus { border-color: var(--clr-primary); }
.field-label { display: block; font-size: 11px; font-weight: 700; color: var(--clr-muted); margin: 10px 0 5px; }
.search-results { max-height: 230px; overflow-y: auto; margin-top: 8px; display: none; flex-direction: column; gap: 4px; }
.search-results.active { display: flex; }
.search-result-item { padding: 8px 10px; background: rgba(255,255,255,.05); border-radius: 6px; cursor: pointer; border: 1px solid transparent; }
.search-result-item:hover { background: rgba(20,184,166,.1); border-color: rgba(20,184,166,.35); }
.search-result-name { font-size: 13px; font-weight: 600; color: #f8fafc; display: block; margin-bottom: 2px; }
.search-result-desc { font-size: 11px; color: var(--clr-muted); display: block; }
.filter-panel { top: 16px; right: 16px; width: 285px; padding: 14px; }
.mode-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 10px; }
.mode-btn {
border: 1px solid var(--clr-border); background: rgba(15,23,42,.8); color: var(--clr-muted);
border-radius: 7px; padding: 8px; cursor: pointer; font-size: 12px; font-weight: 700;
}
.mode-btn.active { border-color: rgba(20,184,166,.8); color: #99f6e4; background: rgba(20,184,166,.14); }
.analysis-box { margin-top: 12px; border-top: 1px solid rgba(148,163,184,.18); padding-top: 12px; font-size: 12px; color: var(--clr-muted); line-height: 1.5; }
.analysis-box strong { color: #f8fafc; }
.analysis-empty { color: var(--clr-muted); }
.stats-panel { left: 16px; bottom: 16px; width: 300px; padding: 14px; }
.stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stat-card { background: rgba(15,23,42,.72); border: 1px solid rgba(148,163,184,.16); border-radius: 8px; padding: 10px; min-height: 68px; }
.stat-value { font-size: 18px; font-weight: 700; color: #f8fafc; margin-bottom: 3px; }
.stat-label { font-size: 11px; color: var(--clr-muted); line-height: 1.35; }
.legend-panel { right: 16px; bottom: 16px; width: 235px; padding: 14px; }
.legend-row { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; color: var(--clr-text); font-size: 12px; }
.legend-line { width: 24px; height: 0; border-top: 4px solid; border-radius: 999px; flex-shrink: 0; }
.legend-swatch { width: 14px; height: 14px; border-radius: 4px; flex-shrink: 0; border: 1px solid rgba(255,255,255,.3); }
.popup-title { font-weight: 700; color: #0f172a; margin-bottom: 4px; }
.popup-row { font-size: 12px; line-height: 1.5; }
@media (max-width: 900px) {
.sidebar { width: var(--sidebar-collapsed); }
.sidebar-logo-text, .sidebar-item-text, .sidebar-section-title { display: none; }
header { align-items: flex-start; flex-direction: column; padding: 10px 14px; }
.search-panel, .filter-panel, .stats-panel, .legend-panel {
left: 12px; right: 12px; width: auto;
}
.search-panel { top: 12px; }
.filter-panel { top: 190px; }
.stats-panel { display: none; }
.legend-panel { bottom: 12px; }
}
</style>
</head>
<body>
<div class="app-wrapper">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-logo">
<div class="sidebar-logo-icon">JP</div>
<div class="sidebar-logo-text">
<h2>WebGIS</h2>
<p>Jalan & Parsil</p>
</div>
</div>
<button class="sidebar-toggle" id="sidebar-toggle">=</button>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">
<div class="sidebar-section-title">Navigasi</div>
<div class="sidebar-menu">
<div class="sidebar-item active">
<div class="sidebar-item-icon">#</div>
<div class="sidebar-item-text">Jalan & Parsil</div>
</div>
<a href="index.php" class="sidebar-item">
<div class="sidebar-item-icon"></div>
<div class="sidebar-item-text">Beranda</div>
</a>
</div>
</div>
</nav>
</aside>
<main class="main-wrapper">
<header>
<div class="header-title">
<h1>Peta Jalan & Parsil Tanah</h1>
<div class="header-breadcrumb">Visualisasi infrastruktur, kepemilikan lahan, dan akses parsil ke jalan</div>
</div>
<div class="header-actions">
<span id="data-count" class="count-pill">Memuat data...</span>
<button id="btn-refresh" class="refresh-btn">Refresh</button>
</div>
</header>
<section class="content-area">
<div id="map"></div>
<div class="panel search-panel">
<h3>Pencarian</h3>
<input id="search-input" class="ctrl-input" type="text" placeholder="Cari nama jalan atau pemilik parsil..." autocomplete="off">
<div id="search-results" class="search-results"></div>
</div>
<div class="panel filter-panel">
<h3>Filter & Analisis</h3>
<div class="mode-grid">
<button class="mode-btn active" data-mode="all">Semua</button>
<button class="mode-btn" data-mode="jalan">Jalan</button>
<button class="mode-btn" data-mode="parsil">Parsil</button>
<button class="mode-btn" data-mode="analysis">Akses</button>
</div>
<label class="field-label" for="filter-jalan">Status Jalan</label>
<select id="filter-jalan" class="ctrl-select">
<option value="all">Semua status</option>
<option value="Nasional">Nasional</option>
<option value="Provinsi">Provinsi</option>
<option value="Kabupaten">Kabupaten</option>
</select>
<label class="field-label" for="filter-parsil">Status Parsil</label>
<select id="filter-parsil" class="ctrl-select">
<option value="all">Semua status</option>
<option value="SHM">SHM</option>
<option value="HGB">HGB</option>
<option value="HGU">HGU</option>
<option value="HP">HP</option>
</select>
<label class="field-label" for="filter-access">Akses Parsil ke Jalan</label>
<select id="filter-access" class="ctrl-select">
<option value="all">Semua jarak</option>
<option value="near">Dekat (&lt; 50 m)</option>
<option value="medium">Sedang (50-200 m)</option>
<option value="far">Jauh (&gt; 200 m)</option>
</select>
<div id="analysis-box" class="analysis-box">
<span class="analysis-empty">Klik satu parsil untuk melihat jalan terdekat dan estimasi jaraknya.</span>
</div>
</div>
<div class="panel stats-panel">
<h3>Statistik Data</h3>
<div class="stats-grid">
<div class="stat-card"><div id="stat-jalan" class="stat-value">0</div><div class="stat-label">Total jalan tampil</div></div>
<div class="stat-card"><div id="stat-parsil" class="stat-value">0</div><div class="stat-label">Total parsil tampil</div></div>
<div class="stat-card"><div id="stat-panjang" class="stat-value">0 km</div><div class="stat-label">Akumulasi panjang jalan</div></div>
<div class="stat-card"><div id="stat-luas" class="stat-value">0 m2</div><div class="stat-label">Akumulasi luas parsil</div></div>
</div>
</div>
<div class="panel legend-panel">
<h3>Legenda</h3>
<div class="legend-row"><div class="legend-line" style="border-color:#ef4444;"></div> Jalan Nasional</div>
<div class="legend-row"><div class="legend-line" style="border-color:#3b82f6;"></div> Jalan Provinsi</div>
<div class="legend-row"><div class="legend-line" style="border-color:#22c55e;"></div> Jalan Kabupaten</div>
<div class="legend-row"><div class="legend-swatch" style="background:#14b8a6;"></div> Parsil SHM</div>
<div class="legend-row"><div class="legend-swatch" style="background:#f59e0b;"></div> Parsil HGB/HGU/HP</div>
</div>
</section>
</main>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
<script>
document.getElementById('sidebar-toggle').addEventListener('click', () => {
document.getElementById('sidebar').classList.toggle('collapsed');
});
const map = L.map('map').setView([-0.026, 109.342], 13);
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 });
const dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 });
osm.addTo(map);
const jalanLayer = L.layerGroup().addTo(map);
const parsilLayer = L.layerGroup().addTo(map);
const highlightLayer = L.layerGroup().addTo(map);
L.control.layers({ 'Standard': osm, 'Dark': dark }, { 'Jalan': jalanLayer, 'Parsil': parsilLayer }, { position: 'bottomleft' }).addTo(map);
let jalanData = [];
let parsilData = [];
let activeMode = 'all';
let selectedParsilId = null;
const jalanStyles = {
Nasional: { color: '#ef4444', weight: 5, opacity: .92 },
Provinsi: { color: '#3b82f6', weight: 4, opacity: .9 },
Kabupaten: { color: '#22c55e', weight: 3, opacity: .88 },
default: { color: '#94a3b8', weight: 3, opacity: .85 }
};
const parsilColors = {
SHM: '#14b8a6',
HGB: '#f59e0b',
HGU: '#a855f7',
HP: '#64748b'
};
function normalizeGeom(geom) {
if (!geom) return null;
const parsed = typeof geom === 'string' ? JSON.parse(geom) : geom;
if (!parsed) return null;
if (parsed.type === 'Feature') return parsed;
if (parsed.type && parsed.coordinates) return { type: 'Feature', properties: {}, geometry: parsed };
return null;
}
function formatLength(meters) {
const value = Number(meters) || 0;
if (value >= 1000) return (value / 1000).toFixed(2) + ' km';
return value.toFixed(0) + ' m';
}
function formatArea(value) {
const area = Number(value) || 0;
if (area >= 10000) return (area / 10000).toFixed(2) + ' ha';
return area.toLocaleString('id-ID', { maximumFractionDigits: 0 }) + ' m2';
}
function jalanStyle(status) {
return jalanStyles[status] || jalanStyles.default;
}
function parsilStyle(status, highlighted = false) {
return {
color: highlighted ? '#f8fafc' : (parsilColors[status] || '#64748b'),
weight: highlighted ? 4 : 2,
opacity: highlighted ? 1 : .95,
fillColor: parsilColors[status] || '#64748b',
fillOpacity: highlighted ? .38 : .24
};
}
function getFilteredJalan() {
const status = document.getElementById('filter-jalan').value;
if (activeMode === 'parsil') return [];
return jalanData.filter(item => status === 'all' || item.status === status);
}
function getFilteredParsil() {
const status = document.getElementById('filter-parsil').value;
const access = document.getElementById('filter-access').value;
if (activeMode === 'jalan') return [];
return parsilData.filter(item => {
if (status !== 'all' && item.status !== status) return false;
if (activeMode !== 'analysis' && access === 'all') return true;
const nearest = findNearestRoad(item);
const distance = nearest ? nearest.distance : Infinity;
if (access === 'near') return distance < 50;
if (access === 'medium') return distance >= 50 && distance <= 200;
if (access === 'far') return distance > 200;
return true;
});
}
function findNearestRoad(parsil) {
const parsilGeom = normalizeGeom(parsil.geom);
if (!parsilGeom || !jalanData.length || !window.turf) return null;
let center;
try {
center = turf.centroid(parsilGeom);
} catch (e) {
return null;
}
let nearest = null;
jalanData.forEach(jalan => {
const roadGeom = normalizeGeom(jalan.geom);
if (!roadGeom) return;
try {
const distance = turf.pointToLineDistance(center, roadGeom, { units: 'meters' });
if (!nearest || distance < nearest.distance) {
nearest = { jalan, distance, center };
}
} catch (e) {}
});
return nearest;
}
function renderJalan() {
jalanLayer.clearLayers();
const filtered = getFilteredJalan();
const bounds = [];
filtered.forEach(item => {
const feature = normalizeGeom(item.geom);
if (!feature) return;
const layer = L.geoJSON(feature, {
style: jalanStyle(item.status),
onEachFeature: (_, lyr) => {
lyr.bindPopup(`
<div class="popup-title">${item.nama_jalan}</div>
<div class="popup-row"><strong>Status:</strong> ${item.status}</div>
<div class="popup-row"><strong>Panjang:</strong> ${formatLength(item.panjang)}</div>
`);
}
}).addTo(jalanLayer);
bounds.push(layer.getBounds());
});
return bounds;
}
function renderParsil() {
parsilLayer.clearLayers();
const filtered = getFilteredParsil();
const bounds = [];
filtered.forEach(item => {
const feature = normalizeGeom(item.geom);
if (!feature) return;
const nearest = findNearestRoad(item);
const layer = L.geoJSON(feature, {
style: parsilStyle(item.status, item.id === selectedParsilId),
onEachFeature: (_, lyr) => {
lyr.on('click', () => selectParsil(item.id));
lyr.bindPopup(`
<div class="popup-title">${item.nama_pemilik}</div>
<div class="popup-row"><strong>Status:</strong> ${item.status}</div>
<div class="popup-row"><strong>Luas:</strong> ${formatArea(item.luas)}</div>
<div class="popup-row"><strong>Jalan terdekat:</strong> ${nearest ? nearest.jalan.nama_jalan : '-'}</div>
<div class="popup-row"><strong>Jarak akses:</strong> ${nearest ? nearest.distance.toFixed(1) + ' m' : '-'}</div>
`);
}
}).addTo(parsilLayer);
bounds.push(layer.getBounds());
});
return bounds;
}
function renderStats() {
const filteredJalan = getFilteredJalan();
const filteredParsil = getFilteredParsil();
const totalPanjang = filteredJalan.reduce((sum, item) => sum + (Number(item.panjang) || 0), 0);
const totalLuas = filteredParsil.reduce((sum, item) => sum + (Number(item.luas) || 0), 0);
document.getElementById('stat-jalan').textContent = filteredJalan.length;
document.getElementById('stat-parsil').textContent = filteredParsil.length;
document.getElementById('stat-panjang').textContent = formatLength(totalPanjang);
document.getElementById('stat-luas').textContent = formatArea(totalLuas);
document.getElementById('data-count').textContent = `${jalanData.length} jalan • ${parsilData.length} parsil`;
}
function renderAnalysis() {
const box = document.getElementById('analysis-box');
highlightLayer.clearLayers();
if (!selectedParsilId) {
box.innerHTML = '<span class="analysis-empty">Klik satu parsil untuk melihat jalan terdekat dan estimasi jaraknya.</span>';
return;
}
const parsil = parsilData.find(item => item.id === selectedParsilId);
const nearest = parsil ? findNearestRoad(parsil) : null;
if (!parsil || !nearest) {
box.innerHTML = '<span class="analysis-empty">Analisis belum tersedia untuk parsil ini.</span>';
return;
}
const center = nearest.center;
const roadGeom = normalizeGeom(nearest.jalan.geom);
if (center && roadGeom) {
try {
const nearestPoint = turf.nearestPointOnLine(roadGeom, center, { units: 'meters' });
const coords = center.geometry.coordinates;
const roadCoords = nearestPoint.geometry.coordinates;
L.polyline([[coords[1], coords[0]], [roadCoords[1], roadCoords[0]]], {
color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: .95
}).addTo(highlightLayer);
} catch (e) {}
}
box.innerHTML = `
<strong>${parsil.nama_pemilik}</strong><br>
Jalan terdekat: <strong>${nearest.jalan.nama_jalan}</strong><br>
Status jalan: <strong>${nearest.jalan.status}</strong><br>
Estimasi jarak akses: <strong>${nearest.distance.toFixed(1)} m</strong>
`;
}
function renderAll(zoomToData = false) {
const roadBounds = renderJalan();
const parcelBounds = renderParsil();
renderStats();
renderAnalysis();
if (zoomToData) {
const allBounds = [...roadBounds, ...parcelBounds].filter(bounds => bounds && bounds.isValid && bounds.isValid());
if (allBounds.length) {
const merged = L.latLngBounds(allBounds[0].getSouthWest(), allBounds[0].getNorthEast());
allBounds.slice(1).forEach(bounds => merged.extend(bounds));
map.fitBounds(merged, { padding: [50, 50] });
}
}
}
function selectParsil(id) {
selectedParsilId = id;
if (activeMode !== 'analysis') setMode('analysis', false);
renderAll(false);
const item = parsilData.find(parsil => parsil.id === id);
const feature = item ? normalizeGeom(item.geom) : null;
if (feature) {
const layer = L.geoJSON(feature);
map.fitBounds(layer.getBounds(), { padding: [70, 70], maxZoom: 18 });
}
}
function setMode(mode, rerender = true) {
activeMode = mode;
document.querySelectorAll('.mode-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === mode));
if (rerender) renderAll(false);
}
function renderSearchResults(query) {
const results = document.getElementById('search-results');
const q = query.trim().toLowerCase();
if (!q) {
results.classList.remove('active');
results.innerHTML = '';
return;
}
const roads = jalanData
.filter(item => item.nama_jalan.toLowerCase().includes(q))
.slice(0, 8)
.map(item => ({ type: 'jalan', id: item.id, title: item.nama_jalan, desc: `Jalan ${item.status}${formatLength(item.panjang)}` }));
const parcels = parsilData
.filter(item => item.nama_pemilik.toLowerCase().includes(q) || item.status.toLowerCase().includes(q))
.slice(0, 8)
.map(item => ({ type: 'parsil', id: item.id, title: item.nama_pemilik, desc: `Parsil ${item.status}${formatArea(item.luas)}` }));
const matches = [...roads, ...parcels].slice(0, 12);
if (!matches.length) {
results.innerHTML = '<div style="padding:8px 10px; color:#94a3b8; font-size:12px;">Tidak ditemukan.</div>';
} else {
results.innerHTML = matches.map(item => `
<div class="search-result-item" onclick="window.selectSearchResult('${item.type}', ${item.id})">
<span class="search-result-name">${item.title}</span>
<span class="search-result-desc">${item.desc}</span>
</div>
`).join('');
}
results.classList.add('active');
}
window.selectSearchResult = function(type, id) {
document.getElementById('search-results').classList.remove('active');
if (type === 'parsil') {
selectParsil(id);
return;
}
const item = jalanData.find(jalan => jalan.id === id);
const feature = item ? normalizeGeom(item.geom) : null;
if (!feature) return;
setMode('jalan', false);
renderAll(false);
const layer = L.geoJSON(feature, { style: { ...jalanStyle(item.status), weight: 8 } }).addTo(highlightLayer);
map.fitBounds(layer.getBounds(), { padding: [70, 70], maxZoom: 18 });
};
async function loadData(zoomToData = true) {
document.getElementById('data-count').textContent = 'Memuat data...';
try {
const [jalanRes, parsilRes] = await Promise.all([
fetch('backend/read_jalan.php'),
fetch('backend/read_parsil.php')
]);
const jalanJson = await jalanRes.json();
const parsilJson = await parsilRes.json();
jalanData = (jalanJson.success && jalanJson.data) ? jalanJson.data : [];
parsilData = (parsilJson.success && parsilJson.data) ? parsilJson.data : [];
renderAll(zoomToData);
} catch (error) {
console.error('Gagal memuat data jalan/parsil:', error);
document.getElementById('data-count').textContent = 'Gagal memuat data';
}
}
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => setMode(btn.dataset.mode));
});
document.getElementById('filter-jalan').addEventListener('change', () => renderAll(false));
document.getElementById('filter-parsil').addEventListener('change', () => renderAll(false));
document.getElementById('filter-access').addEventListener('change', () => {
if (document.getElementById('filter-access').value !== 'all') setMode('analysis', false);
renderAll(false);
});
document.getElementById('search-input').addEventListener('input', e => renderSearchResults(e.target.value));
document.getElementById('btn-refresh').addEventListener('click', async () => {
const btn = document.getElementById('btn-refresh');
btn.textContent = 'Memuat...';
await loadData(false);
btn.textContent = 'Refresh';
});
loadData();
</script>
</body>
</html>
+4
View File
@@ -86,6 +86,10 @@ const LocateControl = L.Control.extend({
container.onclick = function(e) {
e.stopPropagation();
if (!window.isSecureContext) {
showToast('Fitur lokasi hanya tersedia melalui HTTPS.', 'error');
return;
}
showToast('Mencari lokasi...', 'success');
map.locate({ setView: true, maxZoom: 17, enableHighAccuracy: true });
}
+13 -3
View File
@@ -301,11 +301,21 @@
}
window.updateRadius = function(id, val) {
ibadahRadiuses[id] = parseInt(val);
ibadahRadiuses[id] = parseInt(val, 10) || 0;
syncIbadahPoints();
renderBuffers();
renderMiskin();
};
function syncIbadahPoints() {
ibadahPoints = [];
ibadahData.forEach(i => {
const lat = parseFloat(i.latitude), lng = parseFloat(i.longitude);
if (isNaN(lat) || isNaN(lng)) return;
ibadahPoints.push({ id: i.id, geom: [lng, lat], rad: ibadahRadiuses[i.id] || 0 });
});
}
function renderBuffers() {
map.eachLayer(l => { if (l.options && l.options.isBuffer) map.removeLayer(l); });
ibadahData.forEach(item => {
@@ -322,7 +332,8 @@
function renderAll() { renderIbadah(); renderBuffers(); renderMiskin(); }
function renderIbadah(autoZoom = false) {
ibadahLayer.clearLayers(); ibadahPoints = [];
ibadahLayer.clearLayers();
syncIbadahPoints();
const bounds = [];
const f = document.getElementById('search-ibadah').value.toLowerCase();
ibadahData.forEach(i => {
@@ -330,7 +341,6 @@
const lat = parseFloat(i.latitude), lng = parseFloat(i.longitude);
if (isNaN(lat) || isNaN(lng)) return;
const rVal = ibadahRadiuses[i.id] || 0;
ibadahPoints.push({ id: i.id, geom: [lng, lat], rad: rVal });
const marker = L.marker([lat, lng], { icon: L.divIcon({ className: 'custom-marker', html: `<div class="marker-halo ibadah"></div><div class="marker-icon-inner">🕌</div>`, iconSize: [28, 28], iconAnchor: [14, 14], popupAnchor: [0, -14] }) })
.bindPopup(`
+3
View File
@@ -382,6 +382,9 @@
document.getElementById('btn-location').addEventListener('click', () => {
if (!navigator.geolocation) return alert('Geolocation tidak didukung browser Anda.');
if (!window.isSecureContext) {
return alert('Fitur lokasi hanya bisa digunakan melalui HTTPS. Buka situs dengan https:// atau gunakan localhost saat development.');
}
const btn = document.getElementById('btn-location');
btn.textContent = '⏳ Mencari lokasi...';
btn.disabled = true;