Files
reitti/docs/tools/gpx-generator/gpx-generator.js
2025-12-20 14:33:03 +01:00

1583 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GPX Generator JavaScript
let map;
let tracks = []; // Array of track objects
let currentTrackIndex = 0;
let polylines = [];
let markers = []; // Keep for compatibility, but will use canvas
let markerCanvas;
let markerCanvasLayer;
let hoverTooltip;
let previewLine;
let paintMode = false;
let paintActive = false; // Whether painting is currently active
let lastPaintTime = 0;
let paintThrottleMs = 100; // Minimum time between paint points
let paintInterval = null; // Interval for automatic painting
let lastMousePosition = null; // Store last mouse position for painting
let currentMapLayer = 'street'; // 'street' or 'satellite'
let streetLayer, satelliteLayer;
let stopProbability = 0.05; // 5% chance of adding a stop per point when auto-stops enabled
// Track colors for visual distinction
const TRACK_COLORS = [
'#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6',
'#1abc9c', '#34495e', '#e67e22', '#95a5a6', '#d35400'
];
// Global edit mode state
let editModeEnabled = false;
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
initializeTheme();
initializeMap();
initializeDateTime();
initializeControls();
initializeKeyboardShortcuts();
createNewTrack(); // Create the first track
updateStatus();
// Start in view mode (edit disabled)
editModeEnabled = false;
});
// Functions to control edit mode from HTML
window.enableEditMode = function() {
editModeEnabled = true;
updateStatus(); // Update status when edit mode changes
};
window.disableEditMode = function() {
editModeEnabled = false;
// Stop paint mode when disabling edit mode
if (paintMode) {
paintMode = false;
paintActive = false;
stopAutoPainting();
updatePaintModeButton();
if (map) {
map.getContainer().style.cursor = '';
}
}
updateStatus(); // Update status when edit mode changes
};
function initializeTheme() {
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('gpx-generator-theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeButton();
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('gpx-generator-theme', newTheme);
updateThemeButton();
}
function updateThemeButton() {
const button = document.getElementById('themeToggle');
const currentTheme = document.documentElement.getAttribute('data-theme');
button.textContent = currentTheme === 'dark' ? '☀️' : '🌙';
}
function toggleMapLayer() {
if (currentMapLayer === 'street') {
map.removeLayer(streetLayer);
map.addLayer(satelliteLayer);
currentMapLayer = 'satellite';
document.getElementById('layerToggle').textContent = 'Street';
} else {
map.removeLayer(satelliteLayer);
map.addLayer(streetLayer);
currentMapLayer = 'street';
document.getElementById('layerToggle').textContent = 'Satellite';
}
}
function initializeMap() {
// Initialize Leaflet map
map = L.map('map').setView([60.1699, 24.9384], 10); // Helsinki as default
// Create map layers
streetLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
});
satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '© Esri, Maxar, Earthstar Geographics'
});
// Add default layer
streetLayer.addTo(map);
// Initialize canvas marker layer
initializeCanvasMarkers();
// Add event listeners
map.on('click', onMapClick);
map.on('mousemove', onMapMouseMove);
map.on('mouseout', onMapMouseOut);
map.on('zoom', redrawMarkers);
map.on('move', redrawMarkers);
// Polylines will be created per track
polylines = [];
// Initialize preview line
previewLine = L.polyline([], {
color: '#95a5a6',
weight: 2,
opacity: 0.5,
dashArray: '5, 5'
}).addTo(map);
// Create hover tooltip
hoverTooltip = document.createElement('div');
hoverTooltip.className = 'hover-tooltip';
hoverTooltip.style.display = 'none';
document.body.appendChild(hoverTooltip);
}
function initializeDateTime() {
// Set default datetime to current time in user's locale
const now = new Date();
// Format for datetime-local input (YYYY-MM-DDTHH:mm:ss)
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const datetimeString = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
document.getElementById('startDateTime').value = datetimeString;
}
function initializeControls() {
// Update accuracy value display when slider changes
const accuracySlider = document.getElementById('accuracySlider');
const accuracyValue = document.getElementById('accuracyValue');
accuracySlider.addEventListener('input', function() {
accuracyValue.textContent = this.value;
});
}
function initializeKeyboardShortcuts() {
document.addEventListener('keydown', function(e) {
// Only handle shortcuts when paint mode is active
if (!paintMode) return;
// Prevent default behavior for + and - keys to avoid Leaflet zoom conflicts
if (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '_') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const maxSpeedInput = document.getElementById('maxSpeed');
let currentSpeed = parseFloat(maxSpeedInput.value);
let newSpeed = currentSpeed;
let increment = 5;
// Use larger increment with Shift key
if (e.shiftKey) {
increment = 10;
}
if (e.key === '+' || e.key === '=') {
newSpeed = Math.min(currentSpeed + increment, 300); // Max 300 km/h
} else if (e.key === '-' || e.key === '_') {
newSpeed = Math.max(currentSpeed - increment, 1); // Min 1 km/h to prevent browser issues
}
if (newSpeed !== currentSpeed) {
maxSpeedInput.value = newSpeed;
showSpeedChangeNotification(newSpeed);
}
return false;
}
}, true); // Use capture phase to intercept before Leaflet
}
function showSpeedChangeNotification(speed) {
// Remove existing notification if present
const existingNotification = document.querySelector('.speed-notification');
if (existingNotification) {
existingNotification.remove();
}
// Create notification element
const notification = document.createElement('div');
notification.className = 'speed-notification';
notification.textContent = `Max Speed: ${speed} km/h`;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: rgba(26, 32, 44, 0.95);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 10000;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
transition: opacity 0.3s ease;
`;
document.body.appendChild(notification);
// Auto-remove after 2 seconds
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 300);
}, 2000);
}
function createNewTrack() {
const trackIndex = tracks.length;
const color = TRACK_COLORS[trackIndex % TRACK_COLORS.length];
// Collapse all existing tracks
tracks.forEach(track => {
track.collapsed = true;
});
// Set start time based on last point of previous track or current time
let startTime = new Date();
if (tracks.length > 0) {
const lastTrack = tracks[tracks.length - 1];
if (lastTrack.points.length > 0) {
const lastPoint = lastTrack.points[lastTrack.points.length - 1];
const timeInterval = parseInt(document.getElementById('timeInterval').value);
startTime = new Date(lastPoint.timestamp.getTime() + (timeInterval * 1000));
}
}
const track = {
id: trackIndex,
name: `Track ${trackIndex + 1}`,
points: [],
color: color,
collapsed: false,
startTime: startTime
};
tracks.push(track);
currentTrackIndex = trackIndex;
// Create polyline for this track
const polyline = L.polyline([], {
color: color,
weight: 3,
opacity: 0.7
}).addTo(map);
polylines.push(polyline);
updatePointsList();
updateStatus();
}
function newTrack() {
createNewTrack();
}
function onMapClick(e) {
// Prevent point addition if edit mode is disabled
if (!editModeEnabled) {
return;
}
if (paintMode) {
// Toggle paint active state
paintActive = !paintActive;
lastMousePosition = e.latlng; // Store initial position
if (paintActive) {
// Add first point immediately
const currentTrack = tracks[currentTrackIndex];
if (currentTrack && currentTrack.points.length > 0) {
addPointWithInterpolation(lastMousePosition.lat, lastMousePosition.lng);
} else {
addPoint(lastMousePosition.lat, lastMousePosition.lng);
}
// Start automatic painting
startAutoPainting();
} else {
// Stop automatic painting
stopAutoPainting();
}
updatePaintModeButton();
return;
}
const currentTrack = tracks[currentTrackIndex];
if (currentTrack && currentTrack.points.length > 0) {
addPointWithInterpolation(e.latlng.lat, e.latlng.lng);
} else {
addPoint(e.latlng.lat, e.latlng.lng);
}
}
function addPoint(lat, lng) {
if (tracks.length === 0) {
createNewTrack();
}
const currentTrack = tracks[currentTrackIndex];
const pointIndex = currentTrack.points.length;
// Use current datetime input value as timestamp
const startDateTimeValue = document.getElementById('startDateTime').value;
let timestamp = new Date(startDateTimeValue);
// Check if we need to create a new track due to day change
if (shouldCreateNewTrackForDayChange(timestamp, currentTrack)) {
createNewTrack();
return addPoint(lat, lng); // Recursively add to new track
}
// Check for realistic stops
if (document.getElementById('autoStops').checked && shouldAddStop(currentTrack)) {
timestamp = addRealisticStop(timestamp);
}
// Apply GPS accuracy simulation
const accuracy = parseFloat(document.getElementById('accuracySlider').value);
const adjustedCoords = applyGPSNoise(lat, lng, accuracy);
// Calculate elevation with variation
const baseElevation = parseFloat(document.getElementById('elevation').value);
const elevationVariation = parseFloat(document.getElementById('elevationVariation').value);
const elevation = baseElevation + (Math.random() - 0.5) * 2 * elevationVariation;
const point = {
id: pointIndex,
trackId: currentTrackIndex,
lat: adjustedCoords.lat,
lng: adjustedCoords.lng,
originalLat: lat,
originalLng: lng,
timestamp: timestamp,
elevation: elevation,
accuracy: accuracy
};
currentTrack.points.push(point);
// Store marker data for canvas rendering
const markerData = {
lat: adjustedCoords.lat,
lng: adjustedCoords.lng,
color: currentTrack.color,
trackIndex: currentTrackIndex,
pointIndex: pointIndex,
title: `${currentTrack.name} - Point ${pointIndex + 1}`
};
markers.push(markerData);
// Update polyline for current track
updatePolyline(currentTrackIndex);
// Redraw markers to include the new point
redrawMarkers();
// Update datetime input to next interval for next point
const timeInterval = parseInt(document.getElementById('timeInterval').value);
const nextTimestamp = new Date(timestamp.getTime() + (timeInterval * 1000));
// Format for datetime-local input (YYYY-MM-DDTHH:mm:ss)
const year = nextTimestamp.getFullYear();
const month = String(nextTimestamp.getMonth() + 1).padStart(2, '0');
const day = String(nextTimestamp.getDate()).padStart(2, '0');
const hours = String(nextTimestamp.getHours()).padStart(2, '0');
const minutes = String(nextTimestamp.getMinutes()).padStart(2, '0');
const seconds = String(nextTimestamp.getSeconds()).padStart(2, '0');
const datetimeString = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
document.getElementById('startDateTime').value = datetimeString;
// Update UI
updatePointsList();
updateStatus();
// Clear preview line
previewLine.setLatLngs([]);
}
function removePoint(trackIndex, pointIndex) {
const track = tracks[trackIndex];
if (pointIndex < 0 || pointIndex >= track.points.length) return;
// Find the marker to remove
const point = track.points[pointIndex];
const markerIndex = markers.findIndex(marker => {
return marker.trackIndex === trackIndex && marker.pointIndex === pointIndex;
});
// Remove marker from array
if (markerIndex >= 0) {
markers.splice(markerIndex, 1);
}
// Remove from track
track.points.splice(pointIndex, 1);
// Reassign IDs to remaining points in track and update marker data
for (let i = pointIndex; i < track.points.length; i++) {
track.points[i].id = i;
}
// Update marker indices for remaining points in this track
markers.forEach(marker => {
if (marker.trackIndex === trackIndex && marker.pointIndex > pointIndex) {
marker.pointIndex--;
}
});
// Update polyline for this track
updatePolyline(trackIndex);
// Redraw markers
redrawMarkers();
// Update UI
updatePointsList();
updateStatus();
}
function updatePolyline(trackIndex) {
if (trackIndex !== undefined) {
// Update specific track
const track = tracks[trackIndex];
const latLngs = track.points.map(point => [point.lat, point.lng]);
polylines[trackIndex].setLatLngs(latLngs);
} else {
// Update all tracks
tracks.forEach((track, index) => {
const latLngs = track.points.map(point => [point.lat, point.lng]);
polylines[index].setLatLngs(latLngs);
});
}
}
function updatePointsList() {
const pointsList = document.getElementById('pointsList');
const pointCount = document.getElementById('pointCount');
const totalPoints = tracks.reduce((sum, track) => sum + track.points.length, 0);
pointCount.textContent = totalPoints;
updateSpeedLegend();
if (totalPoints === 0) {
pointsList.innerHTML = '<div style="padding: 20px; text-align: center; color: #7f8c8d;">Click on the map to add points</div>';
return;
}
let html = '';
let lastAddedPoint = null;
tracks.forEach((track, trackIndex) => {
const isCollapsed = track.collapsed;
const pointCount = track.points.length;
const isCurrentTrack = trackIndex === currentTrackIndex;
html += `
<div class="track-header ${isCollapsed ? 'collapsed' : ''} ${isCurrentTrack ? 'active' : ''}" onclick="selectTrackFromHeader(${trackIndex})">
<div class="track-info">
<div class="track-color" style="background-color: ${track.color}"></div>
<span>${track.name} (${pointCount} points)</span>
</div>
<div class="track-controls">
<button class="track-export-btn" onclick="event.stopPropagation(); exportTrackGPX(${trackIndex})">Export</button>
<span class="collapse-icon">▼</span>
</div>
</div>
<div class="track-points ${isCollapsed ? 'collapsed' : ''}">
`;
track.points.forEach((point, pointIndex) => {
const speedInfo = pointIndex > 0 ? calculateSpeedInfo(track.points[pointIndex - 1], point) : null;
const speedColor = speedInfo ? getSpeedColor(speedInfo.speed) : '#e2e8f0';
const speedText = speedInfo ? `${speedInfo.speed.toFixed(1)}` : '-';
const pointId = `point-${trackIndex}-${pointIndex}`;
html += `
<div class="point-item" id="${pointId}" onclick="selectPoint(${trackIndex}, ${pointIndex})" style="border-left-color: ${speedColor}">
<div class="point-content">
<div class="point-coords">${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}</div>
<div class="point-time">${formatCompactTimestamp(point.timestamp)}</div>
<div class="point-speed" style="color: ${speedColor}">${speedText}</div>
</div>
<button class="point-delete" onclick="event.stopPropagation(); removePoint(${trackIndex}, ${pointIndex})">×</button>
</div>
`;
// Track the last added point for scrolling
if (trackIndex === currentTrackIndex && pointIndex === track.points.length - 1) {
lastAddedPoint = pointId;
}
});
html += '</div>';
});
pointsList.innerHTML = html;
// Auto-scroll to latest point in paint mode
if (paintMode && paintActive && lastAddedPoint) {
setTimeout(() => {
const element = document.getElementById(lastAddedPoint);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 50);
}
}
function toggleTrack(trackIndex) {
tracks[trackIndex].collapsed = !tracks[trackIndex].collapsed;
updatePointsList();
}
function selectTrackFromHeader(trackIndex) {
// If clicking on current track, just toggle collapse
if (trackIndex === currentTrackIndex) {
toggleTrack(trackIndex);
} else {
// Select new track and ensure it's expanded
currentTrackIndex = trackIndex;
tracks[trackIndex].collapsed = false;
updatePointsList();
updateStatus();
}
}
function selectPoint(trackIndex, pointIndex) {
const track = tracks[trackIndex];
if (pointIndex < 0 || pointIndex >= track.points.length) return;
const point = track.points[pointIndex];
map.setView([point.lat, point.lng], map.getZoom());
// Highlight selected point in list
document.querySelectorAll('.point-item').forEach(item => {
item.classList.remove('selected');
});
// Find and highlight the correct point item
const trackElements = document.querySelectorAll('.track-points');
if (trackElements[trackIndex]) {
const pointItems = trackElements[trackIndex].querySelectorAll('.point-item');
if (pointItems[pointIndex]) {
pointItems[pointIndex].classList.add('selected');
}
}
}
function formatTimestamp(timestamp) {
return timestamp.toLocaleString();
}
function formatCompactTimestamp(timestamp) {
const today = new Date();
const pointDate = new Date(timestamp);
// Check if it's the same date as today
const isToday = pointDate.toDateString() === today.toDateString();
if (isToday) {
return pointDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} else {
// Include date for different days
const dateStr = pointDate.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
const timeStr = pointDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `${dateStr} ${timeStr}`;
}
}
function updateSpeedLegend() {
const speedLegendInline = document.getElementById('speedLegendInline');
const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
const speedRanges = [
{ min: 0, max: 5, label: '0-5' },
{ min: 5, max: 10, label: '5-10' },
{ min: 10, max: 15, label: '10-15' },
{ min: 15, max: 20, label: '15-20' },
{ min: 20, max: 25, label: '20-25' },
{ min: 25, max: 30, label: '25-30' },
{ min: 30, max: 50, label: '30-50' },
{ min: 50, max: 100, label: '50+' }
];
let html = '';
speedRanges.forEach(range => {
const color = getSpeedColor((range.min + range.max) / 2);
html += `
<div class="speed-legend-item">
<div class="speed-color-indicator" style="background-color: ${color}"></div>
<span>${range.label}</span>
</div>
`;
});
speedLegendInline.innerHTML = html;
}
function getSpeedColor(speed) {
// Color gradient based on speed ranges (5km/h intervals)
if (speed < 5) return '#48bb78'; // Green - very slow
if (speed < 10) return '#68d391'; // Light green - slow
if (speed < 15) return '#9ae6b4'; // Lighter green - walking/cycling
if (speed < 20) return '#fbb040'; // Orange - moderate cycling
if (speed < 25) return '#ed8936'; // Dark orange - fast cycling
if (speed < 30) return '#f56565'; // Red - very fast cycling/slow car
if (speed < 50) return '#e53e3e'; // Dark red - car speed
return '#c53030'; // Very dark red - high speed
}
function updateStatus() {
const statusText = document.getElementById('statusText');
const pointsSummary = document.getElementById('pointsSummary');
let paintStatus = 'OFF';
if (paintMode && paintActive) {
paintStatus = 'PAINTING';
} else if (paintMode) {
paintStatus = 'READY';
}
const totalPoints = tracks.reduce((sum, track) => sum + track.points.length, 0);
const maxSpeed = document.getElementById('maxSpeed').value;
const modeText = `Paint: ${paintStatus} • Speed: ${maxSpeed}km/h • Current: ${tracks[currentTrackIndex]?.name || 'None'}`;
if (totalPoints === 0) {
if (!editModeEnabled) {
statusText.textContent = 'View mode - switch to edit mode to add points';
pointsSummary.textContent = 'Switch to edit mode to start creating tracks';
} else if (paintMode && paintActive) {
statusText.textContent = 'Painting active - move mouse to add points (±: adjust speed)';
pointsSummary.textContent = 'Move mouse over the map to paint points';
} else if (paintMode) {
statusText.textContent = 'Paint mode ready - click map to start painting (±: adjust speed)';
pointsSummary.textContent = 'Click on the map to start painting points';
} else {
statusText.textContent = 'Ready to create GPX tracks';
pointsSummary.textContent = 'Click on the map to add points';
}
} else if (totalPoints === 1) {
if (!editModeEnabled) {
statusText.textContent = '1 point - switch to edit mode to add more';
} else {
statusText.textContent = '1 point added - click to add more points';
}
pointsSummary.innerHTML = `<strong>1 point</strong> in ${tracks.length} track(s) • ${modeText}`;
} else {
const duration = calculateTotalDuration();
const totalDistance = calculateTotalDistance();
if (!editModeEnabled) {
statusText.textContent = `${totalPoints} points in ${tracks.length} track(s) - viewing mode`;
} else {
statusText.textContent = `${totalPoints} points in ${tracks.length} track(s) - Total duration: ${formatDuration(duration)}`;
}
pointsSummary.innerHTML = `<strong>${totalPoints} points</strong> • ${formatDistance(totalDistance)}${formatDuration(duration)}${modeText}`;
}
}
function calculateTotalDuration() {
if (tracks.length === 0) return 0;
let firstTimestamp = null;
let lastTimestamp = null;
tracks.forEach(track => {
if (track.points.length > 0) {
const trackFirst = track.points[0].timestamp;
const trackLast = track.points[track.points.length - 1].timestamp;
if (!firstTimestamp || trackFirst < firstTimestamp) {
firstTimestamp = trackFirst;
}
if (!lastTimestamp || trackLast > lastTimestamp) {
lastTimestamp = trackLast;
}
}
});
if (!firstTimestamp || !lastTimestamp) return 0;
return Math.floor((lastTimestamp - firstTimestamp) / 1000); // seconds
}
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
function clearAll() {
const totalPoints = tracks.reduce((sum, track) => sum + track.points.length, 0);
if (totalPoints === 0) return;
if (confirm('Are you sure you want to clear all tracks and points?')) {
// Clear tracks array
tracks = [];
// Clear markers array
markers = [];
// Remove all polylines
polylines.forEach(polyline => map.removeLayer(polyline));
polylines = [];
// Clear canvas
redrawMarkers();
// Create first track
createNewTrack();
// Update UI
updatePointsList();
updateStatus();
}
}
function shouldCreateNewTrackForDayChange(newTimestamp, currentTrack) {
const autoNewTrack = document.getElementById('autoNewTrack').checked;
if (!autoNewTrack || currentTrack.points.length === 0) {
return false;
}
const lastPoint = currentTrack.points[currentTrack.points.length - 1];
const lastDate = new Date(lastPoint.timestamp).toDateString();
const newDate = new Date(newTimestamp).toDateString();
return lastDate !== newDate;
}
function exportTrackGPX(trackIndex) {
const track = tracks[trackIndex];
if (track.points.length === 0) {
alert('This track has no points to export.');
return;
}
const gpxContent = generateTrackGPX(track);
const filename = generateTrackFilename(track);
downloadFile(gpxContent, filename, 'application/gpx+xml');
}
function exportAllGPX() {
const tracksWithPoints = tracks.filter(track => track.points.length > 0);
if (tracksWithPoints.length === 0) {
alert('Please add some points before exporting.');
return;
}
tracksWithPoints.forEach((track, index) => {
setTimeout(() => {
const gpxContent = generateTrackGPX(track);
const filename = generateTrackFilename(track);
downloadFile(gpxContent, filename, 'application/gpx+xml');
}, index * 100); // Small delay between downloads
});
}
function generateTrackGPX(track) {
const startDateTimeValue = document.getElementById('startDateTime').value;
const startDateStr = startDateTimeValue ? new Date(startDateTimeValue).toISOString().split('T')[0] : '';
let gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="GPX Test Data Generator" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${track.name} ${startDateStr}</name>
<desc>Generated test track for Reitti</desc>
<time>${new Date().toISOString()}</time>
</metadata>
<trk>
<name>${track.name}</name>
<trkseg>
`;
track.points.forEach(point => {
const isoTimestamp = point.timestamp.toISOString();
gpx += ` <trkpt lat="${point.lat}" lon="${point.lng}">
<ele>${point.elevation.toFixed(1)}</ele>
<time>${isoTimestamp}</time>
</trkpt>
`;
});
gpx += ` </trkseg>
</trk>
</gpx>`;
return gpx;
}
function generateTrackFilename(track) {
const startDateTimeValue = document.getElementById('startDateTime').value;
let filename = track.name.toLowerCase().replace(/\s+/g, '_');
if (startDateTimeValue) {
const dateTime = new Date(startDateTimeValue);
const dateStr = dateTime.toISOString().split('T')[0];
const timeStr = dateTime.toTimeString().split(' ')[0].replace(/:/g, '');
filename += `_${dateStr}_${timeStr}`;
} else if (track.points.length > 0) {
const firstPoint = track.points[0];
const dateStr = firstPoint.timestamp.toISOString().split('T')[0];
const timeStr = firstPoint.timestamp.toTimeString().split(' ')[0].replace(/:/g, '');
filename += `_${dateStr}_${timeStr}`;
}
return `${filename}.gpx`;
}
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// GPX file handling functions
function handleGPXFiles(event) {
const files = Array.from(event.target.files);
if (files.length === 0) return;
let processedFiles = 0;
let totalPoints = 0;
let totalTracks = 0;
files.forEach((file, index) => {
const reader = new FileReader();
reader.onload = function(e) {
try {
const result = parseAndImportGPX(e.target.result, file.name, index === 0);
totalPoints += result.pointsCount;
totalTracks += result.tracksCount;
processedFiles++;
// Show summary when all files are processed
if (processedFiles === files.length) {
alert(`Successfully imported ${files.length} GPX file(s):\n${totalPoints} total points split into ${totalTracks} track(s).`);
}
} catch (error) {
alert(`Error parsing GPX file "${file.name}": ${error.message}`);
processedFiles++;
}
};
reader.readAsText(file);
});
// Reset the input so the same files can be selected again
event.target.value = '';
}
function handleGPXFile(event) {
// Keep backward compatibility
handleGPXFiles(event);
}
function parseAndImportGPX(gpxContent, filename, isFirstFile = true) {
const parser = new DOMParser();
const gpxDoc = parser.parseFromString(gpxContent, 'text/xml');
// Check for parsing errors
const parserError = gpxDoc.querySelector('parsererror');
if (parserError) {
throw new Error('Invalid GPX file format');
}
// Extract track points from all tracks and track segments
const trackPoints = [];
const gpxTracks = gpxDoc.querySelectorAll('trk');
gpxTracks.forEach(track => {
const trackSegments = track.querySelectorAll('trkseg');
trackSegments.forEach(segment => {
const points = segment.querySelectorAll('trkpt');
points.forEach(point => {
const lat = parseFloat(point.getAttribute('lat'));
const lng = parseFloat(point.getAttribute('lon'));
if (isNaN(lat) || isNaN(lng)) return;
const timeElement = point.querySelector('time');
const elevationElement = point.querySelector('ele');
let timestamp = new Date();
if (timeElement && timeElement.textContent) {
timestamp = new Date(timeElement.textContent);
if (isNaN(timestamp.getTime())) {
timestamp = new Date();
}
}
let elevation = parseFloat(document.getElementById('elevation').value);
if (elevationElement && elevationElement.textContent) {
const parsedElevation = parseFloat(elevationElement.textContent);
if (!isNaN(parsedElevation)) {
elevation = parsedElevation;
}
}
trackPoints.push({
lat: lat,
lng: lng,
timestamp: timestamp,
elevation: elevation
});
});
});
});
if (trackPoints.length === 0) {
alert('No valid track points found in the GPX file.');
return;
}
// Sort points by timestamp
trackPoints.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
// Group points by local timezone day
const pointsByDay = groupPointsByLocalDay(trackPoints);
// Create tracks for each day
let importedTracksCount = 0;
let isFirstImportedTrack = isFirstFile;
Object.keys(pointsByDay).sort().forEach(dayKey => {
const dayPoints = pointsByDay[dayKey];
if (dayPoints.length === 0) return;
let track;
let trackIndex;
let color;
// Check if we can reuse the first empty track
if (isFirstImportedTrack && tracks.length > 0 && tracks[0].points.length === 0) {
// Reuse the existing empty track
track = tracks[0];
trackIndex = 0;
color = track.color;
// Update track properties
track.name = `${filename.replace('.gpx', '')} - ${dayKey}`;
track.startTime = dayPoints[0].timestamp;
track.collapsed = false;
// Collapse all other existing tracks
tracks.forEach((t, index) => {
if (index !== 0) {
t.collapsed = true;
}
});
} else {
// Collapse all existing tracks
tracks.forEach(track => {
track.collapsed = true;
});
// Create new track for this day
trackIndex = tracks.length;
color = TRACK_COLORS[trackIndex % TRACK_COLORS.length];
const trackName = `${filename.replace('.gpx', '')} - ${dayKey}`;
track = {
id: trackIndex,
name: trackName,
points: [],
color: color,
collapsed: false,
startTime: dayPoints[0].timestamp
};
tracks.push(track);
// Create polyline for this track
const polyline = L.polyline([], {
color: color,
weight: 3,
opacity: 0.7
}).addTo(map);
polylines.push(polyline);
}
isFirstImportedTrack = false;
// Add points to the track
dayPoints.forEach((point, pointIndex) => {
const trackPoint = {
id: pointIndex,
trackId: trackIndex,
lat: point.lat,
lng: point.lng,
originalLat: point.lat,
originalLng: point.lng,
timestamp: point.timestamp,
elevation: point.elevation,
accuracy: 0 // Imported points have no accuracy simulation
};
track.points.push(trackPoint);
// Store marker data for canvas rendering
const markerData = {
lat: point.lat,
lng: point.lng,
color: color,
trackIndex: trackIndex,
pointIndex: pointIndex,
title: `${track.name} - Point ${pointIndex + 1}`
};
markers.push(markerData);
});
// Update polyline for this track
updatePolyline(trackIndex);
importedTracksCount++;
});
// Set current track to the last imported track
if (importedTracksCount > 0) {
currentTrackIndex = tracks.length - 1;
// Fit map to show all imported points
if (trackPoints.length > 0) {
const bounds = L.latLngBounds();
trackPoints.forEach(point => {
bounds.extend([point.lat, point.lng]);
});
map.fitBounds(bounds.pad(0.1));
}
}
// Update UI
updatePointsList();
// Redraw all markers
redrawMarkers();
updateStatus();
// Return import statistics for summary
return {
pointsCount: trackPoints.length,
tracksCount: importedTracksCount
};
}
function groupPointsByLocalDay(points) {
const pointsByDay = {};
points.forEach(point => {
// Get local date string (YYYY-MM-DD) using the browser's timezone
const localDate = new Date(point.timestamp.getTime() - (point.timestamp.getTimezoneOffset() * 60000));
const localDateString = localDate.toISOString().split('T')[0];
if (!pointsByDay[localDateString]) {
pointsByDay[localDateString] = [];
}
pointsByDay[localDateString].push(point);
});
return pointsByDay;
}
// New functions for Phase 2 features
function onMapMouseMove(e) {
// Don't show preview or allow painting if edit mode is disabled
if (!editModeEnabled) {
hideHoverTooltip();
previewLine.setLatLngs([]);
return;
}
// Update mouse position for paint mode
if (paintMode) {
lastMousePosition = e.latlng;
if (paintActive) {
// Add point immediately when mouse moves during painting
const now = Date.now();
if (now - lastPaintTime >= paintThrottleMs) {
const currentTrack = tracks[currentTrackIndex];
if (currentTrack && currentTrack.points.length > 0) {
addPointWithInterpolation(lastMousePosition.lat, lastMousePosition.lng);
} else {
addPoint(lastMousePosition.lat, lastMousePosition.lng);
}
lastPaintTime = now;
// Reset the auto-paint interval since we just added a point
resetAutoPaintInterval();
}
return; // Don't show preview when actively painting
}
}
const currentTrack = tracks[currentTrackIndex];
if (!currentTrack || currentTrack.points.length === 0) {
hideHoverTooltip();
previewLine.setLatLngs([]);
return;
}
const lastPoint = currentTrack.points[currentTrack.points.length - 1];
const mouseLatLng = e.latlng;
// Update preview line with current track color
previewLine.setStyle({ color: currentTrack.color });
previewLine.setLatLngs([[lastPoint.lat, lastPoint.lng], [mouseLatLng.lat, mouseLatLng.lng]]);
// Calculate preview information
const distance = calculateDistance(lastPoint.lat, lastPoint.lng, mouseLatLng.lat, mouseLatLng.lng);
const timeInterval = parseInt(document.getElementById('timeInterval').value);
const speed = (distance / 1000) / (timeInterval / 3600); // km/h
const speedClass = getSpeedClass(speed);
// Show hover tooltip
showHoverTooltip(e.originalEvent, mouseLatLng, distance, speed, speedClass);
}
function onMapMouseOut(e) {
hideHoverTooltip();
previewLine.setLatLngs([]);
}
function showHoverTooltip(mouseEvent, latLng, distance, speed, speedClass) {
const tooltip = hoverTooltip;
const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
const timeInterval = parseInt(document.getElementById('timeInterval').value);
const maxDistance = (maxSpeed * 1000 / 3600) * timeInterval; // meters
let interpolationInfo = '';
if (distance > maxDistance) {
const numSegments = Math.ceil(distance / maxDistance);
interpolationInfo = `<div style="color: #ed8936;">Will add ${numSegments} points</div>`;
}
tooltip.innerHTML = `
<div>Lat: ${latLng.lat.toFixed(6)}</div>
<div>Lng: ${latLng.lng.toFixed(6)}</div>
<div>Distance: ${distance.toFixed(0)}m</div>
<div class="${speedClass}">Speed: ${speed.toFixed(1)} km/h</div>
${interpolationInfo}
`;
tooltip.style.left = (mouseEvent.pageX + 10) + 'px';
tooltip.style.top = (mouseEvent.pageY - 10) + 'px';
tooltip.style.display = 'block';
}
function hideHoverTooltip() {
hoverTooltip.style.display = 'none';
}
function calculateDistance(lat1, lng1, lat2, lng2) {
const R = 6371000; // Earth's radius in meters
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng/2) * Math.sin(dLng/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function calculateSpeedInfo(point1, point2) {
const distance = calculateDistance(point1.lat, point1.lng, point2.lat, point2.lng);
const timeDiff = (point2.timestamp - point1.timestamp) / 1000; // seconds
const speed = (distance / 1000) / (timeDiff / 3600); // km/h
return { distance, speed };
}
function getSpeedClass(speed) {
const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
if (speed <= maxSpeed) {
return 'speed-ok';
} else if (speed <= maxSpeed * 2) {
return 'speed-fast';
} else {
return 'speed-unrealistic';
}
}
function applyGPSNoise(lat, lng, accuracyMeters) {
if (accuracyMeters === 0) {
return { lat, lng };
}
// Convert accuracy to degrees (approximate)
const latOffset = (Math.random() - 0.5) * 2 * (accuracyMeters / 111000);
const lngOffset = (Math.random() - 0.5) * 2 * (accuracyMeters / (111000 * Math.cos(lat * Math.PI / 180)));
return {
lat: lat + latOffset,
lng: lng + lngOffset
};
}
function addPointWithInterpolation(targetLat, targetLng) {
const currentTrack = tracks[currentTrackIndex];
if (!currentTrack || currentTrack.points.length === 0) {
addPoint(targetLat, targetLng);
return;
}
const lastPoint = currentTrack.points[currentTrack.points.length - 1];
const distance = calculateDistance(lastPoint.lat, lastPoint.lng, targetLat, targetLng);
const timeInterval = parseInt(document.getElementById('timeInterval').value);
const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
// Calculate max distance for given time interval and speed
const maxDistance = (maxSpeed * 1000 / 3600) * timeInterval; // meters
if (distance <= maxDistance) {
// Direct point addition - speed is acceptable
addPoint(targetLat, targetLng);
} else {
// Need interpolation - calculate intermediate points
const numSegments = Math.ceil(distance / maxDistance);
const latStep = (targetLat - lastPoint.lat) / numSegments;
const lngStep = (targetLng - lastPoint.lng) / numSegments;
// Add intermediate points
for (let i = 1; i <= numSegments; i++) {
const interpolatedLat = lastPoint.lat + (latStep * i);
const interpolatedLng = lastPoint.lng + (lngStep * i);
addPoint(interpolatedLat, interpolatedLng);
}
}
}
function calculateTotalDistance() {
let totalDistance = 0;
tracks.forEach(track => {
if (track.points.length < 2) return;
for (let i = 1; i < track.points.length; i++) {
totalDistance += calculateDistance(
track.points[i-1].lat, track.points[i-1].lng,
track.points[i].lat, track.points[i].lng
);
}
});
return totalDistance;
}
function formatDistance(meters) {
if (meters < 1000) {
return `${meters.toFixed(0)}m`;
} else {
return `${(meters / 1000).toFixed(1)}km`;
}
}
// Paint mode functions
function togglePaintMode() {
// Don't allow paint mode if edit mode is disabled
if (!editModeEnabled) {
return;
}
paintMode = !paintMode;
paintActive = false; // Reset paint active state when toggling mode
// Stop any active painting when toggling mode off
if (!paintMode) {
stopAutoPainting();
lastMousePosition = null;
}
updatePaintModeButton();
// Change cursor style when paint mode is active
if (paintMode) {
map.getContainer().style.cursor = 'crosshair';
} else {
map.getContainer().style.cursor = '';
}
updateStatus();
}
function updatePaintModeButton() {
const button = document.getElementById('paintModeToggle');
if (!paintMode) {
button.textContent = 'Paint Mode: OFF';
button.className = 'control-button';
} else if (paintActive) {
button.textContent = 'Paint Mode: PAINTING';
button.className = 'control-button paint-active';
} else {
button.textContent = 'Paint Mode: READY';
button.className = 'control-button paint-ready';
}
}
function startAutoPainting() {
if (paintInterval) {
clearInterval(paintInterval);
}
paintInterval = setInterval(() => {
if (paintActive && lastMousePosition) {
const currentTrack = tracks[currentTrackIndex];
if (currentTrack && currentTrack.points.length > 0) {
addPointWithInterpolation(lastMousePosition.lat, lastMousePosition.lng);
} else {
addPoint(lastMousePosition.lat, lastMousePosition.lng);
}
lastPaintTime = Date.now();
}
}, 500); // Add point every 500ms
}
function resetAutoPaintInterval() {
if (paintActive && paintInterval) {
// Restart the interval to prevent double-adding points
clearInterval(paintInterval);
paintInterval = setInterval(() => {
if (paintActive && lastMousePosition) {
const currentTrack = tracks[currentTrackIndex];
if (currentTrack && currentTrack.points.length > 0) {
addPointWithInterpolation(lastMousePosition.lat, lastMousePosition.lng);
} else {
addPoint(lastMousePosition.lat, lastMousePosition.lng);
}
lastPaintTime = Date.now();
}
}, 500);
}
}
function stopAutoPainting() {
if (paintInterval) {
clearInterval(paintInterval);
paintInterval = null;
}
}
// Realistic stops functions
function shouldAddStop(track) {
if (track.points.length < 5) return false; // Need some points before considering stops
return Math.random() < stopProbability;
}
function addRealisticStop(baseTimestamp) {
// Add a random stop duration between 30 seconds and 10 minutes
const stopDuration = Math.random() * (10 * 60 - 30) + 30; // 30s to 10min in seconds
return new Date(baseTimestamp.getTime() + (stopDuration * 1000));
}
// Canvas marker implementation for better performance
function initializeCanvasMarkers() {
// Create canvas element
markerCanvas = document.createElement('canvas');
markerCanvas.style.position = 'absolute';
markerCanvas.style.top = '0';
markerCanvas.style.left = '0';
markerCanvas.style.pointerEvents = 'none';
markerCanvas.style.zIndex = '400'; // Above map tiles but below controls
// Create custom Leaflet layer for canvas
markerCanvasLayer = L.Layer.extend({
onAdd: function(map) {
this._map = map;
map.getPanes().overlayPane.appendChild(markerCanvas);
this._reset();
map.on('viewreset', this._reset, this);
map.on('zoom', this._reset, this);
map.on('move', this._reset, this);
},
onRemove: function(map) {
map.getPanes().overlayPane.removeChild(markerCanvas);
map.off('viewreset', this._reset, this);
map.off('zoom', this._reset, this);
map.off('move', this._reset, this);
},
_reset: function() {
const size = this._map.getSize();
const topLeft = this._map.containerPointToLayerPoint([0, 0]);
markerCanvas.style.left = topLeft.x + 'px';
markerCanvas.style.top = topLeft.y + 'px';
markerCanvas.width = size.x;
markerCanvas.height = size.y;
redrawMarkers();
}
});
// Add canvas layer to map
new markerCanvasLayer().addTo(map);
// Add click handler for marker interaction
map.getContainer().addEventListener('contextmenu', handleMarkerContextMenu);
}
function redrawMarkers() {
if (!markerCanvas) return;
const ctx = markerCanvas.getContext('2d');
ctx.clearRect(0, 0, markerCanvas.width, markerCanvas.height);
// Draw all markers
markers.forEach(markerData => {
const point = map.latLngToContainerPoint([markerData.lat, markerData.lng]);
drawMarker(ctx, point.x, point.y, markerData.color);
});
}
function drawMarker(ctx, x, y, color) {
const radius = 4;
const borderWidth = 2;
// Draw white border
ctx.beginPath();
ctx.arc(x, y, radius + borderWidth, 0, 2 * Math.PI);
ctx.fillStyle = 'white';
ctx.fill();
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 1;
ctx.stroke();
// Draw colored center
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
}
function handleMarkerContextMenu(e) {
if (!editModeEnabled) return;
e.preventDefault();
const containerPoint = map.mouseEventToContainerPoint(e);
const tolerance = 10; // pixels
// Find marker near click point
for (let i = 0; i < markers.length; i++) {
const markerData = markers[i];
const markerPoint = map.latLngToContainerPoint([markerData.lat, markerData.lng]);
const distance = Math.sqrt(
Math.pow(containerPoint.x - markerPoint.x, 2) +
Math.pow(containerPoint.y - markerPoint.y, 2)
);
if (distance <= tolerance) {
removePoint(markerData.trackIndex, markerData.pointIndex);
break;
}
}
}
// Time shifting functions
function shiftTrackTime(amount, unit) {
if (tracks.length === 0 || currentTrackIndex >= tracks.length) {
alert('No track selected to shift time.');
return;
}
const currentTrack = tracks[currentTrackIndex];
if (currentTrack.points.length === 0) {
alert('Current track has no points to shift.');
return;
}
// Calculate milliseconds to shift
let shiftMs = 0;
if (unit === 'hour') {
shiftMs = amount * 60 * 60 * 1000; // hours to milliseconds
} else if (unit === 'day') {
shiftMs = amount * 24 * 60 * 60 * 1000; // days to milliseconds
}
// Shift all points in the current track
currentTrack.points.forEach(point => {
point.timestamp = new Date(point.timestamp.getTime() + shiftMs);
});
// Update track start time
if (currentTrack.points.length > 0) {
currentTrack.startTime = new Date(currentTrack.points[0].timestamp);
}
// Update the datetime input to reflect the new time of the last point
if (currentTrack.points.length > 0) {
const lastPoint = currentTrack.points[currentTrack.points.length - 1];
const timeInterval = parseInt(document.getElementById('timeInterval').value);
const nextTimestamp = new Date(lastPoint.timestamp.getTime() + (timeInterval * 1000));
// Format for datetime-local input
const year = nextTimestamp.getFullYear();
const month = String(nextTimestamp.getMonth() + 1).padStart(2, '0');
const day = String(nextTimestamp.getDate()).padStart(2, '0');
const hours = String(nextTimestamp.getHours()).padStart(2, '0');
const minutes = String(nextTimestamp.getMinutes()).padStart(2, '0');
const seconds = String(nextTimestamp.getSeconds()).padStart(2, '0');
const datetimeString = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
document.getElementById('startDateTime').value = datetimeString;
}
// Update UI
updatePointsList();
updateStatus();
// Show confirmation
const unitText = unit === 'hour' ? 'hour' : 'day';
const direction = amount > 0 ? 'forward' : 'backward';
const absAmount = Math.abs(amount);
}