Files
2026-06-12 00:05:22 +07:00

1220 lines
50 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS App</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
#map { height: 100vh; width: 100%; }
#sidebar {
position: fixed;
top: 10px;
right: 10px;
background: #f8f9fa;
padding: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 1000;
border-radius: 4px;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
max-width: 500px;
}
.tool-btn {
padding: 6px 10px;
background: #007bff;
color: white;
border: none;
cursor: pointer;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
}
.tool-btn:hover { background: #0056b3; }
.tool-btn.active { background: #004085; border: 2px solid #000; box-shadow: 0 0 8px rgba(0,0,0,0.5); }
.cancel-btn { background: #dc3545; }
.cancel-btn:hover { background: #c82333; }
#sidebar h2 { display: none; }
#sidebar p { display: none; }
#sidebar hr { display: none; }
.popup-form { display: flex; flex-direction: column; gap: 10px; min-width: 220px; }
.popup-form input, .popup-form select { padding: 8px; font-size: 14px; }
.popup-form button { padding: 8px 12px; background: #28a745; color: white; border: none; cursor: pointer; border-radius: 4px; }
.popup-form button:hover { background: #218838; }
.edit-mode-btn { background: #ff9800; color: white; }
.edit-mode-btn:hover { background: #e68900; }
.legend { background: white; padding: 10px; border-radius: 5px; box-shadow: 0 1px 5px rgba(0,0,0,0.4); font-size: 12px; }
.legend span { display: inline-block; width: 12px; height: 12px; margin-right: 5px; }
/* Edit Mode Styles */
#edit-mode-indicator {
position: fixed;
bottom: 20px;
left: 20px;
background: #ff9800;
color: white;
padding: 12px 16px;
border-radius: 4px;
z-index: 1000;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
display: none;
font-size: 13px;
max-width: 400px;
}
.vertex-marker { z-index: 999; }
.vertex-marker.dragging { filter: drop-shadow(0 0 4px #ff0000); }
.add-point-hint { z-index: 998; cursor: crosshair; }
.edit-popup { background: white; border-left: 4px solid #ff9800; padding: 12px; border-radius: 4px; }
.edit-popup h4 { margin: 0 0 10px 0; color: #333; }
.edit-popup .instructions { font-size: 12px; color: #666; margin-bottom: 10px; padding: 8px; background: #f5f5f5; border-radius: 3px; }
.edit-popup .button-group { display: flex; gap: 5px; }
.edit-popup button { padding: 8px 12px; font-size: 12px; border: none; border-radius: 3px; cursor: pointer; flex: 1; }
.finish-btn { background: #28a745; color: white; }
.finish-btn:hover { background: #218838; }
.cancel-edit-btn { background: #6c757d; color: white; }
.cancel-edit-btn:hover { background: #5a6268; }
#edit-panel {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: white;
border: 3px solid #ff9800;
border-radius: 8px;
padding: 16px;
z-index: 1001;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: none;
max-width: 350px;
font-family: Arial, sans-serif;
}
#edit-panel h4 {
margin: 0 0 12px 0;
color: #333;
font-size: 16px;
text-align: center;
}
#edit-panel .instructions {
font-size: 13px;
color: #555;
margin-bottom: 12px;
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
line-height: 1.6;
}
#edit-panel .button-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
#edit-panel .finish-btn,
#edit-panel .cancel-edit-btn {
flex: 1;
padding: 10px 12px;
font-size: 13px;
font-weight: bold;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
#edit-panel .finish-btn {
background: #28a745;
color: white;
}
#edit-panel .finish-btn:hover {
background: #218838;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
#edit-panel .cancel-edit-btn {
background: #dc3545;
color: white;
}
#edit-panel .cancel-edit-btn:hover {
background: #c82333;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
#edit-panel .keyboard-hint {
font-size: 11px;
text-align: center;
color: #999;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="edit-mode-indicator">✏️ EDIT MODE - Drag points to move • Click point to delete • Click between points to add • Press ESC to exit</div>
<div id="edit-panel">
<h4>✏️ SHAPE EDITOR</h4>
<div class="instructions">
🔴 <strong>Drag red points</strong> to move them<br>
<strong>Click red point</strong> to delete<br>
🟢 <strong>Click green dots</strong> to add points
</div>
<div class="button-group">
<button class="finish-btn" id="finish-btn">✓ Save Changes</button>
<button class="cancel-edit-btn" id="cancel-btn">✕ Cancel</button>
</div>
<div class="keyboard-hint">
💡 Tip: Press <strong>ESC</strong> to cancel anytime
</div>
</div>
<div id="sidebar">
<h2>GIS Digitizer</h2>
<p>1. Select a tool.<br>2. Click on the map to draw.<br>3. Double-click to finish and save.</p>
<hr>
<button id="btn-road" class="tool-btn" onclick="setMode('road')">📍 Mark Road (Polyline)</button>
<button id="btn-land" class="tool-btn" onclick="setMode('land')">🟩 Mark Land (Polygon)</button>
<button class="tool-btn cancel-btn" onclick="setMode('none')">❌ Cancel Drawing</button>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
<script>
// Initialize Map (Centered on Pontianak, Indonesia)
const map = L.map('map').setView([-0.0227, 109.3340], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map);
map.doubleClickZoom.disable();
let currentMode = 'none';
let currentPoints = [];
let tempLayer = null;
let previewLayer = null;
let currentMousePos = null;
let clickTimeout = null;
let clickCount = 0;
// --- EDIT MODE STATE ---
let editModeActive = false;
let editingShape = null;
let editingData = null;
let originalCoordinates = null;
let vertexMarkers = [];
let midpointMarkers = [];
let addPointHintLayer = null;
let isDraggingVertex = false;
let editModeKeyHandler = null;
// --- STYLING HELPERS ---
function getRoadColor(type) {
return type === 'Jalan nasional' ? '#dc3545' :
type === 'Jalan provinsi' ? '#fd7e14' : '#28a745'; // Red, Orange, Green
}
function getLandColor(status) {
return status === 'Sertifikat Hak Milik (SHM)' ? '#007bff' :
status === 'Sertifikat Hak Guna Bangunan (HGB)' ? '#6f42c1' :
status === 'Sertifikat Hak Guna Usaha (HGU)' ? '#17a2b8' : '#ffc107'; // Blue, Purple, Teal, Yellow
}
// --- INITIAL DATA LOAD ---
window.onload = async () => {
await loadRoads();
await loadLands();
};
async function loadRoads() {
const response = await fetch('/api/roads');
const roads = await response.json();
roads.forEach(road => {
const coords = typeof road.coordinates === 'string' ? JSON.parse(road.coordinates) : road.coordinates;
const polyline = L.polyline(coords, { color: getRoadColor(road.road_type), weight: 4 }).addTo(map);
// Store road data on the polyline for later access
polyline.roadData = road;
polyline.on('click', function(e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
showRoadEditPopup(road, polyline);
});
});
}
async function loadLands() {
const response = await fetch('/api/lands');
const lands = await response.json();
lands.forEach(land => {
const coords = typeof land.coordinates === 'string' ? JSON.parse(land.coordinates) : land.coordinates;
const polygon = L.polygon(coords, { color: getLandColor(land.ownership_status), fillOpacity: 0.4 }).addTo(map);
// Store land data on the polygon for later access
polygon.landData = land;
polygon.on('click', function(e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
showLandEditPopup(land, polygon);
});
});
}
// --- EDIT & DELETE POPUPS ---
function showRoadEditPopup(road, polyline) {
const formHTML = `
<div class="popup-form" onclick="event.stopPropagation();">
<h3 style="margin:0 0 5px 0;">Edit Road</h3>
<label><strong>Name:</strong></label>
<input type="text" id="editRoadName" value="${road.name}" required>
<label><strong>Type:</strong></label>
<select id="editRoadType">
<option value="Jalan nasional" ${road.road_type === 'Jalan nasional' ? 'selected' : ''}>Jalan Nasional</option>
<option value="Jalan provinsi" ${road.road_type === 'Jalan provinsi' ? 'selected' : ''}>Jalan Provinsi</option>
<option value="Jalan kabupaten" ${road.road_type === 'Jalan kabupaten' ? 'selected' : ''}>Jalan Kabupaten</option>
</select>
<small>Distance: ${road.distance_meters} m</small>
<div style="display: flex; gap: 5px;">
<button class="edit-mode-btn" onclick="enterEditMode(${road.id}, 'road', this); return false;" style="flex: 0.8;">✏️ Edit Shape</button>
<button style="background: #28a745; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer; flex: 1;" onclick="updateRoad(${road.id}, '${road.distance_meters}'); return false;">Save</button>
<button style="background: #dc3545; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer; flex: 1;" onclick="deleteRoad(${road.id}); return false;">Delete</button>
<button style="background: #6c757d; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer; flex: 1;" onclick="map.closePopup(); return false;">Close</button>
</div>
</div>
`;
polyline.bindPopup(formHTML).openPopup();
window.currentEditPolyline = polyline;
}
function showLandEditPopup(land, polygon) {
const formHTML = `
<div class="popup-form" onclick="event.stopPropagation();">
<h3 style="margin:0 0 5px 0;">Edit Land</h3>
<label><strong>Name:</strong></label>
<input type="text" id="editLandName" value="${land.name}" required>
<label><strong>Status:</strong></label>
<select id="editLandStatus">
<option value="Sertifikat Hak Milik (SHM)" ${land.ownership_status === 'Sertifikat Hak Milik (SHM)' ? 'selected' : ''}>Sertifikat Hak Milik (SHM)</option>
<option value="Sertifikat Hak Guna Bangunan (HGB)" ${land.ownership_status === 'Sertifikat Hak Guna Bangunan (HGB)' ? 'selected' : ''}>Sertifikat Hak Guna Bangunan (HGB)</option>
<option value="Sertifikat Hak Guna Usaha (HGU)" ${land.ownership_status === 'Sertifikat Hak Guna Usaha (HGU)' ? 'selected' : ''}>Sertifikat Hak Guna Usaha (HGU)</option>
<option value="Sertifikat Hak Pakai (HP)" ${land.ownership_status === 'Sertifikat Hak Pakai (HP)' ? 'selected' : ''}>Sertifikat Hak Pakai (HP)</option>
</select>
<small>Area: ${land.area_sq_meters} m²</small>
<div style="display: flex; gap: 5px;">
<button class="edit-mode-btn" onclick="enterEditMode(${land.id}, 'land', this); return false;" style="flex: 0.8;">✏️ Edit Shape</button>
<button style="background: #28a745; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer; flex: 1;" onclick="updateLand(${land.id}, '${land.area_sq_meters}'); return false;">Save</button>
<button style="background: #dc3545; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer; flex: 1;" onclick="deleteLand(${land.id}); return false;">Delete</button>
<button style="background: #6c757d; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer; flex: 1;" onclick="map.closePopup(); return false;">Close</button>
</div>
</div>
`;
polygon.bindPopup(formHTML).openPopup();
window.currentEditPolygon = polygon;
}
// --- EDIT MODE FUNCTIONS ---
function enterEditMode(id, type, button) {
if (editModeActive) return;
editModeActive = true;
const shape = type === 'road' ? window.currentEditPolyline : window.currentEditPolygon;
let points = shape._latlngs;
// Handle polygon nested arrays - Leaflet stores polygon rings as nested arrays
if (type === 'land') {
// Check if points is an array of arrays (multi-ring polygon)
if (Array.isArray(points) && points.length > 0 && Array.isArray(points[0])) {
points = points[0]; // Get the outer ring
}
}
// Ensure points is an array of LatLng objects
if (!Array.isArray(points) || points.length === 0) {
console.error('Invalid points:', points);
alert('Error: Could not read shape coordinates');
return;
}
// Store original coordinates for undo
originalCoordinates = points.map(p => {
if (p.lat && p.lng) {
return [p.lat, p.lng];
} else if (Array.isArray(p) && p.length === 2) {
return p;
}
return [0, 0];
});
editingShape = shape;
editingData = { id, type };
// Close current popup
map.closePopup();
// Show edit mode indicator
document.getElementById('edit-mode-indicator').style.display = 'block';
// Change map style
map.getContainer().style.cursor = 'grab';
// Keep the shape visible but update its style
if (type === 'road') {
editingShape.setStyle({ color: '#666666', weight: 2, dashArray: '5, 5', opacity: 0.7 });
} else {
editingShape.setStyle({ color: '#666666', weight: 2, dashArray: '5, 5', opacity: 0.3, fillOpacity: 0.1 });
}
// Create draggable vertex markers
createVertexMarkers(points);
// Create midpoint visual hints
createMidpointHints(points);
// Show edit panel (fixed on top of map)
document.getElementById('edit-panel').style.display = 'block';
// Set up edit panel button handlers
document.getElementById('finish-btn').onclick = finishEditingShape;
document.getElementById('cancel-btn').onclick = cancelEditMode;
// Add Escape key handler to exit edit mode
editModeKeyHandler = function(e) {
if (e.key === 'Escape') {
e.preventDefault();
cancelEditMode();
}
};
document.addEventListener('keydown', editModeKeyHandler);
// Add event listeners for edit mode
map.on('click', editModeClickHandler);
}
function getShapeCenter(points) {
let lat = 0, lng = 0;
points.forEach(p => {
lat += p.lat;
lng += p.lng;
});
return L.latLng(lat / points.length, lng / points.length);
}
function createVertexMarkers(points) {
vertexMarkers = [];
points.forEach((point, index) => {
// Create circular marker at each vertex
const marker = L.circleMarker(point, {
radius: 8,
fillColor: '#ff6b6b',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8,
className: 'vertex-marker'
}).addTo(map);
marker.pointIndex = index;
marker.isVertex = true;
marker.justDragged = false;
// Make marker draggable
marker.on('mousedown', function(e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
this.justDragged = false;
startDragVertex(this, e);
});
// Click handler to delete vertex
marker.on('click', function(e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
// If we just finished dragging, don't delete
if (this.justDragged) {
this.justDragged = false;
return;
}
deleteVertex(this.pointIndex);
});
// Add tooltip
marker.bindTooltip(`Point ${index + 1}<br>Click to delete<br>Drag to move`, {
permanent: false,
direction: 'top',
offset: [0, -15]
});
vertexMarkers.push(marker);
});
}
function createMidpointHints(points) {
if (midpointMarkers.length > 0) {
midpointMarkers.forEach(m => map.removeLayer(m));
midpointMarkers = [];
}
const maxMidpoints = (editingData && editingData.type === 'road') ? points.length - 1 : points.length;
// Create visual hints at midpoints to add new points
for (let i = 0; i < maxMidpoints; i++) {
const current = points[i];
const next = points[(i + 1) % points.length];
const midLat = (current.lat + next.lat) / 2;
const midLng = (current.lng + next.lng) / 2;
const midMarker = L.circleMarker([midLat, midLng], {
radius: 5,
fillColor: '#4ecdc4',
color: '#fff',
weight: 1,
opacity: 0.6,
fillOpacity: 0.4,
className: 'add-point-hint'
}).addTo(map);
midMarker.pointIndex = i;
midMarker.isMidpoint = true;
// Click handler to add point at midpoint
midMarker.on('click', function(e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
// Get the approximate position between the two points
const pos = L.latLng((current.lat + next.lat) / 2, (current.lng + next.lng) / 2);
addPointAtIndex(this.pointIndex, pos);
});
midMarker.bindTooltip('Click to add point', {
permanent: false,
direction: 'top',
offset: [0, -15]
});
midpointMarkers.push(midMarker);
}
}
let draggedMarker = null;
let dragStartPos = null;
let hasMoved = false;
function startDragVertex(marker, e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
isDraggingVertex = true;
draggedMarker = marker;
dragStartPos = e.latlng;
hasMoved = false;
marker.setStyle({ fillColor: '#ff9800', radius: 10 });
// Disable map dragging during vertex drag
map.dragging.disable();
map.doubleClickZoom.disable();
const moveHandler = (moveEvent) => {
if (!draggedMarker) return;
L.DomEvent.preventDefault(moveEvent);
L.DomEvent.stopPropagation(moveEvent);
hasMoved = true;
const delta = {
lat: moveEvent.latlng.lat - dragStartPos.lat,
lng: moveEvent.latlng.lng - dragStartPos.lng
};
dragStartPos = moveEvent.latlng;
const newPos = L.latLng(
draggedMarker.getLatLng().lat + delta.lat,
draggedMarker.getLatLng().lng + delta.lng
);
draggedMarker.setLatLng(newPos);
updateShapeFromVertices();
};
const endHandler = () => {
if (!draggedMarker) return;
// Mark that we just finished dragging only if it actually moved
if (hasMoved) {
draggedMarker.justDragged = true;
}
draggedMarker.setStyle({ fillColor: '#ff6b6b', radius: 8 });
map.off('mousemove', moveHandler);
map.off('mouseup', endHandler);
// Re-enable map dragging
map.dragging.enable();
map.doubleClickZoom.enable();
isDraggingVertex = false;
const prevMarker = draggedMarker;
draggedMarker = null;
// Reset the flag after a brief moment
setTimeout(() => {
if (prevMarker) prevMarker.justDragged = false;
}, 100);
};
map.on('mousemove', moveHandler);
map.on('mouseup', endHandler);
}
function updateShapeFromVertices() {
const newPoints = vertexMarkers.map(m => {
const latlng = m.getLatLng();
return L.latLng(latlng.lat, latlng.lng);
});
if (editingShape) {
// Handle both polylines and polygons using setLatLngs to safely update paths
if (editingData.type === 'land') {
// Provide an array of points (Leaflet translates this into a ring)
editingShape.setLatLngs([newPoints]);
} else {
// Polyline takes just the array of points
editingShape.setLatLngs(newPoints);
}
}
// Update midpoint hints
const currentPoints = vertexMarkers.map(m => m.getLatLng());
createMidpointHints(currentPoints);
}
function editModeClickHandler(e) {
if (!editModeActive) return;
// Prevent map interactions while in edit mode for direct map clicks
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
}
function deleteVertex(index) {
const minPoints = editingData.type === 'road' ? 2 : 3;
if (vertexMarkers.length <= minPoints) {
alert(`${editingData.type === 'road' ? 'Road' : 'Land'} needs at least ${minPoints} points!`);
return;
}
// Animate deletion
const marker = vertexMarkers[index];
marker.setStyle({ fillColor: '#ff0000', radius: 6 });
// Remove marker from display after a brief delay
setTimeout(() => {
map.removeLayer(marker);
// Remove from array
vertexMarkers.splice(index, 1);
// Update indices
vertexMarkers.forEach((m, i) => m.pointIndex = i);
updateShapeFromVertices();
}, 150);
}
function addPointAtIndex(index, latlng) {
// Create new point at the clicked location
const newMarker = L.circleMarker(latlng, {
radius: 8,
fillColor: '#ff6b6b',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8,
className: 'vertex-marker'
}).addTo(map);
const newIndex = index + 1;
newMarker.pointIndex = newIndex;
newMarker.isVertex = true;
newMarker.justDragged = false;
// Make marker draggable
newMarker.on('mousedown', function(e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
this.justDragged = false;
startDragVertex(this, e);
});
// Click handler to delete vertex
newMarker.on('click', function(e) {
L.DomEvent.preventDefault(e);
L.DomEvent.stopPropagation(e);
// If we just finished dragging, don't delete
if (this.justDragged) {
this.justDragged = false;
return;
}
deleteVertex(this.pointIndex);
});
newMarker.bindTooltip(`Point ${newIndex + 1}<br>Click to delete<br>Drag to move`, {
permanent: false,
direction: 'top',
offset: [0, -15]
});
// Insert into array at correct position
vertexMarkers.splice(newIndex, 0, newMarker);
// Update all indices
vertexMarkers.forEach((m, i) => m.pointIndex = i);
updateShapeFromVertices();
}
function exitEditMode() {
if (!editModeActive) return;
editModeActive = false;
// Remove keyboard event listener
if (editModeKeyHandler) {
document.removeEventListener('keydown', editModeKeyHandler);
editModeKeyHandler = null;
}
// Hide edit panel
document.getElementById('edit-panel').style.display = 'none';
// Remove all vertex and midpoint markers
vertexMarkers.forEach(m => map.removeLayer(m));
midpointMarkers.forEach(m => map.removeLayer(m));
vertexMarkers = [];
midpointMarkers = [];
// Remove edit mode click handler
map.off('click', editModeClickHandler);
// Hide indicator
document.getElementById('edit-mode-indicator').style.display = 'none';
// Reset cursor
map.getContainer().style.cursor = 'grab';
editingShape = null;
editingData = null;
}
function cancelEditMode() {
if (!editModeActive) return;
// Remove keyboard handler first
if (editModeKeyHandler) {
document.removeEventListener('keydown', editModeKeyHandler);
editModeKeyHandler = null;
}
// Restore the original shape coordinates
if (originalCoordinates && editingShape) {
let coordsToSet = originalCoordinates.map(coord => L.latLng(coord[0], coord[1]));
// Handle polygon nested arrays - set coordinates back to original structure
if (editingData.type === 'land') {
if (Array.isArray(editingShape._latlngs[0])) {
// Multi-ring polygon
editingShape._latlngs[0] = coordsToSet;
} else {
// Single-ring polygon
editingShape._latlngs = coordsToSet;
}
} else {
// Polyline
editingShape._latlngs = coordsToSet;
}
editingShape.redraw();
// Restore original style
if (editingData.type === 'road') {
const roadData = window.currentEditPolyline.roadData;
if (roadData) {
editingShape.setStyle({
color: getRoadColor(roadData.road_type),
weight: 4,
dashArray: null,
opacity: 1
});
}
} else {
const landData = window.currentEditPolygon.landData;
if (landData) {
editingShape.setStyle({
color: getLandColor(landData.ownership_status),
weight: 2,
dashArray: null,
opacity: 1,
fillOpacity: 0.4
});
}
}
}
exitEditMode();
map.closePopup();
alert('Changes discarded - shape restored to original state!');
}
async function finishEditingShape() {
if (!editModeActive || !editingData) return;
// Remove keyboard handler first
if (editModeKeyHandler) {
document.removeEventListener('keydown', editModeKeyHandler);
editModeKeyHandler = null;
}
const newPoints = vertexMarkers.map(m => {
const latlng = m.getLatLng();
return [latlng.lat, latlng.lng];
});
// Restore shape visual style before exiting edit mode
if (editingData.type === 'road') {
const roadData = window.currentEditPolyline.roadData;
if (roadData) {
editingShape.setStyle({
color: getRoadColor(roadData.road_type),
weight: 4,
dashArray: null,
opacity: 1
});
await updateRoadCoordinates(editingData.id, newPoints, roadData.name, roadData.road_type);
}
} else {
const landData = window.currentEditPolygon.landData;
if (landData) {
editingShape.setStyle({
color: getLandColor(landData.ownership_status),
weight: 2,
dashArray: null,
opacity: 1,
fillOpacity: 0.4
});
await updateLandCoordinates(editingData.id, newPoints, landData.name, landData.ownership_status);
}
}
exitEditMode();
// Show original popup again after a brief delay
setTimeout(() => {
if (window.currentEditPolyline) {
window.currentEditPolyline.openPopup();
} else if (window.currentEditPolygon) {
window.currentEditPolygon.openPopup();
}
}, 300);
}
async function updateRoadCoordinates(roadId, coordinates, name, type) {
try {
const lineString = turf.lineString(coordinates.map(p => [p[1], p[0]]));
const distanceMeters = turf.length(lineString, {units: 'meters'}).toFixed(2);
const response = await fetch(`/api/roads/${roadId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
road_type: type,
distance_meters: parseFloat(distanceMeters),
coordinates
})
});
if (response.ok) {
window.currentEditPolyline.setStyle({ color: getRoadColor(type) });
window.currentEditPolyline.bindPopup(`<b>${name}</b><br>Type: ${type}<br>Distance: ${distanceMeters} m`);
alert('Road shape updated successfully!');
}
} catch (error) {
console.error("Error updating road coordinates:", error);
alert("Failed to update road shape.");
}
}
async function updateLandCoordinates(landId, coordinates, name, status) {
try {
// Flatten coordinates if they're nested (from polygon structure)
let flatCoordinates = coordinates;
if (Array.isArray(coordinates) && coordinates.length > 0 && Array.isArray(coordinates[0]) && Array.isArray(coordinates[0][0])) {
flatCoordinates = coordinates[0]; // In case it's double-nested
}
let turfPoints = flatCoordinates.map(p => [p[1], p[0]]);
turfPoints.push(turfPoints[0]);
const polygon = turf.polygon([turfPoints]);
const areaSqMeters = turf.area(polygon).toFixed(2);
const response = await fetch(`/api/lands/${landId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
ownership_status: status,
area_sq_meters: parseFloat(areaSqMeters),
coordinates: flatCoordinates
})
});
if (response.ok) {
window.currentEditPolygon.setStyle({ color: getLandColor(status) });
window.currentEditPolygon.bindPopup(`<b>${name}</b><br>Status: ${status}<br>Area: ${areaSqMeters} m²`);
alert('Land shape updated successfully!');
}
} catch (error) {
console.error("Error updating land coordinates:", error);
alert("Failed to update land shape.");
}
}
async function updateRoad(roadId, distance) {
const name = document.getElementById('editRoadName').value;
const type = document.getElementById('editRoadType').value;
if (!name) return alert("Name is required");
const coords = typeof window.currentEditPolyline._latlngs === 'object' ?
window.currentEditPolyline._latlngs.map(l => [l.lat, l.lng]) :
window.currentEditPolyline._latlngs;
try {
const response = await fetch(`/api/roads/${roadId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, road_type: type, distance_meters: parseFloat(distance), coordinates: coords })
});
if (response.ok) {
window.currentEditPolyline.setStyle({ color: getRoadColor(type) });
window.currentEditPolyline.bindPopup(`<b>${name}</b><br>Type: ${type}<br>Distance: ${distance} m`);
map.closePopup();
alert('Road updated successfully!');
}
} catch (error) {
console.error("Error updating road:", error);
alert("Failed to update road.");
}
}
async function deleteRoad(roadId) {
if (!confirm('Are you sure you want to delete this road?')) return;
try {
const response = await fetch(`/api/roads/${roadId}`, {
method: 'DELETE'
});
if (response.ok) {
map.removeLayer(window.currentEditPolyline);
map.closePopup();
alert('Road deleted successfully!');
}
} catch (error) {
console.error("Error deleting road:", error);
alert("Failed to delete road.");
}
}
async function updateLand(landId, area) {
const name = document.getElementById('editLandName').value;
const status = document.getElementById('editLandStatus').value;
if (!name) return alert("Name is required");
const coords = typeof window.currentEditPolygon._latlngs === 'object' ?
window.currentEditPolygon._latlngs.map(l => [l.lat, l.lng]) :
window.currentEditPolygon._latlngs;
try {
const response = await fetch(`/api/lands/${landId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, ownership_status: status, area_sq_meters: parseFloat(area), coordinates: coords })
});
if (response.ok) {
window.currentEditPolygon.setStyle({ color: getLandColor(status) });
window.currentEditPolygon.bindPopup(`<b>${name}</b><br>Status: ${status}<br>Area: ${area} m²`);
map.closePopup();
alert('Land updated successfully!');
}
} catch (error) {
console.error("Error updating land:", error);
alert("Failed to update land.");
}
}
async function deleteLand(landId) {
if (!confirm('Are you sure you want to delete this land?')) return;
try {
const response = await fetch(`/api/lands/${landId}`, {
method: 'DELETE'
});
if (response.ok) {
map.removeLayer(window.currentEditPolygon);
map.closePopup();
alert('Land deleted successfully!');
}
} catch (error) {
console.error("Error deleting land:", error);
alert("Failed to delete land.");
}
}
// --- DRAWING LOGIC ---
function setMode(mode) {
currentMode = mode;
currentPoints = [];
if (tempLayer) map.removeLayer(tempLayer);
if (previewLayer) map.removeLayer(previewLayer);
tempLayer = null;
previewLayer = null;
currentMousePos = null;
if (clickTimeout) {
clearTimeout(clickTimeout);
clickTimeout = null;
}
clickCount = 0;
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
if(mode === 'road') document.getElementById('btn-road').classList.add('active');
if(mode === 'land') document.getElementById('btn-land').classList.add('active');
}
map.on('click', (e) => {
if (currentMode === 'none') return;
clickCount++;
if (clickTimeout) {
clearTimeout(clickTimeout);
}
clickTimeout = setTimeout(() => {
if (clickCount === 1) {
currentPoints.push([e.latlng.lat, e.latlng.lng]);
if (tempLayer) map.removeLayer(tempLayer);
if (currentMode === 'road') {
tempLayer = L.polyline(currentPoints, {color: '#6c757d', dashArray: '5, 5', weight: 3}).addTo(map);
} else if (currentMode === 'land') {
tempLayer = L.polygon(currentPoints, {color: '#6c757d', dashArray: '5, 5'}).addTo(map);
}
}
clickCount = 0;
clickTimeout = null;
}, 250);
});
// --- PREVIEW LINE FOLLOWING CURSOR ---
map.on('mousemove', (e) => {
if (currentMode === 'none' || currentPoints.length === 0) return;
currentMousePos = e.latlng;
if (previewLayer) map.removeLayer(previewLayer);
const previewPoints = [...currentPoints, [e.latlng.lat, e.latlng.lng]];
if (currentMode === 'road') {
previewLayer = L.polyline(previewPoints, {color: '#ff9800', weight: 2, opacity: 0.6}).addTo(map);
} else if (currentMode === 'land') {
previewLayer = L.polygon(previewPoints, {color: '#ff9800', weight: 2, opacity: 0.6, fillOpacity: 0.1}).addTo(map);
}
});
map.on('dblclick', (e) => {
e.originalEvent.preventDefault();
if (clickTimeout) {
clearTimeout(clickTimeout);
clickTimeout = null;
}
clickCount = 0;
if (currentMode === 'none') return;
const minPoints = currentMode === 'road' ? 2 : 3;
if (currentPoints.length < minPoints) {
alert(`${currentMode === 'road' ? 'Road' : 'Land'} needs at least ${minPoints} points!`);
return;
}
const finalPoints = [...currentPoints];
const activeMode = currentMode;
setMode('none');
let finalLayer, formHTML;
window.lastDrawnCoordinates = finalPoints;
if (activeMode === 'road') {
const lineString = turf.lineString(finalPoints.map(p => [p[1], p[0]]));
const distanceMeters = turf.length(lineString, {units: 'meters'}).toFixed(2);
finalLayer = L.polyline(finalPoints, {color: 'black', weight: 4}).addTo(map);
formHTML = `
<div class="popup-form">
<h3 style="margin:0 0 5px 0;">Save Road</h3>
<small>Distance: ${distanceMeters} m</small>
<input type="text" id="roadName" placeholder="Road Name" required>
<select id="roadType">
<option value="Jalan nasional">Jalan Nasional</option>
<option value="Jalan provinsi">Jalan Provinsi</option>
<option value="Jalan kabupaten">Jalan Kabupaten</option>
</select>
<button onclick="saveRoad('${distanceMeters}', window.lastDrawnCoordinates)">Save Road</button>
</div>
`;
} else if (activeMode === 'land') {
if (finalPoints.length < 3) return alert('Polygons need at least 3 points!');
let turfPoints = finalPoints.map(p => [p[1], p[0]]);
turfPoints.push(turfPoints[0]);
const polygon = turf.polygon([turfPoints]);
const areaSqMeters = turf.area(polygon).toFixed(2);
finalLayer = L.polygon(finalPoints, {color: 'black'}).addTo(map);
formHTML = `
<div class="popup-form">
<h3 style="margin:0 0 5px 0;">Save Land</h3>
<small>Area: ${areaSqMeters} m²</small>
<input type="text" id="landName" placeholder="Land Name" required>
<select id="landStatus">
<option value="Sertifikat Hak Milik (SHM)">Sertifikat Hak Milik (SHM)</option>
<option value="Sertifikat Hak Guna Bangunan (HGB)">Sertifikat Hak Guna Bangunan (HGB)</option>
<option value="Sertifikat Hak Guna Usaha (HGU)">Sertifikat Hak Guna Usaha (HGU)</option>
<option value="Sertifikat Hak Pakai (HP)">Sertifikat Hak Pakai (HP)</option>
</select>
<button onclick="saveLand('${areaSqMeters}', window.lastDrawnCoordinates)">Save Land</button>
</div>
`;
}
finalLayer.bindPopup(formHTML).openPopup();
window.lastDrawnLayer = finalLayer;
});
// --- SAVING LOGIC ---
async function saveRoad(distance, coordinates) {
const name = document.getElementById('roadName').value;
const type = document.getElementById('roadType').value;
if(!name) return alert("Name is required");
try {
const response = await fetch('/api/roads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, road_type: type, distance_meters: parseFloat(distance), coordinates })
});
if(response.ok) {
const result = await response.json();
const roadData = { id: result.id, name, road_type: type, distance_meters: parseFloat(distance), coordinates };
window.lastDrawnLayer.roadData = roadData;
window.lastDrawnLayer.setStyle({ color: getRoadColor(type) });
window.lastDrawnLayer.on('click', function() {
showRoadEditPopup(roadData, window.lastDrawnLayer);
});
window.lastDrawnLayer.bindPopup(`<b>${name}</b><br>Type: ${type}<br>Distance: ${distance} m`);
window.lastDrawnLayer.closePopup();
} else {
alert('Error saving road to server.');
}
} catch (err) {
console.error(err);
alert('Connection error saving road.');
}
}
async function saveLand(area, coordinates) {
const name = document.getElementById('landName').value;
const status = document.getElementById('landStatus').value;
if(!name) return alert("Name is required");
try {
const response = await fetch('/api/lands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, ownership_status: status, area_sq_meters: parseFloat(area), coordinates })
});
if(response.ok) {
const result = await response.json();
const landData = { id: result.id, name, ownership_status: status, area_sq_meters: parseFloat(area), coordinates };
window.lastDrawnLayer.landData = landData;
window.lastDrawnLayer.setStyle({ color: getLandColor(status) });
window.lastDrawnLayer.on('click', function() {
showLandEditPopup(landData, window.lastDrawnLayer);
});
window.lastDrawnLayer.bindPopup(`<b>${name}</b><br>Status: ${status}<br>Area: ${area} m²`);
window.lastDrawnLayer.closePopup();
} else {
alert('Error saving land to server.');
}
} catch (err) {
console.error(err);
alert('Connection error saving land.');
}
}
</script>
</body>
</html>