'use strict'; import * as MapData from './mapdata.js'; import * as Pathfinder from './pathfinder.js'; import * as Config from './../config.js'; const state = { graph: null, points: [], route: null, addMode: false, draggingIndex: null, draggingPointerId: null, dragQueued: false, ui: {}, options: { maxSpeed: Config.routing.maxSpeed, maxGrade: Config.routing.maxGrade, gradePenalty: Config.routing.gradePenalty, }, }; const markerColors = { start: '#35c45a', via: '#3b82f6', end: '#ef4444', }; function mapPositionFromEvent(e){ return { x: MapData.view.unconvertX(e.clientX), y: 0, z: MapData.view.unconvertY(e.clientY), }; } function snapPosition(position){ if(!state.graph) return position; const snap = Pathfinder.snapToNetwork(state.graph, position, state.options); if(!snap) return position; return snap.point; } function setPointAt(index, position){ state.points[index] = { position: snapPosition(position), }; } function addPoint(position, insertBeforeEnd = false){ const snapped = snapPosition(position); if(insertBeforeEnd && state.points.length >= 2){ state.points.splice(state.points.length - 1, 0, {position: snapped}); }else{ state.points.push({position: snapped}); } } function clearPoints(){ state.points = []; state.route = null; renderStops(); renderSummary('Click the map to set a start point.'); MapData.view.dynDirty = true; } function stopLabel(index){ return String.fromCharCode(65 + index); } function stopRole(index){ if(index === 0) return 'Start'; if(index === state.points.length - 1) return 'End'; return 'Stop'; } function stopColor(index){ if(index === 0) return markerColors.start; if(index === state.points.length - 1) return markerColors.end; return markerColors.via; } function formatPosition(pos){ return `X ${pos.x.toFixed(1)}, Z ${pos.z.toFixed(1)}`; } function renderStops(){ const list = state.ui.stops; if(!list) return; list.innerHTML = ''; const total = Math.max(state.points.length, 2); for(let i=0; i 0 && i < total - 1 && state.points[i]){ const remove = document.createElement('button'); remove.className = 'directionButton'; remove.textContent = 'Remove'; remove.addEventListener('click', () => { state.points.splice(i, 1); scheduleRouteUpdate(); renderStops(); }); row.appendChild(remove); } list.appendChild(row); } state.ui.addStop.disabled = state.points.length < 2; } function renderSummary(text){ if(!state.ui.summary) return; state.ui.summary.textContent = text; } function formatDuration(seconds){ if(!Number.isFinite(seconds)) return '-'; const mins = Math.round(seconds / 60); if(mins < 60) return `${mins} min`; const hours = Math.floor(mins / 60); const rem = mins % 60; return `${hours} h ${rem} m`; } function updateRoute(){ if(!state.graph || state.points.length < 2){ state.route = null; renderSummary('Click the map to set a start and end point.'); MapData.view.dynDirty = true; return; } let combinedPolyline = []; let combinedEdges = []; for(let i=0; i 0 && result.polyline.length > 0){ const last = combinedPolyline[combinedPolyline.length - 1]; const first = result.polyline[0]; if(last.x === first.x && last.z === first.z){ combinedPolyline = combinedPolyline.concat(result.polyline.slice(1)); }else{ combinedPolyline = combinedPolyline.concat(result.polyline); } }else{ combinedPolyline = combinedPolyline.concat(result.polyline); } } const stats = Pathfinder.computeRouteStats(combinedEdges, state.options); state.route = { polyline: combinedPolyline, stats, }; const distanceKm = (stats.length / 1000).toFixed(2); const duration = formatDuration(stats.time); state.ui.summary.innerHTML = [ `Distance ${distanceKm} km`, `Est. ${duration}`, `Max grade ${stats.maxGrade.toFixed(1)}%`, `Max speed ${Math.round(stats.maxSpeed)} km/h`, `Max speed limit ${Math.round(stats.maxSpeedLimit)} km/h`, ].join(' | '); MapData.view.dynDirty = true; } function scheduleRouteUpdate(){ if(state.dragQueued) return; state.dragQueued = true; requestAnimationFrame(() => { state.dragQueued = false; updateRoute(); }); } function drawPolyline(ctx){ if(!state.route || !state.route.polyline || state.route.polyline.length < 2) return; ctx.save(); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; const width = 6 * MapData.view.pixelRatio; const outline = 10 * MapData.view.pixelRatio; ctx.beginPath(); for(let i=0; i 6) return; const position = mapPositionFromEvent(e); if(state.points.length === 0){ addPoint(position); }else if(state.points.length === 1){ addPoint(position); }else if(state.addMode){ addPoint(position, true); state.addMode = false; state.ui.addStop.classList.remove('active'); }else{ return; } renderStops(); scheduleRouteUpdate(); } export function initialize(){ state.graph = Pathfinder.buildGraph(MapData.railTracks); state.ui.panel = document.getElementById('directionsPanel'); state.ui.contents = document.getElementById('directionsContents'); state.ui.stops = document.getElementById('directionsStops'); state.ui.summary = document.getElementById('directionsSummary'); state.ui.addStop = document.getElementById('directionsAddStop'); state.ui.clear = document.getElementById('directionsClear'); state.ui.maxSpeed = document.getElementById('routeMaxSpeed'); state.ui.maxGrade = document.getElementById('routeMaxGrade'); state.ui.gradePenalty = document.getElementById('routeGradePenalty'); state.ui.toggle = document.getElementById('directionsToggle'); state.ui.maxSpeed.value = state.options.maxSpeed; state.ui.maxGrade.value = state.options.maxGrade; state.ui.gradePenalty.value = state.options.gradePenalty; state.ui.maxSpeed.addEventListener('change', e => { state.options.maxSpeed = Math.max(10, Number(e.target.value) || Config.routing.maxSpeed); scheduleRouteUpdate(); }); state.ui.maxGrade.addEventListener('change', e => { state.options.maxGrade = Math.max(0, Number(e.target.value) || Config.routing.maxGrade); scheduleRouteUpdate(); }); state.ui.gradePenalty.addEventListener('input', e => { state.options.gradePenalty = Math.max(0, Number(e.target.value) || 0); scheduleRouteUpdate(); }); state.ui.addStop.addEventListener('click', () => { if(state.points.length < 2) return; state.addMode = !state.addMode; state.ui.addStop.classList.toggle('active', state.addMode); }); state.ui.clear.addEventListener('click', () => { state.addMode = false; state.ui.addStop.classList.remove('active'); clearPoints(); }); const updatePanelState = () => { const width = state.ui.contents.offsetWidth; if(state.ui.panel.classList.contains('collapsed')){ state.ui.panel.style.transform = `translateX(${-width}px)`; state.ui.toggle.textContent = '>'; }else{ state.ui.panel.style.transform = 'translateX(0px)'; state.ui.toggle.textContent = '<'; } }; state.ui.toggle.addEventListener('click', () => { state.ui.panel.classList.toggle('collapsed'); updatePanelState(); }); window.addEventListener('resize', updatePanelState); const mapContainer = document.getElementById('mapContainer'); mapContainer.addEventListener('pointerdown', e => { state.ui.lastPointerDown = {x:e.clientX, y:e.clientY}; }); mapContainer.addEventListener('pointerup', handleMapPointerUp); renderStops(); renderSummary('Click the map to set a start point.'); updatePanelState(); } export function draw(ctx){ drawPolyline(ctx); drawMarkers(ctx); } function hitTestMarker(e){ if(state.points.length === 0) return null; const radius = (Config.routing.markerRadius + 6) * MapData.view.pixelRatio; const x = e.clientX * MapData.view.pixelRatio; const y = e.clientY * MapData.view.pixelRatio; for(let i=0; i