menambahkan jalan dan parsil di portal
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user