Add foldable pathfinding panel and richer ETA tooltips
This commit is contained in:
1 parent
401dad3170
commit
e7940ae0cb
6 files changed
+1063
-3
No files matched your search
@@ -57,6 +57,23 @@ export const maxZoneLength = 700;
|
|||||||
/** How many pixels around the view is still considered "visible", beyond this and items are culled. */
|
/** How many pixels around the view is still considered "visible", beyond this and items are culled. */
|
||||||
export const viewCullMargin = 60;
|
export const viewCullMargin = 60;
|
||||||
|
|
||||||
|
export const routing = {
|
||||||
|
maxSpeed: 120,
|
||||||
|
maxGrade: 6,
|
||||||
|
gradePenalty: 0.04,
|
||||||
|
minGradeFactor: 0.35,
|
||||||
|
snapSearchSteps: 48,
|
||||||
|
polylineStepMeters: 25,
|
||||||
|
markerRadius: 10,
|
||||||
|
maxSnapDistance: 1200,
|
||||||
|
assumedUnpostedSpeed: 120,
|
||||||
|
unpostedSpeedLimit: 90,
|
||||||
|
tightCurveRadius: 170,
|
||||||
|
tightCurveSpeed: 40,
|
||||||
|
accel: 0.35,
|
||||||
|
decel: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
export const tooltipHitzone = {
|
export const tooltipHitzone = {
|
||||||
default: {radius:12},
|
default: {radius:12},
|
||||||
junction: {width:24, height:35},
|
junction: {width:24, height:35},
|
||||||
@@ -92,4 +109,4 @@ export const spriteBounds = {
|
|||||||
service_diesel: new SpriteBounds(40,120,20,20),
|
service_diesel: new SpriteBounds(40,120,20,20),
|
||||||
service_charger: new SpriteBounds(60,120,20,20),
|
service_charger: new SpriteBounds(60,120,20,20),
|
||||||
player: new SpriteBounds(20,0,20,20),
|
player: new SpriteBounds(20,0,20,20),
|
||||||
}
|
}
|
||||||
+24
@@ -39,6 +39,30 @@
|
|||||||
<span id="redrawTrigger"></span>DV Community Map
|
<span id="redrawTrigger"></span>DV Community Map
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="directionsPanel">
|
||||||
|
<div id="directionsContents" class="panel">
|
||||||
|
<div id="directionsHeader">
|
||||||
|
<span>Pathfinding</span>
|
||||||
|
<div id="directionsHeaderRight">
|
||||||
|
<span class="quiet">Click map to set stops</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="directionsBody">
|
||||||
|
<div id="directionsStops"></div>
|
||||||
|
<div id="directionsControls">
|
||||||
|
<button id="directionsAddStop" class="directionButton">Add stop</button>
|
||||||
|
<button id="directionsClear" class="directionButton">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="directionsOptions">
|
||||||
|
<label title="Caps the speed used for estimation and routing, even if the track limit is higher.">Max Speed <input id="routeMaxSpeed" type="number" min="10" max="200" step="5"></label>
|
||||||
|
<label title="Disallow routes with grades steeper than this percentage.">Max Grade <input id="routeMaxGrade" type="number" min="0" max="12" step="0.5"></label>
|
||||||
|
<label title="Penalizes speed as grade increases to simulate reduced climbing ability.">Grade Penalty <input id="routeGradePenalty" type="number" min="0" max="0.2" step="0.01"></label>
|
||||||
|
</div>
|
||||||
|
<div id="directionsSummary" class="quiet"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="directionsToggle" class="panel orange directionToggle" title="Collapse or expand pathfinding panel"><</button>
|
||||||
|
</div>
|
||||||
<div id="legend">
|
<div id="legend">
|
||||||
<div id="legendContents">
|
<div id="legendContents">
|
||||||
<div class="panel linkpanel">
|
<div class="panel linkpanel">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {Color} from './js/colorlib.js';
|
|||||||
import * as Config from './config.js';
|
import * as Config from './config.js';
|
||||||
import * as MapData from './js/mapdata.js';
|
import * as MapData from './js/mapdata.js';
|
||||||
import * as Legend from './legend.js';
|
import * as Legend from './legend.js';
|
||||||
|
import * as Directions from './js/directions.js';
|
||||||
|
|
||||||
const mapContainer = document.getElementById('mapContainer');
|
const mapContainer = document.getElementById('mapContainer');
|
||||||
const mapCanvas = document.getElementById('mapCanvas');
|
const mapCanvas = document.getElementById('mapCanvas');
|
||||||
@@ -33,6 +34,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
MapData.matrix.initialize();
|
MapData.matrix.initialize();
|
||||||
MapData.view.initialize();
|
MapData.view.initialize();
|
||||||
|
Directions.initialize();
|
||||||
redrawMap();
|
redrawMap();
|
||||||
mapNavigationSetup();
|
mapNavigationSetup();
|
||||||
Legend.initialize();
|
Legend.initialize();
|
||||||
@@ -49,6 +51,7 @@ function mapNavigationSetup(){
|
|||||||
let previousScale = null;
|
let previousScale = null;
|
||||||
const touchDownHandler = e => {
|
const touchDownHandler = e => {
|
||||||
if(e.button != 0) return;
|
if(e.button != 0) return;
|
||||||
|
if(Directions.handlePointerDown(e)) return;
|
||||||
if(touchCache.length == 0){
|
if(touchCache.length == 0){
|
||||||
document.addEventListener('pointermove', touchMoveHandler);
|
document.addEventListener('pointermove', touchMoveHandler);
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ function mapNavigationSetup(){
|
|||||||
mapContainer.style.cursor = 'grabbing';
|
mapContainer.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
const touchMoveHandler = e => {
|
const touchMoveHandler = e => {
|
||||||
|
if(Directions.handlePointerMove(e)) return;
|
||||||
if(!touchCache[e.pointerId]){
|
if(!touchCache[e.pointerId]){
|
||||||
touchCache = [];
|
touchCache = [];
|
||||||
return;
|
return;
|
||||||
@@ -87,6 +91,7 @@ function mapNavigationSetup(){
|
|||||||
MapData.view.dirty = true;
|
MapData.view.dirty = true;
|
||||||
}
|
}
|
||||||
const touchUpHandler = e => {
|
const touchUpHandler = e => {
|
||||||
|
if(Directions.handlePointerUp(e)) return;
|
||||||
if(!touchCache[e.pointerId]) return;
|
if(!touchCache[e.pointerId]) return;
|
||||||
touchCache[e.pointerId] = null;
|
touchCache[e.pointerId] = null;
|
||||||
touchCount--;
|
touchCount--;
|
||||||
@@ -498,6 +503,7 @@ function redrawMap(){
|
|||||||
function redrawDynamics(){
|
function redrawDynamics(){
|
||||||
dynCanvas.width = mapCanvas.width;
|
dynCanvas.width = mapCanvas.width;
|
||||||
dynCanvas.height = mapCanvas.height;
|
dynCanvas.height = mapCanvas.height;
|
||||||
|
Directions.draw(dynctx);
|
||||||
let curSprite;
|
let curSprite;
|
||||||
let spriteSize;
|
let spriteSize;
|
||||||
for(const marker of MapData.dynamicMarkers){
|
for(const marker of MapData.dynamicMarkers){
|
||||||
@@ -607,4 +613,4 @@ function redrawDynamics(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MapData.view.dynDirty = false;
|
MapData.view.dynDirty = false;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
'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<total; i++){
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'directionRow';
|
||||||
|
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'directionBadge';
|
||||||
|
badge.style.background = (i === 0) ? markerColors.start : (i === total - 1 ? markerColors.end : markerColors.via);
|
||||||
|
badge.textContent = stopLabel(i);
|
||||||
|
row.appendChild(badge);
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'directionInput';
|
||||||
|
input.readOnly = true;
|
||||||
|
if(state.points[i]){
|
||||||
|
input.value = formatPosition(state.points[i].position);
|
||||||
|
}else{
|
||||||
|
input.placeholder = i === 0 ? 'Click map to set start' : 'Click map to set end';
|
||||||
|
}
|
||||||
|
row.appendChild(input);
|
||||||
|
|
||||||
|
if(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<state.points.length - 1; i++){
|
||||||
|
const start = state.points[i].position;
|
||||||
|
const end = state.points[i+1].position;
|
||||||
|
const result = Pathfinder.findRoute(state.graph, start, end, state.options);
|
||||||
|
if(!result){
|
||||||
|
state.route = null;
|
||||||
|
renderSummary('No route found with current limits.');
|
||||||
|
MapData.view.dynDirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
combinedEdges = combinedEdges.concat(result.edges);
|
||||||
|
|
||||||
|
if(combinedPolyline.length > 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`,
|
||||||
|
`<span title="Estimated travel time with acceleration and braking between speed changes.">Est. ${duration}</span>`,
|
||||||
|
`<span title="Steepest grade encountered along the route.">Max grade ${stats.maxGrade.toFixed(1)}%</span>`,
|
||||||
|
`<span title="Highest effective speed used in estimation after applying limits, grade penalty, and curve caps.">Max speed ${Math.round(stats.maxSpeed)} km/h</span>`,
|
||||||
|
`<span title="Highest posted track speed limit encountered (with realistic caps for unposted sections).">Max speed limit ${Math.round(stats.maxSpeedLimit)} km/h</span>`,
|
||||||
|
].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<state.route.polyline.length; i++){
|
||||||
|
const p = state.route.polyline[i];
|
||||||
|
const x = MapData.view.convertX(p.x);
|
||||||
|
const y = MapData.view.convertY(p.z);
|
||||||
|
if(i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#0b1220cc';
|
||||||
|
ctx.lineWidth = outline;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#4fa3ff';
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkers(ctx){
|
||||||
|
const radius = Config.routing.markerRadius * MapData.view.pixelRatio;
|
||||||
|
ctx.save();
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = `bold ${10 * MapData.view.pixelRatio}px "Noto Sans"`;
|
||||||
|
for(let i=0; i<state.points.length; i++){
|
||||||
|
const pos = state.points[i].position;
|
||||||
|
const x = MapData.view.convertX(pos.x);
|
||||||
|
const y = MapData.view.convertY(pos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = stopColor(i);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#0b0f1a';
|
||||||
|
ctx.lineWidth = 2 * MapData.view.pixelRatio;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#0b0f1a';
|
||||||
|
ctx.fillText(stopLabel(i), x, y + 0.5 * MapData.view.pixelRatio);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapPointerUp(e){
|
||||||
|
if(e.button !== 0) return;
|
||||||
|
if(state.draggingIndex != null) return;
|
||||||
|
const down = state.ui.lastPointerDown;
|
||||||
|
if(!down) return;
|
||||||
|
const dx = e.clientX - down.x;
|
||||||
|
const dy = e.clientY - down.y;
|
||||||
|
const distance = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
if(distance > 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<state.points.length; i++){
|
||||||
|
const pos = state.points[i].position;
|
||||||
|
const sx = MapData.view.convertX(pos.x);
|
||||||
|
const sy = MapData.view.convertY(pos.z);
|
||||||
|
const dx = sx - x;
|
||||||
|
const dy = sy - y;
|
||||||
|
if(dx*dx + dy*dy <= radius*radius) return i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePointerDown(e){
|
||||||
|
if(e.button !== 0) return false;
|
||||||
|
const hit = hitTestMarker(e);
|
||||||
|
if(hit == null) return false;
|
||||||
|
state.draggingIndex = hit;
|
||||||
|
state.draggingPointerId = e.pointerId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePointerMove(e){
|
||||||
|
if(state.draggingIndex == null) return false;
|
||||||
|
if(state.draggingPointerId !== e.pointerId) return false;
|
||||||
|
const position = mapPositionFromEvent(e);
|
||||||
|
setPointAt(state.draggingIndex, position);
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePointerUp(e){
|
||||||
|
if(state.draggingIndex == null) return false;
|
||||||
|
if(state.draggingPointerId !== e.pointerId) return false;
|
||||||
|
state.draggingIndex = null;
|
||||||
|
state.draggingPointerId = null;
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import * as Bezier from './bezierlib.js';
|
||||||
|
import * as Utils from './utillib.js';
|
||||||
|
import * as Config from './../config.js';
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
maxSpeed: Config.routing.maxSpeed,
|
||||||
|
maxGrade: Config.routing.maxGrade,
|
||||||
|
gradePenalty: Config.routing.gradePenalty,
|
||||||
|
minGradeFactor: Config.routing.minGradeFactor,
|
||||||
|
snapSearchSteps: Config.routing.snapSearchSteps,
|
||||||
|
polylineStepMeters: Config.routing.polylineStepMeters,
|
||||||
|
maxSnapDistance: Config.routing.maxSnapDistance,
|
||||||
|
assumedUnpostedSpeed: Config.routing.assumedUnpostedSpeed,
|
||||||
|
unpostedSpeedLimit: Config.routing.unpostedSpeedLimit,
|
||||||
|
tightCurveRadius: Config.routing.tightCurveRadius,
|
||||||
|
tightCurveSpeed: Config.routing.tightCurveSpeed,
|
||||||
|
accel: Config.routing.accel,
|
||||||
|
decel: Config.routing.decel,
|
||||||
|
};
|
||||||
|
|
||||||
|
function distance2D(a, b){
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dz = a.z - b.z;
|
||||||
|
return Math.sqrt(dx*dx + dz*dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distance3D(a, b){
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = (a.y ?? 0) - (b.y ?? 0);
|
||||||
|
const dz = a.z - b.z;
|
||||||
|
return Math.sqrt(dx*dx + dy*dy + dz*dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsDistance2D(bounds, point){
|
||||||
|
let dx = 0;
|
||||||
|
let dz = 0;
|
||||||
|
if(point.x < bounds.min.x) dx = bounds.min.x - point.x;
|
||||||
|
else if(point.x > bounds.max.x) dx = point.x - bounds.max.x;
|
||||||
|
if(point.z < bounds.min.z) dz = bounds.min.z - point.z;
|
||||||
|
else if(point.z > bounds.max.z) dz = point.z - bounds.max.z;
|
||||||
|
return Math.sqrt(dx*dx + dz*dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
function approximateCurveLength(curve, t0, t1, steps){
|
||||||
|
if(t0 === t1) return 0;
|
||||||
|
const start = Math.min(t0, t1);
|
||||||
|
const end = Math.max(t0, t1);
|
||||||
|
const segments = Math.max(2, steps ?? 16);
|
||||||
|
let length = 0;
|
||||||
|
let prev = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, start);
|
||||||
|
for(let i=1; i<=segments; i++){
|
||||||
|
const t = Utils.lerp(start, end, i/segments);
|
||||||
|
const cur = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, t);
|
||||||
|
length += distance3D(prev, cur);
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGraph(railTracks, precision = 2){
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
const curves = [];
|
||||||
|
const nodeIndex = new Map();
|
||||||
|
|
||||||
|
const keyFor = pos => `${pos.x.toFixed(precision)}|${pos.y.toFixed(precision)}|${pos.z.toFixed(precision)}`;
|
||||||
|
|
||||||
|
const getNodeId = pos => {
|
||||||
|
const key = keyFor(pos);
|
||||||
|
let id = nodeIndex.get(key);
|
||||||
|
if(id == null){
|
||||||
|
id = nodes.length;
|
||||||
|
nodeIndex.set(key, id);
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
position: {x:pos.x, y:pos.y, z:pos.z},
|
||||||
|
});
|
||||||
|
edges[id] = [];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEdge = (from, to, edge) => {
|
||||||
|
edges[from].push(edge);
|
||||||
|
};
|
||||||
|
|
||||||
|
for(const railTrack of railTracks){
|
||||||
|
for(const curve of railTrack.curves){
|
||||||
|
const startId = getNodeId(curve.start);
|
||||||
|
const endId = getNodeId(curve.end);
|
||||||
|
curve._routeStartId = startId;
|
||||||
|
curve._routeEndId = endId;
|
||||||
|
curve._routeTrackName = railTrack.name;
|
||||||
|
const edgeBase = {
|
||||||
|
curve,
|
||||||
|
length: curve.length,
|
||||||
|
grade: curve.grade,
|
||||||
|
speed: curve.postedSpeed,
|
||||||
|
};
|
||||||
|
addEdge(startId, endId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: endId,
|
||||||
|
t0: 0,
|
||||||
|
t1: 1,
|
||||||
|
});
|
||||||
|
addEdge(endId, startId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: startId,
|
||||||
|
t0: 1,
|
||||||
|
t1: 0,
|
||||||
|
});
|
||||||
|
curves.push(curve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {nodes, edges, curves};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapToNetwork(graph, position, options = {}){
|
||||||
|
const opts = {...defaults, ...options};
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
let bestT = 0;
|
||||||
|
let bestPoint = null;
|
||||||
|
for(const curve of graph.curves){
|
||||||
|
const boundDist = boundsDistance2D(curve.bounds, position);
|
||||||
|
if(boundDist > bestDist) continue;
|
||||||
|
const steps = opts.snapSearchSteps;
|
||||||
|
let localBestDist = Infinity;
|
||||||
|
let localBestT = 0;
|
||||||
|
let localBestPoint = null;
|
||||||
|
for(let i=0; i<=steps; i++){
|
||||||
|
const t = i/steps;
|
||||||
|
const p = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, t);
|
||||||
|
const d = distance2D(p, position);
|
||||||
|
if(d < localBestDist){
|
||||||
|
localBestDist = d;
|
||||||
|
localBestT = t;
|
||||||
|
localBestPoint = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(localBestDist < bestDist){
|
||||||
|
bestDist = localBestDist;
|
||||||
|
bestT = localBestT;
|
||||||
|
bestPoint = localBestPoint;
|
||||||
|
best = curve;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(best == null) return null;
|
||||||
|
return {
|
||||||
|
curve: best,
|
||||||
|
t: bestT,
|
||||||
|
point: {x:bestPoint.x, y:bestPoint.y, z:bestPoint.z},
|
||||||
|
distance: bestDist,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MinHeap{
|
||||||
|
constructor(){
|
||||||
|
this.data = [];
|
||||||
|
}
|
||||||
|
push(item){
|
||||||
|
this.data.push(item);
|
||||||
|
this.bubbleUp(this.data.length - 1);
|
||||||
|
}
|
||||||
|
pop(){
|
||||||
|
if(this.data.length === 0) return null;
|
||||||
|
const root = this.data[0];
|
||||||
|
const last = this.data.pop();
|
||||||
|
if(this.data.length > 0){
|
||||||
|
this.data[0] = last;
|
||||||
|
this.bubbleDown(0);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
bubbleUp(index){
|
||||||
|
while(index > 0){
|
||||||
|
const parent = Math.floor((index - 1) / 2);
|
||||||
|
if(this.data[parent].priority <= this.data[index].priority) break;
|
||||||
|
[this.data[parent], this.data[index]] = [this.data[index], this.data[parent]];
|
||||||
|
index = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bubbleDown(index){
|
||||||
|
const length = this.data.length;
|
||||||
|
while(true){
|
||||||
|
let left = index * 2 + 1;
|
||||||
|
let right = left + 1;
|
||||||
|
let smallest = index;
|
||||||
|
if(left < length && this.data[left].priority < this.data[smallest].priority) smallest = left;
|
||||||
|
if(right < length && this.data[right].priority < this.data[smallest].priority) smallest = right;
|
||||||
|
if(smallest === index) break;
|
||||||
|
[this.data[smallest], this.data[index]] = [this.data[index], this.data[smallest]];
|
||||||
|
index = smallest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get size(){
|
||||||
|
return this.data.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectivePostedSpeed(edge, options){
|
||||||
|
let speed = edge.speed;
|
||||||
|
if(options.assumedUnpostedSpeed != null && speed >= options.assumedUnpostedSpeed){
|
||||||
|
speed = Math.min(speed, options.unpostedSpeedLimit);
|
||||||
|
}
|
||||||
|
if(edge.curve && edge.curve.curvature && options.tightCurveRadius != null){
|
||||||
|
const radius = 1 / edge.curve.curvature;
|
||||||
|
if(Number.isFinite(radius) && radius < options.tightCurveRadius){
|
||||||
|
speed = Math.min(speed, options.tightCurveSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveSpeedUsed(edge, options){
|
||||||
|
let speed = effectivePostedSpeed(edge, options);
|
||||||
|
speed = Math.min(speed, options.maxSpeed ?? speed);
|
||||||
|
if(speed <= 0) return 0;
|
||||||
|
const gradePct = Math.abs(edge.grade) * 100;
|
||||||
|
if(options.maxGrade != null && gradePct > options.maxGrade) return 0;
|
||||||
|
const gradeFactor = Math.max(options.minGradeFactor, 1 - options.gradePenalty * gradePct);
|
||||||
|
return speed * gradeFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeCost(edge, options){
|
||||||
|
const speedKmh = effectiveSpeedUsed(edge, options);
|
||||||
|
if(speedKmh <= 0.01) return Infinity;
|
||||||
|
const speedMps = speedKmh * 1000 / 3600;
|
||||||
|
return edge.length / speedMps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentTravelTime(length, v0, v1, vMax, accel, decel){
|
||||||
|
if(vMax <= 0.01) return Infinity;
|
||||||
|
const a = Math.max(accel, 0.01);
|
||||||
|
const d = Math.max(decel, 0.01);
|
||||||
|
v0 = Math.min(v0, vMax);
|
||||||
|
v1 = Math.min(v1, vMax);
|
||||||
|
const accelDist = Math.max(0, (vMax*vMax - v0*v0) / (2 * a));
|
||||||
|
const decelDist = Math.max(0, (vMax*vMax - v1*v1) / (2 * d));
|
||||||
|
if(accelDist + decelDist <= length){
|
||||||
|
const cruise = length - accelDist - decelDist;
|
||||||
|
return (vMax - v0)/a + (vMax - v1)/d + cruise / vMax;
|
||||||
|
}
|
||||||
|
const numerator = 2 * length + (v0*v0)/a + (v1*v1)/d;
|
||||||
|
const denom = (1/a + 1/d);
|
||||||
|
const vPeak = Math.sqrt(Math.max(numerator / denom, 0));
|
||||||
|
return Math.max(0, (vPeak - v0)/a) + Math.max(0, (vPeak - v1)/d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateRouteTime(edges, speedLimits, options){
|
||||||
|
if(edges.length === 0) return 0;
|
||||||
|
const speeds = speedLimits.map(limit => Math.max(0, limit) * 1000 / 3600);
|
||||||
|
const boundaries = new Array(edges.length + 1);
|
||||||
|
boundaries[0] = 0;
|
||||||
|
boundaries[edges.length] = 0;
|
||||||
|
for(let i=1; i<edges.length; i++){
|
||||||
|
boundaries[i] = Math.min(speeds[i-1], speeds[i]);
|
||||||
|
}
|
||||||
|
let time = 0;
|
||||||
|
for(let i=0; i<edges.length; i++){
|
||||||
|
time += segmentTravelTime(
|
||||||
|
edges[i].length,
|
||||||
|
boundaries[i],
|
||||||
|
boundaries[i+1],
|
||||||
|
speeds[i],
|
||||||
|
options.accel,
|
||||||
|
options.decel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeRouteStats(edges, options = {}){
|
||||||
|
const opts = {...defaults, ...options};
|
||||||
|
let totalLength = 0;
|
||||||
|
let maxGrade = 0;
|
||||||
|
let maxSpeedLimit = 0;
|
||||||
|
let maxSpeed = 0;
|
||||||
|
const usedLimits = [];
|
||||||
|
for(const edge of edges){
|
||||||
|
totalLength += edge.length;
|
||||||
|
maxGrade = Math.max(maxGrade, Math.abs(edge.grade) * 100);
|
||||||
|
const posted = effectivePostedSpeed(edge, opts);
|
||||||
|
const used = effectiveSpeedUsed(edge, opts);
|
||||||
|
maxSpeedLimit = Math.max(maxSpeedLimit, posted);
|
||||||
|
maxSpeed = Math.max(maxSpeed, used);
|
||||||
|
usedLimits.push(used);
|
||||||
|
}
|
||||||
|
const time = estimateRouteTime(edges, usedLimits, opts);
|
||||||
|
return {
|
||||||
|
length: totalLength,
|
||||||
|
time,
|
||||||
|
maxGrade,
|
||||||
|
maxSpeed,
|
||||||
|
maxSpeedLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPolyline(edges, options){
|
||||||
|
const points = [];
|
||||||
|
const stepMeters = options.polylineStepMeters;
|
||||||
|
for(const edge of edges){
|
||||||
|
const curve = edge.curve;
|
||||||
|
if(!curve) continue;
|
||||||
|
const steps = Math.max(2, Math.ceil(edge.length / stepMeters));
|
||||||
|
for(let i=0; i<=steps; i++){
|
||||||
|
const t = Utils.lerp(edge.t0, edge.t1, i/steps);
|
||||||
|
const p = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, t);
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if(!last || last.x !== p.x || last.z !== p.z){
|
||||||
|
points.push({x:p.x, z:p.z});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRoute(graph, startPos, endPos, options = {}){
|
||||||
|
const opts = {...defaults, ...options};
|
||||||
|
const startSnap = snapToNetwork(graph, startPos, opts);
|
||||||
|
const endSnap = snapToNetwork(graph, endPos, opts);
|
||||||
|
if(!startSnap || !endSnap) return null;
|
||||||
|
if(opts.maxSnapDistance != null){
|
||||||
|
if(startSnap.distance > opts.maxSnapDistance || endSnap.distance > opts.maxSnapDistance) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCount = graph.nodes.length;
|
||||||
|
const startId = baseCount;
|
||||||
|
const endId = baseCount + 1;
|
||||||
|
const tempPositions = [];
|
||||||
|
tempPositions[startId] = startSnap.point;
|
||||||
|
tempPositions[endId] = endSnap.point;
|
||||||
|
|
||||||
|
const extraEdges = [];
|
||||||
|
const addTempEdge = (from, to, edge) => {
|
||||||
|
if(!extraEdges[from]) extraEdges[from] = [];
|
||||||
|
extraEdges[from].push(edge);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectSnap = (snap, nodeId) => {
|
||||||
|
const curve = snap.curve;
|
||||||
|
const startNode = curve._routeStartId;
|
||||||
|
const endNode = curve._routeEndId;
|
||||||
|
const t = snap.t;
|
||||||
|
const steps = Math.max(6, Math.ceil(curve.length / 80));
|
||||||
|
const lenToStart = approximateCurveLength(curve, 0, t, steps);
|
||||||
|
const lenToEnd = approximateCurveLength(curve, t, 1, steps);
|
||||||
|
const edgeBase = {
|
||||||
|
curve,
|
||||||
|
grade: curve.grade,
|
||||||
|
speed: curve.postedSpeed,
|
||||||
|
};
|
||||||
|
addTempEdge(nodeId, startNode, {
|
||||||
|
...edgeBase,
|
||||||
|
to: startNode,
|
||||||
|
length: lenToStart,
|
||||||
|
t0: t,
|
||||||
|
t1: 0,
|
||||||
|
});
|
||||||
|
addTempEdge(nodeId, endNode, {
|
||||||
|
...edgeBase,
|
||||||
|
to: endNode,
|
||||||
|
length: lenToEnd,
|
||||||
|
t0: t,
|
||||||
|
t1: 1,
|
||||||
|
});
|
||||||
|
addTempEdge(startNode, nodeId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: nodeId,
|
||||||
|
length: lenToStart,
|
||||||
|
t0: 0,
|
||||||
|
t1: t,
|
||||||
|
});
|
||||||
|
addTempEdge(endNode, nodeId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: nodeId,
|
||||||
|
length: lenToEnd,
|
||||||
|
t0: 1,
|
||||||
|
t1: t,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connectSnap(startSnap, startId);
|
||||||
|
connectSnap(endSnap, endId);
|
||||||
|
|
||||||
|
if(startSnap.curve === endSnap.curve){
|
||||||
|
const curve = startSnap.curve;
|
||||||
|
const steps = Math.max(6, Math.ceil(curve.length / 80));
|
||||||
|
const lenBetween = approximateCurveLength(curve, startSnap.t, endSnap.t, steps);
|
||||||
|
const edgeBase = {
|
||||||
|
curve,
|
||||||
|
grade: curve.grade,
|
||||||
|
speed: curve.postedSpeed,
|
||||||
|
};
|
||||||
|
addTempEdge(startId, endId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: endId,
|
||||||
|
length: lenBetween,
|
||||||
|
t0: startSnap.t,
|
||||||
|
t1: endSnap.t,
|
||||||
|
});
|
||||||
|
addTempEdge(endId, startId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: startId,
|
||||||
|
length: lenBetween,
|
||||||
|
t0: endSnap.t,
|
||||||
|
t1: startSnap.t,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalNodes = baseCount + 2;
|
||||||
|
const dist = new Array(totalNodes).fill(Infinity);
|
||||||
|
const prev = new Array(totalNodes).fill(-1);
|
||||||
|
const prevEdge = new Array(totalNodes).fill(null);
|
||||||
|
const visited = new Array(totalNodes).fill(false);
|
||||||
|
const heap = new MinHeap();
|
||||||
|
|
||||||
|
const nodePosition = id => {
|
||||||
|
if(id < baseCount) return graph.nodes[id].position;
|
||||||
|
return tempPositions[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxSpeedMps = (opts.maxSpeed * 1000 / 3600);
|
||||||
|
const heuristic = id => {
|
||||||
|
const pos = nodePosition(id);
|
||||||
|
const target = tempPositions[endId];
|
||||||
|
return distance2D(pos, target) / Math.max(maxSpeedMps, 0.1);
|
||||||
|
};
|
||||||
|
|
||||||
|
dist[startId] = 0;
|
||||||
|
heap.push({id:startId, priority:heuristic(startId)});
|
||||||
|
|
||||||
|
const getNeighbors = id => {
|
||||||
|
const baseEdges = id < baseCount ? graph.edges[id] : [];
|
||||||
|
const extra = extraEdges[id] ?? [];
|
||||||
|
return baseEdges.concat(extra);
|
||||||
|
};
|
||||||
|
|
||||||
|
while(heap.size){
|
||||||
|
const current = heap.pop();
|
||||||
|
if(!current) break;
|
||||||
|
const id = current.id;
|
||||||
|
if(visited[id]) continue;
|
||||||
|
visited[id] = true;
|
||||||
|
if(id === endId) break;
|
||||||
|
for(const edge of getNeighbors(id)){
|
||||||
|
const cost = edgeCost(edge, opts);
|
||||||
|
if(cost === Infinity) continue;
|
||||||
|
const next = edge.to;
|
||||||
|
const alt = dist[id] + cost;
|
||||||
|
if(alt < dist[next]){
|
||||||
|
dist[next] = alt;
|
||||||
|
prev[next] = id;
|
||||||
|
prevEdge[next] = edge;
|
||||||
|
heap.push({id:next, priority:alt + heuristic(next)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!Number.isFinite(dist[endId])) return null;
|
||||||
|
|
||||||
|
const pathEdges = [];
|
||||||
|
let cur = endId;
|
||||||
|
while(cur !== startId && cur !== -1){
|
||||||
|
const edge = prevEdge[cur];
|
||||||
|
if(!edge) break;
|
||||||
|
pathEdges.push(edge);
|
||||||
|
cur = prev[cur];
|
||||||
|
}
|
||||||
|
pathEdges.reverse();
|
||||||
|
|
||||||
|
return {
|
||||||
|
edges: pathEdges,
|
||||||
|
polyline: buildPolyline(pathEdges, opts),
|
||||||
|
stats: computeRouteStats(pathEdges, opts),
|
||||||
|
snaps: {
|
||||||
|
start: startSnap,
|
||||||
|
end: endSnap,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -136,6 +136,134 @@ a:hover{
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#directionsPanel{
|
||||||
|
max-width: min(360px, 92vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
transition: transform 400ms cubic-bezier(0.445, 0.050, 0.550, 0.950);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsContents{
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsHeader{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-variation-settings: "wght" 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsHeaderRight{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsBody{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionToggle{
|
||||||
|
padding: 8px;
|
||||||
|
height: var(--legendButtonSize);
|
||||||
|
width: var(--legendButtonSize);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 0px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsStops{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsControls{
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsOptions{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsOptions label{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsOptions input{
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsSummary{
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionRow{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionBadge{
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variation-settings: "wght" 700;
|
||||||
|
color: #0b0f1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionInput{
|
||||||
|
flex: 1;
|
||||||
|
background: #111827;
|
||||||
|
color: #e9e9e9;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionInput::placeholder{
|
||||||
|
color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionButton{
|
||||||
|
border: 1px solid #374151;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #e9e9e9;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionButton.active{
|
||||||
|
border-color: #e79830;
|
||||||
|
color: #31281b;
|
||||||
|
background: #e79830;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionButton:disabled{
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
#redrawTrigger{
|
#redrawTrigger{
|
||||||
transition: background-color 800ms;
|
transition: background-color 800ms;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -266,4 +394,4 @@ button{
|
|||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
border-bottom: 2px solid #b3b3b396;
|
border-bottom: 2px solid #b3b3b396;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user