Files

564 lines
15 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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>