globalThis.Wegwichtel ??= {}; (function (ns) { 'use strict'; const INTERVAL_MS = 120000; let id = null; let editingId = null; let status = 'idle'; let timer = null; let generation = 0; let pendingGeneration = null; let enabled = false; let manualGeneration = 0; let message = ''; let manualFields = []; let lastPosition = null; function render() { const toggle = document.getElementById('route-recording-toggle'); if (toggle) { toggle.checked = enabled; toggle.setAttribute('aria-checked', String(enabled)); } const control = document.getElementById('route-recording-control'); if (!control) return; control.hidden = status === 'idle'; document.getElementById('route-recording-status').textContent = status === 'idle' ? '' : message || ns.I18n.t(`recorder.${status}`); document.getElementById('route-recording-pause').textContent = ns.I18n.t(status === 'paused' ? 'recorder.resume' : 'recorder.pause'); } async function sample() { if (pendingGeneration === generation || status !== 'recording') return; const currentGeneration = generation; pendingGeneration = currentGeneration; try { let position; try { position = await ns.Geo.current({ maximumAge: 0 }); } catch { position = { timestamp: Date.now() }; } if (currentGeneration !== generation || status !== 'recording') return; lastPosition = Number.isFinite(position.lat) && Number.isFinite(position.lon) ? position : null; const route = ns.LocalRoutes.get(id); if (!route) { stop(); return; } const gpx = ns.LocalRoutes.appendTrackPoint(route.gpx, position, route.route.name); ns.LocalRoutes.save({ ...route, gpx }); message = ns.I18n.t(Number.isFinite(position.lat) && Number.isFinite(position.lon) ? 'recorder.recording' : 'recorder.no-location'); } catch (error) { pause(); ns.StorageUsage?.reportError(error); message = ns.I18n.t('recorder.save-error'); } finally { if (pendingGeneration === currentGeneration) pendingGeneration = null; render(); } } function clearTimer() { if (timer !== null) globalThis.clearInterval(timer); timer = null; generation += 1; } function resume() { if (!id) return; clearTimer(); status = 'recording'; message = ''; timer = globalThis.setInterval(sample, INTERVAL_MS); sample(); render(); } function pause() { clearTimer(); status = id ? 'paused' : 'idle'; message = ''; render(); } function stop() { enabled = false; clearTimer(); id = null; lastPosition = null; status = 'idle'; message = ''; render(); } function start(routeId) { enabled = true; lastPosition = null; id = String(routeId); editingId = id; resume(); } function beginEditing(routeId) { const nextId = String(routeId); if (editingId === nextId) return; if (editingId !== null) endEditing(); editingId = nextId; if (enabled) { id = editingId; resume(); } render(); } function setEnabled(value) { if (!value) { stop(); return; } enabled = true; if (editingId) { id = editingId; resume(); } render(); } function endEditing() { editingId = null; manualGeneration += 1; stop(); } async function fillCurrentLocation(field, button, notice) { if (button.disabled) return; const currentGeneration = manualGeneration; button.disabled = true; notice.textContent = ns.I18n.t('recorder.locating'); try { const position = await ns.Geo.current({ maximumAge: 0 }); if (currentGeneration !== manualGeneration || !manualFields.includes(field)) return; if (!Number.isFinite(position.lat) || !Number.isFinite(position.lon) || Math.abs(position.lat) > 90 || Math.abs(position.lon) > 180) throw new Error('Invalid location'); field.lat.value = String(position.lat); field.lon.value = String(position.lon); notice.textContent = ns.I18n.t('recorder.location-set'); } catch { if (currentGeneration === manualGeneration && manualFields.includes(field)) notice.textContent = ns.I18n.t('recorder.location-error'); } finally { button.disabled = false; } } function addPositionField(position = {}, index = null) { const container = document.getElementById('editor-recorded-positions'); if (!container) return; const row = document.createElement('fieldset'); const legend = document.createElement('legend'); legend.textContent = ns.I18n.t('recorder.position-number', { number: manualFields.length + 1 }); row.appendChild(legend); const fields = {}; for (const [key, textKey, minimum, maximum] of [['lat', 'latitude', -90, 90], ['lon', 'longitude', -180, 180]]) { const label = document.createElement('label'); const input = document.createElement('input'); input.id = `recorded-position-${manualFields.length}-${key}`; label.setAttribute('for', input.id); label.textContent = ns.I18n.t(`recorder.${textKey}`); input.type = 'number'; input.step = 'any'; input.min = minimum; input.max = maximum; input.value = position[key] == null ? '' : String(position[key]); row.appendChild(label); row.appendChild(input); fields[key] = input; } const field = { index, original: position, ...fields }; manualFields.push(field); const button = document.createElement('button'); button.type = 'button'; button.className = 'current-location-button'; button.setAttribute('aria-label', ns.I18n.t('recorder.use-location')); button.title = ns.I18n.t('recorder.use-location'); const icon = document.createElement('img'); icon.src = '/images/icons/current-location.svg'; icon.alt = ''; button.appendChild(icon); const notice = document.createElement('p'); notice.className = 'editor-muted'; notice.setAttribute('aria-live', 'polite'); button.addEventListener('click', () => { void fillCurrentLocation(field, button, notice); }); row.appendChild(button); row.appendChild(notice); container.appendChild(row); } function renderPoints(gpx) { const container = document.getElementById('editor-recorded-positions'); if (!container) return; manualGeneration += 1; container.replaceChildren(); manualFields = []; ns.LocalRoutes.recordedPositions(gpx).forEach(addPositionField); } function editGpx(gpx, routeName) { let changed = false; const positions = ns.LocalRoutes.recordedPositions(gpx); for (const field of manualFields) { const values = ['lat', 'lon'].map(key => field[key].value.trim() === '' ? null : Number(field[key].value)); const [lat, lon] = values; if ((lat === null) !== (lon === null) || (lat !== null && (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180))) { throw new Error(ns.I18n.t('editor.error.station-location')); } if (field.index !== null && lat === field.original.lat && lon === field.original.lon) continue; const point = { ...field.original, lat, lon }; if (field.index === null) positions.push(point); else positions[field.index] = point; changed = true; } return changed ? ns.LocalRoutes.replaceRecordedPositions(gpx, positions, routeName) : gpx; } function bind() { globalThis.addEventListener?.('pagehide', endEditing); document.getElementById('route-recording-toggle')?.addEventListener('change', event => setEnabled(event.target.checked)); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && status === 'recording') void sample(); }); const activate = (elementId, action) => { const element = document.getElementById(elementId); element?.addEventListener('pointerup', event => { if (event.isPrimary === false || (event.pointerType === 'mouse' && event.button !== 0)) return; event.preventDefault(); action(); }); element?.addEventListener('keydown', event => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); action(); }); }; activate('route-recording-pause', () => status === 'recording' ? pause() : resume()); activate('route-recording-stop', stop); activate('editor-add-position', () => addPositionField({ timestamp: new Date().toISOString() })); document.addEventListener('wegwichtel:local-routes-changed', () => { if (id && !ns.LocalRoutes.get(id)) stop(); }); } ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, beginEditing, endEditing, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind, { once: true }); else bind(); }(globalThis.Wegwichtel));