From e7940ae0cb07dd99ac36b294a1650c8ff4516f6a Mon Sep 17 00:00:00 2001 From: ololukaszuk <47779810+ololukaszuk@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:43:26 +0100 Subject: [PATCH] Add foldable pathfinding panel and richer ETA tooltips --- config.js | 19 +- index.html | 24 +++ index.js | 8 +- js/directions.js | 401 +++++++++++++++++++++++++++++++++++++++ js/pathfinder.js | 484 +++++++++++++++++++++++++++++++++++++++++++++++ style.css | 130 ++++++++++++- 6 files changed, 1063 insertions(+), 3 deletions(-) create mode 100644 js/directions.js create mode 100644 js/pathfinder.js diff --git a/config.js b/config.js index b0e6b85..e629be9 100644 --- a/config.js +++ b/config.js @@ -57,6 +57,23 @@ export const maxZoneLength = 700; /** How many pixels around the view is still considered "visible", beyond this and items are culled. */ 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 = { default: {radius:12}, junction: {width:24, height:35}, @@ -92,4 +109,4 @@ export const spriteBounds = { service_diesel: new SpriteBounds(40,120,20,20), service_charger: new SpriteBounds(60,120,20,20), player: new SpriteBounds(20,0,20,20), -} \ No newline at end of file +} diff --git a/index.html b/index.html index 6da3c30..f8f314e 100644 --- a/index.html +++ b/index.html @@ -39,6 +39,30 @@ DV Community Map +
+
+
+ Pathfinding +
+ Click map to set stops +
+
+
+
+
+ + +
+
+ + + +
+
+
+
+ +
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 +}