You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

3215 lines
121 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KML Track Editor</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#map {
height: 100vh;
width: 100%;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
width: 280px;
}
.controls h3 {
margin-bottom: 10px;
font-size: 14px;
color: #333;
}
.controls input[type="file"] {
width: 100%;
padding: 8px;
border: 2px dashed #ccc;
border-radius: 4px;
cursor: pointer;
margin-bottom: 10px;
}
.controls input[type="file"]:hover {
border-color: #007bff;
}
.section {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-title {
font-size: 12px;
font-weight: bold;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
}
.tab-bar {
display: flex;
margin-bottom: 15px;
border-bottom: 2px solid #ddd;
}
.tab-btn {
flex: 1;
padding: 10px;
border: none;
background: #f0f0f0;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: all 0.2s;
}
.tab-btn:first-child {
border-radius: 4px 0 0 0;
}
.tab-btn:last-child {
border-radius: 0 4px 0 0;
}
.tab-btn:hover {
background: #e0e0e0;
}
.tab-btn.active {
background: #007bff;
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.nav-info {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
margin-top: 10px;
}
.nav-distance {
font-size: 24px;
font-weight: bold;
color: #007bff;
margin-bottom: 5px;
}
.route-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.route-overlay-content {
background: white;
padding: 30px 40px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
text-align: center;
}
.route-overlay .spinner {
width: 50px;
height: 50px;
border: 5px solid #e0e0e0;
border-top: 5px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
.route-overlay-text {
font-size: 18px;
font-weight: 500;
color: #333;
}
.compass-indicator {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(255,255,255,0.9);
padding: 5px 15px;
border-radius: 20px;
font-weight: bold;
z-index: 1000;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
display: none;
}
.compass-indicator.active {
display: block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.nav-instruction {
color: #666;
font-size: 12px;
margin-bottom: 10px;
}
.destination-pin {
font-size: 24px;
margin-left: -12px;
margin-top: -24px;
}
.tool-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.tool-btn {
padding: 8px 12px;
border: 2px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.tool-btn:hover {
border-color: #007bff;
background: #f8f9fa;
}
.tool-btn.active {
border-color: #007bff;
background: #007bff;
color: white;
}
.tool-btn.danger {
border-color: #dc3545;
}
.tool-btn.danger:hover, .tool-btn.danger.active {
background: #dc3545;
border-color: #dc3545;
color: white;
}
.action-btn {
width: 100%;
padding: 10px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
margin-bottom: 6px;
}
.action-btn:hover {
background: #218838;
}
.action-btn.secondary {
background: #6c757d;
}
.action-btn.secondary:hover {
background: #5a6268;
}
.action-btn.danger {
background: #dc3545;
}
.action-btn.danger:hover {
background: #c82333;
}
.status {
margin-top: 10px;
font-size: 12px;
color: #666;
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
}
.status.success {
color: #155724;
background: #d4edda;
}
.status.error {
color: #721c24;
background: #f8d7da;
}
.status.info {
color: #0c5460;
background: #d1ecf1;
}
.slider-group {
margin: 10px 0;
}
.slider-group label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.slider-group input[type="range"] {
width: 100%;
}
.slider-value {
font-size: 11px;
color: #333;
text-align: right;
}
.track-list {
max-height: 150px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 10px;
}
.track-item {
padding: 6px 10px;
font-size: 12px;
border-bottom: 1px solid #eee;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.track-item:last-child {
border-bottom: none;
}
.track-item:hover {
background: #f8f9fa;
}
.track-item.selected {
background: #cce5ff;
}
.track-item .delete-btn {
background: none;
border: none;
color: #dc3545;
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
}
.track-item .delete-btn:hover {
background: #dc3545;
color: white;
border-radius: 3px;
}
.slider-group input[type="range"].preview-active {
outline: 3px solid #00cc00;
border-radius: 4px;
}
#applyMergeBtn {
background: #00cc00 !important;
}
#applyMergeBtn:hover {
background: #00aa00 !important;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="compassIndicator" class="compass-indicator">N</div>
<div class="controls">
<div class="tab-bar">
<button class="tab-btn" id="editTab">Edit</button>
<button class="tab-btn active" id="navTab">Navigate</button>
</div>
<div class="tab-content" id="editContent">
<div class="section">
<div class="section-title">File</div>
<input type="file" id="kmlFile" accept=".kml">
<button class="action-btn" id="exportBtn">Export as KML</button>
</div>
<div class="section">
<div class="section-title">Track Tools</div>
<div class="tool-buttons">
<button class="tool-btn" id="selectTool" title="Select track, then press Delete key to remove">Select</button>
<button class="tool-btn" id="splitTool" title="Click on a track to split it">Split</button>
<button class="tool-btn" id="drawTool" title="Draw a new track">Draw</button>
<button class="tool-btn" id="reshapeTool" title="Drag points to reshape track">Reshape</button>
<button class="tool-btn" id="smoothTool" title="Brush over track to smooth it">Smooth</button>
</div>
<div class="slider-group" id="reshapeControls" style="display: none;">
<label>Anchor Distance (points beyond stay fixed)</label>
<input type="range" id="anchorDistance" min="3" max="50" value="10">
<div class="slider-value"><span id="anchorValue">10</span> points</div>
<label style="margin-top: 8px;">Falloff (how sharply effect fades)</label>
<input type="range" id="reshapeFalloff" min="0.5" max="3" value="1" step="0.1">
<div class="slider-value"><span id="falloffValue">1.0</span></div>
</div>
<div class="slider-group" id="smoothControls" style="display: none;">
<label>Brush Size (pixels)</label>
<input type="range" id="smoothBrushSize" min="10" max="100" value="30">
<div class="slider-value"><span id="brushSizeValue">30</span> px</div>
<label style="margin-top: 8px;">Strength</label>
<input type="range" id="smoothStrength" min="0.1" max="1" value="0.5" step="0.1">
<div class="slider-value"><span id="strengthValue">0.5</span></div>
</div>
</div>
<div class="section">
<div class="section-title">Merge / Simplify</div>
<div class="slider-group">
<label>Merge Threshold</label>
<input type="range" id="mergeThreshold" min="1" max="10" value="5" step="0.5">
<div class="slider-value"><span id="thresholdValue">5</span> meters</div>
</div>
<button class="action-btn" id="previewBtn" style="background: #17a2b8;">Preview Merge</button>
<button class="action-btn" id="applyMergeBtn" style="display: none;">Apply Merge</button>
<button class="action-btn secondary" id="cancelPreviewBtn" style="display: none;">Cancel Preview</button>
<div style="margin-top: 8px;">
<button class="action-btn secondary" id="mergeConnectBtn">Connect End-to-End</button>
</div>
<div style="border-top: 1px solid #eee; margin-top: 10px; padding-top: 10px;">
<button class="action-btn" id="selectAllBtn" style="background: #6c757d;">Select All</button>
<button class="action-btn secondary" id="clearSelectionBtn">Clear Selection</button>
</div>
</div>
<div class="section">
<div class="section-title">Tracks (<span id="trackCount">0</span>)</div>
<div class="track-list" id="trackList"></div>
<button class="action-btn" id="undoBtn" style="background: #6c757d;">Undo</button>
</div>
</div>
<div class="tab-content active" id="navContent">
<div class="section">
<div class="section-title">GPS</div>
<button class="action-btn" id="gpsBtn" style="width: 100%;">Show My Location</button>
<button class="action-btn secondary" id="rotateMapBtn" style="width: 100%; margin-top: 5px;">Rotate Map: OFF</button>
<button class="action-btn secondary active" id="autoCenterBtn" style="width: 100%; margin-top: 5px;">Auto-Center: ON</button>
</div>
<div class="section">
<div class="section-title">Navigation</div>
<p class="nav-instruction">Enable GPS, then click on a track to set your destination.</p>
<div id="navInfo" class="nav-info" style="display: none;">
<div class="nav-distance"><span id="navDistance">--</span></div>
<div style="color: #666; font-size: 12px; margin-bottom: 10px;">remaining along track</div>
<button class="action-btn secondary" id="clearNavBtn">Clear Destination</button>
</div>
</div>
<div class="section">
<div class="section-title">Tracks (<span id="trackCountNav">0</span>)</div>
<div class="track-list" id="trackListNav"></div>
</div>
</div>
<div id="status" class="status">Load a KML file or draw tracks</div>
</div>
<!-- Route calculation overlay -->
<div id="routeOverlay" class="route-overlay" style="display: none;">
<div class="route-overlay-content">
<div class="spinner"></div>
<div class="route-overlay-text">Finding route...</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-rotate@0.2.8/dist/leaflet-rotate-src.js"></script>
<script>
// Initialize the map with higher max zoom and rotation support
const map = L.map('map', {
maxZoom: 22,
rotate: true,
rotateControl: false,
touchRotate: false,
bearing: 0
}).setView([30.49, -97.84], 13);
// Base layers
const streetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 22,
maxNativeZoom: 19
});
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '&copy; Esri &mdash; Esri, DeLorme, NAVTEQ',
maxZoom: 22,
maxNativeZoom: 19
});
// Add street map by default
streetMap.addTo(map);
// Layer control
const baseMaps = {
"Street Map": streetMap,
"Satellite": satellite
};
L.control.layers(baseMaps, null, { position: 'bottomleft' }).addTo(map);
// Store all tracks
const tracks = [];
let selectedTracks = []; // Now supports multiple selection
let currentTool = 'select';
let isDrawing = false;
let drawingPoints = [];
let drawingLine = null;
// Preview state
let previewMode = false;
let previewLayers = [];
let previewData = null;
// Undo stack
const undoStack = [];
const maxUndoSteps = 20;
// Reshape/Dragging state
let isDragging = false;
let dragTrack = null;
let dragPointIndex = -1;
let originalCoords = null;
let dragMarker = null;
let affectedMarkers = [];
// Snap state for endpoint merging
let snapTarget = null; // {track, index, latlng}
let snapMarker = null;
const SNAP_DISTANCE_PX = 15; // Snap distance in pixels
// Smooth brush state
let isSmoothing = false;
let smoothBrushCircle = null;
let smoothedPoints = new Set(); // Track which points have been smoothed this stroke
// GPS state
let gpsWatchId = null;
let gpsBackupInterval = null;
let gpsMarker = null;
let gpsAccuracyCircle = null;
let gpsFirstFix = true;
// Navigation state
let navMode = false;
let destinationPin = null;
let destinationTrack = null;
let destinationIndex = null;
let directionArrow = null;
let currentClosestIndex = null;
let currentRoute = null; // Array of {track, fromIndex, toIndex} segments
let routeHighlight = null; // Polyline showing the full route
let rotateMapMode = false;
let currentBearing = 0;
let autoCenterMode = true; // Auto-center on GPS by default
// Trail graph for pathfinding
const INTERSECTION_THRESHOLD = 5; // meters - points within this distance are connected
let trailGraph = null; // Cached graph, rebuilt when tracks change
// Save current state for undo
function saveStateForUndo() {
const state = {
tracks: tracks.map(t => ({
coords: [...t.coords.map(c => [...c])],
name: t.name,
description: t.description
})),
splitMarkers: splitMarkers.map(m => ({
latlng: [m.getLatLng().lat, m.getLatLng().lng],
popupContent: m.getPopup()?.getContent() || ''
}))
};
undoStack.push(state);
if (undoStack.length > maxUndoSteps) {
undoStack.shift(); // Remove oldest
}
updateUndoButton();
}
// Restore previous state
function undo() {
if (undoStack.length === 0) {
updateStatus('Nothing to undo', 'info');
return;
}
const state = undoStack.pop();
// Clear current tracks
tracks.forEach(t => t.remove());
tracks.length = 0;
selectedTracks = [];
// Clear current split markers
splitMarkers.forEach(m => map.removeLayer(m));
splitMarkers.length = 0;
// Restore tracks
for (const trackData of state.tracks) {
const track = new Track(trackData.coords, trackData.name, trackData.description);
tracks.push(track);
}
// Restore split markers
for (const markerData of state.splitMarkers) {
const marker = L.marker(markerData.latlng, {
icon: L.divIcon({
className: 'split-marker',
html: '<div style="background: #ff4444; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).addTo(map);
if (markerData.popupContent) {
marker.bindPopup(markerData.popupContent);
}
splitMarkers.push(marker);
}
updateTrackList();
updateUndoButton();
updateStatus('Undone', 'success');
}
function updateUndoButton() {
const btn = document.getElementById('undoBtn');
btn.textContent = undoStack.length > 0 ? `Undo (${undoStack.length})` : 'Undo';
btn.disabled = undoStack.length === 0;
btn.style.opacity = undoStack.length === 0 ? '0.5' : '1';
}
// GPS functions
function toggleGPS() {
const btn = document.getElementById('gpsBtn');
if (gpsWatchId !== null) {
// Stop tracking
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
if (gpsBackupInterval) {
clearInterval(gpsBackupInterval);
gpsBackupInterval = null;
}
if (gpsMarker) {
map.removeLayer(gpsMarker);
gpsMarker = null;
}
if (gpsAccuracyCircle) {
map.removeLayer(gpsAccuracyCircle);
gpsAccuracyCircle = null;
}
gpsFirstFix = true;
btn.textContent = 'Show My Location';
btn.classList.remove('active');
updateStatus('GPS tracking stopped', 'info');
} else {
// Start tracking
if (!navigator.geolocation) {
updateStatus('GPS not supported by this browser', 'error');
return;
}
btn.textContent = 'Locating...';
updateStatus('Requesting GPS location...', 'info');
gpsWatchId = navigator.geolocation.watchPosition(
onGPSSuccess,
onGPSError,
{
enableHighAccuracy: true,
maximumAge: 1000,
timeout: 15000
}
);
// Backup: poll for position every 3 seconds in case watchPosition doesn't fire
gpsBackupInterval = setInterval(() => {
navigator.geolocation.getCurrentPosition(
onGPSSuccess,
() => {}, // Ignore errors on backup poll
{
enableHighAccuracy: true,
maximumAge: 0,
timeout: 5000
}
);
}, 3000);
}
}
function onGPSSuccess(position) {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
const accuracy = position.coords.accuracy;
const btn = document.getElementById('gpsBtn');
btn.textContent = 'Stop Tracking';
btn.classList.add('active');
// Update or create marker
if (!gpsMarker) {
gpsMarker = L.circleMarker([lat, lng], {
radius: 8,
color: '#ffffff',
fillColor: '#4285f4',
fillOpacity: 1,
weight: 3
}).addTo(map);
} else {
gpsMarker.setLatLng([lat, lng]);
}
// Update or create accuracy circle
if (!gpsAccuracyCircle) {
gpsAccuracyCircle = L.circle([lat, lng], {
radius: accuracy,
color: '#4285f4',
fillColor: '#4285f4',
fillOpacity: 0.15,
weight: 1
}).addTo(map);
} else {
gpsAccuracyCircle.setLatLng([lat, lng]);
gpsAccuracyCircle.setRadius(accuracy);
}
// Center map on first fix or when auto-center is enabled
if (gpsFirstFix) {
map.setView([lat, lng], 17);
gpsFirstFix = false;
} else if (autoCenterMode) {
map.setView([lat, lng], map.getZoom());
}
// Update navigation if in nav mode
if (navMode && destinationTrack) {
try {
// Check if we're still on the current route or need to recalculate
if (currentRoute && currentRoute.length > 0) {
// Check distance to current route
const currentPos = L.latLng(lat, lng);
const distToRoute = getDistanceToRoute(currentPos, currentRoute);
if (distToRoute > 50) {
// More than 50m off route - recalculate
updateStatus('Recalculating route...', 'info');
updateNavigation();
} else {
// Still on route - just update arrow and distance (lightweight)
updateNavigationLight(currentPos);
}
} else {
// No route yet - calculate it
updateNavigation();
}
} catch (e) {
console.error('Navigation update error:', e);
updateStatus(`GPS: ${lat.toFixed(6)}, ${lng.toFixed(6)}`, 'success');
}
} else {
updateStatus(`GPS: ${lat.toFixed(6)}, ${lng.toFixed(6)}${Math.round(accuracy)}m)`, 'success');
}
}
function onGPSError(error) {
const btn = document.getElementById('gpsBtn');
let message;
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'GPS permission denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'GPS position unavailable';
break;
case error.TIMEOUT:
message = 'GPS request timed out';
break;
default:
message = 'GPS error: ' + error.message;
}
updateStatus(message, 'error');
// Reset if we haven't got a fix yet
if (gpsFirstFix) {
btn.textContent = 'Show My Location';
btn.classList.remove('active');
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
}
}
// Navigation functions
function setDestination(track, index) {
// Remove old pin if exists
if (destinationPin) {
map.removeLayer(destinationPin);
}
destinationTrack = track;
destinationIndex = index;
// Create destination pin marker
destinationPin = L.marker(track.coords[index], {
icon: L.divIcon({
className: 'destination-pin',
html: '📍',
iconSize: [24, 24],
iconAnchor: [12, 24]
})
}).addTo(map);
// Show nav info panel
document.getElementById('navInfo').style.display = 'block';
// Save destination to localStorage
saveDestination();
updateNavigation();
updateStatus(`Destination set on "${track.name}"`, 'success');
}
function saveDestination() {
if (destinationTrack && destinationIndex !== null) {
localStorage.setItem('navDestination', JSON.stringify({
trackId: destinationTrack.id,
trackName: destinationTrack.name,
index: destinationIndex,
coord: destinationTrack.coords[destinationIndex]
}));
}
}
function restoreDestination() {
const saved = localStorage.getItem('navDestination');
if (!saved) return;
try {
const data = JSON.parse(saved);
// Find the track by ID or name
let track = tracks.find(t => t.id === data.trackId);
if (!track) {
track = tracks.find(t => t.name === data.trackName);
}
if (track) {
// Find closest point to saved coordinate (in case track was edited)
let bestIndex = data.index;
if (bestIndex >= track.coords.length) {
bestIndex = track.coords.length - 1;
}
// Try to find exact or nearest point
if (data.coord) {
let minDist = Infinity;
for (let i = 0; i < track.coords.length; i++) {
const dist = Math.abs(track.coords[i][0] - data.coord[0]) +
Math.abs(track.coords[i][1] - data.coord[1]);
if (dist < minDist) {
minDist = dist;
bestIndex = i;
}
}
}
setDestination(track, bestIndex);
updateStatus(`Restored destination on "${track.name}"`, 'info');
}
} catch (e) {
console.log('Could not restore destination:', e);
localStorage.removeItem('navDestination');
}
}
function clearDestination() {
if (destinationPin) {
map.removeLayer(destinationPin);
destinationPin = null;
}
if (directionArrow) {
map.removeLayer(directionArrow);
directionArrow = null;
}
clearRouteHighlight();
destinationTrack = null;
destinationIndex = null;
currentClosestIndex = null;
currentRoute = null;
// Clear saved destination
localStorage.removeItem('navDestination');
document.getElementById('navInfo').style.display = 'none';
document.getElementById('navDistance').textContent = '--';
updateStatus('Destination cleared', 'info');
}
function findClosestPointOnTrack(track, latlng) {
let minDist = Infinity;
let closestIndex = 0;
for (let i = 0; i < track.coords.length; i++) {
const dist = map.distance(latlng, L.latLng(track.coords[i]));
if (dist < minDist) {
minDist = dist;
closestIndex = i;
}
}
return closestIndex;
}
function calculateTrackDistance(track, fromIndex, toIndex) {
let distance = 0;
const start = Math.min(fromIndex, toIndex);
const end = Math.max(fromIndex, toIndex);
for (let i = start; i < end; i++) {
distance += map.distance(
L.latLng(track.coords[i]),
L.latLng(track.coords[i + 1])
);
}
return distance;
}
function updateNavigation() {
if (!destinationTrack || !gpsMarker) return;
const currentPos = gpsMarker.getLatLng();
// Check if arrived (within 10 meters of destination)
const distToDestination = map.distance(currentPos, L.latLng(destinationTrack.coords[destinationIndex]));
if (distToDestination < 10) {
clearRouteHighlight();
updateDirectionArrow(currentPos, []);
document.getElementById('routeOverlay').style.display = 'none';
updateStatus('You have arrived!', 'success');
return;
}
// Find the closest point on ANY track to current position
let closestTrack = null;
let closestIndex = 0;
let minDist = Infinity;
for (const track of tracks) {
for (let i = 0; i < track.coords.length; i++) {
const dist = map.distance(currentPos, L.latLng(track.coords[i]));
if (dist < minDist) {
minDist = dist;
closestTrack = track;
closestIndex = i;
}
}
}
if (!closestTrack) {
document.getElementById('routeOverlay').style.display = 'none';
updateStatus('No trails nearby', 'error');
return;
}
// If far from any trail, show direct arrow to nearest trail
const ON_TRAIL_THRESHOLD = 30; // meters
if (minDist > ON_TRAIL_THRESHOLD) {
clearRouteHighlight();
updateDirectionArrow(currentPos, [closestTrack.coords[closestIndex]], '#3388ff');
document.getElementById('routeOverlay').style.display = 'none';
updateStatus(`${Math.round(minDist)}m to nearest trail`, 'info');
return;
}
// Show loading indicator and defer pathfinding to allow UI update
document.getElementById('routeOverlay').style.display = 'flex';
document.getElementById('navInfo').style.display = 'none';
setTimeout(() => {
// Find path from current position to destination through trail network
const pathResult = findShortestPath(closestTrack, closestIndex, destinationTrack, destinationIndex);
document.getElementById('routeOverlay').style.display = 'none';
document.getElementById('navInfo').style.display = 'block';
finishNavUpdate(currentPos, pathResult);
}, 10);
}
function finishNavUpdate(currentPos, pathResult) {
if (!pathResult) {
// No path found - trails not connected
clearRouteHighlight();
updateDirectionArrow(currentPos, [destinationTrack.coords[destinationIndex]], '#ff0000');
updateStatus('No connected path to destination', 'error');
return;
}
// Convert path to route segments
currentRoute = pathToRouteSegments(pathResult.path);
// Update distance display
const distanceText = pathResult.totalDistance < 1000
? `${Math.round(pathResult.totalDistance)}m`
: `${(pathResult.totalDistance / 1000).toFixed(2)}km`;
document.getElementById('navDistance').textContent = distanceText;
// Get route coordinates and display
const routeCoords = getRouteCoordinates(currentRoute);
updateRouteHighlight(routeCoords);
// Show direction arrow for next few points
const arrowPoints = routeCoords.slice(0, Math.min(6, routeCoords.length));
updateDirectionArrow(currentPos, arrowPoints, '#ff6600');
// Status message
if (currentRoute.length > 1) {
const nextTrack = currentRoute[1] ? currentRoute[1].track.name : '';
updateStatus(`${distanceText} via ${currentRoute.length} trails`, 'info');
} else {
updateStatus(`${distanceText} remaining`, 'info');
}
}
// Check how far user is from the current route
function getDistanceToRoute(currentPos, route) {
if (!route || route.length === 0) return Infinity;
let minDist = Infinity;
for (const segment of route) {
const start = Math.min(segment.fromIndex, segment.toIndex);
const end = Math.max(segment.fromIndex, segment.toIndex);
for (let i = start; i <= end; i++) {
const dist = map.distance(currentPos, L.latLng(segment.track.coords[i]));
if (dist < minDist) {
minDist = dist;
}
}
}
return minDist;
}
// Lightweight navigation update - just update arrow and remaining distance
function updateNavigationLight(currentPos) {
if (!currentRoute || currentRoute.length === 0) return;
// Check if arrived
const distToDestination = map.distance(currentPos, L.latLng(destinationTrack.coords[destinationIndex]));
if (distToDestination < 10) {
clearRouteHighlight();
updateDirectionArrow(currentPos, []);
updateStatus('You have arrived!', 'success');
return;
}
// Find where we are on the route and calculate remaining distance
const routeCoords = getRouteCoordinates(currentRoute);
let closestIdx = 0;
let minDist = Infinity;
for (let i = 0; i < routeCoords.length; i++) {
const dist = map.distance(currentPos, L.latLng(routeCoords[i]));
if (dist < minDist) {
minDist = dist;
closestIdx = i;
}
}
// Calculate remaining distance from current position
let remainingDist = minDist; // Distance to route
for (let i = closestIdx; i < routeCoords.length - 1; i++) {
remainingDist += map.distance(L.latLng(routeCoords[i]), L.latLng(routeCoords[i + 1]));
}
// Update distance display
const distanceText = remainingDist < 1000
? `${Math.round(remainingDist)}m`
: `${(remainingDist / 1000).toFixed(2)}km`;
document.getElementById('navDistance').textContent = distanceText;
// Update route highlight to only show remaining path (trim behind user)
const remainingRoute = routeCoords.slice(closestIdx);
updateRouteHighlight(remainingRoute);
// Update direction arrow to point along route from current position
const arrowPoints = routeCoords.slice(closestIdx, Math.min(closestIdx + 6, routeCoords.length));
updateDirectionArrow(currentPos, arrowPoints, '#ff6600');
// Status message
if (currentRoute.length > 1) {
updateStatus(`${distanceText} via ${currentRoute.length} trails`, 'info');
} else {
updateStatus(`${distanceText} remaining`, 'info');
}
// Update map rotation if enabled
if (rotateMapMode && arrowPoints.length >= 2) {
const bearing = calculateBearing(
arrowPoints[0][0], arrowPoints[0][1],
arrowPoints[1][0], arrowPoints[1][1]
);
rotateMap(bearing);
}
}
// Calculate bearing between two points (in degrees, 0 = North)
function calculateBearing(lat1, lng1, lat2, lng2) {
const dLng = (lng2 - lng1) * Math.PI / 180;
const lat1Rad = lat1 * Math.PI / 180;
const lat2Rad = lat2 * Math.PI / 180;
const y = Math.sin(dLng) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng);
let bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360; // Normalize to 0-360
}
// Update GPS marker to show direction of travel
function rotateMap(bearing) {
currentBearing = bearing;
// Rotate the map using leaflet-rotate plugin
// Negate bearing so direction of travel points UP
map.setBearing(-bearing);
// Update compass indicator
const compass = document.getElementById('compassIndicator');
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const index = Math.round(bearing / 45) % 8;
compass.textContent = `Heading: ${directions[index]} ${Math.round(bearing)}°`;
}
// Reset map rotation
function resetMapRotation() {
currentBearing = 0;
map.setBearing(0);
document.getElementById('compassIndicator').classList.remove('active');
}
// Toggle rotate mode
function toggleRotateMap() {
rotateMapMode = !rotateMapMode;
const btn = document.getElementById('rotateMapBtn');
if (rotateMapMode) {
btn.textContent = 'Rotate Map: ON';
btn.classList.add('active');
document.getElementById('compassIndicator').classList.add('active');
} else {
btn.textContent = 'Rotate Map: OFF';
btn.classList.remove('active');
resetMapRotation();
}
}
function toggleAutoCenter() {
autoCenterMode = !autoCenterMode;
const btn = document.getElementById('autoCenterBtn');
if (autoCenterMode) {
btn.textContent = 'Auto-Center: ON';
btn.classList.add('active');
// Immediately center on current GPS position if available
if (gpsMarker) {
map.setView(gpsMarker.getLatLng(), map.getZoom());
}
} else {
btn.textContent = 'Auto-Center: OFF';
btn.classList.remove('active');
}
}
function updateRouteHighlight(coords) {
clearRouteHighlight();
if (coords.length < 2) return;
routeHighlight = L.polyline(coords, {
color: '#00ff00',
weight: 10,
opacity: 0.7
}).addTo(map);
// Make sure route is behind other markers
routeHighlight.bringToBack();
}
function clearRouteHighlight() {
if (routeHighlight) {
map.removeLayer(routeHighlight);
routeHighlight = null;
}
}
function updateDirectionArrow(currentPos, nextPoints, color = '#ff6600') {
if (directionArrow) {
map.removeLayer(directionArrow);
directionArrow = null;
}
if (nextPoints.length === 0) return;
const arrowCoords = [
[currentPos.lat, currentPos.lng],
...nextPoints
];
directionArrow = L.polyline(arrowCoords, {
color: color,
weight: 5,
opacity: 0.8,
dashArray: '12, 8'
}).addTo(map);
}
// Build simplified trail network graph for pathfinding
// Only uses endpoints and intersection points as nodes (much faster)
function buildTrailGraph() {
const graph = {
nodes: new Map() // Key: "trackId:pointIndex", Value: {track, index, neighbors: [{key, distance}]}
};
const nodeKey = (track, index) => `${track.id}:${index}`;
// Step 1: Find all intersection points and endpoints
const keyPoints = new Map(); // trackId -> Set of indices that are key points
for (const track of tracks) {
keyPoints.set(track.id, new Set([0, track.coords.length - 1])); // Start and end
}
// Find intersections between tracks
for (const track of tracks) {
for (const otherTrack of tracks) {
if (track.id >= otherTrack.id) continue; // Avoid duplicate checks
// Sample points to find intersections (check every 10th point for speed)
for (let i = 0; i < track.coords.length; i += Math.max(1, Math.floor(track.coords.length / 50))) {
const point = L.latLng(track.coords[i]);
for (let j = 0; j < otherTrack.coords.length; j += Math.max(1, Math.floor(otherTrack.coords.length / 50))) {
const otherPoint = L.latLng(otherTrack.coords[j]);
const dist = map.distance(point, otherPoint);
if (dist <= INTERSECTION_THRESHOLD) {
keyPoints.get(track.id).add(i);
keyPoints.get(otherTrack.id).add(j);
}
}
}
}
}
// Step 2: Create nodes for key points
for (const track of tracks) {
const indices = Array.from(keyPoints.get(track.id)).sort((a, b) => a - b);
for (const idx of indices) {
const key = nodeKey(track, idx);
graph.nodes.set(key, {
track: track,
index: idx,
neighbors: []
});
}
// Connect consecutive key points on same track
for (let i = 0; i < indices.length - 1; i++) {
const fromIdx = indices[i];
const toIdx = indices[i + 1];
const fromKey = nodeKey(track, fromIdx);
const toKey = nodeKey(track, toIdx);
// Calculate distance along track
let dist = 0;
for (let j = fromIdx; j < toIdx; j++) {
dist += map.distance(
L.latLng(track.coords[j]),
L.latLng(track.coords[j + 1])
);
}
graph.nodes.get(fromKey).neighbors.push({ key: toKey, distance: dist });
graph.nodes.get(toKey).neighbors.push({ key: fromKey, distance: dist });
}
}
// Step 3: Connect intersection points across tracks
for (const track of tracks) {
const trackKeyPoints = keyPoints.get(track.id);
for (const idx of trackKeyPoints) {
const point = L.latLng(track.coords[idx]);
const key = nodeKey(track, idx);
const node = graph.nodes.get(key);
for (const otherTrack of tracks) {
if (track === otherTrack) continue;
const otherKeyPoints = keyPoints.get(otherTrack.id);
for (const otherIdx of otherKeyPoints) {
const otherPoint = L.latLng(otherTrack.coords[otherIdx]);
const dist = map.distance(point, otherPoint);
if (dist <= INTERSECTION_THRESHOLD) {
const otherKey = nodeKey(otherTrack, otherIdx);
if (!node.neighbors.some(n => n.key === otherKey)) {
node.neighbors.push({ key: otherKey, distance: dist });
}
}
}
}
}
}
return graph;
}
// Find nearest graph node on a track to a given index
function findNearestGraphNode(graph, track, index) {
let bestKey = null;
let bestDist = Infinity;
let bestIdx = 0;
for (const [key, node] of graph.nodes) {
if (node.track.id === track.id) {
const dist = Math.abs(node.index - index);
if (dist < bestDist) {
bestDist = dist;
bestKey = key;
bestIdx = node.index;
}
}
}
return { key: bestKey, index: bestIdx };
}
// Calculate distance along track between two indices
function trackDistanceBetween(track, idx1, idx2) {
let dist = 0;
const start = Math.min(idx1, idx2);
const end = Math.max(idx1, idx2);
for (let i = start; i < end; i++) {
dist += map.distance(
L.latLng(track.coords[i]),
L.latLng(track.coords[i + 1])
);
}
return dist;
}
// Find shortest path using Dijkstra's algorithm
function findShortestPath(startTrack, startIndex, endTrack, endIndex) {
const graph = buildTrailGraph();
if (graph.nodes.size === 0) return null;
// Find nearest graph nodes to start and end
const startNode = findNearestGraphNode(graph, startTrack, startIndex);
const endNode = findNearestGraphNode(graph, endTrack, endIndex);
if (!startNode.key || !endNode.key) return null;
// Distance from actual start to nearest graph node
const startToNodeDist = trackDistanceBetween(startTrack, startIndex, startNode.index);
// Distance from nearest graph node to actual end
const nodeToEndDist = trackDistanceBetween(endTrack, endNode.index, endIndex);
const startKey = startNode.key;
const endKey = endNode.key;
// If start and end are on same track segment, just return direct path
if (startTrack.id === endTrack.id) {
const directDist = trackDistanceBetween(startTrack, startIndex, endIndex);
return {
path: [
{ track: startTrack, index: startIndex },
{ track: endTrack, index: endIndex }
],
totalDistance: directDist
};
}
// Dijkstra's algorithm
const distances = new Map();
const previous = new Map();
const unvisited = new Set(graph.nodes.keys());
for (const key of graph.nodes.keys()) {
distances.set(key, Infinity);
}
distances.set(startKey, startToNodeDist);
while (unvisited.size > 0) {
let currentKey = null;
let minDist = Infinity;
for (const key of unvisited) {
if (distances.get(key) < minDist) {
minDist = distances.get(key);
currentKey = key;
}
}
if (currentKey === null || minDist === Infinity) break;
if (currentKey === endKey) break;
unvisited.delete(currentKey);
const currentNode = graph.nodes.get(currentKey);
for (const neighbor of currentNode.neighbors) {
if (!unvisited.has(neighbor.key)) continue;
const newDist = distances.get(currentKey) + neighbor.distance;
if (newDist < distances.get(neighbor.key)) {
distances.set(neighbor.key, newDist);
previous.set(neighbor.key, currentKey);
}
}
}
if (!previous.has(endKey) && startKey !== endKey) {
return null;
}
// Reconstruct path through graph nodes
const graphPath = [];
let current = endKey;
while (current) {
const node = graph.nodes.get(current);
graphPath.unshift({ track: node.track, index: node.index });
current = previous.get(current);
}
// Build full path including actual start and end points
const fullPath = [{ track: startTrack, index: startIndex }];
for (const node of graphPath) {
// Avoid duplicates
const last = fullPath[fullPath.length - 1];
if (last.track.id !== node.track.id || last.index !== node.index) {
fullPath.push(node);
}
}
// Add end point if different from last
const last = fullPath[fullPath.length - 1];
if (last.track.id !== endTrack.id || last.index !== endIndex) {
fullPath.push({ track: endTrack, index: endIndex });
}
return {
path: fullPath,
totalDistance: distances.get(endKey) + nodeToEndDist
};
}
// Convert path to route segments for display
function pathToRouteSegments(path) {
if (!path || path.length === 0) return [];
const segments = [];
let currentSegment = null;
for (const node of path) {
if (!currentSegment || currentSegment.track !== node.track) {
// Start new segment
if (currentSegment) {
segments.push(currentSegment);
}
currentSegment = {
track: node.track,
fromIndex: node.index,
toIndex: node.index
};
} else {
// Extend current segment
currentSegment.toIndex = node.index;
}
}
if (currentSegment) {
segments.push(currentSegment);
}
return segments;
}
// Get all coordinates along the route
function getRouteCoordinates(segments) {
const coords = [];
for (const segment of segments) {
const start = Math.min(segment.fromIndex, segment.toIndex);
const end = Math.max(segment.fromIndex, segment.toIndex);
const reverse = segment.fromIndex > segment.toIndex;
const segmentCoords = [];
for (let i = start; i <= end; i++) {
segmentCoords.push(segment.track.coords[i]);
}
if (reverse) {
segmentCoords.reverse();
}
// Avoid duplicate points at segment boundaries
if (coords.length > 0 && segmentCoords.length > 0) {
const lastCoord = coords[coords.length - 1];
const firstCoord = segmentCoords[0];
if (lastCoord[0] === firstCoord[0] && lastCoord[1] === firstCoord[1]) {
segmentCoords.shift();
}
}
coords.push(...segmentCoords);
}
return coords;
}
function switchTab(tabName) {
const editTab = document.getElementById('editTab');
const navTab = document.getElementById('navTab');
const editContent = document.getElementById('editContent');
const navContent = document.getElementById('navContent');
if (tabName === 'edit') {
editTab.classList.add('active');
navTab.classList.remove('active');
editContent.classList.add('active');
navContent.classList.remove('active');
navMode = false;
// Clear any navigation visuals when leaving nav mode
if (directionArrow) {
map.removeLayer(directionArrow);
directionArrow = null;
}
clearRouteHighlight();
// Reset map rotation when leaving nav mode
if (rotateMapMode) {
toggleRotateMap();
}
} else {
navTab.classList.add('active');
editTab.classList.remove('active');
navContent.classList.add('active');
editContent.classList.remove('active');
navMode = true;
// Deactivate edit tools when entering nav mode
Object.values(toolButtons).forEach(btn => btn.classList.remove('active'));
document.getElementById('reshapeControls').style.display = 'none';
document.getElementById('smoothControls').style.display = 'none';
map.getContainer().style.cursor = '';
currentTool = null;
// Cancel any in-progress operations
if (isDrawing) cancelDrawing();
if (isDragging) cancelReshapeDrag();
// Update nav track list
updateNavTrackList();
}
}
function updateNavTrackList() {
const listEl = document.getElementById('trackListNav');
const countEl = document.getElementById('trackCountNav');
if (!listEl || !countEl) return;
countEl.textContent = tracks.length;
listEl.innerHTML = '';
tracks.forEach(track => {
const item = document.createElement('div');
item.className = 'track-item';
item.innerHTML = `<span>${track.name}</span>`;
listEl.appendChild(item);
});
}
// Tool buttons
const toolButtons = {
select: document.getElementById('selectTool'),
split: document.getElementById('splitTool'),
draw: document.getElementById('drawTool'),
reshape: document.getElementById('reshapeTool'),
smooth: document.getElementById('smoothTool')
};
// Set active tool
function setTool(tool) {
currentTool = tool;
Object.keys(toolButtons).forEach(t => {
toolButtons[t].classList.toggle('active', t === tool);
});
// Cancel any drawing in progress
if (tool !== 'draw' && isDrawing) {
cancelDrawing();
}
// Show/hide tool-specific controls
document.getElementById('reshapeControls').style.display = tool === 'reshape' ? 'block' : 'none';
document.getElementById('smoothControls').style.display = tool === 'smooth' ? 'block' : 'none';
// Update cursor
const container = map.getContainer();
container.style.cursor = (tool === 'draw' || tool === 'reshape' || tool === 'smooth') ? 'crosshair' : '';
updateStatus(getToolHint(tool), 'info');
}
function getToolHint(tool) {
switch(tool) {
case 'select': return 'Click a track to select it, press Delete to remove';
case 'split': return 'Click on a track to split it at that point';
case 'draw': return 'Click to add points, double-click to finish';
case 'reshape': return 'Drag any point on a track to reshape it';
case 'smooth': return 'Click and drag over a track to smooth it';
default: return '';
}
}
// Tool button event listeners
Object.keys(toolButtons).forEach(tool => {
toolButtons[tool].addEventListener('click', () => setTool(tool));
});
// Track class
class Track {
constructor(coords, name = 'Track', description = '') {
this.id = Date.now() + Math.random();
this.coords = coords; // Array of [lat, lng]
this.name = name;
this.description = description;
this.layer = null;
this.createLayer();
}
createLayer() {
if (this.layer) {
map.removeLayer(this.layer);
}
this.layer = L.polyline(this.coords, {
color: '#3388ff',
weight: 4,
opacity: 0.8
});
this.layer.on('click', (e) => this.onClick(e));
this.layer.addTo(map);
}
onClick(e) {
L.DomEvent.stopPropagation(e);
switch(currentTool) {
case 'select':
selectTrack(this);
break;
case 'split':
splitTrack(this, e.latlng);
break;
}
}
setSelected(selected) {
this.layer.setStyle({
color: selected ? '#ff4444' : '#3388ff',
weight: selected ? 6 : 4
});
}
remove() {
if (this.layer) {
map.removeLayer(this.layer);
}
}
getStartPoint() {
return this.coords[0];
}
getEndPoint() {
return this.coords[this.coords.length - 1];
}
}
// === RESHAPE TOOL FUNCTIONS ===
// Find the nearest point on any track to a given latlng
function findNearestTrackPoint(latlng, threshold = 20) {
let best = null;
let bestDist = threshold;
for (const track of tracks) {
const point = map.latLngToContainerPoint(latlng);
for (let i = 0; i < track.coords.length; i++) {
const trackPoint = map.latLngToContainerPoint(L.latLng(track.coords[i]));
const dx = point.x - trackPoint.x;
const dy = point.y - trackPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
best = { track, index: i, dist };
}
}
}
return best;
}
// Calculate distance between two lat/lng points (simple Euclidean for small areas)
function pointDistance(p1, p2) {
const dx = p1[0] - p2[0];
const dy = p1[1] - p2[1];
return Math.sqrt(dx * dx + dy * dy);
}
// Constrain point A to be at most maxDist from point B
function constrainDistance(pointA, pointB, maxDist) {
const dx = pointA[0] - pointB[0];
const dy = pointA[1] - pointB[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= maxDist) {
return [...pointA]; // Already within range, no change
}
// Move point A to be exactly maxDist from B (along the line A-B)
const ratio = maxDist / dist;
return [
pointB[0] + dx * ratio,
pointB[1] + dy * ratio
];
}
// Smooth reshape: weighted translation that preserves curve shape
// Each point moves by the drag delta, weighted by distance from anchor
function applyRopePhysics(originalCoords, dragIndex, newPos, anchorDist) {
const coords = originalCoords.map(c => [...c]); // Deep copy
// Get falloff exponent from slider (controls how sharply weight drops off)
const falloff = parseFloat(document.getElementById('reshapeFalloff').value);
// Calculate anchor indices (both directions from drag point)
const anchorBefore = Math.max(0, dragIndex - anchorDist);
const anchorAfter = Math.min(coords.length - 1, dragIndex + anchorDist);
// Calculate the drag delta (how much the drag point moved)
const deltaLat = newPos[0] - originalCoords[dragIndex][0];
const deltaLng = newPos[1] - originalCoords[dragIndex][1];
// Set dragged point to new position
coords[dragIndex] = [newPos[0], newPos[1]];
// === Handle chain BEFORE drag point ===
if (dragIndex > anchorBefore) {
const chainLength = dragIndex - anchorBefore;
for (let i = anchorBefore; i < dragIndex; i++) {
if (i === anchorBefore) {
// Anchor stays fixed
coords[i] = [...originalCoords[anchorBefore]];
} else {
// Weight: 0 at anchor, 1 at drag point
// Falloff controls the curve: <1 = gradual, 1 = linear, >1 = sharp
const linearWeight = (i - anchorBefore) / chainLength;
const weight = Math.pow(linearWeight, falloff);
// Apply weighted delta to original position
coords[i] = [
originalCoords[i][0] + deltaLat * weight,
originalCoords[i][1] + deltaLng * weight
];
}
}
}
// === Handle chain AFTER drag point ===
if (dragIndex < anchorAfter) {
const chainLength = anchorAfter - dragIndex;
for (let i = dragIndex + 1; i <= anchorAfter; i++) {
if (i === anchorAfter) {
// Anchor stays fixed
coords[i] = [...originalCoords[anchorAfter]];
} else {
// Weight: 1 near drag, 0 at anchor
const linearWeight = 1 - (i - dragIndex) / chainLength;
const weight = Math.pow(linearWeight, falloff);
// Apply weighted delta to original position
coords[i] = [
originalCoords[i][0] + deltaLat * weight,
originalCoords[i][1] + deltaLng * weight
];
}
}
}
return coords;
}
// === SMOOTH BRUSH FUNCTIONS ===
// Start smoothing brush stroke
function startSmoothing(latlng) {
isSmoothing = true;
smoothedPoints.clear();
saveStateForUndo();
// Create brush circle visualization
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
smoothBrushCircle = L.circle(latlng, {
radius: brushSize * getMetersPerPixel(),
color: '#00aaff',
fillColor: '#00aaff',
fillOpacity: 0.2,
weight: 2
}).addTo(map);
// Apply smoothing at initial position
applySmoothing(latlng);
}
// Continue smoothing as brush moves
function continueSmoothing(latlng) {
if (!isSmoothing) return;
// Update brush circle position and size
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
if (smoothBrushCircle) {
smoothBrushCircle.setLatLng(latlng);
smoothBrushCircle.setRadius(brushSize * getMetersPerPixel());
}
// Apply smoothing at current position
applySmoothing(latlng);
}
// Finish smoothing stroke
function finishSmoothing() {
isSmoothing = false;
smoothedPoints.clear();
if (smoothBrushCircle) {
map.removeLayer(smoothBrushCircle);
smoothBrushCircle = null;
}
updateStatus('Smoothing complete', 'success');
}
// Get approximate meters per pixel at current zoom
function getMetersPerPixel() {
const center = map.getCenter();
const zoom = map.getZoom();
// Approximate meters per pixel at equator, adjusted for latitude
return 156543.03392 * Math.cos(center.lat * Math.PI / 180) / Math.pow(2, zoom);
}
// Apply smoothing to points within brush radius
function applySmoothing(latlng) {
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
const strength = parseFloat(document.getElementById('smoothStrength').value);
const brushRadius = brushSize * getMetersPerPixel();
for (const track of tracks) {
let modified = false;
for (let i = 1; i < track.coords.length - 1; i++) {
const pointLatLng = L.latLng(track.coords[i]);
const dist = map.distance(latlng, pointLatLng);
if (dist < brushRadius) {
// Point is within brush - smooth it using wider neighborhood
const curr = track.coords[i];
// Use wider neighborhood for stronger smoothing (up to 3 points each side)
let sumLat = 0, sumLng = 0, count = 0;
const neighborRange = Math.max(1, Math.floor(strength * 3)); // 1-3 neighbors based on strength
for (let j = Math.max(0, i - neighborRange); j <= Math.min(track.coords.length - 1, i + neighborRange); j++) {
sumLat += track.coords[j][0];
sumLng += track.coords[j][1];
count++;
}
const smoothedLat = sumLat / count;
const smoothedLng = sumLng / count;
// Blend based on strength and distance from brush center
const distFactor = 1 - (dist / brushRadius); // 1 at center, 0 at edge
const blend = strength * distFactor;
track.coords[i] = [
curr[0] + (smoothedLat - curr[0]) * blend,
curr[1] + (smoothedLng - curr[1]) * blend
];
modified = true;
}
}
if (modified) {
track.layer.setLatLngs(track.coords);
}
}
}
// Start dragging a point
function startReshapeDrag(latlng) {
const nearest = findNearestTrackPoint(latlng);
if (!nearest) return false;
saveStateForUndo();
isDragging = true;
dragTrack = nearest.track;
dragPointIndex = nearest.index;
originalCoords = dragTrack.coords.map(c => [...c]); // Deep copy
// Create marker at drag point
dragMarker = L.circleMarker(latlng, {
radius: 8,
color: '#ff0000',
fillColor: '#ff0000',
fillOpacity: 1,
weight: 2
}).addTo(map);
// Show affected range markers
showAffectedRange();
// Disable map dragging while we drag the point
map.dragging.disable();
updateStatus(`Dragging point ${dragPointIndex} of "${dragTrack.name}"`, 'info');
return true;
}
// Show markers for affected points range
function showAffectedRange() {
clearAffectedMarkers();
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const anchorBefore = Math.max(0, dragPointIndex - anchorDist);
const anchorAfter = Math.min(originalCoords.length - 1, dragPointIndex + anchorDist);
// Show anchor points (green - fixed)
if (anchorBefore < dragPointIndex) {
const anchorMarkerBefore = L.circleMarker(originalCoords[anchorBefore], {
radius: 8,
color: '#00cc00',
fillColor: '#00cc00',
fillOpacity: 1,
weight: 2
}).addTo(map);
affectedMarkers.push(anchorMarkerBefore);
}
if (anchorAfter > dragPointIndex) {
const anchorMarkerAfter = L.circleMarker(originalCoords[anchorAfter], {
radius: 8,
color: '#00cc00',
fillColor: '#00cc00',
fillOpacity: 1,
weight: 2
}).addTo(map);
affectedMarkers.push(anchorMarkerAfter);
}
// Show chain points (orange - will move)
for (let i = anchorBefore + 1; i < anchorAfter; i++) {
if (i === dragPointIndex) continue; // Skip the drag point itself
const marker = L.circleMarker(originalCoords[i], {
radius: 5,
color: '#ff8800',
fillColor: '#ff8800',
fillOpacity: 0.7,
weight: 1
}).addTo(map);
affectedMarkers.push(marker);
}
}
// Clear affected range markers
function clearAffectedMarkers() {
affectedMarkers.forEach(m => map.removeLayer(m));
affectedMarkers = [];
}
// Find nearest snap target on other tracks
function findSnapTarget(latlng) {
if (!dragTrack) return null;
const snapDistPx = SNAP_DISTANCE_PX;
let nearest = null;
let minDist = Infinity;
for (const track of tracks) {
if (track === dragTrack) continue; // Skip the track we're dragging
for (let i = 0; i < track.coords.length; i++) {
const pointLatLng = L.latLng(track.coords[i]);
const pixelDist = map.latLngToLayerPoint(latlng).distanceTo(
map.latLngToLayerPoint(pointLatLng)
);
if (pixelDist < snapDistPx && pixelDist < minDist) {
minDist = pixelDist;
nearest = {
track: track,
index: i,
latlng: pointLatLng
};
}
}
}
return nearest;
}
// Show/hide snap indicator
function updateSnapIndicator(target) {
if (target) {
if (!snapMarker) {
snapMarker = L.circleMarker(target.latlng, {
radius: 12,
color: '#00ffff',
fillColor: '#00ffff',
fillOpacity: 0.5,
weight: 3
}).addTo(map);
} else {
snapMarker.setLatLng(target.latlng);
}
} else if (snapMarker) {
map.removeLayer(snapMarker);
snapMarker = null;
}
}
// Continue dragging
function continueReshapeDrag(latlng) {
if (!isDragging || !dragTrack) return;
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const newPos = [latlng.lat, latlng.lng];
// Check for snap target if dragging an endpoint
const isEndpoint = dragPointIndex === 0 || dragPointIndex === originalCoords.length - 1;
if (isEndpoint) {
snapTarget = findSnapTarget(latlng);
updateSnapIndicator(snapTarget);
// If snapping, use snap position instead of mouse position
if (snapTarget) {
newPos[0] = snapTarget.latlng.lat;
newPos[1] = snapTarget.latlng.lng;
}
} else {
snapTarget = null;
updateSnapIndicator(null);
}
// Apply rope physics and update track
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, newPos, anchorDist);
dragTrack.coords = newCoords;
dragTrack.layer.setLatLngs(newCoords);
// Update drag marker position
if (dragMarker) {
dragMarker.setLatLng(snapTarget ? snapTarget.latlng : latlng);
}
// Update affected markers positions
updateAffectedMarkersPositions(newCoords);
}
// Update affected markers to new positions
function updateAffectedMarkersPositions(newCoords) {
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const anchorBefore = Math.max(0, dragPointIndex - anchorDist);
const anchorAfter = Math.min(newCoords.length - 1, dragPointIndex + anchorDist);
let markerIdx = 0;
// Skip anchor markers (they don't move), update chain markers
// First marker(s) are anchors, then chain points
if (anchorBefore < dragPointIndex) markerIdx++; // Skip anchor before
if (anchorAfter > dragPointIndex) markerIdx++; // Account for anchor after being in list
// Actually, let's rebuild to be clearer - anchors don't move, only chain points do
// Anchors are at indices 0 and 1 (if both exist), chain points follow
let anchorCount = 0;
if (anchorBefore < dragPointIndex) anchorCount++;
if (anchorAfter > dragPointIndex) anchorCount++;
let chainMarkerIdx = anchorCount;
for (let i = anchorBefore + 1; i < anchorAfter; i++) {
if (i === dragPointIndex) continue;
if (chainMarkerIdx < affectedMarkers.length) {
affectedMarkers[chainMarkerIdx].setLatLng(newCoords[i]);
chainMarkerIdx++;
}
}
}
// Finish dragging
function finishReshapeDrag() {
if (!isDragging) return;
const trackToMerge = dragTrack;
const wasSnapping = snapTarget !== null;
const snapInfo = snapTarget;
const draggingFromStart = dragPointIndex === 0;
const draggingFromEnd = dragPointIndex === originalCoords.length - 1;
isDragging = false;
dragTrack = null;
dragPointIndex = -1;
originalCoords = null;
// Remove markers
if (dragMarker) {
map.removeLayer(dragMarker);
dragMarker = null;
}
clearAffectedMarkers();
updateSnapIndicator(null);
snapTarget = null;
// Re-enable map dragging
map.dragging.enable();
// If snapping, connect the tracks at intersection
if (wasSnapping && snapInfo) {
mergeTracks(trackToMerge, draggingFromStart, snapInfo);
updateStatus('Tracks connected at intersection', 'success');
} else {
updateStatus('Reshape complete', 'success');
}
}
// Connect tracks at snap point (split target if snapping to middle)
function mergeTracks(sourceTrack, fromStart, snapInfo) {
const targetTrack = snapInfo.track;
const targetIndex = snapInfo.index;
const isTargetStart = targetIndex === 0;
const isTargetEnd = targetIndex === targetTrack.coords.length - 1;
const snapCoord = targetTrack.coords[targetIndex];
// Update source track endpoint to snap position
if (fromStart) {
sourceTrack.coords[0] = [...snapCoord];
} else {
sourceTrack.coords[sourceTrack.coords.length - 1] = [...snapCoord];
}
sourceTrack.layer.setLatLngs(sourceTrack.coords);
// If snapping to middle of target, split it into two tracks
if (!isTargetStart && !isTargetEnd) {
// Create first part (start to snap point)
const coords1 = targetTrack.coords.slice(0, targetIndex + 1);
// Create second part (snap point to end)
const coords2 = targetTrack.coords.slice(targetIndex);
// Update target track to be first part
targetTrack.coords = coords1;
targetTrack.layer.setLatLngs(coords1);
targetTrack.name = targetTrack.name + ' (1)';
// Create new track for second part
const newTrack = new Track(coords2, targetTrack.name.replace(' (1)', ' (2)'));
tracks.push(newTrack);
}
updateTrackList();
}
// Cancel dragging (restore original)
function cancelReshapeDrag() {
if (!isDragging || !dragTrack || !originalCoords) return;
// Restore original coordinates
dragTrack.coords = originalCoords;
dragTrack.layer.setLatLngs(originalCoords);
// Remove the undo state we just added
undoStack.pop();
updateUndoButton();
isDragging = false;
dragTrack = null;
dragPointIndex = -1;
originalCoords = null;
if (dragMarker) {
map.removeLayer(dragMarker);
dragMarker = null;
}
clearAffectedMarkers();
updateSnapIndicator(null);
snapTarget = null;
map.dragging.enable();
updateStatus('Reshape cancelled', 'info');
}
// Toggle track selection (multi-select)
function selectTrack(track) {
const idx = selectedTracks.indexOf(track);
if (idx > -1) {
// Deselect
selectedTracks.splice(idx, 1);
track.setSelected(false);
} else {
// Add to selection
selectedTracks.push(track);
track.setSelected(true);
}
updateTrackList();
if (selectedTracks.length === 0) {
updateStatus('Click tracks to select them', 'info');
} else if (selectedTracks.length === 1) {
updateStatus(`Selected: ${selectedTracks[0].name} (${selectedTracks[0].coords.length} points)`);
} else {
updateStatus(`Selected ${selectedTracks.length} tracks`);
}
}
// Clear all selections
function clearSelection() {
selectedTracks.forEach(t => t.setSelected(false));
selectedTracks = [];
updateTrackList();
updateStatus('Selection cleared', 'info');
}
// Select all tracks
function selectAll() {
selectedTracks.forEach(t => t.setSelected(false));
selectedTracks = [...tracks];
selectedTracks.forEach(t => t.setSelected(true));
updateTrackList();
updateStatus(`Selected all ${tracks.length} tracks`);
}
// Store split markers
const splitMarkers = [];
// Split a track at a point
function splitTrack(track, latlng) {
saveStateForUndo();
// Find closest point on track
let minDist = Infinity;
let splitIndex = 0;
for (let i = 0; i < track.coords.length; i++) {
const dist = map.distance(latlng, L.latLng(track.coords[i]));
if (dist < minDist) {
minDist = dist;
splitIndex = i;
}
}
if (splitIndex === 0 || splitIndex === track.coords.length - 1) {
updateStatus('Cannot split at track endpoints', 'error');
return;
}
// Get the actual split point coordinates
const splitPoint = track.coords[splitIndex];
// Create two new tracks
const coords1 = track.coords.slice(0, splitIndex + 1);
const coords2 = track.coords.slice(splitIndex);
const track1 = new Track(coords1, track.name + ' (part 1)', track.description);
const track2 = new Track(coords2, track.name + ' (part 2)', track.description);
// Add a marker at the split point
const splitMarker = L.marker(splitPoint, {
icon: L.divIcon({
className: 'split-marker',
html: '<div style="background: #ff4444; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).addTo(map);
splitMarker.bindPopup(`Split point<br><small>${track.name}</small>`);
splitMarkers.push(splitMarker);
// Remove original and add new tracks
const idx = tracks.indexOf(track);
track.remove();
tracks.splice(idx, 1, track1, track2);
updateTrackList();
updateStatus(`Split "${track.name}" into 2 tracks`);
}
// Delete a track
function deleteTrack(track) {
saveStateForUndo();
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
// Remove from selection if selected
const selIdx = selectedTracks.indexOf(track);
if (selIdx > -1) {
selectedTracks.splice(selIdx, 1);
}
updateTrackList();
updateStatus(`Deleted: ${track.name}`);
}
}
// Drawing functions
function startDrawing(latlng) {
isDrawing = true;
drawingPoints = [latlng];
drawingLine = L.polyline(drawingPoints, {
color: '#ff8800',
weight: 3,
dashArray: '5, 10'
}).addTo(map);
}
function continueDrawing(latlng) {
drawingPoints.push(latlng);
drawingLine.setLatLngs(drawingPoints);
}
function finishDrawing() {
if (drawingPoints.length < 2) {
cancelDrawing();
return;
}
const name = prompt('Enter track name:', `Track ${tracks.length + 1}`);
if (name !== null) {
const coords = drawingPoints.map(ll => [ll.lat, ll.lng]);
const track = new Track(coords, name || `Track ${tracks.length + 1}`);
tracks.push(track);
updateTrackList();
updateStatus(`Created: ${track.name}`);
}
cancelDrawing();
}
function cancelDrawing() {
isDrawing = false;
drawingPoints = [];
if (drawingLine) {
map.removeLayer(drawingLine);
drawingLine = null;
}
}
// Map click handler
map.on('click', (e) => {
// Navigation mode - set destination on track click
if (navMode) {
const nearest = findNearestTrackPoint(e.latlng, 50); // 50 pixel threshold
if (nearest) {
setDestination(nearest.track, nearest.index);
}
return;
}
if (currentTool === 'draw') {
if (!isDrawing) {
startDrawing(e.latlng);
} else {
continueDrawing(e.latlng);
}
}
// Don't auto-deselect on map click - use Clear Selection button instead
});
map.on('dblclick', (e) => {
if (currentTool === 'draw' && isDrawing) {
L.DomEvent.stopPropagation(e);
finishDrawing();
}
});
// Reshape tool mouse handlers
map.on('mousedown', (e) => {
if (currentTool === 'reshape' && !isDragging) {
if (startReshapeDrag(e.latlng)) {
L.DomEvent.stopPropagation(e);
}
}
if (currentTool === 'smooth' && !isSmoothing) {
startSmoothing(e.latlng);
map.dragging.disable();
}
});
map.on('mousemove', (e) => {
if (currentTool === 'reshape' && isDragging) {
continueReshapeDrag(e.latlng);
}
if (currentTool === 'smooth' && isSmoothing) {
continueSmoothing(e.latlng);
}
});
map.on('mouseup', (e) => {
if (currentTool === 'reshape' && isDragging) {
finishReshapeDrag();
}
if (currentTool === 'smooth' && isSmoothing) {
finishSmoothing();
map.dragging.enable();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape to cancel reshape
if (e.key === 'Escape' && isDragging) {
cancelReshapeDrag();
}
// Delete key to delete selected tracks
if (e.key === 'Delete' && selectedTracks.length > 0) {
saveStateForUndo();
const count = selectedTracks.length;
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
selectedTracks = [];
updateTrackList();
updateStatus(`Deleted ${count} track(s)`, 'success');
}
});
// Merge selected tracks by connecting end-to-end (in selection order)
function mergeConnect() {
if (selectedTracks.length < 2) {
updateStatus('Select at least 2 tracks to merge', 'error');
return;
}
saveStateForUndo();
// Connect tracks in selection order, finding best endpoint connections
let mergedCoords = [...selectedTracks[0].coords];
for (let i = 1; i < selectedTracks.length; i++) {
const nextCoords = selectedTracks[i].coords;
// Find best connection: check all 4 endpoint combinations
const currentEnd = L.latLng(mergedCoords[mergedCoords.length - 1]);
const currentStart = L.latLng(mergedCoords[0]);
const nextStart = L.latLng(nextCoords[0]);
const nextEnd = L.latLng(nextCoords[nextCoords.length - 1]);
const d1 = map.distance(currentEnd, nextStart); // end -> start (normal)
const d2 = map.distance(currentEnd, nextEnd); // end -> end (reverse next)
const d3 = map.distance(currentStart, nextStart); // start -> start (reverse current)
const d4 = map.distance(currentStart, nextEnd); // start -> end (reverse both)
const minDist = Math.min(d1, d2, d3, d4);
if (minDist === d1) {
// Normal: append next
mergedCoords = [...mergedCoords, ...nextCoords.slice(1)];
} else if (minDist === d2) {
// Reverse next track
mergedCoords = [...mergedCoords, ...nextCoords.slice(0, -1).reverse()];
} else if (minDist === d3) {
// Reverse current, then append next
mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(1)];
} else {
// Reverse current, reverse next
mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(0, -1).reverse()];
}
}
// Create new track
const numMerged = selectedTracks.length;
const name = prompt('Enter name for merged track:', selectedTracks.map(t => t.name).join(' + '));
if (name === null) return;
const newTrack = new Track(mergedCoords, name || 'Merged Track');
// Remove old tracks
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
tracks.push(newTrack);
selectedTracks = [newTrack];
newTrack.setSelected(true);
updateTrackList();
updateStatus(`Merged ${numMerged} tracks into "${newTrack.name}"`, 'success');
}
// Merge selected tracks by averaging overlapping points
function mergeAverage() {
if (selectedTracks.length < 2) {
updateStatus('Select at least 2 tracks to merge', 'error');
return;
}
const threshold = 25; // meters - points within this distance get averaged
// Use the longest track as the base
const sortedByLength = [...selectedTracks].sort((a, b) => b.coords.length - a.coords.length);
const baseTrack = sortedByLength[0];
const otherTracks = sortedByLength.slice(1);
// For each point in other tracks, check if it's near ANY point in base track
const otherTrackData = otherTracks.map(track => ({
track,
points: track.coords.map(coord => {
// Check if this point is near any base track point
let isNearBase = false;
for (const baseCoord of baseTrack.coords) {
if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) {
isNearBase = true;
break;
}
}
return { coord, isNearBase };
})
}));
// For each point in base, find and average nearby points from other tracks
const result = [];
const usedPoints = otherTrackData.map(d => d.points.map(() => false));
for (let i = 0; i < baseTrack.coords.length; i++) {
const basePt = L.latLng(baseTrack.coords[i]);
let sumLat = baseTrack.coords[i][0];
let sumLng = baseTrack.coords[i][1];
let count = 1;
// Find closest point from each other track
for (let t = 0; t < otherTrackData.length; t++) {
const otherData = otherTrackData[t];
let closestDist = Infinity;
let closestIdx = -1;
for (let j = 0; j < otherData.points.length; j++) {
if (usedPoints[t][j]) continue;
const pt = otherData.points[j];
const dist = map.distance(basePt, L.latLng(pt.coord));
if (dist < closestDist && dist < threshold) {
closestDist = dist;
closestIdx = j;
}
}
if (closestIdx >= 0) {
const pt = otherData.points[closestIdx];
sumLat += pt.coord[0];
sumLng += pt.coord[1];
count++;
usedPoints[t][closestIdx] = true;
}
}
result.push([sumLat / count, sumLng / count]);
}
// Collect non-overlapping segments (points NOT near base track)
const branches = [];
for (let t = 0; t < otherTrackData.length; t++) {
const otherData = otherTrackData[t];
let currentBranch = [];
for (let i = 0; i < otherData.points.length; i++) {
const pt = otherData.points[i];
if (!pt.isNearBase) {
// This point is NOT near the base track - it's a branch
currentBranch.push(pt.coord);
} else {
// Point is near base, save any accumulated branch
if (currentBranch.length >= 2) {
branches.push({
coords: [...currentBranch],
trackName: otherData.track.name
});
}
currentBranch = [];
}
}
// Don't forget trailing branch
if (currentBranch.length >= 2) {
branches.push({
coords: [...currentBranch],
trackName: otherData.track.name
});
}
}
// First, remove old tracks
const numMerged = selectedTracks.length;
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
// Create branch tracks (in orange so they're visible)
const branchTracks = [];
for (let i = 0; i < branches.length; i++) {
const branch = branches[i];
const branchTrack = new Track(branch.coords, `${branch.trackName} (branch ${i + 1})`);
branchTrack.layer.setStyle({ color: '#ff8800', weight: 4 }); // Orange
tracks.push(branchTrack);
branchTracks.push(branchTrack);
}
// Create new track for the averaged main path
const name = prompt('Enter name for averaged track:', baseTrack.name + ' (averaged)');
if (name === null) {
// User cancelled, but we already deleted tracks... restore branches at least
updateTrackList();
updateStatus('Cancelled - branches preserved', 'info');
return;
}
const newTrack = new Track(result, name || 'Averaged Track');
tracks.push(newTrack);
selectedTracks = [newTrack];
newTrack.setSelected(true);
updateTrackList();
const branchMsg = branches.length > 0 ? ` + ${branches.length} branch(es) in orange` : '';
updateStatus(`Averaged ${numMerged} tracks${branchMsg}`, 'success');
}
// === PREVIEW SYSTEM ===
function startPreview() {
if (selectedTracks.length === 0) {
updateStatus('Select at least 1 track to preview', 'error');
return;
}
previewMode = true;
const threshold = parseInt(document.getElementById('mergeThreshold').value);
// Hide original tracks
selectedTracks.forEach(t => t.layer.setStyle({ opacity: 0.2 }));
// Generate and show preview
updatePreview(threshold);
// Update UI
document.getElementById('previewBtn').style.display = 'none';
document.getElementById('applyMergeBtn').style.display = 'block';
document.getElementById('cancelPreviewBtn').style.display = 'block';
document.getElementById('mergeThreshold').classList.add('preview-active');
updateStatus('Adjust slider to fine-tune, then Apply or Cancel', 'info');
}
function updatePreview(threshold) {
// Clear old preview layers
previewLayers.forEach(layer => map.removeLayer(layer));
previewLayers = [];
if (selectedTracks.length === 1) {
// Single track: simplify by merging points that double back
previewData = simplifySingleTrack(selectedTracks[0].coords, threshold);
} else {
// Multiple tracks: merge them together
previewData = mergeMultipleTracks(selectedTracks, threshold);
}
// Show preview of main track (green dashed)
if (previewData.mainCoords.length > 0) {
const mainPreview = L.polyline(previewData.mainCoords, {
color: '#00cc00',
weight: 5,
opacity: 0.9,
dashArray: '10, 5'
}).addTo(map);
previewLayers.push(mainPreview);
}
// Show preview of branches (orange dashed)
previewData.branches.forEach(branch => {
const branchPreview = L.polyline(branch.coords, {
color: '#ff8800',
weight: 4,
opacity: 0.9,
dashArray: '10, 5'
}).addTo(map);
previewLayers.push(branchPreview);
});
// Update status with stats
const reduction = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0) - previewData.mainCoords.length;
const branchInfo = previewData.branches.length > 0 ? `, ${previewData.branches.length} branch(es)` : '';
updateStatus(`Preview: ${previewData.mainCoords.length} points${branchInfo} (${reduction} points merged)`, 'info');
}
function cancelPreview() {
previewMode = false;
// Remove preview layers
previewLayers.forEach(layer => map.removeLayer(layer));
previewLayers = [];
previewData = null;
// Restore original tracks
selectedTracks.forEach(t => {
t.layer.setStyle({ opacity: 0.8 });
t.setSelected(true);
});
// Update UI
document.getElementById('previewBtn').style.display = 'block';
document.getElementById('applyMergeBtn').style.display = 'none';
document.getElementById('cancelPreviewBtn').style.display = 'none';
document.getElementById('mergeThreshold').classList.remove('preview-active');
updateStatus('Preview cancelled', 'info');
}
function applyMerge() {
if (!previewData) return;
saveStateForUndo();
// Remove preview layers
previewLayers.forEach(layer => map.removeLayer(layer));
previewLayers = [];
// Remove old tracks
const numMerged = selectedTracks.length;
const oldNames = selectedTracks.map(t => t.name);
selectedTracks.forEach(track => {
const idx = tracks.indexOf(track);
if (idx > -1) {
track.remove();
tracks.splice(idx, 1);
}
});
// Create branch tracks
previewData.branches.forEach((branch, i) => {
const branchTrack = new Track(branch.coords, `${oldNames[0]} (branch ${i + 1})`);
branchTrack.layer.setStyle({ color: '#ff8800' });
tracks.push(branchTrack);
});
// Create main merged track
const defaultName = numMerged === 1 ? `${oldNames[0]} (simplified)` : oldNames.join(' + ');
const name = prompt('Enter name for merged track:', defaultName);
if (name !== null) {
const newTrack = new Track(previewData.mainCoords, name || defaultName);
tracks.push(newTrack);
selectedTracks = [newTrack];
newTrack.setSelected(true);
} else {
selectedTracks = [];
}
// Reset state
previewMode = false;
previewData = null;
// Update UI
document.getElementById('previewBtn').style.display = 'block';
document.getElementById('applyMergeBtn').style.display = 'none';
document.getElementById('cancelPreviewBtn').style.display = 'none';
document.getElementById('mergeThreshold').classList.remove('preview-active');
updateTrackList();
updateStatus(`Applied merge: ${numMerged} track(s)`, 'success');
}
// Simplify a single track: merge points that are spatially close AND temporally far apart
// This detects double-backs while preserving normal path segments
function simplifySingleTrack(coords, threshold) {
if (coords.length < 3) {
return { mainCoords: coords, branches: [] };
}
// Minimum sequence gap required to consider merging
// Points closer than this in sequence won't be merged even if spatially close
const minSequenceGap = 15;
const clusters = []; // { points: [], centroid: [], pointIndices: [] }
const pointToCluster = new Array(coords.length).fill(-1);
for (let i = 0; i < coords.length; i++) {
const pt = L.latLng(coords[i]);
// Find if this point should join an existing cluster
// Requirements:
// 1. Spatially close (within threshold)
// 2. Temporally far (no points from that cluster in recent window)
let bestCluster = -1;
let bestDist = Infinity;
for (let c = 0; c < clusters.length; c++) {
const cluster = clusters[c];
const dist = map.distance(pt, L.latLng(cluster.centroid));
if (dist < threshold && dist < bestDist) {
// Check temporal distance - cluster must not have recent points
const mostRecentInCluster = Math.max(...cluster.pointIndices);
const sequenceGap = i - mostRecentInCluster;
if (sequenceGap >= minSequenceGap) {
// Far enough apart temporally - this is a double-back
bestDist = dist;
bestCluster = c;
}
}
}
if (bestCluster >= 0) {
// Merge with existing cluster (double-back detected)
const cluster = clusters[bestCluster];
cluster.points.push(coords[i]);
cluster.pointIndices.push(i);
// Recalculate centroid
let sumLat = 0, sumLng = 0;
for (const p of cluster.points) {
sumLat += p[0];
sumLng += p[1];
}
cluster.centroid = [sumLat / cluster.points.length, sumLng / cluster.points.length];
pointToCluster[i] = bestCluster;
} else {
// Create new cluster (new territory or too recent to merge)
clusters.push({
points: [coords[i]],
centroid: [...coords[i]],
pointIndices: [i]
});
pointToCluster[i] = clusters.length - 1;
}
}
// Rebuild path following original order, using cluster centroids
// Remove consecutive duplicates (same cluster visited multiple times in a row)
const result = [];
let lastCluster = -1;
for (let i = 0; i < coords.length; i++) {
const clusterIdx = pointToCluster[i];
if (clusterIdx !== lastCluster) {
result.push([...clusters[clusterIdx].centroid]);
lastCluster = clusterIdx;
}
}
return { mainCoords: result, branches: [] };
}
// Merge multiple tracks together
function mergeMultipleTracks(tracksToMerge, threshold) {
// Use the longest track as the base
const sortedByLength = [...tracksToMerge].sort((a, b) => b.coords.length - a.coords.length);
const baseTrack = sortedByLength[0];
const otherTracks = sortedByLength.slice(1);
// For each point in other tracks, check if it's near ANY point in base track
const otherTrackData = otherTracks.map(track => ({
track,
points: track.coords.map(coord => {
let isNearBase = false;
for (const baseCoord of baseTrack.coords) {
if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) {
isNearBase = true;
break;
}
}
return { coord, isNearBase };
})
}));
// For each base point, find corresponding points in other tracks using sequence matching
const result = [];
// Track position in each other track (to maintain order)
const trackPositions = otherTracks.map(() => 0);
for (let i = 0; i < baseTrack.coords.length; i++) {
const basePt = L.latLng(baseTrack.coords[i]);
let sumLat = baseTrack.coords[i][0];
let sumLng = baseTrack.coords[i][1];
let count = 1;
// For each other track, find the best matching point near current position
for (let t = 0; t < otherTracks.length; t++) {
const otherCoords = otherTracks[t].coords;
let bestIdx = -1;
let bestDist = Infinity;
// Search in a window around the current track position
const searchStart = Math.max(0, trackPositions[t] - 10);
const searchEnd = Math.min(otherCoords.length, trackPositions[t] + 50);
for (let j = searchStart; j < searchEnd; j++) {
const dist = map.distance(basePt, L.latLng(otherCoords[j]));
if (dist < threshold && dist < bestDist) {
bestDist = dist;
bestIdx = j;
}
}
if (bestIdx >= 0) {
sumLat += otherCoords[bestIdx][0];
sumLng += otherCoords[bestIdx][1];
count++;
// Move track position forward (only forward, to maintain order)
if (bestIdx >= trackPositions[t]) {
trackPositions[t] = bestIdx + 1;
}
}
}
result.push([sumLat / count, sumLng / count]);
}
// Collect branches (non-overlapping segments)
const branches = [];
for (let t = 0; t < otherTrackData.length; t++) {
const otherData = otherTrackData[t];
let currentBranch = [];
for (let i = 0; i < otherData.points.length; i++) {
const pt = otherData.points[i];
if (!pt.isNearBase) {
currentBranch.push(pt.coord);
} else {
if (currentBranch.length >= 2) {
branches.push({ coords: [...currentBranch] });
}
currentBranch = [];
}
}
if (currentBranch.length >= 2) {
branches.push({ coords: [...currentBranch] });
}
}
return { mainCoords: result, branches };
}
// Parse KML
function parseKML(kmlText) {
const parser = new DOMParser();
const kml = parser.parseFromString(kmlText, 'text/xml');
const placemarks = kml.querySelectorAll('Placemark');
let count = 0;
placemarks.forEach(placemark => {
const name = placemark.querySelector('name')?.textContent || 'Track';
const description = placemark.querySelector('description')?.textContent || '';
// Handle LineString
const lineStrings = placemark.querySelectorAll('LineString');
lineStrings.forEach(lineString => {
const coordsText = lineString.querySelector('coordinates')?.textContent;
const coords = parseCoordinates(coordsText);
if (coords.length > 0) {
const track = new Track(coords, name, description);
tracks.push(track);
count++;
}
});
});
return count;
}
// Parse coordinates
function parseCoordinates(coordString) {
if (!coordString) return [];
const coords = [];
const points = coordString.trim().split(/\s+/);
points.forEach(point => {
const parts = point.split(',');
if (parts.length >= 2) {
const lng = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lng)) {
coords.push([lat, lng]);
}
}
});
return coords;
}
// Export to KML
function exportToKML() {
let placemarks = '';
tracks.forEach(track => {
const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' ');
placemarks += `
<Placemark>
<name>${escapeXml(track.name)}</name>
<description>${escapeXml(track.description)}</description>
<LineString>
<coordinates>${coords}</coordinates>
</LineString>
</Placemark>`;
});
const kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Exported Tracks</name>${placemarks}
</Document>
</kml>`;
const blob = new Blob([kml], { type: 'application/vnd.google-earth.kml+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'tracks-export.kml';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
updateStatus('Exported KML file', 'success');
}
function escapeXml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Update UI
function updateStatus(message, type = '') {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = 'status' + (type ? ' ' + type : '');
}
function updateTrackList() {
const listEl = document.getElementById('trackList');
const countEl = document.getElementById('trackCount');
countEl.textContent = tracks.length;
listEl.innerHTML = tracks.map((track, i) => `
<div class="track-item ${selectedTracks.includes(track) ? 'selected' : ''}" data-index="${i}">
<span>${track.name}</span>
<button class="delete-btn" data-index="${i}">×</button>
</div>
`).join('');
// Add click handlers
listEl.querySelectorAll('.track-item').forEach(item => {
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('delete-btn')) {
const idx = parseInt(item.dataset.index);
selectTrack(tracks[idx]);
map.fitBounds(tracks[idx].layer.getBounds(), { padding: [50, 50] });
}
});
});
listEl.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.index);
deleteTrack(tracks[idx]);
});
});
}
// Event listeners
document.getElementById('kmlFile').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
updateStatus('Loading...', 'info');
const reader = new FileReader();
reader.onload = function(e) {
try {
const count = parseKML(e.target.result);
updateTrackList();
if (tracks.length > 0) {
const bounds = L.latLngBounds(tracks.flatMap(t => t.coords));
map.fitBounds(bounds, { padding: [20, 20] });
}
updateStatus(`Loaded ${count} track(s) from ${file.name}`, 'success');
} catch (err) {
updateStatus(`Error: ${err.message}`, 'error');
}
};
reader.readAsText(file);
});
document.getElementById('exportBtn').addEventListener('click', exportToKML);
document.getElementById('gpsBtn').addEventListener('click', toggleGPS);
document.getElementById('rotateMapBtn').addEventListener('click', toggleRotateMap);
document.getElementById('autoCenterBtn').addEventListener('click', toggleAutoCenter);
// Tab switching
document.getElementById('editTab').addEventListener('click', () => switchTab('edit'));
document.getElementById('navTab').addEventListener('click', () => switchTab('navigate'));
// Navigation
document.getElementById('clearNavBtn').addEventListener('click', clearDestination);
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('mergeConnectBtn').addEventListener('click', mergeConnect);
document.getElementById('selectAllBtn').addEventListener('click', selectAll);
document.getElementById('clearSelectionBtn').addEventListener('click', clearSelection);
// Preview system
document.getElementById('previewBtn').addEventListener('click', startPreview);
document.getElementById('applyMergeBtn').addEventListener('click', applyMerge);
document.getElementById('cancelPreviewBtn').addEventListener('click', cancelPreview);
// Live slider update during preview
document.getElementById('mergeThreshold').addEventListener('input', (e) => {
document.getElementById('thresholdValue').textContent = e.target.value;
if (previewMode) {
updatePreview(parseInt(e.target.value));
}
});
// Anchor distance slider update
document.getElementById('anchorDistance').addEventListener('input', (e) => {
document.getElementById('anchorValue').textContent = e.target.value;
// If currently dragging, update the affected markers display
if (isDragging && originalCoords) {
showAffectedRange();
// Re-apply rope physics with new anchor distance
const anchorDist = parseInt(e.target.value);
const draggedPoint = dragTrack.coords[dragPointIndex];
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist);
dragTrack.coords = newCoords;
dragTrack.layer.setLatLngs(newCoords);
updateAffectedMarkersPositions(newCoords);
}
});
// Falloff slider update
document.getElementById('reshapeFalloff').addEventListener('input', (e) => {
document.getElementById('falloffValue').textContent = parseFloat(e.target.value).toFixed(1);
// If currently dragging, re-apply with new falloff
if (isDragging && originalCoords) {
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
const draggedPoint = dragTrack.coords[dragPointIndex];
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist);
dragTrack.coords = newCoords;
dragTrack.layer.setLatLngs(newCoords);
updateAffectedMarkersPositions(newCoords);
}
});
// Smooth brush size slider update
document.getElementById('smoothBrushSize').addEventListener('input', (e) => {
document.getElementById('brushSizeValue').textContent = e.target.value;
// Update brush circle if currently smoothing
if (isSmoothing && smoothBrushCircle) {
const brushSize = parseInt(e.target.value);
smoothBrushCircle.setRadius(brushSize * getMetersPerPixel());
}
});
// Smooth strength slider update
document.getElementById('smoothStrength').addEventListener('input', (e) => {
document.getElementById('strengthValue').textContent = parseFloat(e.target.value).toFixed(1);
});
// Initialize
setTool('select');
updateTrackList();
updateUndoButton();
// Start in navigation mode
navMode = true;
// Auto-load default.kml
fetch('default.kml')
.then(response => {
if (!response.ok) throw new Error('default.kml not found');
return response.text();
})
.then(kmlText => {
const count = parseKML(kmlText);
updateTrackList();
updateStatus(`Loaded ${count} track(s) from default.kml`, 'success');
// Restore saved destination after tracks are loaded
restoreDestination();
})
.catch(err => {
console.log('No default.kml found, starting empty');
});
// Auto-start GPS and zoom to location
if (navigator.geolocation) {
toggleGPS();
}
</script>
</body>
</html>