diff --git a/index.js b/index.js
index db9a2e2..07de757 100644
--- a/index.js
+++ b/index.js
@@ -7,6 +7,7 @@ import {Color} from './js/colorlib.js';
import * as Config from './config.js';
import * as MapData from './js/mapdata.js';
import * as Legend from './legend.js';
+import * as Directions from './js/directions.js';
const mapContainer = document.getElementById('mapContainer');
const mapCanvas = document.getElementById('mapCanvas');
@@ -33,6 +34,7 @@ document.addEventListener('DOMContentLoaded', async () => {
MapData.matrix.initialize();
MapData.view.initialize();
+ Directions.initialize();
redrawMap();
mapNavigationSetup();
Legend.initialize();
@@ -49,6 +51,7 @@ function mapNavigationSetup(){
let previousScale = null;
const touchDownHandler = e => {
if(e.button != 0) return;
+ if(Directions.handlePointerDown(e)) return;
if(touchCache.length == 0){
document.addEventListener('pointermove', touchMoveHandler);
@@ -66,6 +69,7 @@ function mapNavigationSetup(){
mapContainer.style.cursor = 'grabbing';
}
const touchMoveHandler = e => {
+ if(Directions.handlePointerMove(e)) return;
if(!touchCache[e.pointerId]){
touchCache = [];
return;
@@ -87,6 +91,7 @@ function mapNavigationSetup(){
MapData.view.dirty = true;
}
const touchUpHandler = e => {
+ if(Directions.handlePointerUp(e)) return;
if(!touchCache[e.pointerId]) return;
touchCache[e.pointerId] = null;
touchCount--;
@@ -498,6 +503,7 @@ function redrawMap(){
function redrawDynamics(){
dynCanvas.width = mapCanvas.width;
dynCanvas.height = mapCanvas.height;
+ Directions.draw(dynctx);
let curSprite;
let spriteSize;
for(const marker of MapData.dynamicMarkers){
@@ -607,4 +613,4 @@ function redrawDynamics(){
}
}
MapData.view.dynDirty = false;
-}
\ No newline at end of file
+}
diff --git a/js/directions.js b/js/directions.js
new file mode 100644
index 0000000..11282da
--- /dev/null
+++ b/js/directions.js
@@ -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 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 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 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,
+ }
+ };
+}
diff --git a/style.css b/style.css
index 884a230..8ad5fd3 100644
--- a/style.css
+++ b/style.css
@@ -136,6 +136,134 @@ a:hover{
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{
transition: background-color 800ms;
display: inline-block;
@@ -266,4 +394,4 @@ button{
padding-bottom: 4px;
opacity: 0.7;
border-bottom: 2px solid #b3b3b396;
-}
\ No newline at end of file
+}