568 lines
17 KiB
HTML
568 lines
17 KiB
HTML
<!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> |