Files
2026-06-11 22:52:58 +07:00

621 lines
27 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="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pontianak WebGIS - Cloropleth & Layers</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- Proj4js for UTM Zone 49S to WGS84 coordinates projection -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.9.0/proj4.js"></script>
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
}
#map {
height: 100vh;
width: 100vw;
z-index: 1;
}
/* Premium Floating Header overlay */
.map-header {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
padding: 10px 24px;
border-radius: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.25);
text-align: center;
pointer-events: auto;
transition: all 0.3s ease;
}
.map-header h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.map-header p {
margin: 2px 0 0 0;
font-size: 11px;
color: #666;
font-weight: 500;
}
/* Glassmorphic Layer Control custom overrides */
.leaflet-control-layers {
background: rgba(255, 255, 255, 0.85) !important;
backdrop-filter: blur(10px) !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
border-radius: 12px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
font-family: 'Segoe UI', sans-serif !important;
padding: 10px 14px !important;
}
.leaflet-control-layers-list label {
margin-bottom: 6px;
font-weight: 500;
font-size: 12px;
color: #444;
display: flex;
align-items: center;
cursor: pointer;
}
.leaflet-control-layers-list input {
margin-right: 8px;
cursor: pointer;
}
/* Custom zoom controls alignment */
.leaflet-bar {
border: none !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
border-radius: 10px !important;
overflow: hidden;
}
.leaflet-bar a {
background-color: rgba(255, 255, 255, 0.85) !important;
backdrop-filter: blur(10px) !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
color: #333 !important;
transition: all 0.2s;
}
.leaflet-bar a:hover {
background-color: #ffffff !important;
color: #007bff !important;
}
/* Loading indicator spinner */
#loader {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #ffffff;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: opacity 0.5s ease;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(0, 123, 255, 0.1);
border-radius: 50%;
border-top-color: #007bff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
margin-top: 20px;
font-size: 14px;
color: #555;
font-weight: 600;
letter-spacing: 0.5px;
}
</style>
</head>
<body>
<!-- Spinner Loader -->
<div id="loader">
<div class="spinner"></div>
<div class="loading-text">Loading WebGIS Environment...</div>
</div>
<!-- Floating Top Header Overlay -->
<div class="map-header">
<h1>Pontianak City WebGIS</h1>
<p>Interactive Cloropleth Administration Map</p>
</div>
<!-- CORS Server Warning Overlay fall back -->
<div id="cors-warning"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.75); backdrop-filter: blur(8px); z-index: 99999; justify-content: center; align-items: center; color: white;">
<div
style="background: rgba(30, 30, 30, 0.95); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 16px; padding: 30px; max-width: 500px; text-align: center; box-shadow: 0 20px 50px rgba(0,0,0,0.6); font-family: 'Segoe UI', sans-serif;">
<div style="font-size: 54px; margin-bottom: 15px;">🌐</div>
<h3 style="margin-top: 0; font-size: 22px; font-weight: 600; color: #ff7800;">Local Web Server Required</h3>
<p style="font-size: 14px; line-height: 1.6; color: #ddd; margin-bottom: 20px;">
Browser security blocks loading local GeoJSON data files directly from a <code>file://</code> URL.
</p>
<div
style="font-size: 13.5px; line-height: 1.6; color: #ccc; margin-bottom: 25px; text-align: left; background: rgba(0,0,0,0.4); padding: 15px; border-radius: 8px; border-left: 4px solid #ff7800;">
<strong>Quick Fixes to run locally:</strong><br>
1. If in VS Code, click the <strong>"Go Live"</strong> button in the bottom right corner (Live
Server).<br>
2. Run <code>python -m http.server</code> in this folder and visit
<code>http://localhost:8000</code>.<br>
3. Run <code>npx serve</code> in this directory in your terminal.
</div>
<button onclick="document.getElementById('cors-warning').style.display='none'"
style="background: #ff7800; border: none; color: white; padding: 12px 28px; font-size: 14px; font-weight: 600; border-radius: 8px; cursor: pointer; transition: background 0.3s; box-shadow: 0 4px 12px rgba(255, 120, 0, 0.3);">Dismiss</button>
</div>
</div>
<!-- Leaflet Container -->
<div id="map"></div>
<script>
// ------------------------------------------------------------------
// INITIALIZE MAP & BASE LAYERS
// ------------------------------------------------------------------
var map = L.map('map', {
zoomControl: false,
attributionControl: true
}).setView([-0.0258, 109.3323], 13); // Centered around Pontianak
// Zoom control in bottom right for cleaner aesthetics
L.control.zoom({ position: 'bottomleft' }).addTo(map);
// OSM Map Layer
var osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// ------------------------------------------------------------------
// PROJ4 COORDINATE CONVERSION (UTM Zone 49S to WGS84)
// ------------------------------------------------------------------
// Define UTM Zone 49S (which Pontianak falls into) and standard Lat/Lng projections
const utm49S = "+proj=utm +zone=49 +south +datum=WGS84 +units=m +no_defs";
const wgs84 = "+proj=longlat +datum=WGS84 +no_defs";
// Dynamic conversion logic for various GeoJSON geometry types
function convertCoords(coords, type) {
const isLatLng = (c) => Math.abs(c[0]) <= 180 && Math.abs(c[1]) <= 90;
if (type === "Point") {
if (isLatLng(coords)) return coords;
const [lng, lat] = proj4(utm49S, wgs84, coords);
return [lng, lat];
} else if (type === "LineString" || type === "MultiPoint") {
if (coords.length > 0 && isLatLng(coords[0])) return coords;
return coords.map(c => {
const [lng, lat] = proj4(utm49S, wgs84, c);
return [lng, lat];
});
} else if (type === "Polygon" || type === "MultiLineString") {
if (coords.length > 0 && coords[0].length > 0 && isLatLng(coords[0][0])) return coords;
return coords.map(ring =>
ring.map(c => {
const [lng, lat] = proj4(utm49S, wgs84, c);
return [lng, lat];
})
);
} else if (type === "MultiPolygon") {
if (coords.length > 0 && coords[0].length > 0 && coords[0][0].length > 0 && isLatLng(coords[0][0][0])) return coords;
return coords.map(polygon =>
polygon.map(ring =>
ring.map(c => {
const [lng, lat] = proj4(utm49S, wgs84, c);
return [lng, lat];
})
)
);
}
return coords;
}
// Deep copies the GeoJSON and converts coordinates recursively
function convertGeoJSON(geojson) {
if (!geojson) return geojson;
const cloned = JSON.parse(JSON.stringify(geojson));
const convertFeature = (feature) => {
if (feature.geometry && feature.geometry.coordinates) {
feature.geometry.coordinates = convertCoords(
feature.geometry.coordinates,
feature.geometry.type
);
}
};
if (cloned.type === "FeatureCollection" && cloned.features) {
cloned.features.forEach(convertFeature);
} else if (cloned.type === "Feature") {
cloned.features = [cloned];
cloned.type = "FeatureCollection";
cloned.features.forEach(convertFeature);
}
return cloned;
}
// ------------------------------------------------------------------
// DUMMY POPULATION DATA — Kecamatan Pontianak (2024 Estimate)
// ------------------------------------------------------------------
const populationData = {
'Pontianak Timur': { population: 108_450, area_km2: 8.72, density: 12437 },
'Pontianak Selatan': { population: 87_320, area_km2: 14.54, density: 6005 },
'Pontianak Barat': { population: 142_610, area_km2: 16.81, density: 8484 },
'Pontianak Kota': { population: 65_180, area_km2: 5.13, density: 12706 },
'Pontianak Utara': { population: 98_760, area_km2: 40.94, density: 2412 },
'Pontianak Tenggara': { population: 73_540, area_km2: 11.26, density: 6531 }
};
// ------------------------------------------------------------------
// CHOROPLETH COLOR SCALE — Sequential blue-green by population
// ------------------------------------------------------------------
// Breaks based on population ranges across the 6 kecamatan
function getColor(ket) {
const data = populationData[ket];
if (!data) return '#8E9AAF';
const pop = data.population;
return pop > 130_000 ? '#084594' :
pop > 110_000 ? '#2171b5' :
pop > 95_000 ? '#4292c6' :
pop > 80_000 ? '#6baed6' :
pop > 65_000 ? '#9ecae1' :
'#c6dbef';
}
// ------------------------------------------------------------------
// LAYER GEOMETRY STYLING & INTERACTION Handlers
// ------------------------------------------------------------------
// 1. Administrative Kecamatan styling & interaction
function styleAdminKecamatan(feature) {
return {
fillColor: getColor(feature.properties.Ket),
weight: 1.5,
opacity: 1,
color: 'white',
dashArray: '',
fillOpacity: 0.5
};
}
function highlightFeature(e) {
var layer = e.target;
layer.setStyle({
weight: 3,
color: '#666',
dashArray: '',
fillOpacity: 0.7
});
layer.bringToFront();
info.update(layer.feature.properties);
}
function resetHighlight(e) {
adminKecamatanLayer.resetStyle(e.target);
info.update();
}
function onEachFeatureAdmin(feature, layer) {
layer.on({
mouseover: highlightFeature,
mouseout: resetHighlight,
click: function (e) {
map.fitBounds(e.target.getBounds());
}
});
if (feature.properties && feature.properties.Ket) {
const ket = feature.properties.Ket;
const pop = populationData[ket];
const popStr = pop ? pop.population.toLocaleString('id-ID') : 'N/A';
const areaStr = pop ? pop.area_km2.toFixed(2) : ((feature.properties.Plan_Area / 1000000).toFixed(2));
const densStr = pop ? pop.density.toLocaleString('id-ID') : 'N/A';
const color = getColor(ket);
layer.bindPopup(`
<div style="font-family: 'Segoe UI', sans-serif; padding: 3px; min-width: 200px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px; border-bottom: 1.5px solid #eee; padding-bottom:6px;">
<span style="display:inline-block; width:12px; height:12px; border-radius:3px; background:${color}; flex-shrink:0;"></span>
<h4 style="margin:0; color:#111; font-weight:700; font-size:13.5px;">${ket}</h4>
</div>
<table style="width:100%; border-collapse:collapse; font-size:12px; color:#444;">
<tr>
<td style="padding:3px 0;">👥 <strong>Jumlah Penduduk</strong></td>
<td style="padding:3px 0; text-align:right; color:#2171b5; font-weight:700;">${popStr} jiwa</td>
</tr>
<tr>
<td style="padding:3px 0;">📐 <strong>Luas Wilayah</strong></td>
<td style="padding:3px 0; text-align:right; color:#555; font-weight:600;">${areaStr} km²</td>
</tr>
<tr>
<td style="padding:3px 0;">🏙️ <strong>Kepadatan</strong></td>
<td style="padding:3px 0; text-align:right; color:#555; font-weight:600;">${densStr} jiwa/km²</td>
</tr>
</table>
<div style="margin-top:8px; font-size:10.5px; color:#aaa; text-align:right;">*Data Estimasi 2024</div>
</div>
`);
}
}
// Declaring Leaflet layers as placeholder layer groups
var adminKecamatanLayer = L.geoJSON(null);
var batasKecamatanLayer = L.geoJSON(null);
var batasWilayahLayer = L.geoJSON(null);
var poligonKotaLayer = L.geoJSON(null);
// ------------------------------------------------------------------
// INTERACTIVE HUD CONTROLS (District Info Panel & Legend)
// ------------------------------------------------------------------
// Custom Hover HUD Info Panel
var info = L.control({ position: 'topright' });
info.onAdd = function (map) {
this._div = L.DomUtil.create('div', 'info');
this._div.style.background = 'rgba(255, 255, 255, 0.85)';
this._div.style.backdropFilter = 'blur(10px)';
this._div.style.padding = '12px 18px';
this._div.style.borderRadius = '14px';
this._div.style.boxShadow = '0 10px 30px rgba(0, 0, 0, 0.15)';
this._div.style.border = '1px solid rgba(255, 255, 255, 0.25)';
this._div.style.fontSize = '13px';
this._div.style.color = '#333';
this._div.style.minWidth = '200px';
this._div.style.fontFamily = "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif";
this.update();
return this._div;
};
info.update = function (props) {
if (!props) {
this._div.innerHTML = '<h4 style="margin: 0 0 8px 0; font-size: 13.5px; font-weight: 700; color: #111; border-bottom: 1px solid rgba(0,0,0,0.06); padding-bottom: 4px;">Informasi Kecamatan</h4>' +
'<span style="color:#777; font-style:italic; font-size:12px;">Arahkan kursor ke kecamatan</span>';
return;
}
const ket = props.Ket;
const pop = populationData[ket];
const popStr = pop ? pop.population.toLocaleString('id-ID') : '—';
const areaStr = pop ? pop.area_km2.toFixed(2) : (props.Plan_Area ? (props.Plan_Area / 1000000).toFixed(2) : '—');
const densStr = pop ? pop.density.toLocaleString('id-ID') : '—';
const color = getColor(ket);
this._div.innerHTML =
'<h4 style="margin: 0 0 8px 0; font-size: 13.5px; font-weight: 700; color: #111; border-bottom: 1px solid rgba(0,0,0,0.06); padding-bottom: 4px;">Informasi Kecamatan</h4>' +
`<div style="display:flex; align-items:center; gap:7px; margin-bottom:6px;">
<span style="display:inline-block; width:11px; height:11px; border-radius:3px; background:${color}; flex-shrink:0;"></span>
<b style="font-size: 14px; color: #1a1a2e; font-weight: 700;">${ket}</b>
</div>
<table style="width:100%; font-size:12px; color:#444; border-collapse:collapse;">
<tr>
<td style="padding:2px 0;">👥 Penduduk</td>
<td style="padding:2px 0; text-align:right; color:#2171b5; font-weight:700;">${popStr} jiwa</td>
</tr>
<tr>
<td style="padding:2px 0;">📐 Luas</td>
<td style="padding:2px 0; text-align:right; font-weight:600;">${areaStr} km²</td>
</tr>
<tr>
<td style="padding:2px 0;">🏙️ Kepadatan</td>
<td style="padding:2px 0; text-align:right; font-weight:600;">${densStr} jiwa/km²</td>
</tr>
</table>`;
};
info.addTo(map);
// Custom Choropleth Population Legend Control
var legend = L.control({ position: 'bottomright' });
legend.onAdd = function (map) {
var div = L.DomUtil.create('div', 'info legend');
div.style.background = 'rgba(255, 255, 255, 0.9)';
div.style.backdropFilter = 'blur(10px)';
div.style.padding = '14px 18px';
div.style.borderRadius = '14px';
div.style.boxShadow = '0 10px 30px rgba(0, 0, 0, 0.15)';
div.style.border = '1px solid rgba(255, 255, 255, 0.25)';
div.style.fontSize = '12px';
div.style.color = '#333';
div.style.fontFamily = "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif";
div.style.minWidth = '195px';
// Population break labels (matching getColor thresholds)
const grades = [0, 65_001, 80_001, 95_001, 110_001, 130_001];
const colors = ['#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#084594'];
const labels = [
'< 65.000',
'65.001 80.000',
'80.001 95.000',
'95.001 110.000',
'110.001 130.000',
'> 130.000'
];
var html = '<h4 style="margin: 0 0 10px 0; font-size: 13px; font-weight: 700; color: #111; border-bottom: 2px solid #eaeaea; padding-bottom: 6px;">🗺️ Legenda Penduduk</h4>';
html += '<div style="font-size: 11px; color: #888; margin-bottom: 8px;">Jumlah Penduduk (jiwa)</div>';
colors.forEach((color, i) => {
html += `
<div style="display: flex; align-items: center; margin-bottom: 5px;">
<span style="display:inline-block; width:16px; height:16px; margin-right:10px; border-radius:4px; background:${color}; border:1px solid rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.1); flex-shrink:0;"></span>
<span style="font-weight:500; color:#444; font-size:11.5px;">${labels[i]}</span>
</div>
`;
});
// Per-kecamatan summary table
html += '<div style="margin-top:12px; border-top:1px solid #eee; padding-top:10px;">';
html += '<div style="font-size:11px; font-weight:700; color:#555; margin-bottom:6px;">Data Per Kecamatan</div>';
const sortedKec = Object.entries(populationData).sort((a, b) => b[1].population - a[1].population);
sortedKec.forEach(([name, data]) => {
const shortName = name.replace('Pontianak ', '');
html += `
<div style="display:flex; align-items:center; gap:7px; margin-bottom:4px;">
<span style="display:inline-block; width:10px; height:10px; border-radius:2px; background:${getColor(name)}; flex-shrink:0;"></span>
<span style="font-size:11px; color:#555; flex:1;">${shortName}</span>
<span style="font-size:11px; font-weight:700; color:#2171b5;">${data.population.toLocaleString('id-ID')}</span>
</div>
`;
});
html += '</div>';
div.innerHTML = html;
return div;
};
legend.addTo(map);
// ------------------------------------------------------------------
// FETCH DATA & INTEGRATE LAYERS
// ------------------------------------------------------------------
async function loadGeoJSONLayers() {
try {
// Fetch all four local GeoJSON files simultaneously
const [adminRes, batasKecRes, batasWilRes, poligonRes] = await Promise.all([
fetch('Admin_Kecamatan.json'),
fetch('Batas_Kecamatan.json'),
fetch('Batas_Wilayah.json'),
fetch('Poligon_Kota.json')
]);
// Ensure HTTP calls were successful
if (!adminRes.ok || !batasKecRes.ok || !batasWilRes.ok || !poligonRes.ok) {
throw new Error("HTTP resource fetching failed.");
}
// Parse into JSON format
const rawAdmin = await adminRes.json();
const rawBatasKec = await batasKecRes.json();
const rawBatasWil = await batasWilRes.json();
const rawPoligon = await poligonRes.json();
// Convert UTM coordinates to WGS84 recursively
const adminData = convertGeoJSON(rawAdmin);
const batasKecData = convertGeoJSON(rawBatasKec);
const batasWilData = convertGeoJSON(rawBatasWil);
const poligonData = convertGeoJSON(rawPoligon);
// --- 1. ADMINISTRATIVE KECAMATAN LAYER ---
adminKecamatanLayer = L.geoJSON(adminData, {
style: styleAdminKecamatan,
onEachFeature: onEachFeatureAdmin
}).addTo(map);
// --- 2. DISTRICT BOUNDARIES LAYER ---
batasKecamatanLayer = L.geoJSON(batasKecData, {
style: {
color: '#495057', // Slate dark boundary color
weight: 2,
dashArray: '5, 5',
opacity: 0.85
}
}).addTo(map);
// --- 3. TERRITORY BOUNDARIES LAYER ---
batasWilayahLayer = L.geoJSON(batasWilRes, {
style: {
color: '#e67e22', // Vivid orange boundary
weight: 3,
opacity: 0.9
}
}).addTo(map);
// --- 4. CITY POLIGON MUNICIPAL LAYER ---
poligonKotaLayer = L.geoJSON(poligonData, {
style: {
color: '#2d3748',
weight: 1.5,
fillColor: '#cbd5e0',
fillOpacity: 0.15
}
}).addTo(map);
// Combine bounds from all active layers to focus correctly
const combinedBounds = L.latLngBounds();
[adminKecamatanLayer, batasKecamatanLayer, batasWilayahLayer, poligonKotaLayer].forEach(layer => {
if (layer.getBounds().isValid()) {
combinedBounds.extend(layer.getBounds());
}
});
if (combinedBounds.isValid()) {
map.fitBounds(combinedBounds, { padding: [30, 30] });
}
// Add glassmorphic base layer toggle control panel
var baseMaps = {
"OpenStreetMap": osmLayer
};
var overlayMaps = {
"Administrative districts": adminKecamatanLayer,
"District boundaries": batasKecamatanLayer,
"Territory boundary lines": batasWilayahLayer,
"City polygon limits": poligonKotaLayer
};
L.control.layers(baseMaps, overlayMaps, { collapsed: false }).addTo(map);
// Transition loader off screen
const loader = document.getElementById('loader');
loader.style.opacity = '0';
setTimeout(() => loader.style.display = 'none', 500);
} catch (error) {
console.error("Error loading WebGIS layers:", error);
// Remove loader spinner and show elegant CORS server overlay details
document.getElementById('loader').style.display = 'none';
document.getElementById('cors-warning').style.display = 'flex';
}
}
// Execute dynamic WebGIS loader upon page boot
window.addEventListener('DOMContentLoaded', loadGeoJSONLayers);
</script>
</body>
</html>