|
|
|
@@ -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 (< 50 m)</option>
|
|
|
|
|
<option value="medium">Sedang (50-200 m)</option>
|
|
|
|
|
<option value="far">Jauh (> 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>
|