diff --git a/index.html b/index.html index c28db7c..8e90f96 100644 --- a/index.html +++ b/index.html @@ -28,10 +28,17 @@
Total Daily Riders: 0
+
Approval: 50%
+

Left Click: Add Point | Drag: Move

- +
diff --git a/src/GameManager.js b/src/GameManager.js index f108954..ae30917 100644 --- a/src/GameManager.js +++ b/src/GameManager.js @@ -4,43 +4,106 @@ export class GameManager { this.uiManager = uiManager; // Game State - this.budget = 1000000; // Start with $1M + this.budget = 1000000; this.day = 1; this.ticketPrice = 2.50; - // Constants - this.COST_PER_METER = 200; // Construction cost - this.BUS_COST = 50000; // Cost per vehicle + // Approval + this.approvalRating = 0; - // Timer for "Daily" cycle (every 5 seconds) + // Config + this.COST_PER_METER = 200; + this.BUS_COST = 50000; this.gameLoopInterval = null; + + // Cache for nodes that have people or jobs + this.censusNodes = []; } start() { + this.buildCensusArrays(); + this.recalculateApproval(); // Initial calc this.updateUI(); - // Start the game loop: Every 5 seconds = 1 Day this.gameLoopInterval = setInterval(() => { this.processDay(); }, 5000); } + buildCensusArrays() { + if (!this.routeManager.graphData) return; + + this.censusNodes = []; // Clear array + const nodes = this.routeManager.graphData.nodes; + + for (const [id, node] of Object.entries(nodes)) { + // Combine Population and Jobs for total "Human Presence" + const totalPeople = (node.pop || 0) + (node.jobs || 0); + + if (totalPeople > 0) { + this.censusNodes.push({ + id: parseInt(id), + count: totalPeople, // Weighting factor + x: node.x, + z: node.y // Graph Y is World Z + }); + } + } + } + + // UPDATED: Weighted Approval Calculation + recalculateApproval() { + if (!this.censusNodes || this.censusNodes.length === 0) { + this.approvalRating = 0; + return; + } + + let totalWeightedScore = 0; + let totalMaxScore = 0; // The score if everyone had 0m walk + + // Constants for walking distance + const MAX_WALK_DIST = 600; // Meters. Beyond this, satisfaction is 0. + const IDEAL_WALK_DIST = 50; // Meters. Below this, satisfaction is 100%. + + for (const node of this.censusNodes) { + // 1. Add this building's population/jobs to the potential max score + totalMaxScore += node.count; + + // 2. Get walking distance to nearest transit + const dist = this.routeManager.getDistanceToNearestTransit(node.x, node.z); + + // 3. Calculate Satisfaction Factor (0.0 to 1.0) + if (dist < MAX_WALK_DIST) { + // Linear falloff from 1.0 (at 50m) to 0.0 (at 600m) + let satisfaction = 1.0 - (Math.max(0, dist - IDEAL_WALK_DIST) / (MAX_WALK_DIST - IDEAL_WALK_DIST)); + satisfaction = Math.max(0, satisfaction); + + // 4. Add weighted score (Satisfaction * People Count) + // A high-rise (count=100) at 50% satisfaction adds 50 points. + // A house (count=3) at 50% satisfaction adds 1.5 points. + totalWeightedScore += (satisfaction * node.count); + } + } + + // Approval % = (Actual Weighted Score / Max Possible Weighted Score) + if (totalMaxScore > 0) { + this.approvalRating = Math.floor((totalWeightedScore / totalMaxScore) * 100); + } else { + this.approvalRating = 0; + } + } + processDay() { this.day++; - // Calculate total income from all active routes const savedRoutes = this.routeManager.getSavedRoutes(); let dailyIncome = 0; - let totalRiders = 0; - savedRoutes.forEach(route => { dailyIncome += route.stats.ridership * this.ticketPrice; - totalRiders += route.stats.ridership; }); this.budget += dailyIncome; - // Flash visual feedback if income > 0 if (dailyIncome > 0) { this.uiManager.showIncomeFeedback(dailyIncome); } @@ -48,17 +111,16 @@ export class GameManager { this.updateUI(); } - /** - * Estimates cost for a route based on length and needed buses - */ + getLastKnownRiders() { + const savedRoutes = this.routeManager.getSavedRoutes(); + let total = 0; + savedRoutes.forEach(r => total += r.stats.ridership); + return total; + } + getProjectedCost(lengthInMeters) { - // Construction Cost const construction = lengthInMeters * this.COST_PER_METER; - - // Fleet Cost: 1 Bus per 800m - const busesNeeded = Math.ceil(lengthInMeters / 800); - const fleet = busesNeeded * this.BUS_COST; - + const fleet = Math.ceil(lengthInMeters / 800) * this.BUS_COST; return Math.floor(construction + fleet); } @@ -72,15 +134,11 @@ export class GameManager { } updateUI() { - // Calculate aggregate stats - const savedRoutes = this.routeManager.getSavedRoutes(); - let totalRiders = 0; - savedRoutes.forEach(r => totalRiders += r.stats.ridership); - this.uiManager.updateGameStats({ budget: this.budget, day: this.day, - totalRiders: totalRiders + totalRiders: this.getLastKnownRiders(), + approval: this.approvalRating }); } } diff --git a/src/RouteManager.js b/src/RouteManager.js index bb840b9..221d8d3 100644 --- a/src/RouteManager.js +++ b/src/RouteManager.js @@ -15,6 +15,10 @@ export class RouteManager { this.markers = []; this.currentPathMesh = null; + this.servedNodes = new Set(); + + this.servedCoordinates = []; + this.ROAD_OFFSET = 2.5; this.onRouteChanged = null; @@ -36,35 +40,65 @@ export class RouteManager { initGraph(data) { this.graphData = data; this.graphData.adjacency = {}; - - // 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) => { if (edge.points) edge.points.forEach(p => { p[1] = -p[1]; }); - if (!this.graphData.adjacency[edge.u]) this.graphData.adjacency[edge.u] = []; - this.graphData.adjacency[edge.u].push({ - to: edge.v, - cost: edge.length || 1, // Fallback if length missing - edgeIndex: index - }); - + this.graphData.adjacency[edge.u].push({ to: edge.v, cost: edge.length || 1, edgeIndex: index }); if (!edge.oneway) { if (!this.graphData.adjacency[edge.v]) this.graphData.adjacency[edge.v] = []; - this.graphData.adjacency[edge.v].push({ - to: edge.u, - cost: edge.length || 1, - edgeIndex: index, - isReverse: true - }); + this.graphData.adjacency[edge.v].push({ to: edge.u, cost: edge.length || 1, edgeIndex: index, isReverse: true }); } }); } + // Helper to check if a node is covered + isNodeServed(nodeId) { + return this.servedNodes.has(parseInt(nodeId)); + } + + // Rebuild the Set of served nodes + refreshServedNodes() { + this.servedNodes.clear(); + this.servedCoordinates = []; + + this.savedRoutes.forEach(route => { + route.nodes.forEach(nodeId => { + if (!this.servedNodes.has(nodeId)) { + this.servedNodes.add(nodeId); + + const node = this.graphData.nodes[nodeId]; + if (node) { + // Cache World Coordinates (x, z) + // Note: node.y in graphData is already flipped to match World Z + this.servedCoordinates.push({ x: node.x, z: node.y }); + } + } + }); + }); + } + + // Returns distance in meters. Returns Infinity if no routes exist. + getDistanceToNearestTransit(x, z) { + if (this.servedCoordinates.length === 0) return Infinity; + + let minSq = Infinity; + + // Optimization: Standard loop is faster than forEach for high freq calls + for (let i = 0; i < this.servedCoordinates.length; i++) { + const sc = this.servedCoordinates[i]; + const dx = sc.x - x; + const dz = sc.z - z; + const d2 = dx * dx + dz * dz; + if (d2 < minSq) minSq = d2; + } + + return Math.sqrt(minSq); + } + + calculateRidership(nodeList) { if (!this.graphData || nodeList.length < 2) return 0; @@ -153,6 +187,8 @@ export class RouteManager { this.currentPathMesh = null; this.resetDraftingState(); + this.refreshServedNodes(); + // Force UI update to show new total riders this.gameManager.updateUI(); } @@ -164,6 +200,9 @@ export class RouteManager { this.currentRouteNodes = [...route.nodes]; if (route.mesh) { this.scene.remove(route.mesh); route.mesh.geometry.dispose(); } this.savedRoutes.splice(index, 1); + + this.refreshServedNodes(); + this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId)); this.updatePathVisuals(); this.gameManager.updateUI(); // Update UI since we removed a route (income drops) @@ -186,6 +225,7 @@ export class RouteManager { const route = this.savedRoutes[index]; if (route.mesh) { this.scene.remove(route.mesh); route.mesh.geometry.dispose(); } this.savedRoutes.splice(index, 1); + this.refreshServedNodes(); this.gameManager.updateUI(); } diff --git a/src/UIManager.js b/src/UIManager.js index 14be074..a15c30f 100644 --- a/src/UIManager.js +++ b/src/UIManager.js @@ -4,23 +4,26 @@ export class UIManager { // UI Elements this.elCurrentLength = document.getElementById('current-length'); - this.elCurrentCost = document.getElementById('current-cost'); // NEW - this.elCurrentRiders = document.getElementById('current-riders'); // NEW + this.elCurrentCost = document.getElementById('current-cost'); + this.elCurrentRiders = document.getElementById('current-riders'); - this.elBudget = document.getElementById('val-budget'); // NEW - this.elDay = document.getElementById('val-day'); // NEW - this.elTotalRiders = document.getElementById('val-riders'); // NEW - this.elIncomeFloat = document.getElementById('income-float'); // NEW + this.elBudget = document.getElementById('val-budget'); + this.elDay = document.getElementById('val-day'); + this.elTotalRiders = document.getElementById('val-riders'); + this.elApproval = document.getElementById('val-approval'); // NEW + this.elIncomeFloat = document.getElementById('income-float'); this.elRouteList = document.getElementById('route-list'); this.elContainer = document.getElementById('ui-container'); this.btnSave = document.getElementById('btn-save'); this.btnDiscard = document.getElementById('btn-discard'); this.btnToggle = document.getElementById('ui-toggle'); - this.btnZoning = document.getElementById('btn-zoning'); - this.onToggleZoning = null; + // NEW: View Mode + this.selectViewMode = document.getElementById('view-mode'); + + this.onViewModeChanged = null; this.initListeners(); } @@ -38,45 +41,43 @@ export class UIManager { this.elContainer.classList.toggle('hidden'); }); - this.btnZoning.addEventListener('click', () => { - const isActive = this.btnZoning.classList.toggle('active'); - this.btnZoning.style.background = isActive ? '#4B5563' : ''; - this.btnZoning.style.color = isActive ? 'white' : ''; - if (this.onToggleZoning) this.onToggleZoning(isActive); + // Handle Dropdown Change + this.selectViewMode.addEventListener('change', (e) => { + if (this.onViewModeChanged) { + this.onViewModeChanged(e.target.value); + } }); } - // Called by GameManager updateGameStats(stats) { this.elBudget.textContent = "$" + stats.budget.toLocaleString(); this.elDay.textContent = stats.day; this.elTotalRiders.textContent = stats.totalRiders.toLocaleString(); + + // Update Approval + this.elApproval.textContent = stats.approval + "%"; + // Color code it + if (stats.approval > 75) this.elApproval.style.color = "#10B981"; // Green + else if (stats.approval < 40) this.elApproval.style.color = "#EF4444"; // Red + else this.elApproval.style.color = "#D97706"; // Orange } showIncomeFeedback(amount) { this.elIncomeFloat.textContent = "+ $" + amount.toLocaleString(); this.elIncomeFloat.style.opacity = 1; this.elIncomeFloat.style.top = "40px"; - - // Reset animation setTimeout(() => { this.elIncomeFloat.style.opacity = 0; this.elIncomeFloat.style.top = "60px"; }, 2000); } - // Called by RouteManager on path change updateDraftStats(stats) { - // Length let lenText = stats.length > 1000 ? (stats.length / 1000).toFixed(2) + " km" : Math.round(stats.length) + " m"; this.elCurrentLength.textContent = lenText; - - // Cost this.elCurrentCost.textContent = "$" + stats.cost.toLocaleString(); - - // Ridership this.elCurrentRiders.textContent = stats.ridership.toLocaleString() + " / day"; } @@ -87,7 +88,6 @@ export class UIManager { routes.forEach((route, index) => { const li = document.createElement('li'); - // Format Length let lenStr = route.stats.length > 1000 ? (route.stats.length / 1000).toFixed(1) + "km" : Math.round(route.stats.length) + "m"; diff --git a/src/main.js b/src/main.js index 4d60935..b1826f6 100644 --- a/src/main.js +++ b/src/main.js @@ -17,6 +17,8 @@ const SETTINGS = { ground: 0xDDDDDD, zoningRes: new THREE.Color(0xA855F7), zoningCom: new THREE.Color(0x3B82F6), + coverageGood: new THREE.Color(0x10B981), + coverageBad: new THREE.Color(0xEF4444), building: new THREE.Color(0xFFFFFF), water: 0xAADAFF, park: 0xC3E6CB, @@ -34,7 +36,9 @@ const SETTINGS = { let scene, camera, renderer, controls; let inputManager, routeManager, uiManager, gameManager, vehicleSystem; -const clock = new THREE.Clock(); // Added Clock +const clock = new THREE.Clock(); + +let currentViewMode = 'none'; // 'none', 'zoning', 'approval' function init() { setupScene(); @@ -51,7 +55,6 @@ function init() { vehicleSystem = new VehicleSystem(scene); routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager - gameManager.start(); // Start the loop // 3. Input @@ -66,11 +69,16 @@ function init() { routeManager.dragNode(markerObject, newPoint); }; - uiManager.onToggleZoning = (isActive) => updateBuildingColors(isActive); + // Wire UI View Mode + uiManager.onViewModeChanged = (mode) => { + currentViewMode = mode; + updateBuildingColors(); + }; - // When path updates, show new stats in UI routeManager.onRouteChanged = (stats) => { uiManager.updateDraftStats(stats); + // If in coverage view, we might need to refresh colors as coverage changes + if (currentViewMode === 'approval') updateBuildingColors(); }; // 4. Load Data @@ -78,39 +86,72 @@ function init() { fetch(SETTINGS.files.visual).then(r => r.json()), fetch(SETTINGS.files.routing).then(r => r.json()) ]).then(([visual, routing]) => { - renderCity(visual); routeManager.initGraph(routing); + renderCity(visual); }); animate(); } -function updateBuildingColors(showZoning) { +function updateBuildingColors() { scene.traverse((obj) => { - // We tagged buildings with userData in renderCity (see below) if (obj.name === 'BUILDING_MESH') { - - if (!showZoning) { - // Revert to white - obj.material.color.setHex(SETTINGS.colors.building.getHex()); - return; - } - - // Get Data - const data = obj.userData.cityData; // We need to ensure we save this during creation + const data = obj.userData.cityData; if (!data) return; - if (data.type === 'residential') { - // Lerp from White to Purple based on density - const color = SETTINGS.colors.building.clone(); - color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5); - obj.material.color.copy(color); + // 1. STANDARD VIEW + if (currentViewMode === 'none') { + obj.material.color.copy(SETTINGS.colors.building); } - else if (data.type === 'commercial') { - // Lerp from White to Blue - const color = SETTINGS.colors.building.clone(); - color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5); - obj.material.color.copy(color); + + // 2. ZONING VIEW + else if (currentViewMode === 'zoning') { + if (data.type === 'residential') { + const color = SETTINGS.colors.building.clone(); + color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5); + obj.material.color.copy(color); + } else if (data.type === 'commercial') { + const color = SETTINGS.colors.building.clone(); + color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5); + obj.material.color.copy(color); + } else { + obj.material.color.copy(SETTINGS.colors.building); + } + } + + // 3. APPROVAL / COVERAGE VIEW (GRADIENT) + else if (currentViewMode === 'approval') { + // Get graph node position + const nearestId = obj.userData.nearestNodeId; + // RouteManager has logic for this + const node = routeManager.graphData.nodes[nearestId]; + + if (node) { + // Calculate distance to nearest transit + // node.y is Z in world space + const dist = routeManager.getDistanceToNearestTransit(node.x, node.y); + + // Color Logic: + // < 100m = Green (Great) + // < 300m = Yellow (Okay) + // > 600m = Red (Bad) + + if (dist === Infinity) { + obj.material.color.copy(SETTINGS.colors.coverageBad); // Deep Red + } else { + const MAX_DIST = 600; + const factor = Math.min(1.0, dist / MAX_DIST); // 0.0 (Close) to 1.0 (Far) + + // Lerp from Green to Red + // (Green at 0, Red at 1) + const color = SETTINGS.colors.coverageGood.clone(); + // We can lerp to Red. + // Or use a Yellow midpoint? + // Simple lerp: Green -> Red + color.lerp(SETTINGS.colors.coverageBad, factor); + obj.material.color.copy(color); + } + } } } }); @@ -219,57 +260,57 @@ function renderCity(data) { scene.add(mesh); }; - createBuildingLayer(data.buildings); + // Dedicated Building Creator to cache Nearest Node ID + const createBuildingLayer = (buildings) => { + if (!buildings || !buildings.length) return; + const mat = new THREE.MeshStandardMaterial({ + color: SETTINGS.colors.building, + roughness: 0.6, + side: THREE.DoubleSide, + shadowSide: THREE.DoubleSide + }); + + buildings.forEach(b => { + const shape = new THREE.Shape(); + if (b.shape.outer.length < 3) return; + shape.moveTo(b.shape.outer[0][0], b.shape.outer[0][1]); + for (let i = 1; i < b.shape.outer.length; i++) shape.lineTo(b.shape.outer[i][0], b.shape.outer[i][1]); + + if (b.shape.holes) { + b.shape.holes.forEach(h => { + const path = new THREE.Path(); + path.moveTo(h[0][0], h[0][1]); + for (let k = 1; k < h.length; k++) path.lineTo(h[k][0], h[k][1]); + shape.holes.push(path); + }); + } + + const geom = new THREE.ExtrudeGeometry(shape, { depth: b.height, bevelEnabled: false }); + geom.rotateX(-Math.PI / 2); + + const mesh = new THREE.Mesh(geom, mat.clone()); + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.name = 'BUILDING_MESH'; + mesh.userData.cityData = b.data; + + // CALCULATE NEAREST NODE FOR APPROVAL MECHANIC + // We use the first point of the outer ring as a proxy for position + const bx = b.shape.outer[0][0]; + const by = b.shape.outer[0][1]; + + const nearestId = routeManager.findNearestNode(bx, -by); + mesh.userData.nearestNodeId = nearestId; + + scene.add(mesh); + }); + }; + + createBuildingLayer(data.buildings); createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false); createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false); createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false); - -} - -function createBuildingLayer(buildings) { - if (!buildings || !buildings.length) return; - - - const mat = new THREE.MeshStandardMaterial({ - color: SETTINGS.colors.building, - roughness: 0.6, - side: THREE.DoubleSide, - shadowSide: THREE.DoubleSide - - }); - - buildings.forEach(b => { - const shape = new THREE.Shape(); - if (b.shape.outer.length < 3) return; - shape.moveTo(b.shape.outer[0][0], b.shape.outer[0][1]); - for (let i = 1; i < b.shape.outer.length; i++) shape.lineTo(b.shape.outer[i][0], b.shape.outer[i][1]); - - if (b.shape.holes) { - b.shape.holes.forEach(h => { - const path = new THREE.Path(); - path.moveTo(h[0][0], h[0][1]); - for (let k = 1; k < h.length; k++) path.lineTo(h[k][0], h[k][1]); - shape.holes.push(path); - }); - } - - const geom = new THREE.ExtrudeGeometry(shape, { - depth: b.height, - bevelEnabled: false - }); - geom.rotateX(-Math.PI / 2); - - const mesh = new THREE.Mesh(geom, mat.clone()); - mesh.castShadow = true; - mesh.receiveShadow = true; - - // Store metadata for the Zoning Toggle - mesh.name = 'BUILDING_MESH'; - mesh.userData.cityData = b.data; - - scene.add(mesh); - }); }