let map; let points = {}; let roads = {}; let parcels = {}; let currentMapClickCoords = null; let drawMode = null; let currentDrawCoords = []; let currentDrawLayer = null; const STORAGE_KEYS = { points: 'mappoints_local_points', roads: 'mappoints_local_roads', parcels: 'mappoints_local_parcels' }; // Initialize map and load stored data document.addEventListener('DOMContentLoaded', function() { initMap(); setupModal(); loadAllData(); }); function setupModal() { const addPointBtn = document.getElementById('add-point-btn'); const drawRoadBtn = document.getElementById('draw-road-btn'); const drawParcelBtn = document.getElementById('draw-parcel-btn'); const finishDrawBtn = document.getElementById('finish-draw-btn'); const cancelDrawBtn = document.getElementById('cancel-draw-btn'); const featureCloseBtn = document.querySelector('#feature-modal .close'); const featureForm = document.getElementById('feature-form'); const roadCloseBtn = document.querySelector('.road-close'); const roadForm = document.getElementById('road-form'); const parcelCloseBtn = document.querySelector('.parcel-close'); const parcelForm = document.getElementById('parcel-form'); addPointBtn.onclick = function() { cancelDraw(); openPointModal(null); }; drawRoadBtn.onclick = function() { startDraw('road'); }; drawParcelBtn.onclick = function() { startDraw('parcel'); }; finishDrawBtn.onclick = function() { finishDraw(); }; cancelDrawBtn.onclick = function() { cancelDraw(); }; featureCloseBtn.onclick = function() { closePointModal(); }; featureForm.onsubmit = function(e) { e.preventDefault(); submitPointForm(); }; roadCloseBtn.onclick = closeRoadModal; roadForm.onsubmit = function(e) { e.preventDefault(); submitRoadForm(); }; parcelCloseBtn.onclick = closeParcelModal; parcelForm.onsubmit = function(e) { e.preventDefault(); submitParcelForm(); }; } function initMap() { map = L.map('map').setView([-0.0225, 109.3425], 13); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); map.on('click', function(e) { if (drawMode) { addDrawVertex(e.latlng); } else { openPointModal(e.latlng); } }); } function loadAllData() { loadPoints(); loadRoads(); loadParcels(); } function loadPoints() { fetch('api/get_points.php') .then(response => response.json()) .then(data => { if (data.success && Array.isArray(data.markers)) { data.markers.forEach(point => { displayPoint(point.id, point.name, point.latitude, point.longitude, point.open_24_hours); }); } }) .catch(error => console.warn('Unable to load points from backend:', error)) .finally(() => { loadLocalPoints().forEach(point => { displayPoint(point.id, point.name, point.latitude, point.longitude, point.open_24_hours, true); }); updateCounts(); }); } function loadRoads() { fetch('api/get_roads.php') .then(response => response.json()) .then(data => { if (data.success && Array.isArray(data.roads)) { data.roads.forEach(road => { displayRoad(road.id, road.name, road.status, parseFloat(road.length), JSON.parse(road.coordinates)); }); } }) .catch(error => console.warn('Unable to load roads from backend:', error)) .finally(() => { loadLocalRoads().forEach(road => { displayRoad(road.id, road.name, road.status, road.length, road.coordinates, true); }); updateCounts(); }); } function loadParcels() { fetch('api/get_parcels.php') .then(response => response.json()) .then(data => { if (data.success && Array.isArray(data.parcels)) { data.parcels.forEach(parcel => { displayParcel(parcel.id, parcel.owner_status, parseFloat(parcel.area), JSON.parse(parcel.coordinates)); }); } }) .catch(error => console.warn('Unable to load parcels from backend:', error)) .finally(() => { loadLocalParcels().forEach(parcel => { displayParcel(parcel.id, parcel.owner_status, parcel.area, parcel.coordinates, true); }); updateCounts(); }); } function loadLocalPoints() { try { const raw = localStorage.getItem(STORAGE_KEYS.points); const data = raw ? JSON.parse(raw) : []; return Array.isArray(data) ? data : []; } catch { return []; } } function loadLocalRoads() { try { const raw = localStorage.getItem(STORAGE_KEYS.roads); const data = raw ? JSON.parse(raw) : []; return Array.isArray(data) ? data : []; } catch { return []; } } function loadLocalParcels() { try { const raw = localStorage.getItem(STORAGE_KEYS.parcels); const data = raw ? JSON.parse(raw) : []; return Array.isArray(data) ? data : []; } catch { return []; } } function persistLocalPoints() { const localPoints = Object.values(points) .filter(item => item._local) .map(item => ({ id: item._localId, name: item._name, latitude: item.getLatLng().lat, longitude: item.getLatLng().lng, open_24_hours: item._open24 })); localStorage.setItem(STORAGE_KEYS.points, JSON.stringify(localPoints)); } function persistLocalRoads() { const localRoads = Object.values(roads) .filter(item => item._local) .map(item => ({ id: item._localId, name: item._name, status: item._status, length: item._length, coordinates: item._coordinates })); localStorage.setItem(STORAGE_KEYS.roads, JSON.stringify(localRoads)); } function persistLocalParcels() { const localParcels = Object.values(parcels) .filter(item => item._local) .map(item => ({ id: item._localId, owner_status: item._ownerStatus, area: item._area, coordinates: item._coordinates })); localStorage.setItem(STORAGE_KEYS.parcels, JSON.stringify(localParcels)); } function generateLocalId(prefix) { return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}`; } function startDraw(type) { cancelDraw(); drawMode = type; currentDrawCoords = []; showDrawControls(true); showNotification(type === 'road' ? 'Draw road vertices on the map.' : 'Draw parcel vertices on the map.'); } function showDrawControls(show) { const drawActions = document.getElementById('draw-actions'); if (drawActions) { drawActions.classList.toggle('hidden', !show); } } function cancelDraw() { drawMode = null; currentDrawCoords = []; if (currentDrawLayer) { map.removeLayer(currentDrawLayer); currentDrawLayer = null; } showDrawControls(false); } function finishDraw() { if (!drawMode) { return; } if (drawMode === 'road') { if (currentDrawCoords.length < 2) { showNotification('Road must have at least 2 points.', true); return; } openRoadModal(); } else if (drawMode === 'parcel') { if (currentDrawCoords.length < 3) { showNotification('Parcel must have at least 3 points.', true); return; } openParcelModal(); } } function addDrawVertex(latlng) { currentDrawCoords.push(latlng); if (currentDrawLayer) { map.removeLayer(currentDrawLayer); currentDrawLayer = null; } if (drawMode === 'road') { currentDrawLayer = L.polyline(currentDrawCoords, { color: '#ff9800', dashArray: '5,8', weight: 5 }).addTo(map); } else if (drawMode === 'parcel') { currentDrawLayer = L.polygon(currentDrawCoords, { color: '#4CAF50', dashArray: '5,8', weight: 4, fillOpacity: 0.15 }).addTo(map); } showNotification(`Vertex added (${currentDrawCoords.length})`); } function openPointModal(coords) { currentMapClickCoords = coords; const modal = document.getElementById('feature-modal'); const form = document.getElementById('feature-form'); const latInput = document.getElementById('feature-latitude'); const lngInput = document.getElementById('feature-longitude'); const nameInput = document.getElementById('feature-name'); const statusGroup = document.getElementById('field-status-group'); const checkboxGroup = document.getElementById('feature-checkbox-group'); const modalTitle = document.getElementById('modal-title'); const formInfo = document.getElementById('feature-form-info'); form.reset(); statusGroup.style.display = 'none'; checkboxGroup.style.display = 'flex'; formInfo.classList.remove('show'); if (coords) { latInput.value = coords.lat.toFixed(6); lngInput.value = coords.lng.toFixed(6); latInput.readOnly = true; lngInput.readOnly = true; modalTitle.textContent = 'Create Point'; formInfo.textContent = 'Coordinates auto-filled from map click.'; formInfo.classList.add('show'); } else { latInput.value = ''; lngInput.value = ''; latInput.readOnly = false; lngInput.readOnly = false; modalTitle.textContent = 'Add New Point'; formInfo.textContent = 'Enter coordinates manually or click on the map.'; formInfo.classList.add('show'); } nameInput.focus(); modal.classList.add('show'); } function closePointModal() { const modal = document.getElementById('feature-modal'); modal.classList.remove('show'); currentMapClickCoords = null; } function closeFeatureModal() { closePointModal(); } function submitPointForm() { const name = document.getElementById('feature-name').value.trim(); const latitude = parseFloat(document.getElementById('feature-latitude').value); const longitude = parseFloat(document.getElementById('feature-longitude').value); const open_24_hours = document.getElementById('feature-24hours').checked ? 1 : 0; if (!name) { showNotification('Please enter a point name.', true); return; } if (isNaN(latitude) || isNaN(longitude)) { showNotification('Please enter valid coordinates.', true); return; } addPoint(name, latitude, longitude, open_24_hours); closePointModal(); } function addPoint(name, lat, lng, open_24_hours) { fetch('api/save_point.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, latitude: lat, longitude: lng, open_24_hours }) }) .then(response => response.json()) .then(data => { if (data.success) { displayPoint(data.id, data.name, data.latitude, data.longitude, data.open_24_hours); showNotification('Point saved successfully.'); updateCounts(); } else { addLocalPoint(name, lat, lng, open_24_hours); } }) .catch(() => { addLocalPoint(name, lat, lng, open_24_hours); }); } function addLocalPoint(name, lat, lng, open_24_hours) { const id = generateLocalId('point'); displayPoint(id, name, lat, lng, open_24_hours, true); persistLocalPoints(); showNotification('Point saved locally.'); updateCounts(); } function displayPoint(id, name, lat, lng, open_24_hours, isLocal = false) { if (points[id]) { map.removeLayer(points[id]); delete points[id]; } const marker = L.marker([lat, lng], { draggable: true }).addTo(map); marker._local = isLocal; marker._localId = id; marker._name = name; marker._open24 = open_24_hours; marker._type = 'point'; marker.bindPopup(getPointPopupContent(id, name, lat, lng, open_24_hours)); marker.on('popupopen', function(e) { bindPopupDeleteAction(e.popup.getElement(), 'point', id); }); marker.on('dragend', function(e) { const position = e.target.getLatLng(); updatePointPosition(id, position.lat, position.lng); }); points[id] = marker; } function updatePointPosition(id, lat, lng) { const marker = points[id]; if (!marker) { return; } marker._name = marker._name || marker._localId; marker.bindPopup(getPointPopupContent(id, marker._name, lat, lng, marker._open24)); marker.setLatLng([lat, lng]); if (marker._local) { persistLocalPoints(); } } function getPointPopupContent(id, name, lat, lng, open_24_hours) { const hoursText = open_24_hours ? '🕐 Open 24 Hours' : '🕐 Limited Hours'; return `
${escapeHtml(name)}
${hoursText}
Lat: ${lat.toFixed(4)}
Lng: ${lng.toFixed(4)}
`; } function openRoadModal() { const modal = document.getElementById('road-modal'); const roadLength = document.getElementById('road-length'); const roadFormInfo = document.getElementById('road-form-info'); const roadName = document.getElementById('road-name'); roadName.value = ''; roadLength.value = computeRoadLength(currentDrawCoords).toFixed(2); roadFormInfo.textContent = `Calculated automatically from ${currentDrawCoords.length} vertices.`; modal.classList.add('show'); } function closeRoadModal() { const modal = document.getElementById('road-modal'); modal.classList.remove('show'); } function submitRoadForm() { const name = document.getElementById('road-name').value.trim(); const status = document.getElementById('road-status').value; const length = parseFloat(document.getElementById('road-length').value); if (!name) { showNotification('Please enter a road name.', true); return; } if (!status) { showNotification('Please choose a road status.', true); return; } addRoad(name, status, currentDrawCoords.slice(), length); closeRoadModal(); cancelDraw(); } function addRoad(name, status, coordinates, length) { fetch('api/save_road.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, status, length, coordinates }) }) .then(response => response.json()) .then(data => { if (data.success) { displayRoad(data.id, data.name, data.status, parseFloat(data.length), JSON.parse(data.coordinates)); showNotification('Road saved successfully.'); updateCounts(); } else { addLocalRoad(name, status, length, coordinates); } }) .catch(() => { addLocalRoad(name, status, length, coordinates); }); } function addLocalRoad(name, status, length, coordinates) { const id = generateLocalId('road'); displayRoad(id, name, status, length, coordinates, true); persistLocalRoads(); showNotification('Road saved locally.'); updateCounts(); } function displayRoad(id, name, status, length, coordinates, isLocal = false) { if (roads[id]) { map.removeLayer(roads[id]); delete roads[id]; } const latlngs = coordinates.map(coord => L.latLng(coord.lat, coord.lng)); const layer = L.polyline(latlngs, { color: getRoadColor(status), weight: 6 }).addTo(map); layer._local = isLocal; layer._localId = id; layer._name = name; layer._status = status; layer._length = length; layer._coordinates = coordinates; layer._type = 'road'; layer.bindPopup(getRoadPopupContent(id, name, status, length)); layer.on('popupopen', function(e) { bindPopupDeleteAction(e.popup.getElement(), 'road', id); }); roads[id] = layer; } function getRoadPopupContent(id, name, status, length) { return `
${escapeHtml(name)}
${escapeHtml(status)}
Length: ${length.toFixed(2)} m
`; } function openParcelModal() { const modal = document.getElementById('parcel-modal'); const parcelArea = document.getElementById('parcel-area'); const parcelFormInfo = document.getElementById('parcel-form-info'); const area = computeParcelArea(currentDrawCoords); parcelArea.value = area.toFixed(2); parcelFormInfo.textContent = `Calculated automatically from ${currentDrawCoords.length} vertices.`; modal.classList.add('show'); } function closeParcelModal() { const modal = document.getElementById('parcel-modal'); modal.classList.remove('show'); } function submitParcelForm() { const ownerStatus = document.getElementById('parcel-owner').value; const area = parseFloat(document.getElementById('parcel-area').value); if (!ownerStatus) { showNotification('Please choose an ownership status.', true); return; } addParcel(ownerStatus, area, currentDrawCoords.slice()); closeParcelModal(); cancelDraw(); } function addParcel(ownerStatus, area, coordinates) { fetch('api/save_parcel.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner_status: ownerStatus, area, coordinates }) }) .then(response => response.json()) .then(data => { if (data.success) { displayParcel(data.id, data.owner_status, parseFloat(data.area), JSON.parse(data.coordinates)); showNotification('Parcel saved successfully.'); updateCounts(); } else { addLocalParcel(ownerStatus, area, coordinates); } }) .catch(() => { addLocalParcel(ownerStatus, area, coordinates); }); } function addLocalParcel(ownerStatus, area, coordinates) { const id = generateLocalId('parcel'); displayParcel(id, ownerStatus, area, coordinates, true); persistLocalParcels(); showNotification('Parcel saved locally.'); updateCounts(); } function displayParcel(id, ownerStatus, area, coordinates, isLocal = false) { if (parcels[id]) { map.removeLayer(parcels[id]); delete parcels[id]; } const latlngs = coordinates.map(coord => L.latLng(coord.lat, coord.lng)); const layer = L.polygon(latlngs, { color: getParcelColor(ownerStatus), fillColor: getParcelColor(ownerStatus), fillOpacity: 0.3, weight: 4 }).addTo(map); layer._local = isLocal; layer._localId = id; layer._ownerStatus = ownerStatus; layer._area = area; layer._coordinates = coordinates; layer._type = 'parcel'; layer.bindPopup(getParcelPopupContent(id, ownerStatus, area)); layer.on('popupopen', function(e) { bindPopupDeleteAction(e.popup.getElement(), 'parcel', id); }); parcels[id] = layer; } function getParcelPopupContent(id, ownerStatus, area) { return `
${escapeHtml(ownerStatus)}
Parcel Status
Area: ${area.toFixed(2)} m²
`; } function bindPopupDeleteAction(popupElement, type, id) { if (!popupElement) { return; } const deleteBtn = popupElement.querySelector('.delete-feature-btn'); if (deleteBtn) { deleteBtn.onclick = function() { deleteFeature(type, id); }; } } function deleteFeature(type, id) { if (!confirm('Are you sure you want to delete this item?')) { return; } if (type === 'point') { deletePoint(id); } else if (type === 'road') { deleteRoad(id); } else if (type === 'parcel') { deleteParcel(id); } } function deletePoint(id) { const point = points[id]; if (!point) { return; } if (point._local) { map.removeLayer(point); delete points[id]; persistLocalPoints(); updateCounts(); showNotification('Point deleted locally.'); return; } fetch('api/delete_point.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) .then(response => response.json()) .then(data => { if (data.success) { map.removeLayer(point); delete points[id]; updateCounts(); showNotification('Point deleted successfully.'); } else { showNotification('Unable to delete point on server.', true); } }) .catch(() => { showNotification('Server unavailable, point deletion aborted.', true); }); } function deleteRoad(id) { const road = roads[id]; if (!road) { return; } if (road._local) { map.removeLayer(road); delete roads[id]; persistLocalRoads(); updateCounts(); showNotification('Road deleted locally.'); return; } fetch('api/delete_road.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) .then(response => response.json()) .then(data => { if (data.success) { map.removeLayer(road); delete roads[id]; updateCounts(); showNotification('Road deleted successfully.'); } else { showNotification('Unable to delete road on server.', true); } }) .catch(() => { showNotification('Server unavailable, road deletion aborted.', true); }); } function deleteParcel(id) { const parcel = parcels[id]; if (!parcel) { return; } if (parcel._local) { map.removeLayer(parcel); delete parcels[id]; persistLocalParcels(); updateCounts(); showNotification('Parcel deleted locally.'); return; } fetch('api/delete_parcel.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) .then(response => response.json()) .then(data => { if (data.success) { map.removeLayer(parcel); delete parcels[id]; updateCounts(); showNotification('Parcel deleted successfully.'); } else { showNotification('Unable to delete parcel on server.', true); } }) .catch(() => { showNotification('Server unavailable, parcel deletion aborted.', true); }); } function updateCounts() { document.getElementById('marker-count').textContent = Object.keys(points).length; document.getElementById('road-count').textContent = Object.keys(roads).length; document.getElementById('parcel-count').textContent = Object.keys(parcels).length; } function getRoadColor(status) { switch (status) { case 'Jalan Nasional': return '#E53935'; case 'Jalan Provinsi': return '#FB8C00'; case 'Jalan Kabupaten': return '#1E88E5'; default: return '#9E9E9E'; } } function getParcelColor(ownerStatus) { switch (ownerStatus) { case 'SHM': return '#388E3C'; case 'HGB': return '#8E24AA'; case 'HGU': return '#00897B'; case 'HP': return '#0288D1'; default: return '#616161'; } } function computeRoadLength(latlngs) { if (!latlngs || latlngs.length < 2) { return 0; } let total = 0; for (let i = 1; i < latlngs.length; i++) { total += map.distance(latlngs[i - 1], latlngs[i]); } return total; } function computeParcelArea(latlngs) { if (!latlngs || latlngs.length < 3) { return 0; } if (window.L && L.GeometryUtil && typeof L.GeometryUtil.geodesicArea === 'function') { return L.GeometryUtil.geodesicArea(latlngs); } return approximatePolygonArea(latlngs); } function approximatePolygonArea(latlngs) { const rad = degrees => degrees * Math.PI / 180; let area = 0; const radius = 6378137; for (let i = 0, len = latlngs.length; i < len; i++) { const p1 = latlngs[i]; const p2 = latlngs[(i + 1) % len]; area += rad(p2.lng - p1.lng) * (2 + Math.sin(rad(p1.lat)) + Math.sin(rad(p2.lat))); } return Math.abs(area * radius * radius / 2); } function showNotification(message, isError = false) { const notification = document.createElement('div'); notification.className = 'notification' + (isError ? ' error' : ''); notification.textContent = message; document.body.appendChild(notification); setTimeout(() => notification.remove(), 3000); } function escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return String(text).replace(/[&<>"']/g, m => map[m]); }