mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
527 lines
16 KiB
JavaScript
527 lines
16 KiB
JavaScript
import { formatDate } from "../maps/helpers";
|
|
import { formatDistance } from "../maps/helpers";
|
|
import { formatSpeed } from "../maps/helpers";
|
|
import { minutesToDaysHoursMinutes } from "../maps/helpers";
|
|
|
|
// Track-specific color palette - different from regular polylines
|
|
export const trackColorPalette = {
|
|
default: 'red', // Green - distinct from blue polylines
|
|
hover: '#FF6B35', // Orange-red for hover
|
|
active: '#E74C3C', // Red for active/clicked
|
|
start: '#2ECC71', // Green for start marker
|
|
end: '#E67E22' // Orange for end marker
|
|
};
|
|
|
|
export function getTrackColor() {
|
|
// All tracks use the same default color
|
|
return trackColorPalette.default;
|
|
}
|
|
|
|
export function createTrackPopupContent(track, distanceUnit) {
|
|
const startTime = formatDate(track.start_at, 'UTC');
|
|
const endTime = formatDate(track.end_at, 'UTC');
|
|
const duration = track.duration || 0;
|
|
const durationFormatted = minutesToDaysHoursMinutes(Math.round(duration / 60));
|
|
|
|
return `
|
|
<div class="track-popup">
|
|
<h4 class="track-popup-title">📍 Track #${track.id}</h4>
|
|
<div class="track-info">
|
|
<strong>🕐 Start:</strong> ${startTime}<br>
|
|
<strong>🏁 End:</strong> ${endTime}<br>
|
|
<strong>⏱️ Duration:</strong> ${durationFormatted}<br>
|
|
<strong>📏 Distance:</strong> ${formatDistance(track.distance / 1000, distanceUnit)}<br>
|
|
<strong>⚡ Avg Speed:</strong> ${formatSpeed(track.avg_speed, distanceUnit)}<br>
|
|
<strong>⛰️ Elevation:</strong> +${track.elevation_gain || 0}m / -${track.elevation_loss || 0}m<br>
|
|
<strong>📊 Max Alt:</strong> ${track.elevation_max || 0}m<br>
|
|
<strong>📉 Min Alt:</strong> ${track.elevation_min || 0}m
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export function addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit) {
|
|
let hoverPopup = null;
|
|
let isClicked = false;
|
|
|
|
// Create start and end markers
|
|
const startIcon = L.divIcon({
|
|
html: "🚀",
|
|
className: "track-start-icon emoji-icon",
|
|
iconSize: [20, 20]
|
|
});
|
|
|
|
const endIcon = L.divIcon({
|
|
html: "🎯",
|
|
className: "track-end-icon emoji-icon",
|
|
iconSize: [20, 20]
|
|
});
|
|
|
|
// Get first and last coordinates from the track path
|
|
const coordinates = getTrackCoordinates(track);
|
|
if (!coordinates || coordinates.length < 2) return;
|
|
|
|
const startCoord = coordinates[0];
|
|
const endCoord = coordinates[coordinates.length - 1];
|
|
|
|
const startMarker = L.marker([startCoord[0], startCoord[1]], { icon: startIcon });
|
|
const endMarker = L.marker([endCoord[0], endCoord[1]], { icon: endIcon });
|
|
|
|
function handleTrackHover(e) {
|
|
if (isClicked) {
|
|
return; // Don't change hover state if clicked
|
|
}
|
|
|
|
// Apply hover style to all segments in the track
|
|
trackGroup.eachLayer((layer) => {
|
|
if (layer instanceof L.Polyline) {
|
|
layer.setStyle({
|
|
color: trackColorPalette.hover,
|
|
weight: 6,
|
|
opacity: 0.9
|
|
});
|
|
layer.bringToFront();
|
|
}
|
|
});
|
|
|
|
// Show markers and popup
|
|
startMarker.addTo(map);
|
|
endMarker.addTo(map);
|
|
|
|
const popupContent = createTrackPopupContent(track, distanceUnit);
|
|
|
|
if (hoverPopup) {
|
|
map.closePopup(hoverPopup);
|
|
}
|
|
|
|
hoverPopup = L.popup()
|
|
.setLatLng(e.latlng)
|
|
.setContent(popupContent)
|
|
.addTo(map);
|
|
}
|
|
|
|
function handleTrackMouseOut(e) {
|
|
if (isClicked) return; // Don't reset if clicked
|
|
|
|
// Reset to original style
|
|
trackGroup.eachLayer((layer) => {
|
|
if (layer instanceof L.Polyline) {
|
|
layer.setStyle({
|
|
color: layer.options.originalColor,
|
|
weight: 4,
|
|
opacity: userSettings.route_opacity || 0.7
|
|
});
|
|
}
|
|
});
|
|
|
|
// Remove markers and popup
|
|
if (hoverPopup) {
|
|
map.closePopup(hoverPopup);
|
|
map.removeLayer(startMarker);
|
|
map.removeLayer(endMarker);
|
|
}
|
|
}
|
|
|
|
function handleTrackClick(e) {
|
|
e.originalEvent.stopPropagation();
|
|
|
|
// Toggle clicked state
|
|
isClicked = !isClicked;
|
|
|
|
if (isClicked) {
|
|
// Apply clicked style
|
|
trackGroup.eachLayer((layer) => {
|
|
if (layer instanceof L.Polyline) {
|
|
layer.setStyle({
|
|
color: trackColorPalette.active,
|
|
weight: 8,
|
|
opacity: 1
|
|
});
|
|
layer.bringToFront();
|
|
}
|
|
});
|
|
|
|
startMarker.addTo(map);
|
|
endMarker.addTo(map);
|
|
|
|
// Show persistent popup
|
|
const popupContent = createTrackPopupContent(track, distanceUnit);
|
|
|
|
L.popup()
|
|
.setLatLng(e.latlng)
|
|
.setContent(popupContent)
|
|
.addTo(map);
|
|
|
|
// Store reference for cleanup
|
|
trackGroup._isTrackClicked = true;
|
|
trackGroup._trackStartMarker = startMarker;
|
|
trackGroup._trackEndMarker = endMarker;
|
|
} else {
|
|
// Reset to hover state or original state
|
|
handleTrackMouseOut(e);
|
|
trackGroup._isTrackClicked = false;
|
|
if (trackGroup._trackStartMarker) map.removeLayer(trackGroup._trackStartMarker);
|
|
if (trackGroup._trackEndMarker) map.removeLayer(trackGroup._trackEndMarker);
|
|
}
|
|
}
|
|
|
|
// Add event listeners to all layers in the track group
|
|
trackGroup.eachLayer((layer) => {
|
|
if (layer instanceof L.Polyline) {
|
|
layer.on('mouseover', handleTrackHover);
|
|
layer.on('mouseout', handleTrackMouseOut);
|
|
layer.on('click', handleTrackClick);
|
|
}
|
|
});
|
|
|
|
// Reset when clicking elsewhere on map
|
|
map.on('click', function() {
|
|
if (trackGroup._isTrackClicked) {
|
|
isClicked = false;
|
|
trackGroup._isTrackClicked = false;
|
|
handleTrackMouseOut({ latlng: [0, 0] });
|
|
if (trackGroup._trackStartMarker) map.removeLayer(trackGroup._trackStartMarker);
|
|
if (trackGroup._trackEndMarker) map.removeLayer(trackGroup._trackEndMarker);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getTrackCoordinates(track) {
|
|
// First check if coordinates are already provided as an array
|
|
if (track.coordinates && Array.isArray(track.coordinates)) {
|
|
return track.coordinates; // If already provided as array of [lat, lng]
|
|
}
|
|
|
|
// If coordinates are provided as a path property
|
|
if (track.path && Array.isArray(track.path)) {
|
|
return track.path;
|
|
}
|
|
|
|
// Try to parse from original_path (PostGIS LineString format)
|
|
if (track.original_path && typeof track.original_path === 'string') {
|
|
try {
|
|
// Parse PostGIS LineString format: "LINESTRING (lng lat, lng lat, ...)" or "LINESTRING(lng lat, lng lat, ...)"
|
|
const match = track.original_path.match(/LINESTRING\s*\(([^)]+)\)/i);
|
|
if (match) {
|
|
const coordString = match[1];
|
|
const coordinates = coordString.split(',').map(pair => {
|
|
const [lng, lat] = pair.trim().split(/\s+/).map(parseFloat);
|
|
if (isNaN(lng) || isNaN(lat)) {
|
|
console.warn(`Invalid coordinates in track ${track.id}: "${pair.trim()}"`);
|
|
return null;
|
|
}
|
|
return [lat, lng]; // Return as [lat, lng] for Leaflet
|
|
}).filter(Boolean); // Remove null entries
|
|
|
|
if (coordinates.length >= 2) {
|
|
return coordinates;
|
|
} else {
|
|
console.warn(`Track ${track.id} has only ${coordinates.length} valid coordinates`);
|
|
}
|
|
} else {
|
|
console.warn(`No LINESTRING match found for track ${track.id}. Raw: "${track.original_path}"`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to parse track original_path for track ${track.id}:`, error);
|
|
console.error(`Raw original_path: "${track.original_path}"`);
|
|
}
|
|
}
|
|
|
|
// For development/testing, create a simple line if we have start/end coordinates
|
|
if (track.start_point && track.end_point) {
|
|
return [
|
|
[track.start_point.lat, track.start_point.lng],
|
|
[track.end_point.lat, track.end_point.lng]
|
|
];
|
|
}
|
|
|
|
console.warn('Track coordinates not available for track', track.id);
|
|
return [];
|
|
}
|
|
|
|
export function createTracksLayer(tracks, map, userSettings, distanceUnit) {
|
|
// Create a custom pane for tracks with higher z-index than regular polylines
|
|
if (!map.getPane('tracksPane')) {
|
|
map.createPane('tracksPane');
|
|
map.getPane('tracksPane').style.zIndex = 460; // Above polylines pane (450)
|
|
}
|
|
|
|
const renderer = L.canvas({
|
|
padding: 0.5,
|
|
pane: 'tracksPane'
|
|
});
|
|
|
|
const trackLayers = tracks.map((track) => {
|
|
const coordinates = getTrackCoordinates(track);
|
|
|
|
if (!coordinates || coordinates.length < 2) {
|
|
console.warn(`Track ${track.id} has insufficient coordinates`);
|
|
return null;
|
|
}
|
|
|
|
const trackColor = getTrackColor();
|
|
const trackGroup = L.featureGroup();
|
|
|
|
// Create polyline segments for the track
|
|
// For now, create a single polyline, but this could be segmented for elevation/speed coloring
|
|
const trackPolyline = L.polyline(coordinates, {
|
|
renderer: renderer,
|
|
color: trackColor,
|
|
originalColor: trackColor,
|
|
opacity: userSettings.route_opacity || 0.7,
|
|
weight: 4,
|
|
interactive: true,
|
|
pane: 'tracksPane',
|
|
bubblingMouseEvents: false,
|
|
trackId: track.id
|
|
});
|
|
|
|
trackGroup.addLayer(trackPolyline);
|
|
|
|
// Add interactions
|
|
addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit);
|
|
|
|
// Store track data for reference
|
|
trackGroup._trackData = track;
|
|
|
|
return trackGroup;
|
|
}).filter(Boolean); // Remove null entries
|
|
|
|
// Create the main layer group
|
|
const tracksLayerGroup = L.layerGroup(trackLayers);
|
|
|
|
// Add CSS for track styling
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.leaflet-tracksPane-pane {
|
|
pointer-events: auto !important;
|
|
}
|
|
.leaflet-tracksPane-pane canvas {
|
|
pointer-events: auto !important;
|
|
}
|
|
.track-popup {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
.track-popup-title {
|
|
margin: 0 0 8px 0;
|
|
color: #2c3e50;
|
|
font-size: 16px;
|
|
}
|
|
.track-info {
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
.track-start-icon, .track-end-icon {
|
|
font-size: 16px;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
return tracksLayerGroup;
|
|
}
|
|
|
|
export function updateTracksColors(tracksLayer) {
|
|
const defaultColor = getTrackColor();
|
|
|
|
tracksLayer.eachLayer((trackGroup) => {
|
|
trackGroup.eachLayer((layer) => {
|
|
if (layer instanceof L.Polyline) {
|
|
layer.setStyle({
|
|
color: defaultColor,
|
|
originalColor: defaultColor
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export function updateTracksOpacity(tracksLayer, opacity) {
|
|
tracksLayer.eachLayer((trackGroup) => {
|
|
trackGroup.eachLayer((layer) => {
|
|
if (layer instanceof L.Polyline) {
|
|
layer.setStyle({ opacity: opacity });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export function toggleTracksVisibility(tracksLayer, map, isVisible) {
|
|
if (isVisible && !map.hasLayer(tracksLayer)) {
|
|
tracksLayer.addTo(map);
|
|
} else if (!isVisible && map.hasLayer(tracksLayer)) {
|
|
map.removeLayer(tracksLayer);
|
|
}
|
|
}
|
|
|
|
// Helper function to filter tracks by criteria
|
|
export function filterTracks(tracks, criteria) {
|
|
return tracks.filter(track => {
|
|
if (criteria.minDistance && track.distance < criteria.minDistance * 1000) return false;
|
|
if (criteria.maxDistance && track.distance > criteria.maxDistance * 1000) return false;
|
|
if (criteria.minDuration && track.duration < criteria.minDuration * 60) return false;
|
|
if (criteria.maxDuration && track.duration > criteria.maxDuration * 60) return false;
|
|
if (criteria.startDate && new Date(track.start_at) < new Date(criteria.startDate)) return false;
|
|
if (criteria.endDate && new Date(track.end_at) > new Date(criteria.endDate)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// === INCREMENTAL TRACK HANDLING ===
|
|
|
|
/**
|
|
* Create a single track layer from track data
|
|
* @param {Object} track - Track data
|
|
* @param {Object} map - Leaflet map instance
|
|
* @param {Object} userSettings - User settings
|
|
* @param {string} distanceUnit - Distance unit preference
|
|
* @returns {L.FeatureGroup} Track layer group
|
|
*/
|
|
export function createSingleTrackLayer(track, map, userSettings, distanceUnit) {
|
|
const coordinates = getTrackCoordinates(track);
|
|
|
|
if (!coordinates || coordinates.length < 2) {
|
|
console.warn(`Track ${track.id} has insufficient coordinates`);
|
|
return null;
|
|
}
|
|
|
|
// Create a custom pane for tracks if it doesn't exist
|
|
if (!map.getPane('tracksPane')) {
|
|
map.createPane('tracksPane');
|
|
map.getPane('tracksPane').style.zIndex = 460;
|
|
}
|
|
|
|
const renderer = L.canvas({
|
|
padding: 0.5,
|
|
pane: 'tracksPane'
|
|
});
|
|
|
|
const trackColor = getTrackColor();
|
|
const trackGroup = L.featureGroup();
|
|
|
|
const trackPolyline = L.polyline(coordinates, {
|
|
renderer: renderer,
|
|
color: trackColor,
|
|
originalColor: trackColor,
|
|
opacity: userSettings.route_opacity || 0.7,
|
|
weight: 4,
|
|
interactive: true,
|
|
pane: 'tracksPane',
|
|
bubblingMouseEvents: false,
|
|
trackId: track.id
|
|
});
|
|
|
|
trackGroup.addLayer(trackPolyline);
|
|
addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit);
|
|
trackGroup._trackData = track;
|
|
|
|
return trackGroup;
|
|
}
|
|
|
|
/**
|
|
* Add or update a track in the tracks layer
|
|
* @param {L.LayerGroup} tracksLayer - Main tracks layer group
|
|
* @param {Object} track - Track data
|
|
* @param {Object} map - Leaflet map instance
|
|
* @param {Object} userSettings - User settings
|
|
* @param {string} distanceUnit - Distance unit preference
|
|
*/
|
|
export function addOrUpdateTrack(tracksLayer, track, map, userSettings, distanceUnit) {
|
|
// Remove existing track if it exists
|
|
removeTrackById(tracksLayer, track.id);
|
|
|
|
// Create new track layer
|
|
const trackLayer = createSingleTrackLayer(track, map, userSettings, distanceUnit);
|
|
|
|
if (trackLayer) {
|
|
tracksLayer.addLayer(trackLayer);
|
|
console.log(`Track ${track.id} added/updated on map`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a track from the tracks layer by ID
|
|
* @param {L.LayerGroup} tracksLayer - Main tracks layer group
|
|
* @param {number} trackId - Track ID to remove
|
|
*/
|
|
export function removeTrackById(tracksLayer, trackId) {
|
|
let layerToRemove = null;
|
|
|
|
tracksLayer.eachLayer((layer) => {
|
|
if (layer._trackData && layer._trackData.id === trackId) {
|
|
layerToRemove = layer;
|
|
return;
|
|
}
|
|
});
|
|
|
|
if (layerToRemove) {
|
|
// Clean up any markers that might be showing
|
|
if (layerToRemove._trackStartMarker) {
|
|
tracksLayer.removeLayer(layerToRemove._trackStartMarker);
|
|
}
|
|
if (layerToRemove._trackEndMarker) {
|
|
tracksLayer.removeLayer(layerToRemove._trackEndMarker);
|
|
}
|
|
|
|
tracksLayer.removeLayer(layerToRemove);
|
|
console.log(`Track ${trackId} removed from map`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a track is within the current map time range
|
|
* @param {Object} track - Track data
|
|
* @param {string} startAt - Start time filter
|
|
* @param {string} endAt - End time filter
|
|
* @returns {boolean} Whether track is in range
|
|
*/
|
|
export function isTrackInTimeRange(track, startAt, endAt) {
|
|
if (!startAt || !endAt) return true;
|
|
|
|
const trackStart = new Date(track.start_at);
|
|
const trackEnd = new Date(track.end_at);
|
|
const rangeStart = new Date(startAt);
|
|
const rangeEnd = new Date(endAt);
|
|
|
|
// Track is in range if it overlaps with the time range
|
|
return trackStart <= rangeEnd && trackEnd >= rangeStart;
|
|
}
|
|
|
|
/**
|
|
* Handle incremental track updates from WebSocket
|
|
* @param {L.LayerGroup} tracksLayer - Main tracks layer group
|
|
* @param {Object} data - WebSocket data
|
|
* @param {Object} map - Leaflet map instance
|
|
* @param {Object} userSettings - User settings
|
|
* @param {string} distanceUnit - Distance unit preference
|
|
* @param {string} currentStartAt - Current time range start
|
|
* @param {string} currentEndAt - Current time range end
|
|
*/
|
|
export function handleIncrementalTrackUpdate(tracksLayer, data, map, userSettings, distanceUnit, currentStartAt, currentEndAt) {
|
|
const { action, track, track_id } = data;
|
|
|
|
switch (action) {
|
|
case 'created':
|
|
// Only add if track is within current time range
|
|
if (isTrackInTimeRange(track, currentStartAt, currentEndAt)) {
|
|
addOrUpdateTrack(tracksLayer, track, map, userSettings, distanceUnit);
|
|
}
|
|
break;
|
|
|
|
case 'updated':
|
|
// Update track if it exists or add if it's now in range
|
|
if (isTrackInTimeRange(track, currentStartAt, currentEndAt)) {
|
|
addOrUpdateTrack(tracksLayer, track, map, userSettings, distanceUnit);
|
|
} else {
|
|
// Remove track if it's no longer in range
|
|
removeTrackById(tracksLayer, track.id);
|
|
}
|
|
break;
|
|
|
|
case 'destroyed':
|
|
removeTrackById(tracksLayer, track_id);
|
|
break;
|
|
|
|
default:
|
|
console.warn('Unknown track update action:', action);
|
|
}
|
|
}
|