/**
* Track Editor for StreetRacer Web Dashboard
* Allows VIP users to create and edit challenge tracks via Mapbox
*/
class TrackEditor {
constructor() {
this.map = null;
this.isInitialized = false;
this.currentTool = 'select';
this.trackPoints = []; // Raw waypoints clicked by user
this.routeCoordinates = []; // Full route from Directions API (road-snapped)
this.checkpoints = [];
this.startPoint = null;
this.finishPoint = null;
this.markers = [];
this.waypointMarkers = []; // Markers for waypoints (draggable)
this.trackLine = null;
this.currentTrackId = null;
this.isLoadingRoute = false;
this.showExistingChallenges = false; // Toggle for showing existing challenges
// Mapbox token (same as mobile app)
this.mapboxToken = 'pk.eyJ1IjoiZnJhbmNvaXNyZWluZXJ0IiwiYSI6ImNtazBja2liYjBvbnozZnM4bWE2OGF0b3UifQ.3k-VptMsIpvD0ys4igFWeg';
console.log('TrackEditor constructed');
}
// Reset the editor (call if initialization failed)
reset() {
if (this.map) {
this.map.remove();
this.map = null;
}
this.isInitialized = false;
this.trackPoints = [];
this.routeCoordinates = [];
this.checkpoints = [];
this.markers = [];
this.waypointMarkers = [];
this.startPoint = null;
this.finishPoint = null;
}
initialize() {
console.log('Track Editor initialize called, isInitialized:', this.isInitialized);
// If already initialized, just resize the map
if (this.isInitialized && this.map) {
console.log('Map already initialized, resizing...');
this.map.resize();
setTimeout(() => this.map.resize(), 100);
setTimeout(() => this.map.resize(), 300);
return;
}
console.log('Initializing Track Editor...');
// Wait for container to be visible and have dimensions
let attempts = 0;
const maxAttempts = 30;
const checkAndInit = () => {
attempts++;
const container = document.getElementById('editor-map');
const tabContent = document.getElementById('tab-track-editor');
const isTabVisible = tabContent && !tabContent.classList.contains('hidden');
// Force compute dimensions
const width = container?.getBoundingClientRect().width || 0;
const height = container?.getBoundingClientRect().height || 0;
console.log(`Attempt ${attempts}: Container ${width}x${height}, Tab visible: ${isTabVisible}`);
if (container && width > 100 && height > 100 && isTabVisible) {
console.log('Container ready, initializing map...');
this.initializeMap();
this.setupToolbar();
this.loadMyTracks();
// Don't auto-load challenge markers - user must toggle them on
} else if (attempts < maxAttempts) {
console.log('Waiting for map container to be ready...');
setTimeout(checkAndInit, 150);
} else {
console.error('Map container not ready after', maxAttempts, 'attempts');
console.log('Final container state:', { width, height, isTabVisible });
if (container) {
// Try one more time with forced dimensions
container.style.width = '100%';
container.style.height = '500px';
container.style.minHeight = '500px';
console.log('Forcing dimensions and retrying...');
setTimeout(() => {
this.initializeMap();
this.setupToolbar();
this.loadMyTracks();
// Don't auto-load challenge markers - user must toggle them on
}, 100);
}
}
};
// Give the DOM a moment to update after tab switch
setTimeout(checkAndInit, 50);
}
initializeMap() {
const container = document.getElementById('editor-map');
if (!container) {
console.error('Map container not found');
return;
}
if (typeof mapboxgl === 'undefined') {
console.error('Mapbox GL JS not loaded');
container.innerHTML = '
Mapbox konnte nicht geladen werden. Bitte Seite neu laden.
';
return;
}
try {
console.log('Creating Mapbox map...');
console.log('Container dimensions:', container.offsetWidth, 'x', container.offsetHeight);
// Ensure container has explicit dimensions
if (container.offsetWidth === 0 || container.offsetHeight === 0) {
console.warn('Container has no dimensions, setting explicit size');
container.style.width = '100%';
container.style.height = '500px';
}
mapboxgl.accessToken = this.mapboxToken;
this.map = new mapboxgl.Map({
container: 'editor-map',
style: 'mapbox://styles/mapbox/dark-v11',
center: [10.0, 51.0], // Germany center
zoom: 6,
attributionControl: true,
failIfMajorPerformanceCaveat: false
});
this.map.on('load', () => {
console.log('Track Editor map loaded successfully');
this.isInitialized = true;
// Add track line source
this.map.addSource('track-line', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: []
}
}
});
// Add track line layer
this.map.addLayer({
id: 'track-line-layer',
type: 'line',
source: 'track-line',
paint: {
'line-color': '#00d9ff',
'line-width': 4,
'line-opacity': 0.8
}
});
// Get user location
this.centerOnUserLocation();
// Initialize toolbar state (disable start/checkpoint/finish initially)
this.updateToolbarState();
// Force multiple resize calls to ensure proper rendering
this.map.resize();
setTimeout(() => this.map.resize(), 100);
setTimeout(() => this.map.resize(), 500);
setTimeout(() => this.map.resize(), 1000);
});
// Also resize when style is loaded
this.map.on('style.load', () => {
console.log('Map style loaded');
this.map.resize();
});
this.map.on('error', (e) => {
console.error('Mapbox error:', e);
if (e.error && e.error.status === 401) {
container.innerHTML = 'Mapbox Token ungültig. Bitte Administrator kontaktieren.
';
}
});
// Debug: Log when map is idle (fully rendered)
this.map.on('idle', () => {
console.log('Map is idle (fully rendered)');
});
// Map click handler
this.map.on('click', (e) => this.handleMapClick(e));
// Resize on window resize
window.addEventListener('resize', () => {
if (this.map) this.map.resize();
});
} catch (error) {
console.error('Error initializing map:', error);
container.innerHTML = 'Fehler beim Laden der Karte: ' + error.message + '
';
}
}
setupToolbar() {
// Tool buttons
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', (e) => {
// Don't activate if disabled
if (btn.classList.contains('disabled')) {
const tool = e.currentTarget.dataset.tool;
const canActivate = this.canActivateTool(tool);
if (!canActivate.allowed) {
this.showToolMessage(canActivate.message);
}
return;
}
const tool = e.currentTarget.dataset.tool;
this.setTool(tool);
});
});
// Action buttons
document.getElementById('btn-undo')?.addEventListener('click', () => this.undo());
document.getElementById('btn-clear')?.addEventListener('click', () => this.clearTrack());
document.getElementById('btn-save-track')?.addEventListener('click', () => this.saveTrack());
document.getElementById('btn-new-track')?.addEventListener('click', () => this.newTrack());
const toggleBtn = document.getElementById('btn-toggle-challenges');
console.log('🔧 Toggle button found:', !!toggleBtn);
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
console.log('🔧 Toggle button clicked!');
this.toggleExistingChallenges();
});
}
// Initial toolbar state
this.updateToolbarState();
}
// Delete a challenge
async deleteTrack(trackId, trackName) {
if (!confirm(`Challenge "${trackName}" wirklich löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`)) {
return;
}
try {
const challengeRef = database.ref(`challenges/${trackId}`);
await challengeRef.remove();
// If this was the currently loaded track, clear the editor
if (this.currentTrackId === trackId) {
this.newTrack();
}
// Refresh lists
this.loadMyTracks();
this.loadExistingChallengesOnMap();
console.log(`🗑️ Challenge "${trackName}" deleted`);
} catch (error) {
console.error('Error deleting challenge:', error);
alert('Fehler beim Löschen: ' + error.message);
}
}
// Start a new challenge (clear everything)
newTrack() {
// Simply clear and start fresh - no confirmation needed
// Old track is either saved already or user consciously clicks + for new
this.clearTrack(true);
this.currentTrackId = null;
// Clear form
document.getElementById('track-name').value = '';
document.getElementById('track-description').value = '';
document.getElementById('track-type').value = 'checkpoint';
// Deselect any active track in the list
document.querySelectorAll('.track-item').forEach(item => {
item.classList.remove('active');
});
console.log('🆕 New track started');
}
setTool(tool) {
// Check if tool can be activated
const canActivate = this.canActivateTool(tool);
if (!canActivate.allowed) {
this.showToolMessage(canActivate.message);
return;
}
this.currentTool = tool;
// Update UI
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tool === tool);
});
// Change cursor
const cursors = {
select: 'grab',
draw: 'crosshair',
checkpoint: 'crosshair',
start: 'crosshair',
finish: 'crosshair'
};
if (this.map) {
this.map.getCanvas().style.cursor = cursors[tool] || 'default';
}
}
canActivateTool(tool) {
const pointCount = this.trackPoints.length;
switch (tool) {
case 'start':
case 'checkpoint':
if (pointCount < 1) {
return { allowed: false, message: 'Zuerst einen Streckenpunkt anlegen' };
}
return { allowed: true };
case 'finish':
if (pointCount < 2) {
return { allowed: false, message: 'Mindestens 2 Streckenpunkte erforderlich' };
}
return { allowed: true };
default:
return { allowed: true };
}
}
showToolMessage(message) {
// Show temporary message near toolbar
const existingMsg = document.querySelector('.tool-message');
if (existingMsg) existingMsg.remove();
const msgEl = document.createElement('div');
msgEl.className = 'tool-message';
msgEl.textContent = message;
msgEl.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 100, 100, 0.95);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: fadeInOut 2s ease-in-out forwards;
`;
document.body.appendChild(msgEl);
// Add animation style if not exists
if (!document.getElementById('tool-message-style')) {
const style = document.createElement('style');
style.id = 'tool-message-style';
style.textContent = `
@keyframes fadeInOut {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
15% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
85% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
}
`;
document.head.appendChild(style);
}
setTimeout(() => msgEl.remove(), 2000);
}
updateToolbarState() {
// Update toolbar buttons based on current state
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
const tool = btn.dataset.tool;
const canActivate = this.canActivateTool(tool);
btn.classList.toggle('disabled', !canActivate.allowed);
btn.title = canActivate.allowed ? '' : canActivate.message;
});
}
handleMapClick(e) {
const { lng, lat } = e.lngLat;
switch (this.currentTool) {
case 'draw':
this.addTrackPoint(lng, lat);
break;
case 'checkpoint':
this.addCheckpoint(lng, lat);
break;
case 'start':
this.setStartPoint(lng, lat);
break;
case 'finish':
this.setFinishPoint(lng, lat);
break;
}
}
async addTrackPoint(lng, lat) {
// Add waypoint
this.trackPoints.push([lng, lat]);
// Add waypoint marker (numbered, draggable)
const pointNum = this.trackPoints.length;
const el = document.createElement('div');
el.className = 'waypoint-marker';
el.innerHTML = `${pointNum}`;
el.style.cssText = `
background: #00d9ff;
color: #000;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 12px;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
cursor: move;
`;
const marker = new mapboxgl.Marker({ element: el, draggable: true })
.setLngLat([lng, lat])
.addTo(this.map);
// Handle drag end to recalculate route
marker.on('dragend', () => {
const lngLat = marker.getLngLat();
const index = this.waypointMarkers.findIndex(m => m.marker === marker);
if (index !== -1) {
this.trackPoints[index] = [lngLat.lng, lngLat.lat];
this.calculateRoute();
}
});
this.waypointMarkers.push({ marker, coords: [lng, lat] });
this.markers.push({ type: 'track', marker, coords: [lng, lat] });
// Calculate route via roads
await this.calculateRoute();
// Update toolbar state (enable Start/Checkpoint/Finish as appropriate)
this.updateToolbarState();
this.updateTrackInfo();
}
async calculateRoute() {
if (this.trackPoints.length < 2) {
// Single point - just show it, no route yet
this.routeCoordinates = this.trackPoints.length === 1 ? [this.trackPoints[0]] : [];
this.updateTrackLine();
return;
}
this.isLoadingRoute = true;
this.showRouteLoading(true);
try {
// Build Mapbox Directions API URL
// Max 25 waypoints per request
const waypoints = this.trackPoints.map(p => p.join(',')).join(';');
const url = `https://api.mapbox.com/directions/v5/mapbox/driving/${waypoints}?` +
`geometries=geojson&overview=full&access_token=${this.mapboxToken}`;
const response = await fetch(url);
const data = await response.json();
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
console.warn('Directions API error:', data);
// Fallback to straight lines
this.routeCoordinates = [...this.trackPoints];
this.showToolMessage('Route konnte nicht berechnet werden - Luftlinie wird verwendet');
} else {
// Use the route geometry
this.routeCoordinates = data.routes[0].geometry.coordinates;
console.log(`Route calculated: ${this.routeCoordinates.length} points`);
}
} catch (error) {
console.error('Error calculating route:', error);
// Fallback to straight lines
this.routeCoordinates = [...this.trackPoints];
}
this.isLoadingRoute = false;
this.showRouteLoading(false);
this.updateTrackLine();
this.updateTrackInfo();
}
showRouteLoading(show) {
let indicator = document.getElementById('route-loading');
if (show) {
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'route-loading';
indicator.innerHTML = '🛣️ Route wird berechnet...';
indicator.style.cssText = `
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 217, 255, 0.9);
color: #000;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
font-size: 12px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
`;
document.getElementById('editor-map')?.appendChild(indicator);
}
} else if (indicator) {
indicator.remove();
}
}
// Add marker only without calculating route (for legacy challenges)
addWaypointMarkerOnly(lng, lat, num) {
const el = document.createElement('div');
el.className = 'waypoint-marker';
el.innerHTML = `${num}`;
el.style.cssText = `
background: #00d9ff;
color: #000;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 12px;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
`;
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(this.map);
this.waypointMarkers.push({ marker, coords: [lng, lat] });
this.markers.push({ type: 'track', marker, coords: [lng, lat] });
}
addCheckpoint(lng, lat, skipSnap = false) {
// Snap to nearest point on route (unless loading from saved data)
const coords = skipSnap ? { lng, lat } : this.snapToRoute(lng, lat);
const checkpointNum = this.checkpoints.length + 1;
const el = document.createElement('div');
el.className = 'checkpoint-marker';
el.innerHTML = `${checkpointNum}`;
el.style.cssText = `
background: #00ff88;
color: #000;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
`;
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([coords.lng, coords.lat])
.addTo(this.map);
this.checkpoints.push({ lng: coords.lng, lat: coords.lat, marker });
this.markers.push({ type: 'checkpoint', marker, coords: [coords.lng, coords.lat] });
this.updateTrackInfo();
// Switch back to select tool after placing (only when user places manually)
if (!skipSnap) {
this.setTool('select');
}
}
setStartPoint(lng, lat, skipSnap = false) {
// Snap to nearest point on route (unless loading from saved data)
const coords = skipSnap ? { lng, lat } : this.snapToRoute(lng, lat);
// Remove existing start marker
if (this.startPoint?.marker) {
this.startPoint.marker.remove();
}
const el = document.createElement('div');
el.innerHTML = '🏁';
el.style.fontSize = '32px';
el.style.filter = 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))';
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([coords.lng, coords.lat])
.addTo(this.map);
this.startPoint = { lng: coords.lng, lat: coords.lat, marker };
// Switch back to select tool after placing (only when user places manually)
if (!skipSnap) {
this.setTool('select');
}
}
setFinishPoint(lng, lat, skipSnap = false) {
// Snap to nearest point on route (unless loading from saved data)
const coords = skipSnap ? { lng, lat } : this.snapToRoute(lng, lat);
// Remove existing finish marker
if (this.finishPoint?.marker) {
this.finishPoint.marker.remove();
}
const el = document.createElement('div');
el.innerHTML = '🎯';
el.style.fontSize = '32px';
el.style.filter = 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))';
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([coords.lng, coords.lat])
.addTo(this.map);
this.finishPoint = { lng: coords.lng, lat: coords.lat, marker };
// Switch back to select tool after placing (only when user places manually)
if (!skipSnap) {
this.setTool('select');
}
}
// Snap a point to the nearest position on the route
snapToRoute(lng, lat) {
const route = this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints;
if (route.length === 0) {
return { lng, lat };
}
let nearestPoint = { lng: route[0][0], lat: route[0][1] };
let minDistance = Infinity;
// Find nearest point on route
for (const [routeLng, routeLat] of route) {
const dist = this.calculateDistance(lat, lng, routeLat, routeLng);
if (dist < minDistance) {
minDistance = dist;
nearestPoint = { lng: routeLng, lat: routeLat };
}
}
// Also check for nearest point on line segments (more accurate)
for (let i = 1; i < route.length; i++) {
const projected = this.projectPointOnSegment(
lng, lat,
route[i - 1][0], route[i - 1][1],
route[i][0], route[i][1]
);
const dist = this.calculateDistance(lat, lng, projected.lat, projected.lng);
if (dist < minDistance) {
minDistance = dist;
nearestPoint = projected;
}
}
return nearestPoint;
}
// Project a point onto a line segment
projectPointOnSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSq = dx * dx + dy * dy;
if (lengthSq === 0) {
return { lng: x1, lat: y1 };
}
// Calculate projection parameter
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq;
t = Math.max(0, Math.min(1, t)); // Clamp to segment
return {
lng: x1 + t * dx,
lat: y1 + t * dy
};
}
updateTrackLine() {
if (!this.map || !this.map.getSource('track-line')) return;
// Use road-snapped route coordinates
const coordinates = this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints;
this.map.getSource('track-line').setData({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: coordinates
}
});
}
updateTrackInfo() {
// Calculate track length from actual route (not straight line)
const coordinates = this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints;
let totalLength = 0;
for (let i = 1; i < coordinates.length; i++) {
totalLength += this.calculateDistance(
coordinates[i - 1][1], coordinates[i - 1][0],
coordinates[i][1], coordinates[i][0]
);
}
document.getElementById('track-waypoints').textContent = this.trackPoints.length;
document.getElementById('track-length').textContent = `${(totalLength / 1000).toFixed(2)} km`;
document.getElementById('track-checkpoints').textContent = this.checkpoints.length;
// Update toolbar state
this.updateToolbarState();
}
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // Earth radius in meters
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * 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(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
async undo() {
if (this.markers.length === 0) return;
const last = this.markers.pop();
last.marker.remove();
if (last.type === 'track') {
this.trackPoints.pop();
this.waypointMarkers.pop();
// Recalculate route
await this.calculateRoute();
} else if (last.type === 'checkpoint') {
this.checkpoints.pop();
// Renumber remaining checkpoints
this.renumberCheckpoints();
}
this.updateTrackInfo();
}
renumberCheckpoints() {
// Update checkpoint marker numbers
this.checkpoints.forEach((cp, index) => {
const el = cp.marker.getElement();
if (el) {
const span = el.querySelector('span');
if (span) span.textContent = index + 1;
}
});
}
clearTrack(skipConfirm = false) {
if (!skipConfirm && !confirm('Strecke wirklich löschen?')) return;
// Remove all markers
this.markers.forEach(m => m.marker.remove());
this.markers = [];
this.waypointMarkers = [];
if (this.startPoint?.marker) this.startPoint.marker.remove();
if (this.finishPoint?.marker) this.finishPoint.marker.remove();
this.trackPoints = [];
this.routeCoordinates = [];
this.checkpoints = [];
this.startPoint = null;
this.finishPoint = null;
this.currentTrackId = null;
this.updateTrackLine();
this.updateTrackInfo();
this.updateToolbarState();
// Clear form
document.getElementById('track-name').value = '';
document.getElementById('track-description').value = '';
}
async saveTrack() {
const user = auth.currentUser;
if (!user) {
alert('Nicht angemeldet');
return;
}
const name = document.getElementById('track-name').value.trim();
if (!name) {
alert('Bitte Streckenname eingeben');
return;
}
if (this.trackPoints.length < 2) {
alert('Strecke muss mindestens 2 Punkte haben');
return;
}
const challengeType = document.getElementById('track-type').value;
const lengthKm = parseFloat(document.getElementById('track-length').textContent) || 0;
if (lengthKm < 0.5) {
alert('Strecke muss mindestens 0.5 km lang sein');
return;
}
// Determine start and finish points
const startPoint = this.startPoint
? { lat: this.startPoint.lat, lng: this.startPoint.lng }
: { lat: this.trackPoints[0][1], lng: this.trackPoints[0][0] };
const finishPoint = this.finishPoint
? { lat: this.finishPoint.lat, lng: this.finishPoint.lng }
: { lat: this.trackPoints[this.trackPoints.length - 1][1], lng: this.trackPoints[this.trackPoints.length - 1][0] };
// Calculate reference time based on 10 km/h average speed
// Time in ms = (distance in km / speed in km/h) * 3600 * 1000
const referenceSpeedKmh = 10;
const referenceTimeMs = Math.round((lengthKm / referenceSpeedKmh) * 3600 * 1000);
// Credit costs for creating challenges
const CREDIT_COSTS = {
checkpoint: 500,
lap: 500,
sprint: 200 // Ghost-like
};
const creditCost = CREDIT_COSTS[challengeType] || 500;
// Get user nickname
let nickname = 'Web-Editor';
try {
const userRef = database.ref(`cars/${user.uid}/nickname`);
const nicknameSnap = await userRef.once('value');
nickname = nicknameSnap.val() || 'Web-Editor';
} catch (e) {
console.warn('Could not load nickname:', e);
}
// Check and deduct credits
try {
const creditsRef = database.ref(`credits/${user.uid}`);
const creditsSnap = await creditsRef.once('value');
const creditsData = creditsSnap.val() || { balance: 0 };
if ((creditsData.balance || 0) < creditCost) {
alert(`Nicht genügend Punkte!\n\nBenötigt: ${creditCost} Punkte\nVorhanden: ${creditsData.balance || 0} Punkte`);
return;
}
// Confirm creation
const confirmMsg = `Challenge erstellen?\n\n` +
`Name: ${name}\n` +
`Typ: ${challengeType}\n` +
`Länge: ${lengthKm.toFixed(2)} km\n` +
`Checkpoints: ${this.checkpoints.length}\n` +
`Referenzzeit (10 km/h): ${this.formatTime(referenceTimeMs)}\n\n` +
`Kosten: ${creditCost} Punkte`;
if (!confirm(confirmMsg)) {
return;
}
// Deduct credits
const now = Date.now();
await creditsRef.update({
balance: (creditsData.balance || 0) - creditCost,
totalSpent: (creditsData.totalSpent || 0) + creditCost,
lastUpdated: now,
[`transactions/${now}`]: {
type: 'challenge_create',
amount: -creditCost,
description: `${challengeType} Challenge erstellt (Web)`,
timestamp: now
}
});
} catch (error) {
console.error('Error checking credits:', error);
alert('Fehler beim Prüfen der Punkte: ' + error.message);
return;
}
// Calculate checkpoint reference times (distributed evenly based on distance)
const checkpointsWithTimes = this.checkpoints.map((cp, index) => {
// Estimate distance to this checkpoint
const distanceRatio = (index + 1) / (this.checkpoints.length + 1);
const checkpointTimeMs = Math.round(referenceTimeMs * distanceRatio);
return {
index,
lat: cp.lat,
lng: cp.lng,
referenceTime: checkpointTimeMs,
distanceFromStart: lengthKm * distanceRatio
};
});
// Build challenge data (format that app expects)
const challengeData = {
// Basic info
type: challengeType === 'sprint' ? 'checkpoint' : challengeType,
name: name,
description: document.getElementById('track-description').value.trim(),
createdBy: user.uid,
creatorId: user.uid,
creatorNickname: nickname,
createdAt: Date.now(),
createdVia: 'web-editor',
status: 'active',
// Track geometry
startPoint: startPoint,
finishPoint: finishPoint,
totalDistance: lengthKm,
// Reference (based on 10 km/h)
referenceTime: referenceTimeMs,
referenceHolder: user.uid,
referenceNickname: nickname + ' (10 km/h)',
// Checkpoints
checkpoints: checkpointsWithTimes,
// Stats
completionCount: 0,
freeAttemptsUsed: 0,
creatorBonusPaid: false,
// Store the road-snapped trail for display (not raw waypoints)
trail: (this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints)
.map(([lng, lat]) => ({ lat, lng })),
// Also store original waypoints for editing
waypoints: this.trackPoints.map(([lng, lat]) => ({ lat, lng })),
// Leaderboard (for lap challenges)
leaderboard: [{
userId: user.uid,
nickname: nickname + ' (10 km/h)',
time: referenceTimeMs,
date: Date.now(),
isPlaceholder: true
}]
};
try {
let challengeRef;
if (this.currentTrackId) {
// Update existing challenge
challengeRef = database.ref(`challenges/${this.currentTrackId}`);
challengeData.updatedAt = Date.now();
await challengeRef.update(challengeData);
} else {
// Create new challenge
challengeRef = database.ref('challenges').push();
challengeData.id = challengeRef.key;
await challengeRef.set(challengeData);
this.currentTrackId = challengeRef.key;
}
// Update user stats
const userStatsRef = database.ref(`users/${user.uid}/challengeStats`);
const statsSnap = await userStatsRef.once('value');
const stats = statsSnap.val() || { challengesCreated: 0 };
await userStatsRef.update({
challengesCreated: (stats.challengesCreated || 0) + 1
});
alert(`Challenge "${name}" erfolgreich erstellt!\n\nDie Challenge ist jetzt in der App verfügbar.\nErster echter Fahrer wird die 10 km/h Referenzzeit schlagen!`);
this.loadMyTracks();
this.loadExistingChallengesOnMap();
// Clear and start new track immediately after saving
this.newTrack();
} catch (error) {
console.error('Error saving challenge:', error);
alert('Fehler beim Speichern: ' + error.message);
}
}
formatTime(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
async loadMyTracks() {
const user = auth.currentUser;
if (!user) return;
const container = document.getElementById('my-tracks-list');
container.innerHTML = 'Lade...
';
try {
// Load challenges created via web editor
const challengesRef = database.ref('challenges');
const snapshot = await challengesRef.orderByChild('createdBy').equalTo(user.uid).once('value');
const challenges = snapshot.val();
if (!challenges) {
container.innerHTML = 'Keine Challenges
';
return;
}
const challengeList = Object.entries(challenges)
.map(([id, data]) => ({ id, ...data }))
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
container.innerHTML = challengeList.map(challenge => `
${challenge.totalDistance?.toFixed(2) || '?'} km · ${challenge.checkpoints?.length || 0} Checkpoints
${challenge.createdVia === 'web-editor' ? ' · Web' : ''}
Ref: ${this.formatTime(challenge.referenceTime || 0)}
`).join('');
// Add click handlers for track items
container.querySelectorAll('.track-item').forEach(item => {
item.addEventListener('click', (e) => {
// Ignore if clicking delete button
if (e.target.closest('.btn-delete-track')) return;
const trackId = item.dataset.trackId;
// Highlight selected
container.querySelectorAll('.track-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
this.loadTrack(trackId);
});
});
// Add click handlers for delete buttons
container.querySelectorAll('.btn-delete-track').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const trackId = btn.dataset.trackId;
const trackName = btn.dataset.trackName;
this.deleteTrack(trackId, trackName);
});
});
} catch (error) {
console.error('Error loading challenges:', error);
container.innerHTML = 'Fehler
';
}
}
// Toggle visibility of existing challenges on map
toggleExistingChallenges() {
console.log('🔄 Toggle challenges called, current state:', this.showExistingChallenges);
this.showExistingChallenges = !this.showExistingChallenges;
console.log('🔄 New state:', this.showExistingChallenges);
// Update button state
const btn = document.getElementById('btn-toggle-challenges');
if (btn) {
btn.classList.toggle('active', this.showExistingChallenges);
const icon = btn.querySelector('.material-icons');
if (icon) {
icon.textContent = this.showExistingChallenges ? 'visibility' : 'visibility_off';
}
}
if (this.showExistingChallenges) {
console.log('🔄 Loading challenges on map...');
this.loadExistingChallengesOnMap();
} else {
console.log('🔄 Hiding challenges, markers count:', this.existingChallengeMarkers?.length || 0);
// Hide all challenge markers
if (this.existingChallengeMarkers) {
this.existingChallengeMarkers.forEach(m => m.remove());
this.existingChallengeMarkers = [];
}
}
}
// Load and display existing challenges as markers on the map
async loadExistingChallengesOnMap() {
console.log('📍 loadExistingChallengesOnMap called');
console.log('📍 map exists:', !!this.map);
console.log('📍 showExistingChallenges:', this.showExistingChallenges);
if (!this.map) {
console.log('📍 No map, returning');
return;
}
if (!this.showExistingChallenges) {
console.log('📍 Toggle is off, returning');
return; // Only load if toggle is active
}
const user = auth.currentUser;
console.log('📍 User:', user?.uid);
if (!user) {
console.log('📍 No user, returning');
return;
}
// Store existing challenge markers for cleanup
if (!this.existingChallengeMarkers) {
this.existingChallengeMarkers = [];
}
// Remove old markers
this.existingChallengeMarkers.forEach(m => m.remove());
this.existingChallengeMarkers = [];
try {
const challengesRef = database.ref('challenges');
const snapshot = await challengesRef.orderByChild('createdBy').equalTo(user.uid).once('value');
const challenges = snapshot.val();
if (!challenges) return;
Object.entries(challenges).forEach(([id, challenge]) => {
if (!challenge.startPoint && (!challenge.trail || challenge.trail.length === 0)) return;
const lat = challenge.startPoint?.lat || challenge.trail[0].lat;
const lng = challenge.startPoint?.lng || challenge.trail[0].lng;
// Create marker element - gold/orange with trophy icon (same as app)
const el = document.createElement('div');
el.className = 'existing-challenge-marker';
el.innerHTML = `${challenge.type === 'lap' ? 'refresh' : 'emoji_events'}`;
el.style.cssText = `cursor: pointer; background: #FF9500; padding: 8px; border-radius: 8px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,0.3);`;
el.title = challenge.name || 'Challenge';
// Add click handler to load this challenge
el.addEventListener('click', (e) => {
e.stopPropagation();
this.loadTrack(id);
// Highlight in the list
document.querySelectorAll('.track-item').forEach(item => {
item.classList.toggle('active', item.dataset.trackId === id);
});
});
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(this.map);
this.existingChallengeMarkers.push(marker);
});
console.log(`🗺️ Loaded ${this.existingChallengeMarkers.length} challenge markers on map`);
} catch (error) {
console.error('Error loading challenges on map:', error);
}
}
async loadTrack(trackId) {
try {
// Load from /challenges/ path
const challengeRef = database.ref(`challenges/${trackId}`);
const snapshot = await challengeRef.once('value');
const challenge = snapshot.val();
if (!challenge) {
alert('Challenge nicht gefunden');
return;
}
// Clear current without confirmation
this.markers.forEach(m => m.marker.remove());
this.markers = [];
this.waypointMarkers = [];
if (this.startPoint?.marker) this.startPoint.marker.remove();
if (this.finishPoint?.marker) this.finishPoint.marker.remove();
this.trackPoints = [];
this.routeCoordinates = [];
this.checkpoints = [];
this.startPoint = null;
this.finishPoint = null;
this.updateTrackLine();
this.currentTrackId = trackId;
// Load data
document.getElementById('track-name').value = challenge.name || '';
document.getElementById('track-description').value = challenge.description || '';
document.getElementById('track-type').value = challenge.type || 'checkpoint';
// Load waypoints (if available) or fall back to trail
const waypoints = challenge.waypoints || challenge.trail;
if (waypoints && Array.isArray(waypoints)) {
// If we have many trail points but no waypoints,
// just load first and last as waypoints (legacy challenges)
if (!challenge.waypoints && waypoints.length > 10) {
// Legacy challenge - just load trail directly without routing
waypoints.forEach(point => {
this.trackPoints.push([point.lng, point.lat]);
});
this.routeCoordinates = [...this.trackPoints];
// Add markers for first and last only
this.addWaypointMarkerOnly(waypoints[0].lng, waypoints[0].lat, 1);
this.addWaypointMarkerOnly(waypoints[waypoints.length - 1].lng, waypoints[waypoints.length - 1].lat, 2);
this.updateTrackLine();
} else {
// New challenge with waypoints - recalculate route
for (const point of waypoints) {
await this.addTrackPoint(point.lng, point.lat);
}
}
}
// Load checkpoints (skip snapping since they're already saved at correct positions)
if (challenge.checkpoints && Array.isArray(challenge.checkpoints)) {
challenge.checkpoints.forEach(cp => {
this.addCheckpoint(cp.lng, cp.lat, true);
});
}
// Load start/finish (skip snapping since they're already saved at correct positions)
if (challenge.startPoint) {
this.setStartPoint(challenge.startPoint.lng, challenge.startPoint.lat, true);
}
if (challenge.finishPoint) {
this.setFinishPoint(challenge.finishPoint.lng, challenge.finishPoint.lat, true);
}
// Center map on track
if (challenge.trail && challenge.trail.length > 0) {
const bounds = new mapboxgl.LngLatBounds();
challenge.trail.forEach(point => bounds.extend([point.lng, point.lat]));
this.map.fitBounds(bounds, { padding: 50 });
}
this.updateTrackInfo();
} catch (error) {
console.error('Error loading challenge:', error);
alert('Fehler beim Laden der Challenge');
}
}
centerOnUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
this.map.flyTo({
center: [position.coords.longitude, position.coords.latitude],
zoom: 14
});
},
(error) => {
console.log('Geolocation error:', error);
}
);
}
}
}
// Initialize track editor
window.trackEditor = new TrackEditor();