564 lines
15 KiB
HTML
564 lines
15 KiB
HTML
<!doctype html>
|
||
<html lang="id">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Sebaran Penduduk Kota Pontianak — Peta Tematik per Kecamatan</title>
|
||
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Libre+Franklin:wght@400;500;600&display=swap" rel="stylesheet" />
|
||
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||
|
||
<style>
|
||
:root {
|
||
--paper: #f7f3ea;
|
||
--paper-deep: #efe9db;
|
||
--ink: #292420;
|
||
--ink-soft: #6f675b;
|
||
--rule: #d9d1bf;
|
||
--accent: #a8501a;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
html, body {
|
||
height: 100%;
|
||
margin: 0;
|
||
font-family: "Libre Franklin", sans-serif;
|
||
color: var(--ink);
|
||
background: var(--paper);
|
||
}
|
||
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: 340px 1fr;
|
||
height: 100%;
|
||
}
|
||
|
||
/* ---------- Sidebar ---------- */
|
||
|
||
.panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-right: 1px solid var(--rule);
|
||
background: var(--paper);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.panel-inner {
|
||
padding: 28px 26px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
}
|
||
|
||
.overline {
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: var(--accent);
|
||
margin: 0 0 14px;
|
||
}
|
||
|
||
h1 {
|
||
font-family: "Fraunces", serif;
|
||
font-weight: 700;
|
||
font-size: 1.85rem;
|
||
line-height: 1.15;
|
||
margin: 0 0 12px;
|
||
}
|
||
|
||
.lede {
|
||
font-size: 0.84rem;
|
||
line-height: 1.55;
|
||
color: var(--ink-soft);
|
||
margin: 0 0 22px;
|
||
}
|
||
|
||
.total {
|
||
border-top: 1px solid var(--ink);
|
||
border-bottom: 1px solid var(--rule);
|
||
padding: 14px 0;
|
||
margin-bottom: 22px;
|
||
}
|
||
|
||
.total-num {
|
||
font-family: "Fraunces", serif;
|
||
font-weight: 600;
|
||
font-size: 2rem;
|
||
line-height: 1;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.total-label {
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-soft);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.16em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-soft);
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
.rows {
|
||
margin: 0 0 24px;
|
||
border-top: 1px solid var(--rule);
|
||
}
|
||
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
grid-template-areas: "name val" "bar bar";
|
||
width: 100%;
|
||
padding: 9px 2px 10px;
|
||
border: 0;
|
||
border-bottom: 1px solid var(--rule);
|
||
background: none;
|
||
font: inherit;
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.row:hover,
|
||
.row.is-active {
|
||
background: var(--paper-deep);
|
||
}
|
||
|
||
.row-name {
|
||
grid-area: name;
|
||
font-size: 0.82rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.row-val {
|
||
grid-area: val;
|
||
font-size: 0.82rem;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--ink-soft);
|
||
}
|
||
|
||
.row-bar {
|
||
grid-area: bar;
|
||
height: 5px;
|
||
margin-top: 7px;
|
||
background: var(--paper-deep);
|
||
}
|
||
|
||
.row-bar i {
|
||
display: block;
|
||
height: 100%;
|
||
}
|
||
|
||
.legend-rows { margin-bottom: 24px; }
|
||
|
||
.legend-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 3px 0;
|
||
font-size: 0.78rem;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--ink-soft);
|
||
}
|
||
|
||
.swatch {
|
||
width: 22px;
|
||
height: 13px;
|
||
border: 1px solid rgba(41, 36, 32, 0.25);
|
||
flex: 0 0 22px;
|
||
}
|
||
|
||
.panel-foot {
|
||
margin-top: auto;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--rule);
|
||
font-size: 0.7rem;
|
||
line-height: 1.6;
|
||
color: var(--ink-soft);
|
||
}
|
||
|
||
.panel-foot .status { font-variant-numeric: tabular-nums; }
|
||
|
||
/* ---------- Map ---------- */
|
||
|
||
.map-wrap { position: relative; }
|
||
|
||
#map {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #eef0ea;
|
||
}
|
||
|
||
.leaflet-container { font: inherit; }
|
||
|
||
.info-box {
|
||
padding: 10px 14px;
|
||
background: #fffdf8;
|
||
border: 1px solid var(--rule);
|
||
border-left: 3px solid var(--accent);
|
||
box-shadow: 0 1px 3px rgba(41, 36, 32, 0.12);
|
||
color: var(--ink);
|
||
line-height: 1.45;
|
||
font-size: 0.8rem;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.info-box b {
|
||
font-family: "Fraunces", serif;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.leaflet-bar a,
|
||
.leaflet-bar a:hover {
|
||
background: #fffdf8;
|
||
color: var(--ink);
|
||
border-bottom-color: var(--rule);
|
||
border-radius: 0;
|
||
}
|
||
|
||
.leaflet-touch .leaflet-bar { border: 1px solid var(--rule); }
|
||
|
||
.leaflet-popup-content-wrapper {
|
||
background: #fffdf8;
|
||
color: var(--ink);
|
||
border: 1px solid var(--rule);
|
||
border-radius: 0;
|
||
box-shadow: 0 1px 3px rgba(41, 36, 32, 0.15);
|
||
}
|
||
|
||
.leaflet-popup-tip { background: #fffdf8; }
|
||
|
||
.leaflet-container .leaflet-control-attribution {
|
||
background: rgba(247, 243, 234, 0.85);
|
||
color: var(--ink-soft);
|
||
font-size: 0.62rem;
|
||
}
|
||
|
||
/* ---------- Mobile ---------- */
|
||
|
||
@media (max-width: 820px) {
|
||
.layout {
|
||
grid-template-columns: 1fr;
|
||
grid-template-rows: auto 1fr;
|
||
}
|
||
|
||
.panel {
|
||
max-height: 46vh;
|
||
border-right: 0;
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
|
||
.panel-inner { padding: 20px 18px 14px; }
|
||
h1 { font-size: 1.4rem; }
|
||
.total-num { font-size: 1.5rem; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="layout">
|
||
<aside class="panel">
|
||
<div class="panel-inner">
|
||
<p class="overline">Peta Tematik · Kalimantan Barat</p>
|
||
<h1>Sebaran Penduduk Kota Pontianak</h1>
|
||
<p class="lede">
|
||
Choropleth jumlah penduduk pada enam kecamatan di Kota Pontianak.
|
||
Semakin pekat warnanya, semakin besar jumlah penduduknya. Klik
|
||
kecamatan pada daftar atau peta untuk memperbesar wilayahnya.
|
||
</p>
|
||
|
||
<div class="total">
|
||
<div class="total-num" id="total-pop">—</div>
|
||
<div class="total-label">Total penduduk · 6 kecamatan</div>
|
||
</div>
|
||
|
||
<p class="section-title">Kecamatan</p>
|
||
<div class="rows" id="rows"></div>
|
||
|
||
<p class="section-title">Jumlah penduduk (jiwa)</p>
|
||
<div class="legend-rows" id="legend"></div>
|
||
|
||
<div class="panel-foot">
|
||
Sumber batas administrasi: Admin_Kecamatan.json (EPSG:32749, dikonversi ke WGS 84).<br />
|
||
<span class="status" id="status">Memuat data spasial…</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="map-wrap">
|
||
<div id="map"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.9.2/proj4.min.js"></script>
|
||
|
||
<script>
|
||
const statusEl = document.getElementById('status');
|
||
|
||
const map = L.map('map', {
|
||
zoomControl: true,
|
||
minZoom: 10,
|
||
maxZoom: 16,
|
||
preferCanvas: true
|
||
});
|
||
|
||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||
subdomains: 'abcd'
|
||
}).addTo(map);
|
||
|
||
map.createPane('labels');
|
||
map.getPane('labels').style.zIndex = 650;
|
||
map.getPane('labels').style.pointerEvents = 'none';
|
||
|
||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png', {
|
||
pane: 'labels',
|
||
subdomains: 'abcd'
|
||
}).addTo(map);
|
||
|
||
proj4.defs('EPSG:32749', '+proj=utm +zone=49 +south +datum=WGS84 +units=m +no_defs +type=crs');
|
||
|
||
const palette = ['#fdf3d8', '#f7dfa2', '#eec06c', '#dd9440', '#bc651f', '#8c400d'];
|
||
|
||
function convertCoords(coords) {
|
||
if (!Array.isArray(coords[0])) {
|
||
const [x, y] = coords;
|
||
return proj4('EPSG:32749', 'EPSG:4326', [x, y]);
|
||
}
|
||
return coords.map(convertCoords);
|
||
}
|
||
|
||
function convertFeatureCollectionToWGS84(featureCollection) {
|
||
return {
|
||
type: 'FeatureCollection',
|
||
features: featureCollection.features.map((feature) => ({
|
||
type: 'Feature',
|
||
properties: { ...feature.properties },
|
||
geometry: {
|
||
type: feature.geometry.type,
|
||
coordinates: convertCoords(feature.geometry.coordinates)
|
||
}
|
||
}))
|
||
};
|
||
}
|
||
|
||
function colorFromBreaks(value, breaks) {
|
||
if (value <= breaks[1]) return palette[0];
|
||
if (value <= breaks[2]) return palette[1];
|
||
if (value <= breaks[3]) return palette[2];
|
||
if (value <= breaks[4]) return palette[3];
|
||
if (value <= breaks[5]) return palette[4];
|
||
return palette[5];
|
||
}
|
||
|
||
const populationData = {
|
||
'Pontianak Selatan': 91290,
|
||
'Pontianak Tenggara': 49176,
|
||
'Pontianak Timur': 110904,
|
||
'Pontianak Barat': 150517,
|
||
'Pontianak Kota': 123867,
|
||
'Pontianak Utara': 149714
|
||
};
|
||
|
||
const info = L.control({ position: 'topright' });
|
||
|
||
info.onAdd = function () {
|
||
this._div = L.DomUtil.create('div', 'info-box');
|
||
this.update();
|
||
return this._div;
|
||
};
|
||
|
||
info.update = function (props) {
|
||
this._div.innerHTML = props
|
||
? `<b>${props.Ket}</b><br>${props.pop.toLocaleString('id-ID')} jiwa`
|
||
: 'Arahkan kursor ke kecamatan';
|
||
};
|
||
|
||
info.addTo(map);
|
||
|
||
function renderLegend(breaks) {
|
||
const wrap = document.getElementById('legend');
|
||
|
||
for (let i = 0; i < palette.length; i++) {
|
||
const from = breaks[i];
|
||
const to = breaks[i + 1];
|
||
const label = to
|
||
? `${from.toLocaleString('id-ID')} – ${to.toLocaleString('id-ID')}`
|
||
: `> ${from.toLocaleString('id-ID')}`;
|
||
const row = document.createElement('div');
|
||
row.className = 'legend-row';
|
||
row.innerHTML = `<span class="swatch" style="background:${palette[i]}"></span><span>${label}</span>`;
|
||
wrap.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function renderRows(features, breaks, layerByName, layerRef) {
|
||
const wrap = document.getElementById('rows');
|
||
const maxPop = Math.max(...features.map((f) => f.properties.pop));
|
||
const sorted = [...features].sort((a, b) => b.properties.pop - a.properties.pop);
|
||
|
||
sorted.forEach((feature) => {
|
||
const { Ket, pop } = feature.properties;
|
||
const row = document.createElement('button');
|
||
row.type = 'button';
|
||
row.className = 'row';
|
||
row.dataset.name = Ket;
|
||
row.innerHTML = `
|
||
<span class="row-name">${Ket}</span>
|
||
<span class="row-val">${pop.toLocaleString('id-ID')}</span>
|
||
<span class="row-bar"><i style="width:${(pop / maxPop) * 100}%;background:${colorFromBreaks(pop, breaks)}"></i></span>
|
||
`;
|
||
|
||
row.addEventListener('mouseenter', () => {
|
||
const layer = layerByName.get(Ket);
|
||
if (layer) highlight(layer);
|
||
});
|
||
row.addEventListener('mouseleave', () => {
|
||
const layer = layerByName.get(Ket);
|
||
if (layer) {
|
||
layerRef.resetStyle(layer);
|
||
info.update();
|
||
setActiveRow(null);
|
||
}
|
||
});
|
||
row.addEventListener('click', () => {
|
||
const layer = layerByName.get(Ket);
|
||
if (layer) map.fitBounds(layer.getBounds(), { padding: [24, 24] });
|
||
});
|
||
|
||
wrap.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function setActiveRow(name) {
|
||
document.querySelectorAll('.row').forEach((row) => {
|
||
row.classList.toggle('is-active', row.dataset.name === name);
|
||
});
|
||
}
|
||
|
||
function highlight(layer) {
|
||
layer.setStyle({ weight: 2.4, color: '#292420', fillOpacity: 0.95 });
|
||
layer.bringToFront();
|
||
info.update(layer.feature.properties);
|
||
setActiveRow(layer.feature.properties.Ket);
|
||
}
|
||
|
||
function bindFeatureEvents(layerRef) {
|
||
return function (layer) {
|
||
layer.on({
|
||
mouseover: (e) => highlight(e.target),
|
||
mouseout: (e) => {
|
||
layerRef.resetStyle(e.target);
|
||
info.update();
|
||
setActiveRow(null);
|
||
},
|
||
click: (e) => {
|
||
map.fitBounds(e.target.getBounds(), { padding: [24, 24] });
|
||
}
|
||
});
|
||
|
||
const p = layer.feature.properties;
|
||
layer.bindPopup(
|
||
`<b>${p.Ket}</b><br/>Jumlah penduduk: <b>${p.pop.toLocaleString('id-ID')} jiwa</b>`
|
||
);
|
||
};
|
||
}
|
||
|
||
async function fetchGeoJsonWithFallback() {
|
||
const candidates = [
|
||
'./Admin_Kecamatan.json',
|
||
'Admin_Kecamatan.json',
|
||
'/WebGIS_Choropleth/Admin_Kecamatan.json',
|
||
'/Admin_Kecamatan.json'
|
||
];
|
||
|
||
const errors = [];
|
||
|
||
for (const url of candidates) {
|
||
try {
|
||
const response = await fetch(url, { cache: 'no-store' });
|
||
if (!response.ok) {
|
||
errors.push(`${url} -> HTTP ${response.status}`);
|
||
continue;
|
||
}
|
||
|
||
const text = await response.text();
|
||
const trimmed = text.trimStart();
|
||
|
||
if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) {
|
||
errors.push(`${url} -> respon bukan JSON`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (parseErr) {
|
||
errors.push(`${url} -> JSON invalid (${parseErr.message})`);
|
||
}
|
||
} catch (fetchErr) {
|
||
errors.push(`${url} -> ${fetchErr.message}`);
|
||
}
|
||
}
|
||
|
||
throw new Error(errors.join(' | '));
|
||
}
|
||
|
||
async function init() {
|
||
try {
|
||
const rawGeoJson = await fetchGeoJsonWithFallback();
|
||
const geojson = convertFeatureCollectionToWGS84(rawGeoJson);
|
||
|
||
let totalPop = 0;
|
||
geojson.features.forEach((f) => {
|
||
const pop = populationData[f.properties.Ket] || 0;
|
||
f.properties.pop = pop;
|
||
totalPop += pop;
|
||
});
|
||
|
||
const breaks = [0, 50000, 95000, 115000, 135000, 150000];
|
||
|
||
const layerRef = L.geoJSON(geojson, {
|
||
style: (feature) => ({
|
||
color: '#8a8270',
|
||
weight: 1,
|
||
fillColor: colorFromBreaks(feature.properties.pop, breaks),
|
||
fillOpacity: 0.82
|
||
})
|
||
});
|
||
|
||
const layerByName = new Map();
|
||
layerRef.eachLayer((layer) => {
|
||
layerByName.set(layer.feature.properties.Ket, layer);
|
||
});
|
||
layerRef.eachLayer(bindFeatureEvents(layerRef));
|
||
layerRef.addTo(map);
|
||
|
||
map.fitBounds(layerRef.getBounds(), { padding: [24, 24] });
|
||
|
||
document.getElementById('total-pop').textContent = totalPop.toLocaleString('id-ID');
|
||
renderRows(geojson.features, breaks, layerByName, layerRef);
|
||
renderLegend(breaks);
|
||
|
||
statusEl.textContent = `Data termuat: ${geojson.features.length} kecamatan.`;
|
||
} catch (error) {
|
||
statusEl.textContent = 'Terjadi error saat memuat data spasial.';
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|