chore: prepare docker webgis deployment

This commit is contained in:
Andrie
2026-06-11 18:14:21 +07:00
parent d2214ad9c8
commit a90748d9c1
149 changed files with 20844 additions and 5 deletions
+311
View File
@@ -0,0 +1,311 @@
// 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]) => `<tr><td class="pp-k">${escapeHTML(String(k))}</td><td class="pp-v">${escapeHTML(String(v ?? '—'))}</td></tr>`)
.join('');
return `<div class="choro-popup"><table>${rows}</table></div>`;
}
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]) => `
<div class="leg-row">
<span class="leg-swatch" style="background:${color}"></span>
<span class="leg-range">${escapeHTML(String(val))}</span>
</div>`).join('');
} else {
classes = pal.map((color, i) => `
<div class="leg-row">
<span class="leg-swatch" style="background:${color}"></span>
<span class="leg-range">${fmtVal(breaks[i])} ${fmtVal(breaks[i + 1])}</span>
</div>`).join('');
}
return `<div class="leg-block">
<div class="leg-title">${escapeHTML(meta.nama)}</div>
<div class="leg-attr">${escapeHTML(meta.attribute_key)}</div>
${classes}
</div>`;
}).join('');
}
// ── Layer panel ──────────────────────────────────────────────────
function refreshPanel() {
const list = document.getElementById('layerList');
if (!list) return;
if (allMeta.length === 0) {
list.innerHTML = '<div class="layer-empty">Belum ada layer.<br>Upload GeoJSON dari ArcGIS.</div>';
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 => `<span class="pal-dot" style="background:${c}"></span>`).join('');
const safeNama = m.nama.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const delBtn = window._IS_ADMIN
? `<button class="layer-del" onclick="window._choro.deleteLayer(${m.id},'${safeNama}')" title="Hapus"><i data-lucide="trash-2"></i></button>`
: '';
return `
<div class="layer-item ${isVisible ? '' : 'is-hidden'}" id="li${m.id}">
<div class="layer-item-row">
<button class="layer-eye" onclick="window._choro.toggleLayer(${m.id})" title="${isVisible ? 'Sembunyikan' : 'Tampilkan'}">
<i data-lucide="${eyeIcon}" id="eye${m.id}"></i>
</button>
<div class="layer-info">
<div class="layer-name">${escapeHTML(m.nama)}</div>
<div class="layer-attr">${escapeHTML(m.attribute_key)}</div>
</div>
${delBtn}
</div>
<div class="layer-pal">${palDots}</div>
</div>`;
}).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;
})();
+432
View File
@@ -0,0 +1,432 @@
// modules/jalan.js — Logika Pertemuan 2: Manajemen Data Jalan (Polyline)
(function () {
const layerGroup = L.layerGroup();
let allLayers = {}; // { id: polylineObject }
let active = false;
// ── Warna per status jalan ────────────────────────
const JALAN_COLORS = {
'Nasional': '#ef4444',
'Provinsi': '#f97316',
'Kabupaten': '#eab308'
};
function getColor(status) {
return JALAN_COLORS[status] || '#888';
}
// ── Render polyline ───────────────────────────────
function addPolylineToMap(row) {
let coords;
try {
const gj = typeof row.geojson === 'string' ? JSON.parse(row.geojson) : row.geojson;
// GeoJSON coordinates: [lng, lat] → Leaflet: [lat, lng]
coords = gj.coordinates.map(c => [c[1], c[0]]);
} catch (e) {
console.error('GeoJSON parse error (jalan id=' + row.id + ')', e);
return;
}
const poly = L.polyline(coords, {
color: getColor(row.status_jalan),
weight: 5,
opacity: 0.85,
pane: 'jalanPane'
});
poly._jalanId = row.id;
poly._jalanNama = row.nama_jalan;
poly._jalanStatus = row.status_jalan;
allLayers[row.id] = poly;
poly.bindPopup(buildInfoPopup(row), { maxWidth: 300 });
layerGroup.addLayer(poly);
}
function buildInfoPopup(row) {
const color = getColor(row.status_jalan);
const panjang = row.panjang_meter
? parseFloat(row.panjang_meter) >= 1000
? (parseFloat(row.panjang_meter) / 1000).toFixed(2) + ' km'
: parseFloat(row.panjang_meter).toFixed(1) + ' m'
: '—';
return `
<div class="info-popup">
<div class="info-popup-header">
<div class="info-popup-icon" style="background:${color}22;">🛣️</div>
<div>
<div class="info-popup-name">${escapeHTML(row.nama_jalan)}</div>
<div class="info-popup-id">#${row.id}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">🏷️</div>
<div>
<div class="info-row-label">Status Jalan</div>
<div class="info-row-value">
<span class="status-badge-inline"
style="background:${color}22;color:${color};border:1px solid ${color}55;">
Jalan ${escapeHTML(row.status_jalan)}
</span>
</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">📏</div>
<div>
<div class="info-row-label">Panjang Jalan</div>
<div class="info-row-value" style="font-family:var(--font-mono);">${panjang}</div>
</div>
</div>
<button class="btn-hapus" onclick="window._jalanHapus(${row.id}, this)">🗑 Hapus Jalan</button>
</div>
`;
}
// ── Load data ───────────────────────────────────────────────────
function loadData() {
layerGroup.clearLayers();
allLayers = {};
fetch('api/jalan/ambil.php?_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
updateCount(j.total, '🛣️');
j.data.forEach(addPolylineToMap);
refreshJalanList(j.data);
})
.catch(err => { showToast('Gagal memuat data jalan.', 'error'); console.error(err); });
}
// ── Refresh panel list Data Jalan ──────────────────────────────────
function refreshJalanList(data) {
if (typeof window.dlRefreshList !== 'function') return;
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
const items = (data || []).map(row => ({
id: row.id,
name: row.nama_jalan,
badge: row.status_jalan,
badgeColor: COLORS[row.status_jalan] || '#888',
dotColor: COLORS[row.status_jalan] || '#888'
}));
window.dlRefreshList('Jalan', items);
}
// ── Hapus jalan ───────────────────────────────────
window._jalanHapus = function (id, btnEl) {
showDeleteConfirm('Yakin ingin menghapus data jalan ini?').then(confirmed => {
if (!confirmed) return;
btnEl.disabled = true;
btnEl.textContent = '⏳ Menghapus...';
const fd = new FormData();
fd.append('id', id);
fetch('api/jalan/hapus.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
updateCount(Object.keys(allLayers).length, '🛣️');
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
const remaining = Object.values(allLayers).map(l => ({
id: l._jalanId, name: l._jalanNama,
badge: l._jalanStatus,
badgeColor: COLORS[l._jalanStatus] || '#888',
dotColor: COLORS[l._jalanStatus] || '#888'
}));
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Jalan', remaining);
showToast('Data jalan berhasil dihapus.');
} else throw new Error(j.message);
})
.catch(err => {
showToast(err.message || 'Gagal menghapus.', 'error');
btnEl.disabled = false;
btnEl.textContent = '🗑 Hapus Jalan';
});
});
};
// ── Drawing: gambar polyline baru ────────────────
let drawingPoints = [];
let drawingLayer = null; // polyline permanen per point click
let liveLineLayer = null; // polyline preview kursor ke ujung terakhir
let liveTooltip = null; // tooltip info ukuran
let drawingMarkers = []; // titik-titik sementara
let isDrawing = false;
let lastClickAt = 0;
function startDrawing() {
isDrawing = true;
drawingPoints = [];
lastClickAt = 0;
document.getElementById('drawHint').classList.add('visible');
map.doubleClickZoom.disable();
}
function stopDrawing(save) {
isDrawing = false;
document.getElementById('drawHint').classList.remove('visible');
map.doubleClickZoom.enable();
// Bersihkan preview
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
if (liveLineLayer) { map.removeLayer(liveLineLayer); liveLineLayer = null; }
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
drawingMarkers.forEach(m => map.removeLayer(m));
drawingMarkers = [];
if (save && drawingPoints.length >= 2) {
showFormJalan(drawingPoints.slice());
}
drawingPoints = [];
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
}
function finishDrawing() {
if (!isDrawing) return;
if (drawingPoints.length >= 2) {
stopDrawing(true);
} else {
showToast('Minimal 2 titik untuk membuat jalan (Polyline)', 'error');
}
}
function onMapClick(e) {
if (currentMode !== 2 || currentSubMode !== 'jalan') return;
if (!isDrawing) return;
const now = Date.now();
// Deteksi double-click: klik kedua dalam 350ms → finalisasi tanpa menambah titik
if (now - lastClickAt < 350) {
lastClickAt = 0;
finishDrawing();
return;
}
lastClickAt = now;
const latlng = e.latlng;
drawingPoints.push(latlng);
// Titik visual
const m = L.circleMarker(latlng, { radius: 5, color: '#388bfd', fillColor: '#388bfd', fillOpacity: 1, pane: 'drawPane' });
m.addTo(map);
drawingMarkers.push(m);
// Preview polyline
if (drawingLayer) map.removeLayer(drawingLayer);
if (drawingPoints.length >= 2) {
drawingLayer = L.polyline(drawingPoints, { color: '#388bfd', weight: 4, opacity: .9, pane: 'drawPane' }).addTo(map);
}
}
function onMapDoubleClick(e) {
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
if (e.originalEvent) L.DomEvent.stop(e.originalEvent);
finishDrawing();
}
function onMouseMove(e) {
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
if (drawingPoints.length === 0) {
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
return;
}
const lastPoint = drawingPoints[drawingPoints.length - 1];
const currentPoint = e.latlng;
// Gambar garis tipis (preview) dari titik terakhir ke cursor
if (liveLineLayer) map.removeLayer(liveLineLayer);
liveLineLayer = L.polyline([lastPoint, currentPoint], {
color: '#79c0ff', weight: 2, dashArray: '5,6', opacity: 0.6, pane: 'drawPane'
}).addTo(map);
// Hitung total panjang sampai titik ini
let totalMeter = 0;
for (let i = 0; i < drawingPoints.length - 1; i++) {
totalMeter += drawingPoints[i].distanceTo(drawingPoints[i + 1]);
}
totalMeter += lastPoint.distanceTo(currentPoint);
const txt = (totalMeter >= 1000)
? (totalMeter / 1000).toFixed(2) + ' km'
: totalMeter.toFixed(1) + ' m';
// Tampilkan tooltip berjalan
if (!liveTooltip) {
liveTooltip = L.tooltip({ permanent: true, className: 'live-tooltip', direction: 'auto', offset: [15, 15], pane: 'drawPane' })
.setLatLng(currentPoint)
.setContent('<b> Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai<br>Panjang: ' + txt)
.addTo(map);
} else {
liveTooltip.setLatLng(currentPoint);
liveTooltip.setContent('<b> Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai<br>Panjang: ' + txt);
}
}
function onKeyDown(e) {
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
finishDrawing();
}
if (e.key === 'Escape') stopDrawing(false);
}
// ── Form popup setelah gambar selesai ─────────────
function showFormJalan(points) {
// Hitung panjang via Leaflet
let totalMeter = 0;
for (let i = 0; i < points.length - 1; i++) {
totalMeter += points[i].distanceTo(points[i + 1]);
}
totalMeter = totalMeter.toFixed(2);
// Build GeoJSON LineString
const geojson = JSON.stringify({
type: 'LineString',
coordinates: points.map(p => [p.lng, p.lat])
});
// Popup di titik tengah
const midIdx = Math.floor(points.length / 2);
const midPt = points[midIdx];
L.popup({ maxWidth: 340, closeOnClick: false, closeButton: true })
.setLatLng(midPt)
.setContent(`
<div class="form-popup">
<div class="form-popup-header">
<div class="form-popup-icon blue">🛣️</div>
<div>
<div class="form-popup-title">Tambah Data Jalan</div>
<div class="form-popup-coords">${points.length} titik · ${totalMeter} m</div>
</div>
</div>
<div class="form-group">
<label>Nama Jalan *</label>
<input type="text" id="fj_nama" placeholder="cth. Jl. Ahmad Yani" maxlength="100" autocomplete="off">
</div>
<div class="form-group">
<label>Status Jalan *</label>
<select id="fj_status">
<option value="Nasional">Jalan Nasional</option>
<option value="Provinsi">Jalan Provinsi</option>
<option value="Kabupaten" selected>Jalan Kabupaten</option>
</select>
</div>
<div class="form-group">
<label>Panjang Jalan (otomatis)</label>
<div class="input-readonly">${totalMeter} meter</div>
</div>
<input type="hidden" id="fj_geojson" value='${escapeHTML(geojson)}'>
<input type="hidden" id="fj_panjang" value="${totalMeter}">
<button class="btn-save" id="btnSimpanJalan" onclick="window._jalanSimpan()">💾 Simpan Jalan</button>
<div class="form-status" id="jalanStatus"></div>
</div>
`)
.openOn(map);
setTimeout(() => { const el = document.getElementById('fj_nama'); if (el) el.focus(); }, 200);
// Kalau popup ditutup tanpa simpan: hapus preview
map.once('popupclose', () => {
// preview sudah di-clear di stopDrawing
});
}
// ── Simpan jalan ──────────────────────────────────
window._jalanSimpan = function () {
const nama = document.getElementById('fj_nama').value.trim();
const status = document.getElementById('fj_status').value;
const geojson = document.getElementById('fj_geojson').value;
const panjang = document.getElementById('fj_panjang').value;
const statusEl = document.getElementById('jalanStatus');
const btn = document.getElementById('btnSimpanJalan');
if (!nama) {
statusEl.textContent = '⚠ Nama jalan wajib diisi.';
statusEl.className = 'form-status error';
document.getElementById('fj_nama').focus();
return;
}
btn.disabled = true;
btn.textContent = '⏳ Menyimpan...';
const fd = new FormData();
fd.append('nama_jalan', nama);
fd.append('status_jalan', status);
fd.append('geojson', geojson);
fd.append('panjang_meter', panjang);
fetch('api/jalan/simpan.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
addPolylineToMap(j.data);
updateCount(Object.keys(allLayers).length, '🛣️');
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
const allItems = Object.values(allLayers).map(l => ({
id: l._jalanId, name: l._jalanNama,
badge: l._jalanStatus,
badgeColor: COLORS[l._jalanStatus] || '#888',
dotColor: COLORS[l._jalanStatus] || '#888'
}));
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Jalan', allItems);
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
} else throw new Error(j.message);
})
.catch(err => {
statusEl.textContent = '✕ ' + err.message;
statusEl.className = 'form-status error';
btn.disabled = false;
btn.textContent = '💾 Simpan Jalan';
});
};
// ── Init / cleanup ───────────────────────────────────────────
window.initJalan = function () {
if (!active) {
active = true;
map.on('click', onMapClick);
map.on('dblclick', onMapDoubleClick);
map.on('mousemove', onMouseMove);
document.addEventListener('keydown', onKeyDown);
}
// Daftarkan fungsi focus untuk panel list
window._dlFocusFns['Jalan'] = function(id) {
const layer = allLayers[id];
if (!layer) return;
map.fitBounds(layer.getBounds(), { padding: [40, 40], animate: true });
setTimeout(() => layer.openPopup(layer.getBounds().getCenter()), 400);
};
layerGroup.addTo(map);
loadData();
};
// Ekspos ke global agar bisa dipanggil dari index.php
window._jalanStartDraw = startDrawing;
window._jalanFinishDraw = finishDrawing;
window._jalanStopDraw = function() { stopDrawing(false); };
// Filter Toggle
window._jalanToggleLayer = function(visible) {
if (visible) {
map.addLayer(layerGroup);
} else {
map.removeLayer(layerGroup);
stopDrawing(false); // Batalkan mode draw jika layer disembunyikan
}
};
})();
+459
View File
@@ -0,0 +1,459 @@
// modules/parsil.js — Logika Pertemuan 2: Manajemen Parsil Tanah (Polygon)
(function () {
const layerGroup = L.layerGroup();
let allLayers = {}; // { id: polygonObject }
let active = false;
// ── Warna per status kepemilikan ──────────────────
const PARSIL_COLORS = {
'SHM': '#3b82f6',
'HGB': '#8b5cf6',
'HGU': '#ec4899',
'HP': '#14b8a6'
};
function getColor(status) {
return PARSIL_COLORS[status] || '#888';
}
// ── Hitung Luas Polygon (Spherical Area) ──────────
function calculatePolygonArea(latLngs) {
let radius = 6378137; // Earth's radius in meters
let area = 0;
let d2r = Math.PI / 180;
if (latLngs.length > 2) {
for (let i = 0; i < latLngs.length; i++) {
let p1 = latLngs[i];
let p2 = latLngs[(i + 1) % latLngs.length];
area += ((p2.lng - p1.lng) * d2r) * (2 + Math.sin(p1.lat * d2r) + Math.sin(p2.lat * d2r));
}
area = area * radius * radius / 2.0;
}
return Math.abs(area);
}
// ── Render polygon ────────────────────────────────
function addPolygonToMap(row) {
let coords;
try {
const gj = typeof row.geojson === 'string' ? JSON.parse(row.geojson) : row.geojson;
// GeoJSON Polygon coordinates are MultiArray: [[[lng, lat], [lng, lat]...]]
// We just need the exterior ring [0] maps to latlng
let extRing = gj.coordinates[0];
coords = extRing.map(c => [c[1], c[0]]);
} catch (e) {
console.error('GeoJSON parse error (parsil id=' + row.id + ')', e);
return;
}
const poly = L.polygon(coords, {
color: getColor(row.status_kepemilikan),
fillColor: getColor(row.status_kepemilikan),
weight: 2,
fillOpacity: 0.4,
pane: 'parsilPane'
});
poly._parsilId = row.id;
poly._parsilNama = row.nama_parsil;
poly._parsilStatus = row.status_kepemilikan;
allLayers[row.id] = poly;
poly.bindPopup(buildInfoPopup(row), { maxWidth: 300 });
layerGroup.addLayer(poly);
}
function buildInfoPopup(row) {
const color = getColor(row.status_kepemilikan);
const luas = row.luas_m2
? parseFloat(row.luas_m2).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) + ' m²'
: '—';
return `
<div class="info-popup">
<div class="info-popup-header">
<div class="info-popup-icon" style="background:${color}22;">🏘️</div>
<div>
<div class="info-popup-name">${escapeHTML(row.nama_parsil)}</div>
<div class="info-popup-id">#${row.id}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">📜</div>
<div>
<div class="info-row-label">Status Kepemilikan</div>
<div class="info-row-value">
<span class="status-badge-inline"
style="background:${color}22;color:${color};border:1px solid ${color}55;">
${escapeHTML(row.status_kepemilikan)}
</span>
</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">📐</div>
<div>
<div class="info-row-label">Luas Tanah</div>
<div class="info-row-value" style="font-family:var(--font-mono);">${luas}</div>
</div>
</div>
<button class="btn-hapus" onclick="window._parsilHapus(${row.id}, this)">🗑 Hapus Parsil</button>
</div>
`;
}
// ── Load data ───────────────────────────────────────────────────
function loadData() {
layerGroup.clearLayers();
allLayers = {};
fetch('api/parsil/ambil.php?_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
updateCount(j.total, '🏘️');
j.data.forEach(addPolygonToMap);
refreshParsilList(j.data);
})
.catch(err => { showToast('Gagal memuat data parsil.', 'error'); console.error(err); });
}
// ── Refresh panel list Parsil Tanah ─────────────────────────────────
function refreshParsilList(data) {
if (typeof window.dlRefreshList !== 'function') return;
const items = (data || []).map(row => ({
id: row.id,
name: row.nama_parsil,
badge: row.status_kepemilikan,
badgeColor: getColor(row.status_kepemilikan),
dotColor: getColor(row.status_kepemilikan)
}));
window.dlRefreshList('Parsil', items);
}
// ── Hapus parsil ──────────────────────────────────
window._parsilHapus = function (id, btnEl) {
showDeleteConfirm('Yakin ingin menghapus data parsil tanah ini?').then(confirmed => {
if (!confirmed) return;
btnEl.disabled = true;
btnEl.textContent = '⏳ Menghapus...';
const fd = new FormData();
fd.append('id', id);
fetch('api/parsil/hapus.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
updateCount(Object.keys(allLayers).length, '🏘️');
const remaining = Object.values(allLayers).map(l => ({
id: l._parsilId, name: l._parsilNama,
badge: l._parsilStatus,
badgeColor: getColor(l._parsilStatus),
dotColor: getColor(l._parsilStatus)
}));
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Parsil', remaining);
showToast('Data parsil berhasil dihapus.');
} else throw new Error(j.message);
})
.catch(err => {
showToast(err.message || 'Gagal menghapus.', 'error');
btnEl.disabled = false;
btnEl.textContent = '🗑 Hapus Parsil';
});
});
};
// ── Drawing: gambar polygon baru ──────────────────
let drawingPoints = [];
let drawingLayer = null; // polyline/polygon sementara
let liveLineLayer = null; // polyline preview kursor
let liveTooltip = null; // tooltip info ukuran
let drawingMarkers = []; // titik-titik sementara
let isDrawing = false;
let lastClickAt = 0;
function startDrawing() {
isDrawing = true;
drawingPoints = [];
lastClickAt = 0;
document.getElementById('drawHint').classList.add('visible');
map.doubleClickZoom.disable();
}
function stopDrawing(save) {
isDrawing = false;
document.getElementById('drawHint').classList.remove('visible');
map.doubleClickZoom.enable();
// Bersihkan preview
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
if (liveLineLayer) { map.removeLayer(liveLineLayer); liveLineLayer = null; }
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
drawingMarkers.forEach(m => map.removeLayer(m));
drawingMarkers = [];
if (save && drawingPoints.length >= 3) {
showFormParsil(drawingPoints.slice());
} else if (save && drawingPoints.length < 3) {
showToast('Minimal 3 titik untuk membuat parsil (Polygon)', 'error');
}
drawingPoints = [];
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
}
function finishDrawing() {
if (!isDrawing) return;
if (drawingPoints.length >= 3) {
stopDrawing(true);
} else {
showToast('Minimal 3 titik untuk membuat parsil (Polygon)', 'error');
}
}
function onMapClick(e) {
if (currentMode !== 2 || currentSubMode !== 'parsil') return;
if (!isDrawing) return;
const now = Date.now();
// Deteksi double-click: klik kedua dalam 350ms → finalisasi tanpa menambah titik
if (now - lastClickAt < 350) {
lastClickAt = 0;
finishDrawing();
return;
}
lastClickAt = now;
const latlng = e.latlng;
drawingPoints.push(latlng);
// Titik visual
const m = L.circleMarker(latlng, { radius: 5, color: '#eab308', fillColor: '#eab308', fillOpacity: 1, pane: 'drawPane' });
m.addTo(map);
drawingMarkers.push(m);
// Preview polygon
if (drawingLayer) map.removeLayer(drawingLayer);
if (drawingPoints.length >= 3) {
drawingLayer = L.polygon(drawingPoints, { color: '#eab308', weight: 2, fillOpacity: 0.3, pane: 'drawPane' }).addTo(map);
} else if (drawingPoints.length === 2) {
drawingLayer = L.polyline(drawingPoints, { color: '#eab308', weight: 2, dashArray: '6,4', opacity: 0.7, pane: 'drawPane' }).addTo(map);
}
}
function onMapDoubleClick(e) {
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
if (e.originalEvent) L.DomEvent.stop(e.originalEvent);
finishDrawing();
}
function onMouseMove(e) {
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
if (drawingPoints.length === 0) {
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
return;
}
const lastPoint = drawingPoints[drawingPoints.length - 1];
const currentPoint = e.latlng;
// Gambar garis tipis preview dari titik terakhir ke cursor (dan dari kursor ke titik awal jika >= 2 titik)
if (liveLineLayer) map.removeLayer(liveLineLayer);
const previewCoords = [lastPoint, currentPoint];
if (drawingPoints.length >= 2) {
previewCoords.push(drawingPoints[0]); // Garis penutup bayangan
}
liveLineLayer = L.polyline(previewCoords, {
color: '#eab308', weight: 2, dashArray: '5,6', opacity: 0.6, pane: 'drawPane'
}).addTo(map);
// Hitung total luas jika + titik baru ini (minimal 3 titik)
let txt = '';
if (drawingPoints.length >= 2) {
const tempPoints = [...drawingPoints, currentPoint];
let luasArea = calculatePolygonArea(tempPoints);
txt = '<br>Luas: ' + parseFloat(luasArea.toFixed(2)).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) + ' m²';
}
// Tampilkan tooltip berjalan
if (!liveTooltip) {
liveTooltip = L.tooltip({ permanent: true, className: 'live-tooltip', direction: 'auto', offset: [15, 15], pane: 'drawPane' })
.setLatLng(currentPoint)
.setContent('<b> Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai' + txt)
.addTo(map);
} else {
liveTooltip.setLatLng(currentPoint);
liveTooltip.setContent('<b> Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai' + txt);
}
}
function onKeyDown(e) {
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
finishDrawing();
}
if (e.key === 'Escape') stopDrawing(false);
}
// ── Form popup setelah gambar selesai ─────────────
function showFormParsil(points) {
// Hitung luas area
let luasArea = calculatePolygonArea(points);
luasArea = luasArea.toFixed(2);
let luasStr = parseFloat(luasArea).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
// Build GeoJSON Polygon
let geojsonPoints = points.map(p => [p.lng, p.lat]);
// GeoJSON First and last position must be strictly equivalent
geojsonPoints.push([points[0].lng, points[0].lat]);
const geojson = JSON.stringify({
type: 'Polygon',
coordinates: [geojsonPoints]
});
// Popup di bounds / center polygon
const tempPoly = L.polygon(points);
const centerPt = tempPoly.getBounds().getCenter();
L.popup({ maxWidth: 340, closeOnClick: false, closeButton: true })
.setLatLng(centerPt)
.setContent(`
<div class="form-popup">
<div class="form-popup-header">
<div class="form-popup-icon amber">🏘️</div>
<div>
<div class="form-popup-title">Tambah Data Parsil (Kavling)</div>
<div class="form-popup-coords">${points.length} titik · Luas : ${luasStr} m²</div>
</div>
</div>
<div class="form-group">
<label>Nama/Keterangan Parsil *</label>
<input type="text" id="fp_nama" placeholder="cth. Kavling Blok C" maxlength="100" autocomplete="off">
</div>
<div class="form-group">
<label>Status Kepemilikan *</label>
<select id="fp_status">
<option value="SHM" selected>Sertifikat Hak Milik (SHM)</option>
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
<option value="HP">Hak Pakai (HP)</option>
</select>
</div>
<div class="form-group">
<label>Luas Tanah (otomatis)</label>
<div class="input-readonly">${luasStr} m²</div>
</div>
<input type="hidden" id="fp_geojson" value='${escapeHTML(geojson)}'>
<input type="hidden" id="fp_luas" value="${luasArea}">
<button class="btn-save" id="btnSimpanParsil" style="background:var(--c-warn);color:#111" onclick="window._parsilSimpan()">💾 Simpan Parsil</button>
<div class="form-status" id="parsilStatus"></div>
</div>
`)
.openOn(map);
setTimeout(() => { const el = document.getElementById('fp_nama'); if (el) el.focus(); }, 200);
// Kalau popup ditutup tanpa simpan: hapus preview
map.once('popupclose', () => {
// preview sudah di-clear di stopDrawing
});
}
// ── Simpan parsil ─────────────────────────────────
window._parsilSimpan = function () {
const nama = document.getElementById('fp_nama').value.trim();
const status = document.getElementById('fp_status').value;
const geojson = document.getElementById('fp_geojson').value;
const luas = document.getElementById('fp_luas').value;
const statusEl = document.getElementById('parsilStatus');
const btn = document.getElementById('btnSimpanParsil');
if (!nama) {
statusEl.textContent = '⚠ Nama parsil wajib diisi.';
statusEl.className = 'form-status error';
document.getElementById('fp_nama').focus();
return;
}
btn.disabled = true;
btn.textContent = '⏳ Menyimpan...';
const fd = new FormData();
fd.append('nama_parsil', nama);
fd.append('status_kepemilikan', status);
fd.append('geojson', geojson);
fd.append('luas_m2', luas);
fetch('api/parsil/simpan.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
addPolygonToMap(j.data);
updateCount(Object.keys(allLayers).length, '🏘️');
const allItems = Object.values(allLayers).map(l => ({
id: l._parsilId, name: l._parsilNama,
badge: l._parsilStatus,
badgeColor: getColor(l._parsilStatus),
dotColor: getColor(l._parsilStatus)
}));
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Parsil', allItems);
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
} else throw new Error(j.message);
})
.catch(err => {
statusEl.textContent = '✕ ' + err.message;
statusEl.className = 'form-status error';
btn.disabled = false;
btn.textContent = '💾 Simpan Parsil';
});
};
// ── Init / cleanup ────────────────────────────────────────────
window.initParsil = function () {
if (!active) {
active = true;
map.on('click', onMapClick);
map.on('dblclick', onMapDoubleClick);
map.on('mousemove', onMouseMove);
document.addEventListener('keydown', onKeyDown);
}
// Daftarkan fungsi focus untuk panel list
window._dlFocusFns['Parsil'] = function(id) {
const layer = allLayers[id];
if (!layer) return;
map.fitBounds(layer.getBounds(), { padding: [60, 60], animate: true });
setTimeout(() => layer.openPopup(layer.getBounds().getCenter()), 400);
};
layerGroup.addTo(map);
loadData();
};
// Ekspos ke global agar bisa dipanggil dari index.php
window._parsilStartDraw = startDrawing;
window._parsilFinishDraw = finishDrawing;
window._parsilStopDraw = function() { stopDrawing(false); };
// Filter Toggle
window._parsilToggleLayer = function(visible) {
if (visible) {
map.addLayer(layerGroup);
} else {
map.removeLayer(layerGroup);
stopDrawing(false); // Batalkan mode draw jika layer disembunyikan
}
};
})();
+315
View File
@@ -0,0 +1,315 @@
// modules/point.js — Logika Pertemuan 1: Point of Interest (POI)
// Dipanggil oleh index.php saat mode = 1
(function () {
// ── Layer group ──────────────────────────────────
const layerGroup = L.layerGroup();
let allMarkers = {}; // { id: markerObject }
let tempMarker = null;
let active = false;
// ── Custom Icons ─────────────────────────────────
function createIcon(color = '#2ea043') {
return L.divIcon({
className: '',
html: `<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity=".4"/></filter>
<path d="M14 0C6.268 0 0 6.268 0 14C0 24.5 14 38 14 38C14 38 28 24.5 28 14C28 6.268 21.732 0 14 0Z"
fill="${color}" filter="url(#sh)"/>
<circle cx="14" cy="14" r="6" fill="white" opacity=".9"/>
</svg>`,
iconSize: [28, 38],
iconAnchor: [14, 38],
popupAnchor: [0, -40]
});
}
const iconDefault = createIcon('#2ea043');
const iconClosed = createIcon('#f85149');
const iconTemp = createIcon('#e3b341');
// ── Render marker on map ─────────────────────────
function addMarkerToMap(poi) {
const isOpen = Number(poi.buka_24jam) === 1;
const marker = L.marker(
[poi.latitude, poi.longitude],
{ icon: isOpen ? iconDefault : iconClosed, draggable: true, pane: 'pointPane' }
);
marker._poiId = poi.id;
marker._poiNama = poi.nama_tempat;
marker._poiBuka = Number(poi.buka_24jam) === 1;
allMarkers[poi.id] = marker;
// Drag to update position
marker.on('dragend', function (e) {
const { lat, lng } = e.target.getLatLng();
const fd = new FormData();
fd.append('id', poi.id);
fd.append('latitude', lat.toFixed(8));
fd.append('longitude', lng.toFixed(8));
fetch('api/point/update_posisi.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(j => {
if (j.status === 'success') showToast(`Posisi "${escapeHTML(poi.nama_tempat)}" diperbarui!`);
else throw new Error(j.message);
})
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
});
// Popup info
const noWa = poi.no_wa ? poi.no_wa.replace(/\D/g, '') : '';
const waBtn = noWa
? `<a class="btn-wa" href="https://wa.me/${noWa}" target="_blank" rel="noopener"
style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:14px;padding:9px;background:#25d366;color:#fff;border:none;border-radius:7px;font-family:var(--font-body);font-size:13px;font-weight:700;text-decoration:none;">
💬 Chat WhatsApp
</a>`
: `<div style="font-size:12px;color:var(--c-muted);text-align:center;padding:8px 0;margin-top:8px;">Tidak ada nomor WA</div>`;
const buka24Badge = isOpen
? `<span class="status-badge-inline" style="background:rgba(46,160,67,.2);color:#3fb950;border:1px solid rgba(46,160,67,.35);">🟢 Buka 24 Jam</span>`
: `<span class="status-badge-inline" style="background:rgba(248,81,73,.12);color:#f85149;border:1px solid rgba(248,81,73,.3);">🔴 Tidak 24 Jam</span>`;
marker.bindPopup(`
<div class="info-popup">
<div class="info-popup-header">
<div class="info-popup-icon">📍</div>
<div>
<div class="info-popup-name">${escapeHTML(poi.nama_tempat)}</div>
<div class="info-popup-id">#${poi.id}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">📞</div>
<div>
<div class="info-row-label">No. WhatsApp</div>
<div class="info-row-value">${poi.no_wa ? escapeHTML(poi.no_wa) : '—'}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">🕐</div>
<div>
<div class="info-row-label">Jam Operasional</div>
<div class="info-row-value">${buka24Badge}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">🌐</div>
<div>
<div class="info-row-label">Koordinat</div>
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
${parseFloat(poi.latitude).toFixed(6)}, ${parseFloat(poi.longitude).toFixed(6)}
</div>
</div>
</div>
${waBtn}
<button class="btn-hapus" onclick="window._pointHapus(${poi.id}, this)">🗑 Hapus Lokasi</button>
</div>
`, { maxWidth: 300 });
layerGroup.addLayer(marker);
}
// ── Load semua POI dari server ──────────────────
function loadData() {
layerGroup.clearLayers();
allMarkers = {};
fetch('api/point/ambil.php?_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
updateCount(j.total, '📍');
j.data.forEach(addMarkerToMap);
refreshPointList(j.data);
})
.catch(err => {
showToast('Gagal memuat data POI.', 'error');
console.error(err);
});
}
// ── Refresh panel list Lokasi Usaha ──────────────────
function refreshPointList(data) {
if (typeof window.dlRefreshList !== 'function') return;
const items = (data || []).map(poi => ({
id: poi.id,
name: poi.nama_tempat,
badge: Number(poi.buka_24jam) === 1 ? '24 Jam' : 'Non-24',
badgeColor: Number(poi.buka_24jam) === 1 ? '#2ea043' : '#f85149',
dotColor: Number(poi.buka_24jam) === 1 ? '#2ea043' : '#f85149'
}));
window.dlRefreshList('Point', items);
}
// ── Hapus marker ────────────────────────────────────
window._pointHapus = function (id, btnEl) {
showDeleteConfirm('Yakin ingin menghapus lokasi ini?').then(confirmed => {
if (!confirmed) return;
btnEl.disabled = true;
btnEl.textContent = '⏳ Menghapus...';
const fd = new FormData();
fd.append('id', id);
fetch('api/point/hapus.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (allMarkers[id]) { layerGroup.removeLayer(allMarkers[id]); delete allMarkers[id]; }
updateCount(Math.max(0, Object.keys(allMarkers).length), '📍');
// rebuild list dari allMarkers (ambil nama dari marker)
const remaining = Object.keys(allMarkers).map(k => ({
id: k,
name: allMarkers[k]._poiNama || ('#' + k),
badge: allMarkers[k]._poiBuka ? '24 Jam' : 'Non-24',
badgeColor: allMarkers[k]._poiBuka ? '#2ea043' : '#f85149',
dotColor: allMarkers[k]._poiBuka ? '#2ea043' : '#f85149'
}));
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Point', remaining);
showToast('Lokasi berhasil dihapus.');
} else throw new Error(j.message);
})
.catch(err => {
showToast(err.message || 'Gagal menghapus.', 'error');
btnEl.disabled = false;
btnEl.textContent = '🗑 Hapus Lokasi';
});
});
};
// ── Klik peta = form tambah POI ──────────────────
function onMapClick(e) {
if (currentMode !== 1) return;
const lat = e.latlng.lat.toFixed(8);
const lng = e.latlng.lng.toFixed(8);
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng], { icon: iconTemp, pane: 'drawPane' }).addTo(map);
L.popup({ maxWidth: 320, closeOnClick: false })
.setLatLng(e.latlng)
.setContent(`
<div class="form-popup">
<div class="form-popup-header">
<div class="form-popup-icon"></div>
<div>
<div class="form-popup-title">Tambah Lokasi Baru</div>
<div class="form-popup-coords">${lat}, ${lng}</div>
</div>
</div>
<div class="form-group">
<label>Nama Tempat *</label>
<input type="text" id="f_nama" placeholder="cth. Warung Kopi Aroma" maxlength="100" autocomplete="off">
</div>
<div class="form-group">
<label>No. WhatsApp</label>
<input type="text" id="f_wa" placeholder="628xxxxxxxxxx" maxlength="15" autocomplete="off">
</div>
<div class="form-group">
<label>Buka 24 Jam?</label>
<select id="f_buka24">
<option value="0">❌ Tidak</option>
<option value="1">✅ Ya</option>
</select>
</div>
<input type="hidden" id="f_lat" value="${lat}">
<input type="hidden" id="f_lng" value="${lng}">
<button class="btn-save" id="btnSimpanPoi" onclick="window._pointSimpan()">💾 Simpan Lokasi</button>
<div class="form-status" id="poiStatus"></div>
</div>
`)
.openOn(map);
setTimeout(() => { const el = document.getElementById('f_nama'); if (el) el.focus(); }, 200);
}
// ── Simpan POI ────────────────────────────────────
window._pointSimpan = function () {
const nama = document.getElementById('f_nama').value.trim();
const wa = document.getElementById('f_wa').value.trim();
const buka24 = document.getElementById('f_buka24').value;
const lat = document.getElementById('f_lat').value;
const lng = document.getElementById('f_lng').value;
const status = document.getElementById('poiStatus');
const btn = document.getElementById('btnSimpanPoi');
if (!nama) {
status.textContent = '⚠ Nama tempat wajib diisi.';
status.className = 'form-status error';
document.getElementById('f_nama').focus();
return;
}
btn.disabled = true;
btn.textContent = '⏳ Menyimpan...';
const fd = new FormData();
fd.append('nama_tempat', nama);
fd.append('no_wa', wa);
fd.append('buka_24jam', buka24);
fd.append('latitude', lat);
fd.append('longitude', lng);
fetch('api/point/simpan.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
addMarkerToMap(j.data);
updateCount(Object.keys(allMarkers).length, '📍');
// Tambahkan item baru ke list
const allItems = Object.values(allMarkers).map(m => ({
id: m._poiId,
name: m._poiNama || ('#' + m._poiId),
badge: m._poiBuka ? '24 Jam' : 'Non-24',
badgeColor: m._poiBuka ? '#2ea043' : '#f85149',
dotColor: m._poiBuka ? '#2ea043' : '#f85149'
}));
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Point', allItems);
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
} else throw new Error(j.message);
})
.catch(err => {
status.textContent = '✕ ' + err.message;
status.className = 'form-status error';
btn.disabled = false;
btn.textContent = '💾 Simpan Lokasi';
});
};
// ── Init / cleanup ────────────────────────────────────
window.initPoint = function () {
if (active) { /* already added to map, just refresh data if needed */ loadData(); return; }
active = true;
layerGroup.addTo(map);
map.on('click', onMapClick);
// Daftarkan fungsi focus untuk panel list
window._dlFocusFns['Point'] = function(id) {
const marker = allMarkers[id];
if (!marker) return;
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
setTimeout(() => marker.openPopup(), 350);
};
loadData();
};
// Filter Toggle
window._pointToggleLayer = function(visible) {
if (visible) {
map.addLayer(layerGroup);
} else {
map.removeLayer(layerGroup);
// Juga tutup popup & bersihkan titik sementara jika ada
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
}
};
})();