// modules/choropleth.js — Choropleth visualizer untuk Project 01 (function () { const PALETTES = { blue: ['#c6dbef', '#9ecae1', '#6baed6', '#3182bd', '#08519c'], orange: ['#fdd0a2', '#fdae6b', '#fd8d3c', '#e6550d', '#a63603'], green: ['#c7e9c0', '#a1d99b', '#74c476', '#31a354', '#006d2c'], purple: ['#dadaeb', '#bcbddc', '#9e9ac8', '#6a51a3', '#3f007d'], }; // Qualitative palette — 10 distinct colors for categorical data const CAT_COLORS = [ '#4e79a7','#f28e2b','#e15759','#76b7b2', '#59a14f','#edc948','#b07aa1','#ff9da7', '#9c755f','#bab0ac', ]; let layers = []; // { meta, leafletLayer, colorMode, categoryMap, breaks, pal } let allMeta = []; // metadata rows from API (no geojson blob) // ── Color scale helpers ────────────────────────────────────────── function quantileBreaks(values) { const sorted = values.filter(v => isFinite(v)).sort((a, b) => a - b); if (sorted.length === 0) return [0, 0, 0, 0, 0, 0]; const breaks = []; for (let i = 0; i <= 5; i++) { breaks.push(sorted[Math.round(i * (sorted.length - 1) / 5)]); } return breaks; } function getColor(val, breaks, pal) { for (let i = 0; i < pal.length; i++) { if (val <= breaks[i + 1]) return pal[i]; } return pal[pal.length - 1]; } // Returns {colorMode:'categorical'|'quantile', categoryMap, breaks} function buildColorConfig(features, attrKey, pal) { const rawVals = features.map(f => f.properties[attrKey]); const numVals = rawVals.map(v => parseFloat(v)); const allNumeric = numVals.every(v => isFinite(v)); const uniqueRaw = [...new Set(rawVals.map(String))]; // Use categorical when: any non-numeric value, OR ≤ 10 unique values if (!allNumeric || uniqueRaw.length <= 10) { const categoryMap = {}; uniqueRaw.sort().forEach((v, i) => { categoryMap[v] = CAT_COLORS[i % CAT_COLORS.length]; }); return { colorMode: 'categorical', categoryMap, breaks: null }; } const breaks = quantileBreaks(numVals); return { colorMode: 'quantile', categoryMap: null, breaks }; } function fmtVal(v) { const n = parseFloat(v); if (isFinite(n)) return n.toLocaleString('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return String(v ?? '—'); } function isClassicDrawingActive() { return typeof window.currentMode !== 'undefined' && Number(window.currentMode) !== 0; } function setLayerHitTesting(leafletLayer, enabled) { if (!leafletLayer || typeof leafletLayer.eachLayer !== 'function') return; leafletLayer.eachLayer(layer => { if (layer._path && layer._path.style) { layer._path.style.pointerEvents = enabled ? '' : 'none'; } }); } function setAllHitTesting(enabled) { layers.forEach(rendered => setLayerHitTesting(rendered.leafletLayer, enabled)); } // ── Render one GeoJSON layer on map ───────────────────────────── function renderLeafletLayer(meta, geojson) { const pal = PALETTES[meta.palette] || PALETTES.blue; const { colorMode, categoryMap, breaks } = buildColorConfig(geojson.features, meta.attribute_key, pal); function featureColor(f) { const raw = f.properties[meta.attribute_key]; if (colorMode === 'categorical') { return categoryMap[String(raw)] || '#cccccc'; } const val = parseFloat(raw); return isFinite(val) ? getColor(val, breaks, pal) : '#cccccc'; } const lyr = L.geoJSON(geojson, { pane: 'choroplethPane', style: function (f) { return { fillColor: featureColor(f), weight: 1, color: '#ffffff', fillOpacity: 0.78, opacity: 0.9, }; }, onEachFeature: function (f, layer) { const props = f.properties || {}; function buildPopup(closeBtn) { const rows = Object.entries(props) .map(([k, v]) => `${escapeHTML(String(k))}${escapeHTML(String(v ?? '—'))}`) .join(''); return `
${rows}
`; } layer.on('mouseover', function (e) { if (isClassicDrawingActive()) return; L.popup({ closeButton: false, className: 'choro-popup-wrap', offset: [0, -4] }) .setLatLng(e.latlng) .setContent(buildPopup(false)) .openOn(map); layer.setStyle({ weight: 2, color: '#1c1612' }); }); layer.on('mouseout', function () { if (isClassicDrawingActive()) return; map.closePopup(); lyr.resetStyle(layer); }); layer.on('click', function (e) { if (isClassicDrawingActive()) return; L.popup({ closeButton: true, className: 'choro-popup-wrap' }) .setLatLng(e.latlng) .setContent(buildPopup(true)) .openOn(map); L.DomEvent.stopPropagation(e); }); }, }); return { meta, leafletLayer: lyr, colorMode, categoryMap, breaks, pal }; } // ── Legend ─────────────────────────────────────────────────────── function buildLegends() { const container = document.getElementById('legendContainer'); if (!container) return; const visible = layers.filter(l => l.meta.is_visible); if (visible.length === 0) { container.innerHTML = ''; container.style.display = 'none'; return; } container.style.display = 'block'; container.innerHTML = visible.map(({ meta, colorMode, categoryMap, breaks, pal }) => { let classes; if (colorMode === 'categorical') { classes = Object.entries(categoryMap).map(([val, color]) => `
${escapeHTML(String(val))}
`).join(''); } else { classes = pal.map((color, i) => `
${fmtVal(breaks[i])} – ${fmtVal(breaks[i + 1])}
`).join(''); } return `
${escapeHTML(meta.nama)}
${escapeHTML(meta.attribute_key)}
${classes}
`; }).join(''); } // ── Layer panel ────────────────────────────────────────────────── function refreshPanel() { const list = document.getElementById('layerList'); if (!list) return; if (allMeta.length === 0) { list.innerHTML = '
Belum ada layer.
Upload GeoJSON dari ArcGIS.
'; return; } list.innerHTML = allMeta.map(m => { const isVisible = m.is_visible; const eyeIcon = isVisible ? 'eye' : 'eye-off'; const palDots = (PALETTES[m.palette] || PALETTES.blue) .map(c => ``).join(''); const safeNama = m.nama.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); const delBtn = window._IS_ADMIN ? `` : ''; return `
${escapeHTML(m.nama)}
${escapeHTML(m.attribute_key)}
${delBtn}
${palDots}
`; }).join(''); if (typeof lucide !== 'undefined') lucide.createIcons(); } // ── Load GeoJSON for one layer, add to map ─────────────────────── function loadLayerGeojson(meta) { return fetch('api/choropleth/geojson.php?id=' + meta.id + '&_=' + Date.now()) .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(geojson => { const rendered = renderLeafletLayer(meta, geojson); if (meta.is_visible) rendered.leafletLayer.addTo(map); layers.push(rendered); setLayerHitTesting(rendered.leafletLayer, !isClassicDrawingActive()); return rendered; }); } // ── Initial load ───────────────────────────────────────────────── function loadAll() { layers = []; return fetch('api/choropleth/ambil.php?_=' + Date.now()) .then(r => r.json()) .then(j => { if (j.status !== 'success') throw new Error(j.message || 'Gagal memuat layer.'); allMeta = j.data; refreshPanel(); return Promise.all(allMeta.map(meta => loadLayerGeojson(meta))); }) .then(() => buildLegends()) .catch(err => { showToast('Gagal memuat data: ' + err.message, 'error'); console.error(err); }); } // ── Public API ─────────────────────────────────────────────────── window._choro = { toggleLayer: function (id) { const rendered = layers.find(l => l.meta.id === id); const meta = allMeta.find(m => m.id === id); if (!meta) return; const newVisible = !meta.is_visible; meta.is_visible = newVisible; if (rendered) { rendered.meta.is_visible = newVisible; if (newVisible) rendered.leafletLayer.addTo(map); else map.removeLayer(rendered.leafletLayer); } refreshPanel(); buildLegends(); showToast(escapeHTML(meta.nama) + (newVisible ? ' ditampilkan.' : ' disembunyikan.')); if (window._IS_ADMIN) { const fd = new FormData(); fd.append('id', id); fetch('api/choropleth/toggle.php', { method: 'POST', body: fd }).catch(() => {}); } }, deleteLayer: function (id, nama) { showDeleteConfirm('Yakin ingin menghapus layer "' + nama + '"?').then(ok => { if (!ok) return; const fd = new FormData(); fd.append('id', id); fetch('api/choropleth/hapus.php', { method: 'POST', body: fd }) .then(r => r.json()) .then(j => { if (j.status !== 'success') throw new Error(j.message); const idx = layers.findIndex(l => l.meta.id === id); if (idx !== -1) { map.removeLayer(layers[idx].leafletLayer); layers.splice(idx, 1); } allMeta = allMeta.filter(m => m.id !== id); refreshPanel(); buildLegends(); showToast('Layer "' + escapeHTML(nama) + '" berhasil dihapus.'); }) .catch(err => showToast(err.message || 'Gagal menghapus.', 'error')); }); }, // Called after successful upload to add new layer without full reload addLayer: function (meta) { allMeta.push(meta); loadLayerGeojson(meta).then(() => { refreshPanel(); buildLegends(); // Zoom to new layer const rendered = layers.find(l => l.meta.id === meta.id); if (rendered) { try { map.fitBounds(rendered.leafletLayer.getBounds(), { padding: [40, 40] }); } catch (_) {} } }).catch(err => showToast('Layer tersimpan tapi gagal render: ' + err.message, 'error')); }, setHitTesting: setAllHitTesting, }; window.initChoropleth = loadAll; })();