population and job data for map, add visualization of data
This commit is contained in:
@@ -4,7 +4,9 @@ import os
|
||||
import networkx as nx
|
||||
from shapely.ops import unary_union
|
||||
from shapely.geometry import Polygon, MultiPolygon
|
||||
from scipy.spatial import cKDTree
|
||||
import re
|
||||
import numpy as np
|
||||
|
||||
# ==========================================
|
||||
# 1. Configuration
|
||||
@@ -29,6 +31,12 @@ DEFAULT_WIDTHS = {
|
||||
}
|
||||
|
||||
|
||||
# Census/Zoning Simulation Settings
|
||||
# Approximate density per cubic meter of building volume
|
||||
POP_DENSITY_FACTOR = 0.05 # People per m3 (Residential)
|
||||
JOB_DENSITY_FACTOR = 0.08 # Jobs per m3 (Commercial)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 2. Helpers
|
||||
# ==========================================
|
||||
@@ -56,7 +64,6 @@ def get_height(row):
|
||||
|
||||
def estimate_road_width(row):
|
||||
"""Estimates width with US-unit safety checks."""
|
||||
# 1. Explicit width tag
|
||||
for key in ["width", "width:carriageway", "est_width"]:
|
||||
if key in row and str(row[key]) != "nan":
|
||||
val_str = str(row[key]).lower()
|
||||
@@ -66,13 +73,12 @@ def estimate_road_width(row):
|
||||
val = float(nums[0])
|
||||
if "'" in val_str or "ft" in val_str or "feet" in val_str:
|
||||
val *= 0.3048
|
||||
elif val > 50: # Sanity check for feet without units
|
||||
elif val > 50:
|
||||
val *= 0.3048
|
||||
return val
|
||||
except:
|
||||
pass
|
||||
|
||||
# 2. Lanes
|
||||
if "lanes" in row and str(row["lanes"]) != "nan":
|
||||
try:
|
||||
clean = re.findall(r"\d+", str(row["lanes"]))
|
||||
@@ -83,18 +89,81 @@ def estimate_road_width(row):
|
||||
except:
|
||||
pass
|
||||
|
||||
# 3. Default based on type
|
||||
highway = row.get("highway", "residential")
|
||||
if isinstance(highway, list):
|
||||
highway = highway[0]
|
||||
return DEFAULT_WIDTHS.get(highway, 4.0)
|
||||
|
||||
|
||||
def classify_building(row, height, area):
|
||||
"""
|
||||
Classifies a building as Residential (Pop) or Commercial (Jobs)
|
||||
and estimates the count based on volume.
|
||||
"""
|
||||
b_type = str(row.get("building", "yes")).lower()
|
||||
amenity = str(row.get("amenity", "")).lower()
|
||||
office = str(row.get("office", "")).lower()
|
||||
shop = str(row.get("shop", "")).lower()
|
||||
|
||||
volume = area * height
|
||||
|
||||
# Lists of tags
|
||||
residential_tags = [
|
||||
"apartments",
|
||||
"residential",
|
||||
"house",
|
||||
"detached",
|
||||
"terrace",
|
||||
"dormitory",
|
||||
"hotel",
|
||||
]
|
||||
commercial_tags = [
|
||||
"commercial",
|
||||
"office",
|
||||
"retail",
|
||||
"industrial",
|
||||
"university",
|
||||
"school",
|
||||
"hospital",
|
||||
"public",
|
||||
]
|
||||
|
||||
is_res = any(t in b_type for t in residential_tags)
|
||||
is_com = (
|
||||
any(t in b_type for t in commercial_tags)
|
||||
or (amenity != "nan" and amenity != "")
|
||||
or (office != "nan" and office != "")
|
||||
or (shop != "nan" and shop != "")
|
||||
)
|
||||
|
||||
# Default logic if generic "yes"
|
||||
if not is_res and not is_com:
|
||||
# Small buildings likely houses, big generic likely commercial in city center
|
||||
if volume > 5000:
|
||||
is_com = True
|
||||
else:
|
||||
is_res = True
|
||||
|
||||
pop = 0
|
||||
jobs = 0
|
||||
category = "none"
|
||||
density_score = 0
|
||||
|
||||
if is_res:
|
||||
pop = round(volume * POP_DENSITY_FACTOR)
|
||||
category = "residential"
|
||||
density_score = min(1.0, pop / 500) # Normalize for color (0-1)
|
||||
elif is_com:
|
||||
jobs = round(volume * JOB_DENSITY_FACTOR)
|
||||
category = "commercial"
|
||||
density_score = min(1.0, jobs / 1000) # Normalize for color (0-1)
|
||||
|
||||
return category, density_score, pop, jobs
|
||||
|
||||
|
||||
def parse_geometry(geom, center_x, center_y):
|
||||
"""Parses geometry into {outer, holes} structure."""
|
||||
if geom.is_empty:
|
||||
return []
|
||||
|
||||
polys = []
|
||||
if geom.geom_type == "Polygon":
|
||||
source_geoms = [geom]
|
||||
@@ -116,12 +185,10 @@ def parse_geometry(geom, center_x, center_y):
|
||||
]
|
||||
holes.append(hole_coords)
|
||||
polys.append({"outer": outer, "holes": holes})
|
||||
|
||||
return polys
|
||||
|
||||
|
||||
def parse_line_points(geom, center_x, center_y):
|
||||
"""Simple parser for LineStrings (Routing Graph)."""
|
||||
if geom.geom_type == "LineString":
|
||||
return [
|
||||
[round(x - center_x, 2), round(y - center_y, 2)] for x, y in geom.coords
|
||||
@@ -139,6 +206,9 @@ tags_visual = {
|
||||
"natural": ["water", "bay"],
|
||||
"leisure": ["park", "garden"],
|
||||
"landuse": ["grass", "forest", "park"],
|
||||
"amenity": True, # Fetch amenities to help classify jobs
|
||||
"office": True,
|
||||
"shop": True,
|
||||
}
|
||||
gdf_visual = ox.features.features_from_address(PLACE_NAME, tags=tags_visual, dist=DIST)
|
||||
|
||||
@@ -158,24 +228,42 @@ center_y = gdf_visual.geometry.centroid.y.mean()
|
||||
output_visual = {"buildings": [], "water": [], "parks": [], "roads": []}
|
||||
output_routing = {"nodes": {}, "edges": []}
|
||||
|
||||
print("3. Processing Visual Layers...")
|
||||
# We will store building data to map it to graph nodes later
|
||||
building_data_points = [] # (x, y, pop, jobs)
|
||||
|
||||
print("3. Processing Visual Layers & Census Simulation...")
|
||||
for idx, row in gdf_visual.iterrows():
|
||||
polygons = parse_geometry(row.geometry, center_x, center_y)
|
||||
|
||||
for poly_data in polygons:
|
||||
# 1. Buildings
|
||||
# 1. Buildings (With Zoning Logic)
|
||||
if "building" in row and str(row["building"]) != "nan":
|
||||
height = get_height(row)
|
||||
area = row.geometry.area
|
||||
|
||||
# Zoning / Census Simulation
|
||||
cat, score, pop, jobs = classify_building(row, height, area)
|
||||
|
||||
# Store centroid for graph mapping
|
||||
cx = row.geometry.centroid.x - center_x
|
||||
cy = row.geometry.centroid.y - center_y
|
||||
building_data_points.append([cx, cy, pop, jobs])
|
||||
|
||||
output_visual["buildings"].append(
|
||||
{"shape": poly_data, "height": get_height(row)}
|
||||
{
|
||||
"shape": poly_data,
|
||||
"height": height,
|
||||
"data": {"type": cat, "density": score, "pop": pop, "jobs": jobs},
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Water (Explicit check for NaN)
|
||||
# 2. Water
|
||||
elif ("natural" in row and str(row["natural"]) != "nan") or (
|
||||
"water" in row and str(row["water"]) != "nan"
|
||||
):
|
||||
output_visual["water"].append({"shape": poly_data})
|
||||
|
||||
# 3. Parks (Fallback)
|
||||
# 3. Parks
|
||||
else:
|
||||
output_visual["parks"].append({"shape": poly_data})
|
||||
|
||||
@@ -187,17 +275,40 @@ for idx, row in gdf_edges.iterrows():
|
||||
road_polys.append(buffered)
|
||||
|
||||
if road_polys:
|
||||
print(" Merging road polygons...")
|
||||
merged_roads = unary_union(road_polys)
|
||||
road_shapes = parse_geometry(merged_roads, center_x, center_y)
|
||||
for shape in road_shapes:
|
||||
output_visual["roads"].append({"shape": shape})
|
||||
|
||||
print("4. Processing Routing Graph...")
|
||||
print("4. Mapping Census Data to Graph Nodes...")
|
||||
# Create a KDTree of building centroids
|
||||
if building_data_points:
|
||||
b_coords = np.array([[b[0], b[1]] for b in building_data_points])
|
||||
b_data = np.array([[b[2], b[3]] for b in building_data_points]) # pop, jobs
|
||||
tree = cKDTree(b_coords)
|
||||
|
||||
for node_id, row in gdf_nodes.iterrows():
|
||||
nx = row.geometry.x - center_x
|
||||
ny = row.geometry.y - center_y
|
||||
|
||||
# Find all buildings within 100m of this node
|
||||
if building_data_points:
|
||||
indices = tree.query_ball_point([nx, ny], r=100)
|
||||
if indices:
|
||||
# Sum pop/jobs of nearby buildings
|
||||
nearby_stats = np.sum(b_data[indices], axis=0)
|
||||
node_pop = int(nearby_stats[0])
|
||||
node_jobs = int(nearby_stats[1])
|
||||
else:
|
||||
node_pop, node_jobs = 0, 0
|
||||
else:
|
||||
node_pop, node_jobs = 0, 0
|
||||
|
||||
output_routing["nodes"][int(node_id)] = {
|
||||
"x": round(row.geometry.x - center_x, 2),
|
||||
"y": round(row.geometry.y - center_y, 2),
|
||||
"x": round(nx, 2),
|
||||
"y": round(ny, 2),
|
||||
"pop": node_pop, # Store for gameplay later
|
||||
"jobs": node_jobs,
|
||||
}
|
||||
|
||||
for u, v, k in G.edges(keys=True):
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<div class="header">
|
||||
<h2>Route Planner</h2>
|
||||
<p>Left Click: Add Point<br>Drag: Move Point</p>
|
||||
<button id="btn-zoning" class="secondary" style="margin-top:10px; width:100%">Toggle Zoning View</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,6 +9,10 @@ export class UIManager {
|
||||
this.btnSave = document.getElementById('btn-save');
|
||||
this.btnDiscard = document.getElementById('btn-discard');
|
||||
this.btnToggle = document.getElementById('ui-toggle');
|
||||
this.btnZoning = document.getElementById('btn-zoning');
|
||||
|
||||
// We need a callback to main.js to actually change colors
|
||||
this.onToggleZoning = null;
|
||||
|
||||
this.initListeners();
|
||||
}
|
||||
@@ -29,6 +33,17 @@ export class UIManager {
|
||||
this.btnToggle.addEventListener('click', () => {
|
||||
this.elContainer.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
this.btnZoning.addEventListener('click', () => {
|
||||
const isActive = this.btnZoning.classList.toggle('active');
|
||||
this.btnZoning.style.background = isActive ? '#4B5563' : ''; // Darken when active
|
||||
this.btnZoning.style.color = isActive ? 'white' : '';
|
||||
|
||||
if (this.onToggleZoning) {
|
||||
this.onToggleZoning(isActive);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
updateStats(lengthInMeters) {
|
||||
|
||||
94
src/main.js
94
src/main.js
@@ -14,7 +14,9 @@ const SETTINGS = {
|
||||
colors: {
|
||||
background: 0xE6E6E6,
|
||||
ground: 0xDDDDDD,
|
||||
building: 0xFFFFFF,
|
||||
zoningRes: new THREE.Color(0xA855F7),
|
||||
zoningCom: new THREE.Color(0x3B82F6),
|
||||
building: new THREE.Color(0xFFFFFF),
|
||||
water: 0xAADAFF,
|
||||
park: 0xC3E6CB,
|
||||
road: 0x999999,
|
||||
@@ -57,6 +59,10 @@ function init() {
|
||||
uiManager.updateStats(dist);
|
||||
};
|
||||
|
||||
uiManager.onToggleZoning = (isActive) => {
|
||||
updateBuildingColors(isActive);
|
||||
};
|
||||
|
||||
// 3. Data Load
|
||||
Promise.all([
|
||||
fetch(SETTINGS.files.visual).then(r => r.json()),
|
||||
@@ -70,6 +76,38 @@ function init() {
|
||||
animate();
|
||||
}
|
||||
|
||||
function updateBuildingColors(showZoning) {
|
||||
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
|
||||
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);
|
||||
}
|
||||
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. Scene Setup
|
||||
// ==========================================
|
||||
@@ -97,6 +135,10 @@ function setupScene() {
|
||||
dirLight.shadow.camera.right = 1500;
|
||||
dirLight.shadow.camera.top = 1500;
|
||||
dirLight.shadow.camera.bottom = -1500;
|
||||
dirLight.shadow.camera.near = 0.5;
|
||||
dirLight.shadow.camera.far = 3000; // Must be > 1225 to reach the ground
|
||||
dirLight.shadow.bias = -0.01; // Clean up shadow artifacts
|
||||
|
||||
scene.add(dirLight);
|
||||
|
||||
const plane = new THREE.Mesh(
|
||||
@@ -168,12 +210,60 @@ function renderCity(data) {
|
||||
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);
|
||||
createLayer(data.buildings, SETTINGS.colors.building, 10, 0, true);
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
|
||||
Reference in New Issue
Block a user