From c5437de834138ce7879312bab5253036e0815135 Mon Sep 17 00:00:00 2001 From: Evan Scamehorn Date: Tue, 2 Dec 2025 13:19:40 -0600 Subject: [PATCH] very basic UI --- index.html | 33 ++++++-- src/RouteManager.js | 179 +++++++++++++++++++++++++++----------------- src/UIManager.js | 70 +++++++++++++++++ src/main.js | 24 +++--- style.css | 66 ++++++++++++++++ 5 files changed, 288 insertions(+), 84 deletions(-) create mode 100644 src/UIManager.js create mode 100644 style.css diff --git a/index.html b/index.html index 4131025..d5b2260 100644 --- a/index.html +++ b/index.html @@ -5,15 +5,36 @@ 3D City Sim - + + +
+
+

Route Planner

+

Left Click: Add Point
Drag: Move Point

+
+ +
+

Current Draft

+
+ Length: + 0 m +
+
+ + +
+
+ +
+

Saved Routes

+
    + +
+
+
diff --git a/src/RouteManager.js b/src/RouteManager.js index 14e595e..ada41f6 100644 --- a/src/RouteManager.js +++ b/src/RouteManager.js @@ -7,37 +7,38 @@ export class RouteManager { this.graphData = null; - // State: A route is an ordered list of Node IDs + // -- State -- this.currentRouteNodes = []; + this.savedRoutes = []; // { nodes: [], length: number, mesh: THREE.Mesh } - // Visuals - this.markers = []; // Array of Meshes. index matches currentRouteNodes - this.pathMesh = null; + // -- Visuals -- + this.markers = []; // Draggable spheres + this.currentPathMesh = null; // The tube being edited - this.ROAD_OFFSET = 2.5; // Meters + this.ROAD_OFFSET = 2.5; + + // -- Callbacks -- + this.onRouteChanged = null; // function(lengthInMeters) } initGraph(data) { this.graphData = data; this.graphData.adjacency = {}; - // 1. Flip Coordinates (Data is +Y North, 3D is -Z North) + // 1. Flip Coordinates for (let key in this.graphData.nodes) { this.graphData.nodes[key].y = -this.graphData.nodes[key].y; } // 2. Build Adjacency this.graphData.edges.forEach((edge, index) => { - // Flip edge geometry if (edge.points) edge.points.forEach(p => { p[1] = -p[1]; }); - // Forward if (!this.graphData.adjacency[edge.u]) this.graphData.adjacency[edge.u] = []; this.graphData.adjacency[edge.u].push({ to: edge.v, cost: edge.length || 1, edgeIndex: index }); - // Reverse (if not oneway) if (!edge.oneway) { if (!this.graphData.adjacency[edge.v]) this.graphData.adjacency[edge.v] = []; this.graphData.adjacency[edge.v].push({ @@ -48,7 +49,7 @@ export class RouteManager { } // ============================ - // Interaction Methods + // API Methods (For UI/Input) // ============================ addNodeByWorldPosition(vector3) { @@ -56,126 +57,172 @@ export class RouteManager { const nodeId = this.findNearestNode(vector3.x, vector3.z); if (nodeId === null) return; - // Don't add duplicate adjacent nodes if (this.currentRouteNodes.length > 0 && this.currentRouteNodes[this.currentRouteNodes.length - 1] === nodeId) { return; } this.currentRouteNodes.push(nodeId); - - // Add new marker this.addMarkerVisual(nodeId); - - // Update path this.updatePathVisuals(); } - /** - * Called while dragging a marker. - * Updates the node at markerIndex to the nearest graph node at worldPoint. - */ dragNode(markerObject, worldPoint) { if (!this.graphData) return; - - // 1. Identify which node index this marker represents const index = this.markers.indexOf(markerObject); if (index === -1) return; - // 2. Find nearest node to new mouse position const newNodeId = this.findNearestNode(worldPoint.x, worldPoint.z); - // 3. Optimization: Only update if the node ID actually changed if (this.currentRouteNodes[index] !== newNodeId) { - this.currentRouteNodes[index] = newNodeId; - // Update Marker Visual Position const nodeData = this.graphData.nodes[newNodeId]; markerObject.position.set(nodeData.x, 2, nodeData.y); - markerObject.userData.nodeId = newNodeId; // Keep sync + markerObject.userData.nodeId = newNodeId; - // Recalculate Path this.updatePathVisuals(); } } + saveCurrentRoute() { + if (this.currentRouteNodes.length < 2 || !this.currentPathMesh) return; + + // 1. Calculate final length + const totalLength = this.currentPathMesh.userData.length || 0; + + // 2. Freeze the mesh (Change color to indicate saved state) + this.currentPathMesh.material.color.setHex(0x10B981); // Emerald Green + + // 3. Store in saved list + this.savedRoutes.push({ + nodes: [...this.currentRouteNodes], + length: totalLength, + mesh: this.currentPathMesh + }); + + // 4. Detach mesh from "current" reference so we don't delete it on reset + this.currentPathMesh = null; + + // 5. Clear drafting state (remove markers, clear node list) + this.resetDraftingState(); + } + + clearCurrentRoute() { + // 1. Remove current mesh from scene + if (this.currentPathMesh) { + this.scene.remove(this.currentPathMesh); + this.currentPathMesh.geometry.dispose(); + this.currentPathMesh = null; + } + // 2. Reset state + this.resetDraftingState(); + } + + resetDraftingState() { + this.currentRouteNodes = []; + // Remove all markers + this.markers.forEach(m => this.scene.remove(m)); + this.markers = []; + + // Notify UI + if (this.onRouteChanged) this.onRouteChanged(0); + } + + deleteSavedRoute(index) { + if (index < 0 || index >= this.savedRoutes.length) return; + + const route = this.savedRoutes[index]; + + // Remove mesh from scene + if (route.mesh) { + this.scene.remove(route.mesh); + route.mesh.geometry.dispose(); + } + + this.savedRoutes.splice(index, 1); + } + + getSavedRoutes() { + return this.savedRoutes; + } + // ============================ - // Visual Logic + // Visuals & Logic // ============================ updatePathVisuals() { - // Need 2+ nodes to make a path + // Needs 2+ nodes if (this.currentRouteNodes.length < 2) { - if (this.pathMesh) { - this.scene.remove(this.pathMesh); - this.pathMesh = null; + if (this.currentPathMesh) { + this.scene.remove(this.currentPathMesh); + this.currentPathMesh = null; } + if (this.onRouteChanged) this.onRouteChanged(0); return; } - // 1. Calculate Geometry let fullPathPoints = []; + let totalDist = 0; for (let i = 0; i < this.currentRouteNodes.length - 1; i++) { const start = this.currentRouteNodes[i]; const end = this.currentRouteNodes[i + 1]; - // Run A* for this segment const segmentEdges = this.computePathAStar(start, end); - if (!segmentEdges) { - // No path found (disconnected graph?), just draw straight line or skip - continue; - } + if (!segmentEdges) continue; - // Process Geometry segmentEdges.forEach(step => { + totalDist += step.edgeData.cost || 0; // Accumulate distance + const rawPoints = step.edgeData.points; let segmentPoints = rawPoints.map(p => new THREE.Vector2(p[0], p[1])); if (step.isReverse) segmentPoints.reverse(); - // Offset const offsetSegment = this.getOffsetPath(segmentPoints, this.ROAD_OFFSET); offsetSegment.forEach(p => fullPathPoints.push(new THREE.Vector3(p.x, 0.5, p.y))); }); } - // 2. Update/Create Mesh - if (this.pathMesh) { - this.scene.remove(this.pathMesh); - this.pathMesh.geometry.dispose(); + // Update Mesh + if (this.currentPathMesh) { + this.scene.remove(this.currentPathMesh); + this.currentPathMesh.geometry.dispose(); } if (fullPathPoints.length < 2) return; const curve = new THREE.CatmullRomCurve3(fullPathPoints); - // Low tension = smoother corners const tubeGeom = new THREE.TubeGeometry(curve, fullPathPoints.length, 1.5, 6, false); const tubeMat = new THREE.MeshBasicMaterial({ color: this.settings.colors.route }); - this.pathMesh = new THREE.Mesh(tubeGeom, tubeMat); - this.scene.add(this.pathMesh); + this.currentPathMesh = new THREE.Mesh(tubeGeom, tubeMat); + // Store length on the mesh for easy access later + this.currentPathMesh.userData.length = totalDist; + + this.scene.add(this.currentPathMesh); + + // Update markers color (First=Green, Last=Red, Others=Yellow) + this.updateMarkerColors(); + + // Trigger Callback + if (this.onRouteChanged) this.onRouteChanged(totalDist); + } + + updateMarkerColors() { + this.markers.forEach((marker, i) => { + let color = 0xFFFF00; // Default Yellow (Waypoint) + if (i === 0) color = this.settings.colors.pathStart; // Green + else if (i === this.markers.length - 1) color = this.settings.colors.pathEnd; // Red + marker.material.color.setHex(color); + }); } addMarkerVisual(nodeId) { const node = this.graphData.nodes[nodeId]; const geom = new THREE.SphereGeometry(4); - - // Color Logic: Start(Green) -> End(Red). Intermediate? Yellow. - let color = this.settings.colors.pathStart; - if (this.markers.length > 0) color = this.settings.colors.pathEnd; // Default to End color - - // If we are adding a new end, turn the PREVIOUS end into a waypoint (Yellow) - if (this.markers.length > 0) { - // Change the previous last marker to yellow (waypoint) - // Unless it was the start marker (index 0) - if (this.markers.length > 1) { - this.markers[this.markers.length - 1].material.color.setHex(0xFFFF00); - } - } - - const mat = new THREE.MeshBasicMaterial({ color: color }); + const mat = new THREE.MeshBasicMaterial({ color: this.settings.colors.pathEnd }); const mesh = new THREE.Mesh(geom, mat); mesh.position.set(node.x, 2, node.y); @@ -183,10 +230,12 @@ export class RouteManager { this.scene.add(mesh); this.markers.push(mesh); + + this.updateMarkerColors(); } // ============================ - // Algorithms (A* & Math) + // Algorithms (A* & Helpers) // ============================ findNearestNode(x, z) { @@ -206,12 +255,10 @@ export class RouteManager { computePathAStar(start, end) { if (start === end) return []; - const openSet = new Set([start]); const cameFrom = {}; const gScore = {}; const fScore = {}; - gScore[start] = 0; fScore[start] = this.heuristic(start, end); @@ -222,9 +269,7 @@ export class RouteManager { const score = fScore[node] !== undefined ? fScore[node] : Infinity; if (score < minF) { minF = score; current = node; } } - if (current === end) return this.reconstructPath(cameFrom, current); - openSet.delete(current); const neighbors = this.graphData.adjacency[current] || []; diff --git a/src/UIManager.js b/src/UIManager.js new file mode 100644 index 0000000..c6f7aa3 --- /dev/null +++ b/src/UIManager.js @@ -0,0 +1,70 @@ +export class UIManager { + constructor(routeManager) { + this.routeManager = routeManager; + + // DOM Elements + this.elCurrentLength = document.getElementById('current-length'); + this.elRouteList = document.getElementById('route-list'); + this.btnSave = document.getElementById('btn-save'); + this.btnDiscard = document.getElementById('btn-discard'); + + this.initListeners(); + } + + initListeners() { + this.btnSave.addEventListener('click', () => { + this.routeManager.saveCurrentRoute(); + this.renderRouteList(); + this.updateStats(0); + }); + + this.btnDiscard.addEventListener('click', () => { + this.routeManager.clearCurrentRoute(); + this.updateStats(0); + }); + } + + /** + * Updates the text display for current route length + * @param {number} lengthInMeters + */ + updateStats(lengthInMeters) { + // Format: If > 1000m, show km. Else meters. + let text = ""; + if (lengthInMeters > 1000) { + text = (lengthInMeters / 1000).toFixed(2) + " km"; + } else { + text = Math.round(lengthInMeters) + " m"; + } + this.elCurrentLength.textContent = text; + } + + renderRouteList() { + this.elRouteList.innerHTML = ''; + const routes = this.routeManager.getSavedRoutes(); + + routes.forEach((route, index) => { + const li = document.createElement('li'); + + // Format Length + let lenStr = route.length > 1000 + ? (route.length / 1000).toFixed(2) + " km" + : Math.round(route.length) + " m"; + + li.innerHTML = ` + Route ${index + 1} (${lenStr}) + `; + + // Delete Button + const btnDel = document.createElement('button'); + btnDel.textContent = "✕"; + btnDel.onclick = () => { + this.routeManager.deleteSavedRoute(index); + this.renderRouteList(); + }; + + li.appendChild(btnDel); + this.elRouteList.appendChild(li); + }); + } +} diff --git a/src/main.js b/src/main.js index ddd8673..899b5b6 100644 --- a/src/main.js +++ b/src/main.js @@ -2,9 +2,10 @@ import * as THREE from 'three'; import { MapControls } from 'three/addons/controls/MapControls.js'; import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js'; -// Import our Classes import { InputManager } from './InputManager.js'; import { RouteManager } from './RouteManager.js'; +import { UIManager } from './UIManager.js'; + // ========================================== // 1. Configuration @@ -28,34 +29,35 @@ const SETTINGS = { }; let scene, camera, renderer, controls; -let inputManager, routeManager; +let inputManager, routeManager, uiManager; function init() { setupScene(); - // -- INITIALIZATION -- - // 1. Create Route Manager + // 1. Managers routeManager = new RouteManager(scene, SETTINGS); - - // 2. Create Input Manager (Pass controls so we can disable them during drag) inputManager = new InputManager(camera, renderer.domElement, scene, controls); + uiManager = new UIManager(routeManager); // Wire UI to Route Logic + + // 2. Events inputManager.init(); - // 3. Wire Events - - // Handle Click (Add Node) inputManager.onClick = (point, object) => { if (object.name === "GROUND") { routeManager.addNodeByWorldPosition(point); } }; - // Handle Drag (Move Node) inputManager.onDrag = (markerObject, newPoint) => { routeManager.dragNode(markerObject, newPoint); }; - // 4. Load Data + // Wire RouteManager back to UI (to update stats when dragging) + routeManager.onRouteChanged = (dist) => { + uiManager.updateStats(dist); + }; + + // 3. Data Load Promise.all([ fetch(SETTINGS.files.visual).then(r => r.json()), fetch(SETTINGS.files.routing).then(r => r.json()) diff --git a/style.css b/style.css new file mode 100644 index 0000000..a66cca8 --- /dev/null +++ b/style.css @@ -0,0 +1,66 @@ +body { + margin: 0; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +#ui-container { + position: absolute; + top: 20px; + left: 20px; + width: 280px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); + user-select: none; + max-height: 90vh; + overflow-y: auto; +} + +.header h2 { margin: 0 0 5px 0; font-size: 18px; color: #333; } +.header p { margin: 0; font-size: 12px; color: #666; } + +.section { margin-top: 20px; border-top: 1px solid #eee; padding-top: 15px; } +.section h3 { margin: 0 0 10px 0; font-size: 14px; text-transform: uppercase; color: #888; letter-spacing: 0.5px; } + +.stat-row { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 10px; font-size: 16px; } + +.button-row { display: flex; gap: 10px; } +button { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 13px; + transition: opacity 0.2s; +} +button:hover { opacity: 0.8; } + +.primary { background-color: #2563EB; color: white; } +.danger { background-color: #ef4444; color: white; } +.secondary { background-color: #e5e7eb; color: #374151; } + +/* Route List */ +#route-list { list-style: none; padding: 0; margin: 0; } +#route-list li { + background: #f3f4f6; + margin-bottom: 8px; + padding: 10px; + border-radius: 6px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; +} +#route-list li button { + flex: 0; + margin-left: 10px; + padding: 4px 8px; + font-size: 12px; + background: #fee2e2; + color: #991b1b; +}