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);
- });
}