This commit is contained in:
2026-06-10 18:07:01 +07:00
parent c4295c5395
commit 5257946cfa
69 changed files with 10741 additions and 0 deletions
+568
View File
@@ -0,0 +1,568 @@
<!DOCTYPE html>
<html lang="id">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGIS Choropleth — Kota Pontianak</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #58a6ff;
--text: #e6edf3;
--muted: #8b949e;
--c1: #fff7bc;
--c2: #fec44f;
--c3: #fb9a29;
--c4: #ec7014;
--c5: #cc4c02;
--c6: #8c2d04;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Space Mono', monospace;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── HEADER ─────────────────────────────────────── */
header {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 1000;
flex-shrink: 0;
}
.logo-dot {
width: 10px; height: 10px;
background: var(--accent);
border-radius: 50%;
box-shadow: 0 0 12px var(--accent);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:.5; transform:scale(1.4); }
}
header h1 {
font-family: 'Syne', sans-serif;
font-size: 16px;
font-weight: 800;
letter-spacing: .05em;
color: var(--text);
}
header span.sub {
font-size: 11px;
color: var(--muted);
margin-left: 4px;
font-weight: 400;
}
.mode-btns {
margin-left: auto;
display: flex;
gap: 6px;
}
.mode-btn {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--muted);
font-family: 'Space Mono', monospace;
font-size: 11px;
cursor: pointer;
transition: all .2s;
}
.mode-btn.active,
.mode-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(88,166,255,.08);
}
/* ── MAIN ────────────────────────────────────────── */
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── SIDEBAR ─────────────────────────────────────── */
.sidebar {
width: 280px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-section {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
}
.sidebar-section h3 {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 12px;
}
/* Legend */
.legend-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 7px;
font-size: 11px;
}
.legend-swatch {
width: 24px; height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label { color: var(--text); }
.legend-sub { color: var(--muted); font-size: 10px; }
/* Stat cards */
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
cursor: pointer;
transition: border-color .2s;
}
.stat-card:hover, .stat-card.active {
border-color: var(--accent);
}
.stat-name {
font-size: 10px;
color: var(--muted);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-value {
font-family: 'Syne', sans-serif;
font-size: 15px;
font-weight: 800;
color: var(--text);
}
.stat-unit { font-size: 10px; color: var(--muted); font-weight: 400; }
/* Detail panel */
#detail-panel {
display: none;
}
#detail-panel.visible {
display: block;
}
.detail-name {
font-family: 'Syne', sans-serif;
font-size: 18px;
font-weight: 800;
color: var(--text);
margin-bottom: 14px;
line-height: 1.2;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 7px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.detail-row:last-child { border-bottom: none; }
.detail-key { color: var(--muted); }
.detail-val { color: var(--text); font-weight: 700; }
.density-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
}
.density-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--c2), var(--c5));
transition: width .6s ease;
}
#placeholder {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
#placeholder em {
display: block;
font-size: 24px;
margin-bottom: 8px;
opacity: .4;
}
/* ── MAP ─────────────────────────────────────────── */
#map {
flex: 1;
background: #0a0f16;
}
/* Override Leaflet UI for dark mode */
.leaflet-control-zoom a {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.leaflet-control-zoom a:hover {
background: var(--border) !important;
}
.leaflet-bar { border: 1px solid var(--border) !important; }
/* ── INFO TOOLTIP ────────────────────────────────── */
.info-box {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 6px;
padding: 10px 14px;
font-family: 'Space Mono', monospace;
font-size: 12px;
color: var(--text) !important;
box-shadow: 0 4px 20px rgba(0,0,0,.4) !important;
min-width: 160px;
}
.info-box strong {
display: block;
font-family: 'Syne', sans-serif;
font-size: 14px;
font-weight: 800;
margin-bottom: 6px;
color: var(--accent);
}
.info-box span {
display: block;
color: var(--muted);
font-size: 11px;
}
/* ── TOOLTIP POPUPS ─────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
box-shadow: 0 8px 32px rgba(0,0,0,.6) !important;
}
.leaflet-popup-tip { background: var(--surface) !important; }
.leaflet-popup-content {
color: var(--text) !important;
font-family: 'Space Mono', monospace !important;
font-size: 12px !important;
margin: 12px 16px !important;
}
/* scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
</style>
</head>
<body>
<header>
<div class="logo-dot"></div>
<div>
<h1>WebGIS Pontianak <span class="sub">// Choropleth</span></h1>
</div>
<div class="mode-btns">
<button class="mode-btn active" onclick="setMode('density',this)">Kepadatan</button>
<button class="mode-btn" onclick="setMode('penduduk',this)">Penduduk</button>
</div>
</header>
<div class="main">
<!-- SIDEBAR -->
<div class="sidebar">
<!-- Legend -->
<div class="sidebar-section" id="legend-wrap">
<h3>Legenda</h3>
<div id="legend-items"></div>
</div>
<!-- Kecamatan Cards -->
<div class="sidebar-section">
<h3>Kecamatan</h3>
<div class="stat-grid" id="stat-grid"></div>
</div>
<!-- Detail -->
<div class="sidebar-section">
<h3>Detail</h3>
<div id="detail-panel">
<div class="detail-name" id="d-name"></div>
<div class="detail-row"><span class="detail-key">Penduduk</span><span class="detail-val" id="d-pop"></span></div>
<div class="detail-row"><span class="detail-key">Luas Wilayah</span><span class="detail-val" id="d-area"></span></div>
<div class="detail-row"><span class="detail-key">Kepadatan</span><span class="detail-val" id="d-den"></span></div>
<div class="density-bar"><div class="density-fill" id="d-bar"></div></div>
</div>
<div id="placeholder">
<em></em>
Klik area di peta untuk melihat data kecamatan secara detail.
</div>
</div>
</div>
<!-- MAP -->
<div id="map"></div>
</div>
<script src="pontianak.js"></script>
<script>
// ── CONFIG ────────────────────────────────────────────────────────────────────
const MODES = {
density: {
key: 'density',
label: 'Kepadatan Penduduk',
unit: 'jiwa/km²',
breaks: [2000, 5000, 7000, 10000, 12000],
colors: ['#fff7bc','#fec44f','#fb9a29','#ec7014','#cc4c02','#8c2d04'],
fmt: v => v.toLocaleString('id-ID') + ' jiwa/km²',
},
penduduk: {
key: 'penduduk',
label: 'Jumlah Penduduk',
unit: 'jiwa',
breaks: [75000, 85000, 95000, 105000, 115000],
colors: ['#c6dbef','#9ecae1','#6baed6','#3182bd','#2171b5','#084594'],
fmt: v => v.toLocaleString('id-ID') + ' jiwa',
}
};
let currentMode = 'density';
let geojsonLayer, lastSelected;
// ── MAP INIT ──────────────────────────────────────────────────────────────────
const map = L.map('map', { zoomControl: true }).setView([-0.02, 109.34], 12);
// Dark OSM tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
maxZoom: 19
}).addTo(map);
// ── HELPERS ───────────────────────────────────────────────────────────────────
function getColor(value, mode) {
const m = MODES[mode];
for (let i = 0; i < m.breaks.length; i++) {
if (value <= m.breaks[i]) return m.colors[i];
}
return m.colors[m.colors.length - 1];
}
function style(feature) {
const val = feature.properties[MODES[currentMode].key];
return {
fillColor: getColor(val, currentMode),
fillOpacity: 0.72,
color: '#0d1117',
weight: 1.5,
opacity: 1,
};
}
function highlightStyle(feature) {
return {
fillOpacity: 0.9,
color: '#58a6ff',
weight: 2.5,
};
}
// ── SIDEBAR UPDATERS ──────────────────────────────────────────────────────────
function buildLegend() {
const m = MODES[currentMode];
const container = document.getElementById('legend-items');
container.innerHTML = '';
const labels = [
`< ${m.breaks[0].toLocaleString('id-ID')}`,
...m.breaks.slice(0,-1).map((v,i) => `${v.toLocaleString('id-ID')} ${m.breaks[i+1].toLocaleString('id-ID')}`),
`> ${m.breaks[m.breaks.length-1].toLocaleString('id-ID')}`,
];
m.colors.forEach((c,i) => {
container.innerHTML += `
<div class="legend-item">
<div class="legend-swatch" style="background:${c}"></div>
<span class="legend-label">${labels[i]} <span class="legend-sub">${m.unit}</span></span>
</div>`;
});
}
function buildCards() {
const grid = document.getElementById('stat-grid');
grid.innerHTML = '';
const m = MODES[currentMode];
pontianakData.features.forEach(f => {
const p = f.properties;
const val = p[m.key];
grid.innerHTML += `
<div class="stat-card" id="card-${slugify(p.Ket)}" onclick="selectByName('${p.Ket}')">
<div class="stat-name">${p.Ket.replace('Pontianak ','')}</div>
<div class="stat-value">${formatShort(val)} <span class="stat-unit">${m.unit.split('/')[0]}</span></div>
</div>`;
});
}
function slugify(s) { return s.replace(/\s+/g,'_'); }
function formatShort(v) {
if (v >= 1000) return (v/1000).toFixed(1) + 'k';
return v.toLocaleString('id-ID');
}
function showDetail(props) {
document.getElementById('detail-panel').classList.add('visible');
document.getElementById('placeholder').style.display = 'none';
document.getElementById('d-name').textContent = props.Ket;
document.getElementById('d-pop').textContent = props.penduduk.toLocaleString('id-ID') + ' jiwa';
document.getElementById('d-area').textContent = props.luas_km2.toFixed(2) + ' km²';
document.getElementById('d-den').textContent = props.density.toLocaleString('id-ID') + ' jiwa/km²';
// Bar fill based on density max (~12000)
const pct = Math.min(100, (props.density / 13000) * 100);
document.getElementById('d-bar').style.width = pct + '%';
// Highlight card
document.querySelectorAll('.stat-card').forEach(c => c.classList.remove('active'));
const card = document.getElementById('card-' + slugify(props.Ket));
if (card) card.classList.add('active');
}
// ── INTERACTION ───────────────────────────────────────────────────────────────
function onEachFeature(feature, layer) {
layer.on({
mouseover(e) {
const l = e.target;
l.setStyle(highlightStyle(feature));
l.bringToFront();
infoBox.update(feature.properties);
},
mouseout(e) {
if (lastSelected !== e.target) {
geojsonLayer.resetStyle(e.target);
}
infoBox.update(null);
},
click(e) {
map.fitBounds(e.target.getBounds(), { padding: [40, 40] });
if (lastSelected && lastSelected !== e.target) {
geojsonLayer.resetStyle(lastSelected);
}
e.target.setStyle(highlightStyle(feature));
e.target.bringToFront();
lastSelected = e.target;
showDetail(feature.properties);
}
});
}
function selectByName(name) {
geojsonLayer.eachLayer(l => {
if (l.feature.properties.Ket === name) {
map.fitBounds(l.getBounds(), { padding: [40, 40] });
if (lastSelected && lastSelected !== l) geojsonLayer.resetStyle(lastSelected);
l.setStyle(highlightStyle(l.feature));
l.bringToFront();
lastSelected = l;
showDetail(l.feature.properties);
}
});
}
// ── INFO BOX ──────────────────────────────────────────────────────────────────
const infoBox = L.control({ position: 'topright' });
infoBox.onAdd = function() {
this._div = L.DomUtil.create('div', 'info-box');
this.update(null);
return this._div;
};
infoBox.update = function(props) {
if (!props) {
this._div.innerHTML = '<strong>Peta Pontianak</strong><span>Arahkan kursor ke area</span>';
return;
}
const m = MODES[currentMode];
const val = props[m.key];
this._div.innerHTML = `
<strong>${props.Ket}</strong>
<span>${m.label}</span>
<span style="color:#e6edf3;font-size:14px;font-weight:700;margin-top:2px;display:block">
${val.toLocaleString('id-ID')} <small style="color:#8b949e;font-size:10px">${m.unit}</small>
</span>`;
};
infoBox.addTo(map);
// ── RENDER ─────────────────────────────────────────────────────────────────────
function renderMap() {
if (geojsonLayer) map.removeLayer(geojsonLayer);
lastSelected = null;
geojsonLayer = L.geoJson(pontianakData, {
style,
onEachFeature,
}).addTo(map);
buildLegend();
buildCards();
// Reset detail
document.getElementById('detail-panel').classList.remove('visible');
document.getElementById('placeholder').style.display = '';
document.querySelectorAll('.stat-card').forEach(c => c.classList.remove('active'));
}
function setMode(mode, btn) {
currentMode = mode;
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderMap();
if (lastSelected) infoBox.update(null);
}
// Init
renderMap();
</script>
</body>
</html>