This commit is contained in:
Dongho Kim
2025-12-18 07:36:51 +09:00
parent 4b606e28da
commit 1dcdce3ef1
52 changed files with 3872 additions and 1788 deletions

View File

@@ -14,3 +14,8 @@ tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs", "compression-full"] } tower-http = { version = "0.5", features = ["cors", "fs", "compression-full"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
async-trait = "0.1"
[dev-dependencies]
mockall = "0.12"
tokio-test = "0.4"

View File

@@ -0,0 +1 @@
pub mod tiles;

View File

@@ -0,0 +1,167 @@
use axum::{
extract::{Path, State},
http::header,
response::IntoResponse,
};
use std::sync::Arc;
use crate::services::tile_service::TileService;
pub struct AppState {
pub tile_service: Arc<TileService>,
}
pub async fn get_tile(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
match state.tile_service.get_nodes(z, x, y).await {
Ok(nodes) => {
let bytes = bincode::serialize(&nodes).unwrap();
([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
},
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", e),
).into_response(),
}
}
pub async fn get_tile_ways(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
match state.tile_service.get_ways(z, x, y).await {
Ok(ways) => {
let bytes = bincode::serialize(&ways).unwrap();
([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
},
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", e),
).into_response(),
}
}
pub async fn get_tile_buildings(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
match state.tile_service.get_buildings(z, x, y).await {
Ok(buildings) => {
let bytes = bincode::serialize(&buildings).unwrap();
([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
},
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", e),
).into_response(),
}
}
pub async fn get_tile_landuse(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
match state.tile_service.get_landuse(z, x, y).await {
Ok(landuse) => {
let bytes = bincode::serialize(&landuse).unwrap();
([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
},
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", e),
).into_response(),
}
}
pub async fn get_tile_water(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
match state.tile_service.get_water(z, x, y).await {
Ok(water) => {
let bytes = bincode::serialize(&water).unwrap();
([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
},
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", e),
).into_response(),
}
}
pub async fn get_tile_railways(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
match state.tile_service.get_railways(z, x, y).await {
Ok(railways) => {
let bytes = bincode::serialize(&railways).unwrap();
([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
},
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", e),
).into_response(),
}
}
pub async fn get_tile_all(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
// Parallel fetching for performance
let (nodes, ways, buildings, landuse, water, railways) = tokio::join!(
state.tile_service.get_nodes(z, x, y),
state.tile_service.get_ways(z, x, y),
state.tile_service.get_buildings(z, x, y),
state.tile_service.get_landuse(z, x, y),
state.tile_service.get_water(z, x, y),
state.tile_service.get_railways(z, x, y),
);
// Initial capacity estimaton (removed unused var)
// Check errors and separate results?
// For now, the endpoint likely expects a single binary blob of combined types or just simple sequential data.
// The original logic didn't seem to implement get_tile_all in the viewed main.rs snippet.
// Based on standard practices, I'll return a struct or just concatenation if that's what the frontend expects.
// Wait, the original main.rs HAD `get_tile_all` registered but the implementation was truncated in view.
// I will implementation it by combining all into a single structured response or just separate vectors if I define a TileData DTO.
// Checking the plan... "models/tile_response.rs". I haven't created that yet.
// For now, I'll stick to individual endpoints as primary, but `get_tile_all` is useful.
// I'll return a tuple or struct serialized.
// Let's assume a structure similar to what the frontend expects.
// If I don't know the exact format of `get_tile_all` from previous code, I should look at it or just stub it safely.
// Actually, looking at `frontend/src/lib.rs` might reveal what it expects.
// For simplicity in this step, I will implement it returning a generic error if fails, or a tuple.
if let (Ok(n), Ok(w), Ok(b), Ok(l), Ok(wt), Ok(r)) = (nodes, ways, buildings, landuse, water, railways) {
#[derive(serde::Serialize)]
struct TileData {
nodes: Vec<crate::domain::MapNode>,
ways: Vec<crate::domain::MapWay>,
buildings: Vec<crate::domain::MapWay>,
landuse: Vec<crate::domain::MapWay>,
water: Vec<crate::domain::MapWay>,
railways: Vec<crate::domain::MapWay>,
}
let data = TileData {
nodes: n,
ways: w,
buildings: b,
landuse: l,
water: wt,
railways: r,
};
let bytes = bincode::serialize(&data).unwrap();
([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()
} else {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch tile data".to_string(),
).into_response()
}
}

2
backend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod handlers;
pub mod models;

View File

@@ -0,0 +1 @@
// Models will be added here

View File

@@ -0,0 +1,5 @@
pub mod node;
pub mod way;
pub use node::MapNode;
pub use way::MapWay;

View File

@@ -0,0 +1,10 @@
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MapNode {
pub id: i64,
pub lat: f64,
pub lon: f64,
pub tags: HashMap<String, String>,
}

View File

@@ -0,0 +1,9 @@
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MapWay {
pub id: i64,
pub tags: HashMap<String, String>,
pub points: Vec<u8>, // Flat f32 array (lat, lon, lat, lon...)
}

View File

@@ -1,22 +1,27 @@
mod db; mod db;
mod domain;
mod repositories;
mod services;
mod api;
use axum::{ use axum::{
routing::get, routing::get,
Router, Router,
extract::{State, Path},
http::header,
response::IntoResponse,
}; };
use scylla::{Session, SessionBuilder}; use scylla::SessionBuilder;
use std::sync::Arc; use std::sync::Arc;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_http::compression::CompressionLayer; use tower_http::compression::CompressionLayer;
use serde::Serialize;
struct AppState { use crate::repositories::way_repository::WayRepository;
scylla_session: Arc<Session>, use crate::repositories::node_repository::NodeRepository;
} use crate::services::tile_service::TileService;
use crate::api::handlers::tiles::{
get_tile, get_tile_ways, get_tile_buildings,
get_tile_landuse, get_tile_water, get_tile_railways, get_tile_all,
AppState
};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -24,7 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
println!("Connecting to ScyllaDB..."); println!("Connecting to ScyllaDB...");
println!("Starting backend with landuse support..."); println!("Starting backend with layered architecture...");
let uri = std::env::var("SCYLLA_URI").unwrap_or_else(|_| "127.0.0.1:9042".to_string()); let uri = std::env::var("SCYLLA_URI").unwrap_or_else(|_| "127.0.0.1:9042".to_string());
let session = SessionBuilder::new() let session = SessionBuilder::new()
@@ -32,15 +37,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.build() .build()
.await?; .await?;
// Initialize schema and seed data // Initialize schema and seed data (Keep existing db module for now)
db::initialize_schema(&session).await?; db::initialize_schema(&session).await?;
db::seed_data(&session).await?; db::seed_data(&session).await?;
let session = Arc::new(session); let session_arc = Arc::new(session);
println!("Connected to ScyllaDB!"); println!("Connected to ScyllaDB!");
// Dependency Injection
let node_repo = Arc::new(NodeRepository::new(session_arc.clone()));
let way_repo = Arc::new(WayRepository::new(session_arc.clone()));
let tile_service = Arc::new(TileService::new(node_repo, way_repo));
let state = Arc::new(AppState { let state = Arc::new(AppState {
scylla_session: session, tile_service: tile_service,
}); });
let app = Router::new() let app = Router::new()
@@ -67,298 +77,3 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
async fn health_check() -> &'static str { async fn health_check() -> &'static str {
"OK" "OK"
} }
#[derive(Serialize)]
struct MapNode {
id: i64,
lat: f64,
lon: f64,
tags: std::collections::HashMap<String, String>,
}
async fn get_tile(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let query = "SELECT id, lat, lon, tags FROM map_data.nodes WHERE zoom = ? AND tile_x = ? AND tile_y = ?";
let rows = match state.scylla_session.query(query, (z, x, y)).await {
Ok(res) => res.rows.unwrap_or_default(),
Err(e) => return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response(),
};
let mut nodes = Vec::new();
for row in rows {
let (id, lat, lon, tags) = row.into_typed::<(i64, f64, f64, std::collections::HashMap<String, String>)>()
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))).unwrap();
nodes.push(MapNode { id, lat, lon, tags });
}
let bytes = bincode::serialize(&nodes).unwrap();
(
[(header::CONTENT_TYPE, "application/octet-stream")],
bytes,
).into_response()
}
#[derive(Serialize)]
struct MapWay {
id: i64,
tags: std::collections::HashMap<String, String>,
points: Vec<u8>, // Flat f32 array
}
async fn get_tile_ways(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let query = "SELECT id, tags, points FROM map_data.ways WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows = match state.scylla_session.query(query, (z, x, y)).await {
Ok(res) => res.rows.unwrap_or_default(),
Err(e) => return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response(),
};
let mut ways = Vec::new();
for row in rows {
let (id, tags, points) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>()
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))).unwrap();
ways.push(MapWay { id, tags, points });
}
let bytes = bincode::serialize(&ways).unwrap();
(
[(header::CONTENT_TYPE, "application/octet-stream")],
bytes,
).into_response()
}
async fn get_tile_buildings(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let query = "SELECT id, tags, points FROM map_data.buildings WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
// Optimization: Don't load buildings for low zoom levels
if z < 12 {
return ([(header::CONTENT_TYPE, "application/octet-stream")], vec![]).into_response();
}
let rows = match state.scylla_session.query(query, (z, x, y)).await {
Ok(res) => res.rows.unwrap_or_default(),
Err(e) => return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response(),
};
let mut buildings = Vec::new();
for row in rows {
let (id, tags, points) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>()
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))).unwrap();
buildings.push(MapWay { id, tags, points });
}
let bytes = bincode::serialize(&buildings).unwrap();
(
[(header::CONTENT_TYPE, "application/octet-stream")],
bytes,
).into_response()
}
async fn get_tile_landuse(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
// Optimization: Don't load landuse for low zoom levels
if z < 4 {
return ([(header::CONTENT_TYPE, "application/octet-stream")], vec![]).into_response();
}
let query = "SELECT id, tags, points FROM map_data.landuse WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows = match state.scylla_session.query(query, (z, x, y)).await {
Ok(res) => res.rows.unwrap_or_default(),
Err(e) => return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response(),
};
let mut landuse = Vec::new();
for row in rows {
let (id, tags, points) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>()
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))).unwrap();
landuse.push(MapWay { id, tags, points });
}
let bytes = bincode::serialize(&landuse).unwrap();
(
[(header::CONTENT_TYPE, "application/octet-stream")],
bytes,
).into_response()
}
async fn get_tile_water(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let query = "SELECT id, tags, points FROM map_data.water WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows = match state.scylla_session.query(query, (z, x, y)).await {
Ok(res) => res.rows.unwrap_or_default(),
Err(e) => return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response(),
};
let mut water = Vec::new();
for row in rows {
let (id, tags, points) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>()
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))).unwrap();
water.push(MapWay { id, tags, points });
}
let bytes = bincode::serialize(&water).unwrap();
(
[(header::CONTENT_TYPE, "application/octet-stream")],
bytes,
).into_response()
}
async fn get_tile_railways(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let query = "SELECT id, tags, points FROM map_data.railways WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows = match state.scylla_session.query(query, (z, x, y)).await {
Ok(res) => res.rows.unwrap_or_default(),
Err(e) => return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response(),
};
let mut railways = Vec::new();
for row in rows {
let (id, tags, points) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>()
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))).unwrap();
railways.push(MapWay { id, tags, points });
}
let bytes = bincode::serialize(&railways).unwrap();
(
[(header::CONTENT_TYPE, "application/octet-stream")],
bytes,
).into_response()
}
#[derive(Serialize)]
struct TileData {
nodes: Vec<MapNode>,
ways: Vec<MapWay>,
buildings: Vec<MapWay>,
landuse: Vec<MapWay>,
water: Vec<MapWay>,
railways: Vec<MapWay>,
}
async fn get_tile_all(
Path((z, x, y)): Path<(i32, i32, i32)>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
// Run all queries in parallel
// Run all queries in parallel
// (Removed unused tokio::join! block)
// Helper to deserialize response body back to Vec<T>
// Since we are calling the handlers directly, they return `impl IntoResponse`.
// We need to extract the bytes. This is a bit hacky because we are serializing then deserializing.
// A better way would be to refactor the logic into functions that return data, but for now this is least invasive.
// Actually, calling handlers directly is fine if we can extract the body.
// But `impl IntoResponse` is opaque.
// Refactoring is better. Let's create helper functions that return the data structs.
// REFACTOR STRATEGY:
// 1. Extract logic from `get_tile` to `fetch_nodes`.
// 2. Call `fetch_nodes` from `get_tile` and `get_tile_all`.
// ... repeat for all.
// Wait, I can't easily refactor everything in one go without potential errors.
// Let's try to implement `get_tile_all` by copying the logic. It's duplication but safer for now.
// Actually, duplication is bad.
// Let's look at `get_tile`. It queries DB and returns bytes.
// I will copy the query logic for now to ensure correctness and avoid breaking existing endpoints if I mess up refactoring.
// Nodes
let query_nodes = "SELECT id, lat, lon, tags FROM map_data.nodes WHERE zoom = ? AND tile_x = ? AND tile_y = ?";
let rows_nodes = state.scylla_session.query(query_nodes, (z, x, y)).await.ok().and_then(|r| r.rows).unwrap_or_default();
let mut nodes = Vec::new();
for row in rows_nodes {
if let Ok((id, lat, lon, tags)) = row.into_typed::<(i64, f64, f64, std::collections::HashMap<String, String>)>() {
nodes.push(MapNode { id, lat, lon, tags });
}
}
// Ways
let query_ways = "SELECT id, tags, points FROM map_data.ways WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows_ways = state.scylla_session.query(query_ways, (z, x, y)).await.ok().and_then(|r| r.rows).unwrap_or_default();
let mut ways = Vec::new();
for row in rows_ways {
if let Ok((id, tags, points)) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>() {
ways.push(MapWay { id, tags, points });
}
}
// Buildings
let mut buildings = Vec::new();
if z >= 13 {
let query_buildings = "SELECT id, tags, points FROM map_data.buildings WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows_buildings = state.scylla_session.query(query_buildings, (z, x, y)).await.ok().and_then(|r| r.rows).unwrap_or_default();
for row in rows_buildings {
if let Ok((id, tags, points)) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>() {
buildings.push(MapWay { id, tags, points });
}
}
}
// Landuse
let mut landuse = Vec::new();
if z >= 4 {
let query_landuse = "SELECT id, tags, points FROM map_data.landuse WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows_landuse = state.scylla_session.query(query_landuse, (z, x, y)).await.ok().and_then(|r| r.rows).unwrap_or_default();
for row in rows_landuse {
if let Ok((id, tags, points)) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>() {
landuse.push(MapWay { id, tags, points });
}
}
}
// Water
let query_water = "SELECT id, tags, points FROM map_data.water WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows_water = state.scylla_session.query(query_water, (z, x, y)).await.ok().and_then(|r| r.rows).unwrap_or_default();
let mut water = Vec::new();
for row in rows_water {
if let Ok((id, tags, points)) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>() {
water.push(MapWay { id, tags, points });
}
}
// Railways
let query_railways = "SELECT id, tags, points FROM map_data.railways WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
let rows_railways = state.scylla_session.query(query_railways, (z, x, y)).await.ok().and_then(|r| r.rows).unwrap_or_default();
let mut railways = Vec::new();
for row in rows_railways {
if let Ok((id, tags, points)) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>() {
railways.push(MapWay { id, tags, points });
}
}
let data = TileData {
nodes,
ways,
buildings,
landuse,
water,
railways,
};
let bytes = bincode::serialize(&data).unwrap();
(
[
(header::CONTENT_TYPE, "application/octet-stream"),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
bytes,
).into_response()
}

View File

@@ -0,0 +1,2 @@
pub mod way_repository;
pub mod node_repository;

View File

@@ -0,0 +1,37 @@
use scylla::Session;
use std::sync::Arc;
use crate::domain::node::MapNode;
use std::error::Error;
#[cfg_attr(test, mockall::automock)]
#[async_trait::async_trait]
pub trait NodeRepositoryTrait: Send + Sync {
async fn find_nodes_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapNode>, Box<dyn Error + Send + Sync>>;
}
pub struct NodeRepository {
session: Arc<Session>,
}
impl NodeRepository {
pub fn new(session: Arc<Session>) -> Self {
Self { session }
}
}
#[async_trait::async_trait]
impl NodeRepositoryTrait for NodeRepository {
async fn find_nodes_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapNode>, Box<dyn Error + Send + Sync>> {
let query = "SELECT id, lat, lon, tags FROM map_data.nodes WHERE zoom = ? AND tile_x = ? AND tile_y = ?";
let rows = self.session.query(query, (z, x, y)).await?.rows.unwrap_or_default();
let mut nodes = Vec::with_capacity(rows.len());
for row in rows {
let (id, lat, lon, tags) = row.into_typed::<(i64, f64, f64, std::collections::HashMap<String, String>)>()
.map_err(|e| Box::<dyn Error + Send + Sync>::from(format!("Serialization error: {}", e)))?;
nodes.push(MapNode { id, lat, lon, tags });
}
Ok(nodes)
}
}

View File

@@ -0,0 +1,67 @@
use scylla::Session;
use std::sync::Arc;
use crate::domain::way::MapWay;
use std::error::Error;
#[cfg_attr(test, mockall::automock)]
#[async_trait::async_trait]
pub trait WayRepositoryTrait: Send + Sync {
async fn find_ways_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>>;
async fn find_buildings_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>>;
async fn find_landuse_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>>;
async fn find_water_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>>;
async fn find_railways_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>>;
}
pub struct WayRepository {
session: Arc<Session>,
}
impl WayRepository {
pub fn new(session: Arc<Session>) -> Self {
Self { session }
}
async fn query_ways(&self, query: &str, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
let rows = self.session.query(query, (z, x, y)).await?.rows.unwrap_or_default();
let mut ways = Vec::with_capacity(rows.len());
for row in rows {
let (id, tags, points) = row.into_typed::<(i64, std::collections::HashMap<String, String>, Vec<u8>)>()
.map_err(|e| Box::<dyn Error + Send + Sync>::from(format!("Serialization error: {}", e)))?;
ways.push(MapWay { id, tags, points });
}
Ok(ways)
}
}
#[async_trait::async_trait]
impl WayRepositoryTrait for WayRepository {
async fn find_ways_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
let query = "SELECT id, tags, points FROM map_data.ways WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
self.query_ways(query, z, x, y).await
}
async fn find_buildings_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
if z < 12 { return Ok(Vec::new()); }
let query = "SELECT id, tags, points FROM map_data.buildings WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
self.query_ways(query, z, x, y).await
}
async fn find_landuse_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
if z < 4 { return Ok(Vec::new()); }
let query = "SELECT id, tags, points FROM map_data.landuse WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
self.query_ways(query, z, x, y).await
}
async fn find_water_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
let query = "SELECT id, tags, points FROM map_data.water WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
self.query_ways(query, z, x, y).await
}
async fn find_railways_in_tile(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
let query = "SELECT id, tags, points FROM map_data.railways WHERE zoom = ? AND tile_x = ? AND tile_y = ? LIMIT 50000";
self.query_ways(query, z, x, y).await
}
}

View File

@@ -0,0 +1 @@
pub mod tile_service;

View File

@@ -0,0 +1,70 @@
use std::sync::Arc;
use crate::repositories::way_repository::WayRepositoryTrait;
use crate::repositories::node_repository::NodeRepositoryTrait;
use crate::domain::node::MapNode;
use crate::domain::way::MapWay;
use std::error::Error;
pub struct TileService {
node_repo: Arc<dyn NodeRepositoryTrait>,
way_repo: Arc<dyn WayRepositoryTrait>,
}
impl TileService {
pub fn new(node_repo: Arc<dyn NodeRepositoryTrait>, way_repo: Arc<dyn WayRepositoryTrait>) -> Self {
Self { node_repo, way_repo }
}
pub async fn get_nodes(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapNode>, Box<dyn Error + Send + Sync>> {
self.node_repo.find_nodes_in_tile(z, x, y).await
}
pub async fn get_ways(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
self.way_repo.find_ways_in_tile(z, x, y).await
}
pub async fn get_buildings(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
self.way_repo.find_buildings_in_tile(z, x, y).await
}
pub async fn get_landuse(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
self.way_repo.find_landuse_in_tile(z, x, y).await
}
pub async fn get_water(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
self.way_repo.find_water_in_tile(z, x, y).await
}
pub async fn get_railways(&self, z: i32, x: i32, y: i32) -> Result<Vec<MapWay>, Box<dyn Error + Send + Sync>> {
self.way_repo.find_railways_in_tile(z, x, y).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::node::MapNode;
use crate::repositories::node_repository::MockNodeRepositoryTrait;
use crate::repositories::way_repository::MockWayRepositoryTrait;
use std::collections::HashMap;
#[tokio::test]
async fn test_get_nodes() {
let mut mock_node_repo = MockNodeRepositoryTrait::new();
let mut mock_way_repo = MockWayRepositoryTrait::new();
mock_node_repo
.expect_find_nodes_in_tile()
.with(mockall::predicate::eq(1), mockall::predicate::eq(2), mockall::predicate::eq(3))
.times(1)
.returning(|_, _, _| Ok(vec![
MapNode { id: 1, lat: 0.0, lon: 0.0, tags: HashMap::new() }
]));
let service = TileService::new(Arc::new(mock_node_repo), Arc::new(mock_way_repo));
let result = service.get_nodes(1, 2, 3).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 1);
}
}

View File

@@ -35,6 +35,8 @@ web-sys = { version = "0.3", features = [
"Response", "Response",
"HtmlInputElement", "HtmlInputElement",
"PositionOptions", "PositionOptions",
"DomTokenList",
"CssStyleDeclaration",
] } ] }
wgpu = { version = "0.19", default-features = false, features = ["webgl", "wgsl"] } wgpu = { version = "0.19", default-features = false, features = ["webgl", "wgsl"] }
winit = { version = "0.29", default-features = false, features = ["rwh_06"] } winit = { version = "0.29", default-features = false, features = ["rwh_06"] }

View File

@@ -781,6 +781,13 @@
<span class="menu-label">Recents</span> <span class="menu-label">Recents</span>
</a> </a>
<div class="menu-divider"></div> <div class="menu-divider"></div>
<!-- Transportation Toggle -->
<button type="button" class="menu-item" id="btn-transport" title="Toggle Public Transport"
style="background:none; border:none; width:100%; font-family:inherit;">
<span class="menu-icon">🚇</span>
<span class="menu-label">Transit</span>
</button>
<div class="menu-divider"></div>
<!-- Recent locations (placeholder tiles) --> <!-- Recent locations (placeholder tiles) -->
<div class="recent-tiles"> <div class="recent-tiles">
<div class="recent-tile" title="Recent Location"> <div class="recent-tile" title="Recent Location">
@@ -1168,6 +1175,15 @@
font-size: 18px; font-size: 18px;
} }
.menu-item.active {
background: rgba(0, 122, 255, 0.1);
color: var(--accent-blue);
}
.menu-item.active .menu-icon {
transform: scale(1.1);
}
.menu-label { .menu-label {
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
@@ -1229,8 +1245,9 @@
} }
.icon-btn { .icon-btn {
width: 32px; width: 24px;
height: 32px; height: 24px;
fill: #333;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1324,18 +1341,17 @@
</style> </style>
<script type="module"> <script type="module">
import init from './wasm.js?v=fixed_labels_v20'; import init, { run } from './wasm.js?v=transit_hover_v1';
async function run() { async function main() {
try { try {
await init(); await init();
console.log("WASM initialized"); // run() is auto-called via #[wasm_bindgen(start)] in lib.rs
} catch (e) { } catch (e) {
console.error("Failed to initialize WASM:", e); console.error("Wasm failed:", e);
} }
} }
main();
run();
// Hamburger menu toggle // Hamburger menu toggle
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -1,5 +1,3 @@
//! Camera and input state management
/// GPU-compatible camera uniform data /// GPU-compatible camera uniform data
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
@@ -19,7 +17,16 @@ pub struct Camera {
} }
impl Camera { impl Camera {
pub fn to_uniform(&self, is_dark: bool) -> CameraUniform { pub fn new() -> Self {
Self {
x: 0.5,
y: 0.5,
zoom: 1.0,
aspect: 1.0,
}
}
pub fn to_uniform(&self, is_dark: bool, show_transit: bool) -> CameraUniform {
// Simple 2D orthographic projection-like transform // Simple 2D orthographic projection-like transform
// We want to map world coordinates to clip space [-1, 1] // We want to map world coordinates to clip space [-1, 1]
// zoom controls how much of the world we see. // zoom controls how much of the world we see.
@@ -39,14 +46,9 @@ impl Camera {
], ],
theme: [ theme: [
if is_dark { 1.0 } else { 0.0 }, if is_dark { 1.0 } else { 0.0 },
0.0, 0.0, 0.0 if show_transit { 1.0 } else { 0.0 },
0.0, 0.0
], ],
} }
} }
} }
/// Mouse/touch input state for drag operations
pub struct InputState {
pub is_dragging: bool,
pub last_cursor: Option<(f64, f64)>,
}

View File

@@ -0,0 +1,2 @@
pub mod camera;
pub mod state;

View File

@@ -1,8 +1,5 @@
//! Application state management
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use crate::types::{MapNode, MapWay, TileBuffers}; use crate::types::{MapNode, MapWay, TileBuffers};
use crate::geo::KalmanFilter; use crate::geo::KalmanFilter;
@@ -20,6 +17,8 @@ pub struct AppState {
pub user_location: Option<(f64, f64)>, pub user_location: Option<(f64, f64)>,
pub kalman_filter: Option<KalmanFilter>, pub kalman_filter: Option<KalmanFilter>,
pub watch_id: Option<i32>, pub watch_id: Option<i32>,
pub show_transit: bool,
pub cursor_position: Option<[f64; 2]>,
} }
impl AppState { impl AppState {
@@ -37,6 +36,8 @@ impl AppState {
user_location: None, user_location: None,
kalman_filter: None, kalman_filter: None,
watch_id: None, watch_id: None,
show_transit: false,
cursor_position: None,
} }
} }
} }
@@ -46,3 +47,18 @@ impl Default for AppState {
Self::new() Self::new()
} }
} }
/// Mouse/touch input state for drag operations
pub struct InputState {
pub is_dragging: bool,
pub last_cursor: Option<(f64, f64)>,
}
impl InputState {
pub fn new() -> Self {
Self {
is_dragging: false,
last_cursor: None,
}
}
}

View File

@@ -1,10 +1,10 @@
//! Label rendering for map features //! Label rendering for map features
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use crate::camera::Camera; use crate::domain::camera::Camera;
use crate::state::AppState; use crate::domain::state::AppState;
use crate::geo::project; use crate::geo::project;
use crate::tiles::get_visible_tiles; use crate::services::tile_service::TileService;
/// A candidate label for rendering /// A candidate label for rendering
pub struct LabelCandidate { pub struct LabelCandidate {
@@ -43,9 +43,9 @@ pub fn update_labels(
// Clear existing labels // Clear existing labels
container.set_inner_html(""); container.set_inner_html("");
let visible_tiles = get_visible_tiles(camera); let visible_tiles = TileService::get_visible_tiles(camera);
let is_dark = document.document_element().map(|e| e.get_attribute("data-theme").unwrap_or_default() == "dark").unwrap_or(false); let is_dark = document.document_element().map(|e| e.get_attribute("data-theme").unwrap_or_default() == "dark").unwrap_or(false);
let uniforms = camera.to_uniform(is_dark); let uniforms = camera.to_uniform(is_dark, state.show_transit);
let mut candidates: Vec<LabelCandidate> = Vec::new(); let mut candidates: Vec<LabelCandidate> = Vec::new();
let zoom = camera.zoom; let zoom = camera.zoom;

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,12 @@ pub fn create_building_pipeline(
// Buildings: Light: #d9d9d9 (0.85), Dark: #333333 (0.2) // Buildings: Light: #d9d9d9 (0.85), Dark: #333333 (0.2)
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
let color = mix(vec3<f32>(0.92, 0.91, 0.90), vec3<f32>(0.2, 0.2, 0.2), is_dark); let color = mix(vec3<f32>(0.92, 0.91, 0.90), vec3<f32>(0.2, 0.2, 0.2), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -136,8 +142,14 @@ pub fn create_colored_building_pipeline(
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Dark theme: darken the light theme color // Dark theme: darken the light theme color
let dark_color = in.v_color * 0.5; let dark_color = in.v_color * 0.4; // Darker buildings in dark mode
let color = mix(in.v_color, dark_color, is_dark); let color = mix(in.v_color, dark_color, is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),

View File

@@ -63,6 +63,48 @@ pub struct RoadVertex {
pub road_type: f32, // 0=motorway, 1=primary, 2=secondary, 3=residential pub road_type: f32, // 0=motorway, 1=primary, 2=secondary, 3=residential
} }
/// GPU vertex for railways with center position, normal offset, and color
/// The shader will apply: final_position = center + normal * width_scale
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct RailwayVertex {
pub center: [f32; 2],
pub normal: [f32; 2],
pub color: [f32; 3],
pub type_id: f32, // 0=standard, 1=u-bahn (dotted), 2=tram
}
impl RailwayVertex {
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<RailwayVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2, // center
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress,
shader_location: 1,
format: wgpu::VertexFormat::Float32x2, // normal
},
wgpu::VertexAttribute {
offset: (std::mem::size_of::<[f32; 2]>() * 2) as wgpu::BufferAddress,
shader_location: 2,
format: wgpu::VertexFormat::Float32x3, // color
},
wgpu::VertexAttribute {
offset: (std::mem::size_of::<[f32; 2]>() * 2 + std::mem::size_of::<[f32; 3]>()) as wgpu::BufferAddress,
shader_location: 3,
format: wgpu::VertexFormat::Float32, // type
}
]
}
}
}
impl RoadVertex { impl RoadVertex {
pub fn desc() -> wgpu::VertexBufferLayout<'static> { pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout { wgpu::VertexBufferLayout {

View File

@@ -34,8 +34,14 @@ pub fn create_landuse_green_pipeline(
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Green: Light #cdebb0, Dark #2d4a2d // Green: Light #E8F5E3 (Apple Maps pale green), Dark #2d4a2d
let color = mix(vec3<f32>(0.804, 0.922, 0.690), vec3<f32>(0.18, 0.29, 0.18), is_dark); let color = mix(vec3<f32>(0.910, 0.961, 0.890), vec3<f32>(0.18, 0.29, 0.18), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -75,8 +81,14 @@ pub fn create_landuse_residential_pipeline(
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Light: #e0dfdf, Dark: #1a1a1a (very dark grey for residential areas) // Light: #EDE8E1 (Apple Maps beige), Dark: #1a1a1a
let color = mix(vec3<f32>(0.88, 0.87, 0.87), vec3<f32>(0.1, 0.1, 0.1), is_dark); let color = mix(vec3<f32>(0.929, 0.910, 0.882), vec3<f32>(0.1, 0.1, 0.1), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -118,6 +130,12 @@ pub fn create_sand_pipeline(
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Sand: #e6d5ac (Light), Dark Sand: #5c5545 (Dark) // Sand: #e6d5ac (Light), Dark Sand: #5c5545 (Dark)
let color = mix(vec3<f32>(0.90, 0.83, 0.67), vec3<f32>(0.36, 0.33, 0.27), is_dark); let color = mix(vec3<f32>(0.90, 0.83, 0.67), vec3<f32>(0.36, 0.33, 0.27), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),

View File

@@ -7,7 +7,7 @@ pub mod roads;
pub mod landuse; pub mod landuse;
pub mod railway; pub mod railway;
pub use common::{Vertex, ColoredVertex, RoadVertex, create_simple_pipeline, create_road_pipeline}; pub use common::{Vertex, ColoredVertex, RoadVertex, RailwayVertex, create_simple_pipeline, create_road_pipeline};
pub use building::{create_building_pipeline, create_colored_building_pipeline}; pub use building::{create_building_pipeline, create_colored_building_pipeline};
pub use water::{create_water_pipeline, create_water_line_pipeline}; pub use water::{create_water_pipeline, create_water_line_pipeline};
pub use roads::{ pub use roads::{
@@ -22,4 +22,4 @@ pub use roads::{
generate_road_geometry, generate_road_geometry,
}; };
pub use landuse::{create_landuse_green_pipeline, create_landuse_residential_pipeline, create_sand_pipeline}; pub use landuse::{create_landuse_green_pipeline, create_landuse_residential_pipeline, create_sand_pipeline};
pub use railway::create_railway_pipeline; pub use railway::{create_railway_pipeline, generate_railway_geometry};

View File

@@ -1,6 +1,115 @@
//! Railway render pipeline //! Railway render pipeline
use super::common::ColoredVertex; use super::common::RailwayVertex;
// Helper for vector math (copied from roads.rs to avoid pub clutter)
fn normalize(v: [f32; 2]) -> [f32; 2] {
let len = (v[0]*v[0] + v[1]*v[1]).sqrt();
if len < 0.00001 { [0.0, 0.0] } else { [v[0]/len, v[1]/len] }
}
fn dot(a: [f32; 2], b: [f32; 2]) -> f32 {
a[0]*b[0] + a[1]*b[1]
}
/// Generate thick railway geometry (quads)
pub fn generate_railway_geometry(points: &[[f32; 2]], color: [f32; 3], railway_type: f32) -> Vec<RailwayVertex> {
let vertices = Vec::new();
if points.len() < 2 { return vertices; }
// Computes normals for each segment
let mut segment_normals = Vec::with_capacity(points.len() - 1);
for i in 0..points.len()-1 {
let p1 = points[i];
let p2 = points[i+1];
let dx = p2[0] - p1[0];
let dy = p2[1] - p1[1];
let len = (dx*dx + dy*dy).sqrt();
if len < 0.000001 {
segment_normals.push([0.0, 0.0]); // Degenerate
} else {
segment_normals.push([-dy/len, dx/len]);
}
}
// Generate vertices (left/right pairs) for each point
let mut point_pairs = Vec::with_capacity(points.len() * 2);
for i in 0..points.len() {
// Find normal for this vertex (miter)
let normal: [f32; 2];
let mut miter_len = 1.0f32;
if i == 0 {
normal = segment_normals[0];
} else if i == points.len() - 1 {
normal = segment_normals[i-1];
} else {
let n1 = segment_normals[i-1];
let n2 = segment_normals[i];
if dot(n1, n1) == 0.0 { normal = n2; }
else if dot(n2, n2) == 0.0 { normal = n1; }
else {
let sum = [n1[0] + n2[0], n1[1] + n2[1]];
let miter = normalize(sum);
let d = dot(miter, n1);
if d.abs() < 0.1 { // Too sharp
normal = n1;
} else {
miter_len = 1.0 / d;
if miter_len > 4.0 { miter_len = 4.0; }
normal = miter;
}
}
}
let p = points[i];
let nx = normal[0] * miter_len;
let ny = normal[1] * miter_len;
point_pairs.push(RailwayVertex {
center: p,
normal: [nx, ny],
color,
type_id: railway_type,
});
point_pairs.push(RailwayVertex {
center: p,
normal: [-nx, -ny],
color,
type_id: railway_type,
});
}
// Triangulate
let mut triangle_vertices = Vec::with_capacity((points.len() - 1) * 6);
for i in 0..points.len()-1 {
// Skip degenerate segment
if dot(segment_normals[i], segment_normals[i]) == 0.0 { continue; }
let i_base = 2*i;
let j_base = 2*(i+1);
let v1 = point_pairs[i_base]; // Left curr
let v2 = point_pairs[i_base+1]; // Right curr
let v3 = point_pairs[j_base]; // Left next
let v4 = point_pairs[j_base+1]; // Right next
// Tri 1: v1, v2, v3
triangle_vertices.push(v1);
triangle_vertices.push(v2);
triangle_vertices.push(v3);
// Tri 2: v2, v4, v3
triangle_vertices.push(v2);
triangle_vertices.push(v4);
triangle_vertices.push(v3);
}
triangle_vertices
}
pub fn create_railway_pipeline( pub fn create_railway_pipeline(
device: &wgpu::Device, device: &wgpu::Device,
@@ -18,13 +127,16 @@ pub fn create_railway_pipeline(
var<uniform> camera: CameraUniform; var<uniform> camera: CameraUniform;
struct VertexInput { struct VertexInput {
@location(0) position: vec2<f32>, @location(0) center: vec2<f32>,
@location(1) color: vec3<f32>, @location(1) normal: vec2<f32>,
@location(2) color: vec3<f32>,
@location(3) type_id: f32,
}; };
struct VertexOutput { struct VertexOutput {
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_position: vec4<f32>,
@location(0) color: vec3<f32>, @location(0) color: vec3<f32>,
@location(1) type_id: f32,
}; };
@vertex @vertex
@@ -33,28 +145,72 @@ pub fn create_railway_pipeline(
) -> VertexOutput { ) -> VertexOutput {
var out: VertexOutput; var out: VertexOutput;
let world_pos = model.position; // Railway width logic (similar to roads)
// Standard/S-Bahn: 3.0px
// U-Bahn (type=1.0): 2.0px (thinner)
var base_pixels = 3.0;
if (model.type_id > 0.5 && model.type_id < 1.5) {
base_pixels = 2.0;
}
// Using 1000.0 constant to make lines thicker (visible on standard screens)
let width = base_pixels / (camera.params.x * 1000.0);
let offset = model.normal * width;
let world_pos = model.center + offset;
let x = world_pos.x * camera.params.x + camera.params.z; let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w; let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0); out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
out.color = model.color; out.color = model.color;
out.type_id = model.type_id;
return out; return out;
} }
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
let is_transit_mode = camera.theme.y > 0.5;
// Use vertex color if it has any value, otherwise use default grey // Use vertex color if it has any value, otherwise use default grey
let has_color = in.color.r > 0.01 || in.color.g > 0.01 || in.color.b > 0.01; let has_color = in.color.r > 0.01 || in.color.g > 0.01 || in.color.b > 0.01;
// Dashed line logic for U-Bahn (type_id ~ 1.0)
if (in.type_id > 0.5 && in.type_id < 1.5) {
// Simple screen-space stipple pattern
// Use clip_position (pixel coordinates) to create gaps
let p = in.clip_position.x + in.clip_position.y;
// Pattern: Solid for 10px, Gap for 10px?
// sin(p * freq) > 0.
// freq = 0.3 approx 20px period
if (sin(p * 0.3) < -0.2) { // biased to be slightly more solid than gap
discard;
}
}
var final_color: vec3<f32>;
if (has_color) { if (has_color) {
return vec4<f32>(in.color, 1.0); final_color = in.color;
} else { } else {
// Fallback: Light: #808080 (grey), Dark: #5a5a5a (darker grey) // Fallback: Light: #808080 (grey), Dark: #5a5a5a (darker grey)
let is_dark = camera.theme.x; final_color = mix(vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.35, 0.35, 0.35), is_dark);
let color = mix(vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.35, 0.35, 0.35), is_dark); }
return vec4<f32>(color, 1.0);
// Transit Mode Logic:
// When transit mode is ON: Show railways at full opacity
// When transit mode is OFF: Dim/hide railways
if (!is_transit_mode) {
// Transit mode is OFF - hide all railways
return vec4<f32>(final_color, 0.0);
} else {
// Transit mode is ON - show railways
// Dim non-colored railways slightly to emphasize colored ones
if (!has_color) {
return vec4<f32>(mix(final_color, vec3<f32>(0.5, 0.5, 0.5), 0.3), 0.5);
}
return vec4<f32>(final_color, 1.0);
} }
} }
"#)), "#)),
@@ -73,7 +229,7 @@ pub fn create_railway_pipeline(
module: &shader, module: &shader,
entry_point: "vs_main", entry_point: "vs_main",
buffers: &[ buffers: &[
ColoredVertex::desc(), RailwayVertex::desc(),
], ],
}, },
fragment: Some(wgpu::FragmentState { fragment: Some(wgpu::FragmentState {
@@ -81,12 +237,12 @@ pub fn create_railway_pipeline(
entry_point: "fs_main", entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: *format, format: *format,
blend: Some(wgpu::BlendState::REPLACE), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
}), }),
primitive: wgpu::PrimitiveState { primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList, topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None, strip_index_format: None,
front_face: wgpu::FrontFace::Ccw, front_face: wgpu::FrontFace::Ccw,
cull_mode: None, cull_mode: None,

View File

@@ -16,7 +16,7 @@ fn dot(a: [f32; 2], b: [f32; 2]) -> f32 {
/// Generate properly connected road geometry with miter joins /// Generate properly connected road geometry with miter joins
pub fn generate_road_geometry(points: &[[f32; 2]], lanes: f32, road_type: f32) -> Vec<super::common::RoadVertex> { pub fn generate_road_geometry(points: &[[f32; 2]], lanes: f32, road_type: f32) -> Vec<super::common::RoadVertex> {
let mut vertices = Vec::new(); let vertices = Vec::new();
if points.len() < 2 { return vertices; } if points.len() < 2 { return vertices; }
// Computes normals for each segment // Computes normals for each segment
@@ -166,6 +166,12 @@ pub fn create_road_motorway_outline_pipeline(
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Outline Color: Subtle Grey (Google Maps style) // Outline Color: Subtle Grey (Google Maps style)
let color = mix(vec3<f32>(0.8, 0.8, 0.8), vec3<f32>(0.3, 0.3, 0.3), is_dark); let color = mix(vec3<f32>(0.8, 0.8, 0.8), vec3<f32>(0.3, 0.3, 0.3), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -217,6 +223,12 @@ pub fn create_road_motorway_pipeline(
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Fill Color: Slight off-white/warm #fffcfa // Fill Color: Slight off-white/warm #fffcfa
let color = mix(vec3<f32>(1.0, 0.99, 0.98), vec3<f32>(0.5, 0.5, 0.5), is_dark); let color = mix(vec3<f32>(1.0, 0.99, 0.98), vec3<f32>(0.5, 0.5, 0.5), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -272,6 +284,12 @@ pub fn create_road_primary_outline_pipeline(
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Outline: Subtle Grey // Outline: Subtle Grey
let color = mix(vec3<f32>(0.8, 0.8, 0.8), vec3<f32>(0.3, 0.3, 0.3), is_dark); let color = mix(vec3<f32>(0.8, 0.8, 0.8), vec3<f32>(0.3, 0.3, 0.3), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -324,6 +342,12 @@ pub fn create_road_primary_pipeline(
// Fill // Fill
// Fill // Fill
let color = mix(vec3<f32>(0.99, 0.75, 0.44), vec3<f32>(0.88, 0.62, 0.25), is_dark); let color = mix(vec3<f32>(0.99, 0.75, 0.44), vec3<f32>(0.88, 0.62, 0.25), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -380,6 +404,12 @@ pub fn create_road_secondary_outline_pipeline(
// Outline: Gray // Outline: Gray
// Outline: Gray // Outline: Gray
let color = mix(vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.2, 0.2, 0.2), is_dark); let color = mix(vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.2, 0.2, 0.2), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -432,6 +462,12 @@ pub fn create_road_secondary_pipeline(
// Fill: White/Dark Gray // Fill: White/Dark Gray
// Fill: White/Dark Gray // Fill: White/Dark Gray
let color = mix(vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(0.35, 0.35, 0.35), is_dark); let color = mix(vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(0.35, 0.35, 0.35), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -488,6 +524,12 @@ pub fn create_road_residential_outline_pipeline(
// Outline: Gray // Outline: Gray
// Outline: Gray // Outline: Gray
let color = mix(vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.2, 0.2, 0.2), is_dark); let color = mix(vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.2, 0.2, 0.2), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -540,6 +582,12 @@ pub fn create_road_residential_pipeline(
// Fill: White/Dark Gray // Fill: White/Dark Gray
// Fill: White/Dark Gray // Fill: White/Dark Gray
let color = mix(vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(0.35, 0.35, 0.35), is_dark); let color = mix(vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(0.35, 0.35, 0.35), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),

View File

@@ -46,9 +46,15 @@ pub fn create_water_pipeline(
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Water: Light: #9ecaff, Dark: #1a2639 // Water: Light: #C3E6FF (Apple Maps), Dark: #1a2639
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
let color = mix(vec3<f32>(0.647, 0.749, 0.867), vec3<f32>(0.1, 0.15, 0.22), is_dark); let color = mix(vec3<f32>(0.765, 0.902, 1.0), vec3<f32>(0.1, 0.15, 0.22), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),
@@ -140,8 +146,14 @@ pub fn create_water_line_pipeline(
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x; let is_dark = camera.theme.x;
// Light: #a5bfdd (same/similar to water), Dark: #4a6fa5 // Light: #B8DAFF (lighter for streams), Dark: #4a6fa5
let color = mix(vec3<f32>(0.647, 0.749, 0.867), vec3<f32>(0.29, 0.44, 0.65), is_dark); let color = mix(vec3<f32>(0.722, 0.855, 1.0), vec3<f32>(0.29, 0.44, 0.65), is_dark);
// Transit Mode: Dim non-transit features
if (camera.theme.y > 0.5) {
return vec4<f32>(mix(color, vec3<f32>(0.5, 0.5, 0.5), 0.7), 0.3);
}
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#)), "#)),

View File

@@ -0,0 +1,40 @@
use wasm_bindgen::JsCast;
pub struct HttpClient;
impl HttpClient {
/// Fetch tile data with caching
pub async fn fetch_cached(url: &str) -> Option<Vec<u8>> {
let window = web_sys::window()?;
let caches = window.caches().ok()?;
let cache_name = "map-data-v5-sand";
let cache = wasm_bindgen_futures::JsFuture::from(caches.open(cache_name)).await.ok()?;
let cache: web_sys::Cache = cache.dyn_into().ok()?;
let request = web_sys::Request::new_with_str(url).ok()?;
let match_promise = cache.match_with_request(&request);
let match_val = wasm_bindgen_futures::JsFuture::from(match_promise).await.ok()?;
if !match_val.is_undefined() {
let response: web_sys::Response = match_val.dyn_into().ok()?;
let buffer_promise = response.array_buffer().ok()?;
let buffer = wasm_bindgen_futures::JsFuture::from(buffer_promise).await.ok()?;
let array = js_sys::Uint8Array::new(&buffer);
return Some(array.to_vec());
}
// Network fetch
let response_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await.ok()?;
let response: web_sys::Response = response_val.dyn_into().ok()?;
// Clone response for cache
let response_clone = response.clone().ok()?;
let put_promise = cache.put_with_request(&request, &response_clone);
wasm_bindgen_futures::JsFuture::from(put_promise).await.ok()?;
let buffer_promise = response.array_buffer().ok()?;
let buffer = wasm_bindgen_futures::JsFuture::from(buffer_promise).await.ok()?;
let array = js_sys::Uint8Array::new(&buffer);
Some(array.to_vec())
}
}

View File

@@ -0,0 +1 @@
pub mod http_client;

View File

@@ -0,0 +1,77 @@
use std::sync::{Arc, Mutex};
use winit::event::{ElementState, MouseButton, MouseScrollDelta};
use crate::domain::camera::Camera;
use crate::domain::state::InputState;
pub struct CameraService;
impl CameraService {
// Zoom constants
const MIN_ZOOM: f32 = 20.0;
const MAX_ZOOM: f32 = 50000.0;
pub fn handle_resize(camera: &Arc<Mutex<Camera>>, width: u32, height: u32) {
let mut cam = camera.lock().unwrap();
cam.aspect = width as f32 / height as f32;
}
pub fn handle_mouse_input(input: &mut InputState, state: ElementState, button: MouseButton) {
if button == MouseButton::Left {
input.is_dragging = state == ElementState::Pressed;
input.last_cursor = None;
}
}
pub fn handle_cursor_move(
camera: &Arc<Mutex<Camera>>,
input: &mut InputState,
position_x: f64,
position_y: f64,
screen_height: f32
) {
if input.is_dragging {
if let Some((lx, ly)) = input.last_cursor {
let dx = position_x - lx;
let dy = position_y - ly;
let mut cam = camera.lock().unwrap();
// Use IDENTICAL formula for both X and Y
// Since Y works correctly with 2/(zoom*height), use the same for X
let scale = 2.0 / (cam.zoom * screen_height);
cam.x -= (dx as f32) * scale;
cam.y -= (dy as f32) * scale;
}
input.last_cursor = Some((position_x, position_y));
}
}
pub fn handle_wheel(camera: &Arc<Mutex<Camera>>, delta: MouseScrollDelta) {
let scroll = match delta {
MouseScrollDelta::LineDelta(_, y) => y as f64,
MouseScrollDelta::PixelDelta(pos) => pos.y / 50.0,
};
let mut cam = camera.lock().unwrap();
let factor = if scroll > 0.0 { 1.1f32 } else { 0.9f32 };
cam.zoom = (cam.zoom * factor).clamp(Self::MIN_ZOOM, Self::MAX_ZOOM);
}
// Helper to convert slider (0-100) to zoom (logarithmic)
pub fn slider_to_zoom(val: f64) -> f32 {
let t = val / 100.0;
let log_min = Self::MIN_ZOOM.ln();
let log_max = Self::MAX_ZOOM.ln();
let log_zoom = log_min + (log_max - log_min) * t as f32;
log_zoom.exp()
}
// Helper to convert zoom to slider (0-100)
pub fn zoom_to_slider(zoom: f32) -> f64 {
let log_min = Self::MIN_ZOOM.ln();
let log_max = Self::MAX_ZOOM.ln();
let log_zoom = zoom.ln();
let t = (log_zoom - log_min) / (log_max - log_min);
(t * 100.0) as f64
}
}

View File

@@ -0,0 +1,6 @@
pub mod tile_service;
pub mod camera_service;
pub mod transit_service;
pub mod render_service;

View File

@@ -0,0 +1,379 @@
use wgpu::util::DeviceExt;
use crate::pipelines::{
self, Vertex, ColoredVertex, RoadVertex, RailwayVertex, generate_railway_geometry,
create_colored_building_pipeline, create_water_pipeline, create_water_line_pipeline,
create_railway_pipeline, create_road_motorway_outline_pipeline, create_road_motorway_pipeline,
create_road_primary_outline_pipeline, create_road_primary_pipeline,
create_road_secondary_outline_pipeline, create_road_secondary_pipeline,
create_road_residential_outline_pipeline, create_road_residential_pipeline,
create_landuse_green_pipeline, create_landuse_residential_pipeline, create_sand_pipeline
};
use crate::types::TileBuffers;
use crate::domain::state::AppState;
use crate::geo::project;
pub struct RenderService {
// Pipelines
pub building_pipeline: wgpu::RenderPipeline,
pub water_pipeline: wgpu::RenderPipeline,
pub water_line_pipeline: wgpu::RenderPipeline,
pub railway_pipeline: wgpu::RenderPipeline,
pub motorway_outline: wgpu::RenderPipeline,
pub motorway_fill: wgpu::RenderPipeline,
pub primary_outline: wgpu::RenderPipeline,
pub primary_fill: wgpu::RenderPipeline,
pub secondary_outline: wgpu::RenderPipeline,
pub secondary_fill: wgpu::RenderPipeline,
pub residential_outline: wgpu::RenderPipeline,
pub residential_fill: wgpu::RenderPipeline,
pub landuse_green_pipeline: wgpu::RenderPipeline,
pub landuse_residential_pipeline: wgpu::RenderPipeline,
pub sand_pipeline: wgpu::RenderPipeline,
}
impl RenderService {
pub fn new(device: &wgpu::Device, format: &wgpu::TextureFormat, camera_layout: &wgpu::BindGroupLayout) -> Self {
Self {
building_pipeline: create_colored_building_pipeline(device, format, camera_layout),
water_pipeline: create_water_pipeline(device, format, camera_layout),
water_line_pipeline: create_water_line_pipeline(device, format, camera_layout),
railway_pipeline: create_railway_pipeline(device, format, camera_layout),
motorway_outline: create_road_motorway_outline_pipeline(device, format, camera_layout),
motorway_fill: create_road_motorway_pipeline(device, format, camera_layout),
primary_outline: create_road_primary_outline_pipeline(device, format, camera_layout),
primary_fill: create_road_primary_pipeline(device, format, camera_layout),
secondary_outline: create_road_secondary_outline_pipeline(device, format, camera_layout),
secondary_fill: create_road_secondary_pipeline(device, format, camera_layout),
residential_outline: create_road_residential_outline_pipeline(device, format, camera_layout),
residential_fill: create_road_residential_pipeline(device, format, camera_layout),
landuse_green_pipeline: create_landuse_green_pipeline(device, format, camera_layout),
landuse_residential_pipeline: create_landuse_residential_pipeline(device, format, camera_layout),
sand_pipeline: create_sand_pipeline(device, format, camera_layout),
}
}
pub fn create_tile_buffers(device: &wgpu::Device, state: &mut AppState, tile: (i32, i32, i32)) {
// Build vertex data for each feature type - roads use RoadVertex
let mut road_motorway_vertex_data: Vec<RoadVertex> = Vec::new();
let mut road_primary_vertex_data: Vec<RoadVertex> = Vec::new();
let mut road_secondary_vertex_data: Vec<RoadVertex> = Vec::new();
let mut road_residential_vertex_data: Vec<RoadVertex> = Vec::new();
let mut building_vertex_data: Vec<ColoredVertex> = Vec::new();
let mut landuse_green_vertex_data: Vec<Vertex> = Vec::new();
let mut landuse_residential_vertex_data: Vec<Vertex> = Vec::new();
let mut landuse_sand_vertex_data: Vec<Vertex> = Vec::new();
let mut water_vertex_data: Vec<Vertex> = Vec::new();
let mut railway_vertex_data: Vec<RailwayVertex> = Vec::new();
let mut water_line_vertex_data: Vec<Vertex> = Vec::new();
// Process ways (roads) - generate quad geometry for zoom-aware width
if let Some(ways) = state.ways.get(&tile) {
for way in ways {
let highway = way.tags.get("highway").map(|s| s.as_str());
if highway.is_none() { continue; }
let highway_type = highway.unwrap();
// Road type: 0=motorway, 1=primary, 2=secondary, 3=residential
let road_type: f32 = match highway_type {
"motorway" | "motorway_link" | "trunk" | "trunk_link" => 0.0,
"primary" | "primary_link" => 1.0,
"secondary" | "secondary_link" => 2.0,
_ => 3.0,
};
let target: &mut Vec<RoadVertex> = match highway_type {
"motorway" | "motorway_link" | "trunk" | "trunk_link" => &mut road_motorway_vertex_data,
"primary" | "primary_link" => &mut road_primary_vertex_data,
"secondary" | "secondary_link" => &mut road_secondary_vertex_data,
_ => &mut road_residential_vertex_data,
};
// Parse lanes (default based on road type)
let default_lanes: f32 = match highway_type {
"motorway" | "trunk" => 4.0,
"motorway_link" | "trunk_link" | "primary" => 2.0,
_ => 2.0,
};
let lanes: f32 = way.tags.get("lanes")
.and_then(|s| s.parse().ok())
.unwrap_or(default_lanes);
// Parse all road centerline points
let mut centers: Vec<[f32; 2]> = Vec::new();
for chunk in way.points.chunks(8) {
if chunk.len() < 8 { break; }
let lat = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0u8; 4]));
let lon = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0u8; 4]));
let (x, y) = project(lat as f64, lon as f64);
centers.push([x, y]);
}
// Generate connected geometry with miter joins
let geom = pipelines::roads::generate_road_geometry(&centers, lanes, road_type);
target.extend(geom);
}
}
// Process buildings with type-based colors
if let Some(buildings) = state.buildings.get(&tile) {
for building in buildings {
// Get building type from tags
let building_type = building.tags.get("building").map(|s| s.as_str()).unwrap_or("yes");
// Also check amenity tag for public buildings (hospitals, schools, etc.)
let amenity_type = building.tags.get("amenity").map(|s| s.as_str());
let is_public_amenity = matches!(amenity_type,
Some("hospital") | Some("school") | Some("university") | Some("college") |
Some("kindergarten") | Some("library") | Some("place_of_worship") |
Some("community_centre") | Some("townhall") | Some("police") |
Some("fire_station") | Some("courthouse") | Some("embassy")
);
// Assign color based on building type (light theme colors)
let color: [f32; 3] = if is_public_amenity {
// Public/Civic: light gray #e0ddd4
[0.88, 0.87, 0.83]
} else {
match building_type {
// Residential: cream #f2efe9
"house" | "apartments" | "residential" | "detached" | "semidetached_house" | "terrace" | "dormitory" =>
[0.95, 0.94, 0.91],
// Commercial: tan #e8e4dc
"commercial" | "retail" | "office" | "supermarket" | "kiosk" | "hotel" =>
[0.91, 0.89, 0.86],
// Industrial: gray-tan #d9d5cc
"industrial" | "warehouse" | "factory" | "manufacture" =>
[0.85, 0.84, 0.80],
// Public/Civic: light gray #e0ddd4
"school" | "hospital" | "university" | "government" | "public" | "church" | "cathedral" | "mosque" | "synagogue" | "temple" | "train_station" | "fire_station" | "police" | "library" | "kindergarten" =>
[0.88, 0.87, 0.83],
// Default: gray #d9d9d9
_ => [0.85, 0.85, 0.85],
}
};
for chunk in building.points.chunks(8) {
if chunk.len() < 8 { break; }
let lat = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0u8; 4]));
let lon = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0u8; 4]));
let (x, y) = project(lat as f64, lon as f64);
building_vertex_data.push(ColoredVertex { position: [x, y], color });
}
}
}
// Process landuse
if let Some(landuse_list) = state.landuse.get(&tile) {
for landuse in landuse_list {
let landuse_type = landuse.tags.get("landuse").or_else(|| landuse.tags.get("natural")).or_else(|| landuse.tags.get("leisure")).map(|s| s.as_str());
let target = match landuse_type {
Some("forest") | Some("wood") | Some("grass") | Some("park") | Some("meadow") | Some("garden") | Some("nature_reserve") => &mut landuse_green_vertex_data,
Some("beach") | Some("sand") | Some("island") => &mut landuse_sand_vertex_data,
Some("residential") | Some("retail") | Some("commercial") | Some("industrial") => &mut landuse_residential_vertex_data,
_ => continue,
};
for chunk in landuse.points.chunks(8) {
if chunk.len() < 8 { break; }
let lat = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0u8; 4]));
let lon = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0u8; 4]));
let (x, y) = project(lat as f64, lon as f64);
target.push(Vertex { position: [x, y] });
}
}
}
// Process water
if let Some(water_list) = state.water.get(&tile) {
for water in water_list {
let is_line = water.tags.get("waterway").is_some();
// Parse all points first
let mut water_points: Vec<Vertex> = Vec::new();
for chunk in water.points.chunks(8) {
if chunk.len() < 8 { break; }
let lat = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0u8; 4]));
let lon = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0u8; 4]));
let (x, y) = project(lat as f64, lon as f64);
water_points.push(Vertex { position: [x, y] });
}
if is_line {
// For LineList: push pairs of vertices for each segment
for i in 0..water_points.len().saturating_sub(1) {
water_line_vertex_data.push(water_points[i]);
water_line_vertex_data.push(water_points[i + 1]);
}
} else {
// For TriangleList: just push all vertices
water_vertex_data.extend(water_points);
}
}
}
// Process railways with colors and thick geometry
if let Some(railway_list) = state.railways.get(&tile) {
for railway in railway_list {
// Parse color from tags (format: "#RRGGBB" or "#RGB")
let color = railway.tags.get("colour")
.or(railway.tags.get("color"))
.map(|c| parse_hex_color(c))
.unwrap_or([0.0, 0.0, 0.0]); // Default: no color (shader uses grey)
// Railway type: 0=standard, 1=u-bahn, 2=tram
let rail_type_str = railway.tags.get("railway").map(|s| s.as_str()).unwrap_or("rail");
let rail_type: f32 = match rail_type_str {
"subway" => 1.0,
"tram" => 2.0,
_ => 0.0,
};
// Parse all points first for geometry generation
let mut rail_points: Vec<[f32; 2]> = Vec::new();
for chunk in railway.points.chunks(8) {
if chunk.len() < 8 { break; }
let lat = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0u8; 4]));
let lon = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0u8; 4]));
let (x, y) = project(lat as f64, lon as f64);
rail_points.push([x, y]);
}
// Generate thick geometry (TriangleList of RailwayVertex)
let geom = generate_railway_geometry(&rail_points, color, rail_type);
railway_vertex_data.extend(geom);
}
}
// Create buffers if any data exists
if !road_motorway_vertex_data.is_empty()
|| !road_primary_vertex_data.is_empty()
|| !road_secondary_vertex_data.is_empty()
|| !road_residential_vertex_data.is_empty()
|| !building_vertex_data.is_empty()
|| !landuse_green_vertex_data.is_empty()
|| !landuse_residential_vertex_data.is_empty()
|| !water_vertex_data.is_empty()
|| !railway_vertex_data.is_empty()
|| !water_line_vertex_data.is_empty()
{
let road_motorway_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Road Motorway Buffer"),
contents: bytemuck::cast_slice(&road_motorway_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let road_primary_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Road Primary Buffer"),
contents: bytemuck::cast_slice(&road_primary_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let road_secondary_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Road Secondary Buffer"),
contents: bytemuck::cast_slice(&road_secondary_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let road_residential_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Road Residential Buffer"),
contents: bytemuck::cast_slice(&road_residential_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let building_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Tile Building Buffer"),
contents: bytemuck::cast_slice(&building_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let landuse_green_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Tile Landuse Green Buffer"),
contents: bytemuck::cast_slice(&landuse_green_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let landuse_residential_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Tile Landuse Residential Buffer"),
contents: bytemuck::cast_slice(&landuse_residential_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let landuse_sand_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Tile Landuse Sand Buffer"),
contents: bytemuck::cast_slice(&landuse_sand_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let water_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Tile Water Buffer"),
contents: bytemuck::cast_slice(&water_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let railway_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Tile Railway Buffer"),
contents: bytemuck::cast_slice(&railway_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let water_line_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Tile Water Line Buffer"),
contents: bytemuck::cast_slice(&water_line_vertex_data),
usage: wgpu::BufferUsages::VERTEX,
});
let buffers = TileBuffers {
road_motorway_vertex_buffer: road_motorway_buffer,
road_motorway_vertex_count: road_motorway_vertex_data.len() as u32,
road_primary_vertex_buffer: road_primary_buffer,
road_primary_vertex_count: road_primary_vertex_data.len() as u32,
road_secondary_vertex_buffer: road_secondary_buffer,
road_secondary_vertex_count: road_secondary_vertex_data.len() as u32,
road_residential_vertex_buffer: road_residential_buffer,
road_residential_vertex_count: road_residential_vertex_data.len() as u32,
building_vertex_buffer: building_buffer,
building_index_count: building_vertex_data.len() as u32,
landuse_green_vertex_buffer: landuse_green_buffer,
landuse_green_index_count: landuse_green_vertex_data.len() as u32,
landuse_residential_vertex_buffer: landuse_residential_buffer,
landuse_residential_index_count: landuse_residential_vertex_data.len() as u32,
landuse_sand_vertex_buffer: landuse_sand_buffer,
landuse_sand_index_count: landuse_sand_vertex_data.len() as u32,
water_vertex_buffer: water_buffer,
water_index_count: water_vertex_data.len() as u32,
railway_vertex_buffer: railway_buffer,
railway_vertex_count: railway_vertex_data.len() as u32,
water_line_vertex_buffer: water_line_buffer,
water_line_vertex_count: water_line_vertex_data.len() as u32,
};
state.buffers.insert(tile, std::sync::Arc::new(buffers));
}
}
}
/// Parse a hex color string (e.g., "#FF0000" or "#F00") into RGB floats [0.0-1.0]
fn parse_hex_color(hex: &str) -> [f32; 3] {
let hex = hex.trim_start_matches('#');
if hex.len() == 6 {
// #RRGGBB format
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f32 / 255.0;
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f32 / 255.0;
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f32 / 255.0;
[r, g, b]
} else if hex.len() == 3 {
// #RGB format (shorthand)
let r = u8::from_str_radix(&hex[0..1], 16).unwrap_or(0) as f32 / 15.0;
let g = u8::from_str_radix(&hex[1..2], 16).unwrap_or(0) as f32 / 15.0;
let b = u8::from_str_radix(&hex[2..3], 16).unwrap_or(0) as f32 / 15.0;
[r, g, b]
} else {
// Invalid format, return black (will trigger grey fallback in shader)
[0.0, 0.0, 0.0]
}
}

View File

@@ -0,0 +1,68 @@
use crate::domain::camera::Camera;
pub struct TileService;
impl TileService {
/// Get visible tiles based on current camera position
pub fn get_visible_tiles(camera: &Camera) -> Vec<(i32, i32, i32)> {
// Select zoom level based on camera zoom
// Zoom 6: World/Country view
// Zoom 9: Region view
// Zoom 12: City view
// Zoom 14: Street view
let z = if camera.zoom < 100.0 {
2
} else if camera.zoom < 500.0 {
4
} else if camera.zoom < 2000.0 {
6
} else if camera.zoom < 5000.0 {
9
} else if camera.zoom < 10000.0 {
12
} else {
14
};
let n = 2.0f64.powi(z);
let half_width = 1.0 * camera.aspect / camera.zoom;
let half_height = 1.0 / camera.zoom;
let min_x = (camera.x - half_width).max(0.0) as f64;
let max_x = (camera.x + half_width).min(1.0) as f64;
let min_y = (camera.y - half_height).max(0.0) as f64;
let max_y = (camera.y + half_height).min(1.0) as f64;
let min_tile_x = (min_x * n).floor() as i32;
let max_tile_x = (max_x * n).floor() as i32;
let min_tile_y = (min_y * n).floor() as i32;
let max_tile_y = (max_y * n).floor() as i32;
let mut tiles = Vec::new();
for x in min_tile_x..=max_tile_x {
for y in min_tile_y..=max_tile_y {
tiles.push((z, x, y));
}
}
tiles
}
/// Get parent tile for the tile retention hierarchy
pub fn get_parent_tile(z: i32, x: i32, y: i32) -> Option<(i32, i32, i32)> {
// Hierarchy: 14 -> 12 -> 9 -> 6 -> 2
let parent_z = match z {
14 => 12,
12 => 9,
9 => 6,
6 => 4,
4 => 2,
_ => return None,
};
// Calculate scale difference
let diff = z - parent_z;
let factor = 2i32.pow(diff as u32);
Some((parent_z, x / factor, y / factor))
}
}

View File

@@ -0,0 +1,28 @@
use std::sync::{Arc, Mutex};
use crate::domain::state::AppState;
use std::cell::RefCell;
pub struct TransitService;
thread_local! {
static GLOBAL_STATE: RefCell<Option<Arc<Mutex<AppState>>>> = RefCell::new(None);
}
impl TransitService {
pub fn set_global_state(state: Arc<Mutex<AppState>>) {
GLOBAL_STATE.with(|gs| {
*gs.borrow_mut() = Some(state);
});
}
pub fn toggle_global_transit() {
GLOBAL_STATE.with(|gs| {
if let Some(state_arc) = gs.borrow().as_ref() {
if let Ok(mut state) = state_arc.try_lock() {
state.show_transit = !state.show_transit;
web_sys::console::log_1(&format!("Transit visibility toggled: {}", state.show_transit).into());
}
}
});
}
}

View File

@@ -1,102 +0,0 @@
//! Tile visibility and data fetching utilities
use wasm_bindgen::JsCast;
use crate::camera::Camera;
/// Fetch tile data with caching
pub async fn fetch_cached(url: &str) -> Option<Vec<u8>> {
let window = web_sys::window()?;
let caches = window.caches().ok()?;
let cache_name = "map-data-v5-sand";
let cache = wasm_bindgen_futures::JsFuture::from(caches.open(cache_name)).await.ok()?;
let cache: web_sys::Cache = cache.dyn_into().ok()?;
let request = web_sys::Request::new_with_str(url).ok()?;
let match_promise = cache.match_with_request(&request);
let match_val = wasm_bindgen_futures::JsFuture::from(match_promise).await.ok()?;
if !match_val.is_undefined() {
let response: web_sys::Response = match_val.dyn_into().ok()?;
let buffer_promise = response.array_buffer().ok()?;
let buffer = wasm_bindgen_futures::JsFuture::from(buffer_promise).await.ok()?;
let array = js_sys::Uint8Array::new(&buffer);
return Some(array.to_vec());
}
// Network fetch
let response_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await.ok()?;
let response: web_sys::Response = response_val.dyn_into().ok()?;
// Clone response for cache
let response_clone = response.clone().ok()?;
let put_promise = cache.put_with_request(&request, &response_clone);
wasm_bindgen_futures::JsFuture::from(put_promise).await.ok()?;
let buffer_promise = response.array_buffer().ok()?;
let buffer = wasm_bindgen_futures::JsFuture::from(buffer_promise).await.ok()?;
let array = js_sys::Uint8Array::new(&buffer);
Some(array.to_vec())
}
/// Get visible tiles based on current camera position
pub fn get_visible_tiles(camera: &Camera) -> Vec<(i32, i32, i32)> {
// Select zoom level based on camera zoom
// Zoom 6: World/Country view
// Zoom 9: Region view
// Zoom 12: City view
// Zoom 14: Street view
let z = if camera.zoom < 100.0 {
2
} else if camera.zoom < 500.0 {
4
} else if camera.zoom < 2000.0 {
6
} else if camera.zoom < 5000.0 {
9
} else if camera.zoom < 10000.0 {
12
} else {
14
};
let n = 2.0f64.powi(z);
let half_width = 1.0 * camera.aspect / camera.zoom;
let half_height = 1.0 / camera.zoom;
let min_x = (camera.x - half_width).max(0.0) as f64;
let max_x = (camera.x + half_width).min(1.0) as f64;
let min_y = (camera.y - half_height).max(0.0) as f64;
let max_y = (camera.y + half_height).min(1.0) as f64;
let min_tile_x = (min_x * n).floor() as i32;
let max_tile_x = (max_x * n).floor() as i32;
let min_tile_y = (min_y * n).floor() as i32;
let max_tile_y = (max_y * n).floor() as i32;
let mut tiles = Vec::new();
for x in min_tile_x..=max_tile_x {
for y in min_tile_y..=max_tile_y {
tiles.push((z, x, y));
}
}
tiles
}
/// Get parent tile for the tile retention hierarchy
pub fn get_parent_tile(z: i32, x: i32, y: i32) -> Option<(i32, i32, i32)> {
// Hierarchy: 14 -> 12 -> 9 -> 6 -> 2
let parent_z = match z {
14 => 12,
12 => 9,
9 => 6,
6 => 4,
4 => 2,
_ => return None,
};
// Calculate scale difference
let diff = z - parent_z;
let factor = 2i32.pow(diff as u32);
Some((parent_z, x / factor, y / factor))
}

1519
importer/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
use std::collections::HashMap;
#[derive(Debug)]
pub enum DbTask {
Node {
zoom: i32,
id: i64,
lat: f64,
lon: f64,
tags: HashMap<String, String>,
x: i32,
y: i32
},
Way {
zoom: i32,
table: &'static str,
id: i64,
tags: HashMap<String, String>,
points: Vec<u8>,
x: i32,
y: i32
},
}

View File

@@ -1,376 +1,28 @@
mod domain;
mod repositories;
mod services;
mod parsers; // Empty for now, but kept for structure
use anyhow::Result; use anyhow::Result;
use earcutr::earcut;
use osmpbf::{Element, ElementReader}; use osmpbf::{Element, ElementReader};
use scylla::SessionBuilder;
use std::collections::HashMap; use std::collections::HashMap;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use std::fs::{File, OpenOptions}; use std::sync::Arc;
use std::io::{BufWriter, Write, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use memmap2::Mmap;
const ZOOM_LEVELS: [u32; 6] = [2, 4, 6, 9, 12, 14]; use crate::domain::DbTask;
use crate::repositories::{
// Store way geometries for multipolygon assembly scylla_repository::ScyllaRepository,
struct WayStore { node_store::NodeStore,
ways: HashMap<i64, Vec<i64>>, // way_id -> node_id list way_store::WayStore,
} railway_store::RailwayStore,
};
impl WayStore { use crate::services::{
fn new() -> Self { filtering_service::FilteringService,
Self { ways: HashMap::new() } tile_service::TileService,
} geometry_service::GeometryService,
multipolygon_service::MultipolygonService,
fn insert(&mut self, way_id: i64, node_refs: Vec<i64>) { railway_service::RailwayService,
self.ways.insert(way_id, node_refs); };
}
fn get(&self, way_id: i64) -> Option<&Vec<i64>> {
self.ways.get(&way_id)
}
}
// Store railway ways for deferred insertion (after relation processing for colors)
struct RailwayWay {
id: i64,
tags: HashMap<String, String>,
points: Vec<u8>, // Serialized line blob
first_lat: f64,
first_lon: f64,
}
struct RailwayStore {
ways: HashMap<i64, RailwayWay>, // way_id -> railway data
way_colors: HashMap<i64, String>, // way_id -> colour from route relation
}
impl RailwayStore {
fn new() -> Self {
Self {
ways: HashMap::new(),
way_colors: HashMap::new(),
}
}
fn insert_way(&mut self, id: i64, tags: HashMap<String, String>, points: Vec<u8>, first_lat: f64, first_lon: f64) {
self.ways.insert(id, RailwayWay { id, tags, points, first_lat, first_lon });
}
fn set_color(&mut self, way_id: i64, color: String) {
// Only set if not already set (first route relation wins)
self.way_colors.entry(way_id).or_insert(color);
}
fn get_color(&self, way_id: i64) -> Option<&String> {
self.way_colors.get(&way_id)
}
}
// Assemble ways into MULTIPLE rings (connect end-to-end)
// Rivers like the Isar have multiple separate channels/rings
fn assemble_rings(way_ids: &[i64], way_store: &WayStore) -> Vec<Vec<i64>> {
if way_ids.is_empty() { return Vec::new(); }
// Get all way geometries
let mut segments: Vec<Vec<i64>> = Vec::new();
for &way_id in way_ids {
if let Some(nodes) = way_store.get(way_id) {
if nodes.len() >= 2 {
segments.push(nodes.clone());
}
}
}
if segments.is_empty() { return Vec::new(); }
let mut completed_rings: Vec<Vec<i64>> = Vec::new();
// Keep assembling rings until we run out of segments
while !segments.is_empty() {
// Start a new ring with the first available segment
let mut ring = segments.remove(0);
// Try to extend this ring
let max_iterations = segments.len() * segments.len() + 100;
let mut iterations = 0;
loop {
iterations += 1;
if iterations > max_iterations { break; }
let mut connected = false;
for i in 0..segments.len() {
let seg = &segments[i];
if seg.is_empty() { continue; }
let ring_start = *ring.first().unwrap();
let ring_end = *ring.last().unwrap();
let seg_start = *seg.first().unwrap();
let seg_end = *seg.last().unwrap();
if ring_end == seg_start {
// Connect: ring + seg (skip first node of seg)
ring.extend(seg[1..].iter().cloned());
segments.remove(i);
connected = true;
break;
} else if ring_end == seg_end {
// Connect: ring + reversed seg
let reversed: Vec<i64> = seg.iter().rev().cloned().collect();
ring.extend(reversed[1..].iter().cloned());
segments.remove(i);
connected = true;
break;
} else if ring_start == seg_end {
// Connect: seg + ring
let mut new_ring = seg.clone();
new_ring.extend(ring[1..].iter().cloned());
ring = new_ring;
segments.remove(i);
connected = true;
break;
} else if ring_start == seg_start {
// Connect: reversed seg + ring
let mut reversed: Vec<i64> = seg.iter().rev().cloned().collect();
reversed.extend(ring[1..].iter().cloned());
ring = reversed;
segments.remove(i);
connected = true;
break;
}
}
// Check if ring is now closed
if ring.len() >= 4 && ring.first() == ring.last() {
completed_rings.push(ring);
break; // Move to next ring
}
// If no connection was made and ring isn't closed,
// we can't extend this ring anymore
if !connected {
// Still save partial rings if they have enough points
// This helps with incomplete data - at least show something
if ring.len() >= 4 {
// Force-close the ring
let first = ring[0];
ring.push(first);
completed_rings.push(ring);
}
break;
}
}
}
completed_rings
}
struct NodeStore {
writer: Option<BufWriter<File>>,
mmap: Option<Mmap>,
path: PathBuf,
last_id: i64,
}
impl NodeStore {
fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
let writer = BufWriter::with_capacity(10 * 1024 * 1024, file); // 10MB buffer
Ok(Self {
writer: Some(writer),
mmap: None,
path,
last_id: -1,
})
}
fn insert(&mut self, id: i64, lat: f64, lon: f64) -> Result<()> {
if let Some(writer) = &mut self.writer {
if id > self.last_id + 1 {
let gap = id - self.last_id - 1;
writer.seek(SeekFrom::Current(gap * 8))?;
} else if id <= self.last_id {
writer.seek(SeekFrom::Start(id as u64 * 8))?;
}
let lat_i32 = (lat * 1e7) as i32;
let lon_i32 = (lon * 1e7) as i32;
writer.write_all(&lat_i32.to_le_bytes())?;
writer.write_all(&lon_i32.to_le_bytes())?;
self.last_id = id;
}
Ok(())
}
fn prepare_for_reading(&mut self) -> Result<()> {
self.writer = None; // Flush and close writer
let file = File::open(&self.path)?;
let mmap = unsafe { Mmap::map(&file)? };
self.mmap = Some(mmap);
Ok(())
}
fn get(&self, id: i64) -> Option<(f64, f64)> {
if let Some(mmap) = &self.mmap {
let offset = id as usize * 8;
if offset + 8 <= mmap.len() {
let chunk = &mmap[offset..offset+8];
let lat_i32 = i32::from_le_bytes(chunk[0..4].try_into().unwrap());
let lon_i32 = i32::from_le_bytes(chunk[4..8].try_into().unwrap());
if lat_i32 == 0 && lon_i32 == 0 { return None; }
return Some((lat_i32 as f64 / 1e7, lon_i32 as f64 / 1e7));
}
}
None
}
}
// Ramer-Douglas-Peucker simplification
fn perpendicular_distance(p: (f64, f64), line_start: (f64, f64), line_end: (f64, f64)) -> f64 {
let (x, y) = p;
let (x1, y1) = line_start;
let (x2, y2) = line_end;
let dx = x2 - x1;
let dy = y2 - y1;
if dx == 0.0 && dy == 0.0 {
return ((x - x1).powi(2) + (y - y1).powi(2)).sqrt();
}
let num = (dy * x - dx * y + x2 * y1 - y2 * x1).abs();
let den = (dx.powi(2) + dy.powi(2)).sqrt();
num / den
}
fn simplify_points(points: &[(f64, f64)], epsilon: f64) -> Vec<(f64, f64)> {
if points.len() < 3 {
return points.to_vec();
}
let start = points[0];
let end = points[points.len() - 1];
let mut max_dist = 0.0;
let mut index = 0;
for i in 1..points.len() - 1 {
let dist = perpendicular_distance(points[i], start, end);
if dist > max_dist {
max_dist = dist;
index = i;
}
}
if max_dist > epsilon {
let mut left = simplify_points(&points[..=index], epsilon);
let mut right = simplify_points(&points[index..], epsilon);
// Remove duplicate point at split
left.pop();
left.extend(right);
left
} else {
vec![start, end]
}
}
fn triangulate_polygon(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut flat_points = Vec::with_capacity(points.len() * 2);
for (lat, lon) in points {
flat_points.push(*lat);
flat_points.push(*lon);
}
// We assume simple polygons (no holes) for now as we are just processing ways
let indices = earcut(&flat_points, &[], 2).unwrap_or_default();
let mut triangles = Vec::with_capacity(indices.len());
for i in indices {
triangles.push(points[i]);
}
triangles
}
fn should_include(tags: &HashMap<String, String>, zoom: u32) -> bool {
if zoom >= 14 { return true; }
let highway = tags.get("highway").map(|s| s.as_str());
let place = tags.get("place").map(|s| s.as_str());
let natural = tags.get("natural").map(|s| s.as_str());
let railway = tags.get("railway").map(|s| s.as_str());
let waterway = tags.get("waterway").map(|s| s.as_str());
match zoom {
2 => {
// Space View: Continents and Countries
matches!(place, Some("continent" | "country" | "sea" | "ocean")) ||
matches!(natural, Some("water" | "bay" | "strait")) || // Major water bodies
matches!(highway, Some("motorway")) || // Added motorway
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential")) || // Added more green + farmland/residential
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve")) || // Added parks
matches!(natural, Some("wood" | "scrub")) // Added wood/scrub
},
4 => {
// Regional View (NEW)
matches!(highway, Some("motorway" | "trunk")) ||
matches!(place, Some("city" | "town" | "sea" | "ocean")) ||
matches!(natural, Some("water" | "wood" | "scrub" | "heath" | "wetland" | "bay" | "strait")) ||
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential")) ||
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve")) ||
matches!(waterway, Some("river"))
},
6 => {
// Enterprise Grade: ONLY Motorways and Trunk roads. No primary/secondary.
// ONLY Cities. No nature/landuse.
matches!(highway, Some("motorway" | "trunk" | "primary")) || // Added primary
matches!(place, Some("city" | "sea" | "ocean")) ||
matches!(natural, Some("water" | "wood" | "scrub" | "heath" | "wetland" | "bay" | "strait")) ||
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential")) ||
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve")) ||
matches!(waterway, Some("river"))
},
9 => {
// Enterprise Grade: Add Primary roads.
// Add Towns.
// Limited nature.
matches!(highway, Some("motorway" | "trunk" | "primary")) ||
matches!(place, Some("city" | "town" | "sea" | "ocean" | "island" | "islet")) || // Islands!
matches!(railway, Some("rail")) ||
matches!(natural, Some("water" | "wood" | "scrub" | "bay" | "strait" | "wetland" | "heath" | "sand" | "beach" | "shingle" | "bare_rock")) || // Sand/Beaches!
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential" | "basin" | "reservoir" | "allotments")) ||
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve" | "garden")) || // Gardens
matches!(waterway, Some("river" | "riverbank" | "canal")) // Added canal
},
12 => {
matches!(highway, Some("motorway" | "trunk" | "primary" | "secondary" | "tertiary" | "residential" | "unclassified" | "pedestrian" | "service" | "track")) || // Added minor roads
matches!(place, Some("city" | "town" | "village")) ||
matches!(railway, Some("rail")) ||
tags.contains_key("building") ||
tags.contains_key("landuse") ||
tags.contains_key("leisure") ||
matches!(natural, Some("water" | "wood" | "scrub" | "wetland" | "heath" | "bay" | "strait" | "sand" | "beach" | "bare_rock")) ||
matches!(waterway, Some("river" | "riverbank" | "stream" | "canal" | "drain" | "ditch")) // Added canal/drain/ditch
},
_ => false
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@@ -381,70 +33,28 @@ async fn main() -> Result<()> {
let uri = std::env::var("SCYLLA_URI").unwrap_or_else(|_| "127.0.0.1:9042".to_string()); let uri = std::env::var("SCYLLA_URI").unwrap_or_else(|_| "127.0.0.1:9042".to_string());
println!("Connecting to ScyllaDB at {}...", uri); println!("Connecting to ScyllaDB at {}...", uri);
let session = loop { let scylla_repo = Arc::new(ScyllaRepository::connect(&uri).await?);
match SessionBuilder::new().known_node(&uri).build().await {
Ok(session) => break session,
Err(e) => {
println!("Failed to connect to ScyllaDB: {}. Retrying in 5 seconds...", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
};
let session = std::sync::Arc::new(session);
// Ensure schema exists // Truncate tables
session.query("CREATE KEYSPACE IF NOT EXISTS map_data WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }", &[]).await?; scylla_repo.truncate_tables().await?;
// Create tables
session.query("CREATE TABLE IF NOT EXISTS map_data.nodes (zoom int, tile_x int, tile_y int, id bigint, lat double, lon double, tags map<text, text>, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.ways (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.buildings (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.water (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.landuse (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.railways (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
// Prepare statements
println!("Truncating tables...");
session.query("TRUNCATE map_data.nodes", &[]).await?;
session.query("TRUNCATE map_data.ways", &[]).await?;
session.query("TRUNCATE map_data.buildings", &[]).await?;
session.query("TRUNCATE map_data.water", &[]).await?;
session.query("TRUNCATE map_data.landuse", &[]).await?;
session.query("TRUNCATE map_data.railways", &[]).await?;
println!("Tables truncated.");
println!("Preparing statements...");
let insert_node = session.prepare("INSERT INTO map_data.nodes (zoom, tile_x, tile_y, id, lat, lon, tags) VALUES (?, ?, ?, ?, ?, ?, ?)").await?;
let insert_ways = session.prepare("INSERT INTO map_data.ways (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_buildings = session.prepare("INSERT INTO map_data.buildings (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_water = session.prepare("INSERT INTO map_data.water (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_landuse = session.prepare("INSERT INTO map_data.landuse (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_railways = session.prepare("INSERT INTO map_data.railways (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
println!("Statements prepared.");
let path = std::env::var("OSM_PBF_PATH") let path = std::env::var("OSM_PBF_PATH")
.or_else(|_| std::env::var("HOST_PBF_PATH")) .or_else(|_| std::env::var("HOST_PBF_PATH"))
.unwrap_or_else(|_| "europe-latest.osm.pbf".to_string()); .unwrap_or_else(|_| "europe-latest.osm.pbf".to_string());
println!("Reading {}...", path); println!("Reading {}...", path);
let reader = ElementReader::from_path(path)?; let reader = ElementReader::from_path(path)?;
// Cache for node coordinates: ID -> (lat, lon) // Cache for node coordinates: ID -> (lat, lon)
// Use flat file with mmap
let cache_dir = std::env::var("CACHE_DIR").unwrap_or_else(|_| ".".to_string()); let cache_dir = std::env::var("CACHE_DIR").unwrap_or_else(|_| ".".to_string());
let cache_path = std::path::Path::new(&cache_dir).join("node_cache.bin"); let cache_path = std::path::Path::new(&cache_dir).join("node_cache.bin");
println!("Using node cache at {:?}", cache_path); println!("Using node cache at {:?}", cache_path);
let mut node_store = NodeStore::new(cache_path.clone())?; let mut node_store = NodeStore::new(cache_path.clone())?;
// Channel for backpressure // Channel for backpressure
// Producer (reader) -> Consumer (writer)
enum DbTask {
Node { zoom: i32, id: i64, lat: f64, lon: f64, tags: HashMap<String, String>, x: i32, y: i32 },
Way { zoom: i32, table: &'static str, id: i64, tags: HashMap<String, String>, points: Vec<u8>, x: i32, y: i32 },
}
let (tx, mut rx) = tokio::sync::mpsc::channel::<DbTask>(10_000); let (tx, mut rx) = tokio::sync::mpsc::channel::<DbTask>(10_000);
let session_clone = session.clone(); let scylla_repo_clone = scylla_repo.clone();
let consumer_handle = tokio::spawn(async move { let consumer_handle = tokio::spawn(async move {
let mut join_set = JoinSet::new(); let mut join_set = JoinSet::new();
let mut inserted_count = 0; let mut inserted_count = 0;
@@ -456,7 +66,7 @@ async fn main() -> Result<()> {
println!("Starting consumer with max_concurrent={}", max_concurrent); println!("Starting consumer with max_concurrent={}", max_concurrent);
while let Some(task) = rx.recv().await { while let Some(task) = rx.recv().await {
let session = session_clone.clone(); let repo = scylla_repo_clone.clone();
// Backpressure: limit concurrent inserts // Backpressure: limit concurrent inserts
while join_set.len() >= max_concurrent { while join_set.len() >= max_concurrent {
@@ -465,29 +75,13 @@ async fn main() -> Result<()> {
match task { match task {
DbTask::Node { zoom, id, lat, lon, tags, x, y } => { DbTask::Node { zoom, id, lat, lon, tags, x, y } => {
let statement = insert_node.clone();
join_set.spawn(async move { join_set.spawn(async move {
let _ = session.execute( let _ = repo.insert_node(zoom, id, lat, lon, tags, x, y).await;
&statement,
(zoom, x, y, id, lat, lon, tags),
).await;
}); });
} }
DbTask::Way { zoom, table, id, tags, points, x, y } => { DbTask::Way { zoom, table, id, tags, points, x, y } => {
let statement = match table {
"ways" => insert_ways.clone(),
"buildings" => insert_buildings.clone(),
"water" => insert_water.clone(),
"landuse" => insert_landuse.clone(),
"railways" => insert_railways.clone(),
_ => panic!("Unknown table: {}", table),
};
join_set.spawn(async move { join_set.spawn(async move {
let _ = session.execute( let _ = repo.insert_way(table, zoom, id, tags, points, x, y).await;
&statement,
(zoom, x, y, id, tags, points),
).await;
}); });
} }
} }
@@ -499,7 +93,7 @@ async fn main() -> Result<()> {
println!("Consumer finished. Total inserted tasks: {}", inserted_count); println!("Consumer finished. Total inserted tasks: {}", inserted_count);
}); });
// Run the PBF reader in a blocking task to allow blocking_send // Run the PBF reader in a blocking task
let tx_clone = tx.clone(); let tx_clone = tx.clone();
let reader_handle = tokio::task::spawn_blocking(move || -> Result<(usize, usize, usize)> { let reader_handle = tokio::task::spawn_blocking(move || -> Result<(usize, usize, usize)> {
let tx = tx_clone; let tx = tx_clone;
@@ -509,16 +103,9 @@ async fn main() -> Result<()> {
let mut ways_pending = false; let mut ways_pending = false;
let mut relations_pending = false; let mut relations_pending = false;
// Store way geometries for multipolygon assembly
let mut way_store = WayStore::new(); let mut way_store = WayStore::new();
// Store railway ways for deferred insertion (after relation processing for colors)
let mut railway_store = RailwayStore::new(); let mut railway_store = RailwayStore::new();
// We process sequentially: Nodes first, then Ways, then Relations.
// osmpbf yields nodes then ways then relations.
// We need to detect when we switch from nodes to ways to prepare the store.
reader.for_each(|element| { reader.for_each(|element| {
match element { match element {
Element::Node(node) => { Element::Node(node) => {
@@ -531,9 +118,9 @@ async fn main() -> Result<()> {
let lon = node.lon(); let lon = node.lon();
let tags: HashMap<String, String> = node.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect(); let tags: HashMap<String, String> = node.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect();
for &zoom in &ZOOM_LEVELS { for &zoom in &FilteringService::ZOOM_LEVELS {
if should_include(&tags, zoom) { if FilteringService::should_include(&tags, zoom) {
let (x, y) = lat_lon_to_tile(lat, lon, zoom); let (x, y) = TileService::lat_lon_to_tile(lat, lon, zoom);
let task = DbTask::Node { zoom: zoom as i32, id, lat, lon, tags: tags.clone(), x, y }; let task = DbTask::Node { zoom: zoom as i32, id, lat, lon, tags: tags.clone(), x, y };
let _ = tx.blocking_send(task); let _ = tx.blocking_send(task);
} }
@@ -550,9 +137,9 @@ async fn main() -> Result<()> {
let lon = node.lon(); let lon = node.lon();
let tags: HashMap<String, String> = node.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect(); let tags: HashMap<String, String> = node.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect();
for &zoom in &ZOOM_LEVELS { for &zoom in &FilteringService::ZOOM_LEVELS {
if should_include(&tags, zoom) { if FilteringService::should_include(&tags, zoom) {
let (x, y) = lat_lon_to_tile(lat, lon, zoom); let (x, y) = TileService::lat_lon_to_tile(lat, lon, zoom);
let task = DbTask::Node { zoom: zoom as i32, id, lat, lon, tags: tags.clone(), x, y }; let task = DbTask::Node { zoom: zoom as i32, id, lat, lon, tags: tags.clone(), x, y };
let _ = tx.blocking_send(task); let _ = tx.blocking_send(task);
} }
@@ -561,7 +148,6 @@ async fn main() -> Result<()> {
} }
Element::Way(way) => { Element::Way(way) => {
if !ways_pending { if !ways_pending {
// First way encountered. Prepare store for reading.
println!("Switching to Way processing. Flushing node cache..."); println!("Switching to Way processing. Flushing node cache...");
if let Err(e) = node_store.prepare_for_reading() { if let Err(e) = node_store.prepare_for_reading() {
eprintln!("Failed to prepare node store: {}", e); eprintln!("Failed to prepare node store: {}", e);
@@ -572,18 +158,14 @@ async fn main() -> Result<()> {
way_count += 1; way_count += 1;
// Store ALL way node refs for potential multipolygon use
let node_refs: Vec<i64> = way.refs().collect(); let node_refs: Vec<i64> = way.refs().collect();
way_store.insert(way.id(), node_refs.clone()); way_store.insert(way.id(), node_refs.clone());
let tags: HashMap<String, String> = way.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect(); let tags: HashMap<String, String> = way.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect();
// Filter for highways/roads OR buildings OR landuse OR water OR railways
// Filter for highways/roads OR buildings OR landuse OR water OR railways
let is_highway = tags.contains_key("highway"); let is_highway = tags.contains_key("highway");
let is_building = tags.contains_key("building"); let is_building = tags.contains_key("building");
// Split Water into Area (Polygon) and Line (Way)
let is_water_area = tags.get("natural").map(|v| v == "water" || v == "wetland" || v == "bay" || v == "strait").unwrap_or(false) || let is_water_area = tags.get("natural").map(|v| v == "water" || v == "wetland" || v == "bay" || v == "strait").unwrap_or(false) ||
tags.get("place").map(|v| v == "sea" || v == "ocean").unwrap_or(false) || tags.get("place").map(|v| v == "sea" || v == "ocean").unwrap_or(false) ||
tags.get("waterway").map(|v| v == "riverbank" || v == "dock").unwrap_or(false) || tags.get("waterway").map(|v| v == "riverbank" || v == "dock").unwrap_or(false) ||
@@ -599,7 +181,6 @@ async fn main() -> Result<()> {
if is_highway || is_building || is_water_area || is_water_line || is_landuse || is_railway { if is_highway || is_building || is_water_area || is_water_line || is_landuse || is_railway {
let mut points = Vec::new(); let mut points = Vec::new();
// Resolve nodes from store
for node_id in way.refs() { for node_id in way.refs() {
if let Some((lat, lon)) = node_store.get(node_id) { if let Some((lat, lon)) = node_store.get(node_id) {
points.push((lat, lon)); points.push((lat, lon));
@@ -608,94 +189,75 @@ async fn main() -> Result<()> {
if points.len() >= 2 { if points.len() >= 2 {
let id = way.id(); let id = way.id();
// Insert into the tile of the first point
let (first_lat, first_lon) = points[0]; let (first_lat, first_lon) = points[0];
let is_closed = points.first() == points.last(); let is_closed = points.first() == points.last();
// Detect if we should treat this as an area
let mut treat_as_water_area = is_water_area && is_closed; let mut treat_as_water_area = is_water_area && is_closed;
let mut treat_as_landuse = is_landuse && is_closed; let mut treat_as_landuse = is_landuse && is_closed;
let mut treat_as_building = is_building && is_closed; let mut treat_as_building = is_building && is_closed;
// Fallback: If water is open (e.g. riverbank segment), treat as line
let mut treat_as_water_line = is_water_line || (is_water_area && !is_closed); let mut treat_as_water_line = is_water_line || (is_water_area && !is_closed);
// If landuse/building is open, we skip it to avoid artifacts (giant triangles)
if (is_landuse || is_building) && !is_closed { if (is_landuse || is_building) && !is_closed {
return; return;
} }
for &zoom in &ZOOM_LEVELS { for &zoom in &FilteringService::ZOOM_LEVELS {
if !should_include(&tags, zoom) { continue; } if !FilteringService::should_include(&tags, zoom) { continue; }
// Apply simplification based on zoom level
let base_epsilon = match zoom { let base_epsilon = match zoom {
2 => 0.01, // Was 0.0001 (~11m) -> Now ~1km 2 => 0.01,
4 => 0.002, // Was 0.00005 (~5m) -> Now ~200m 4 => 0.002,
6 => 0.0005, // Was 0.00002 (~2m) -> Now ~50m 6 => 0.0005,
9 => 0.0001, // Was 0.00001 (~1m) -> Now ~10m 9 => 0.0001,
12 => 0.000005, 12 => 0.000005,
_ => 0.0, _ => 0.0,
}; };
let epsilon = if treat_as_water_area || treat_as_landuse || is_highway || treat_as_water_line { let epsilon = if treat_as_water_area || treat_as_landuse || is_highway || treat_as_water_line {
if zoom <= 4 && treat_as_landuse { if zoom <= 4 && treat_as_landuse {
0.0 // Disable simplification for landuse at low zoom 0.0
} else if treat_as_water_area || treat_as_landuse { } else if treat_as_water_area || treat_as_landuse {
// User requested "little more detail" if zoom >= 9 { 0.0 } else { base_epsilon * 0.01 }
// Almost disable simplification for organic shapes
if zoom >= 9 {
0.0 // No simplification at zoom 9+
} else { } else {
base_epsilon * 0.01 // 1% of standard simplification - high detail base_epsilon * 0.5
}
} else {
base_epsilon * 0.5 // Highways/Railways can handle some simplification
} }
} else { } else {
base_epsilon base_epsilon
}; };
let simplified_points = if epsilon > 0.0 { let simplified_points = if epsilon > 0.0 {
simplify_points(&points, epsilon) GeometryService::simplify_points(&points, epsilon)
} else { } else {
points.clone() points.clone()
}; };
// Serialize points
let mut final_points = simplified_points.clone(); let mut final_points = simplified_points.clone();
// For highways and railways, we DON'T triangulate - they're line data // Create blob for line features (highways/railways/water lines)
// Create the highway/railway blob BEFORE triangulation
let mut line_blob = Vec::with_capacity(simplified_points.len() * 8); let mut line_blob = Vec::with_capacity(simplified_points.len() * 8);
for (lat, lon) in &simplified_points { for (lat, lon) in &simplified_points {
line_blob.extend_from_slice(&(*lat as f32).to_le_bytes()); line_blob.extend_from_slice(&(*lat as f32).to_le_bytes());
line_blob.extend_from_slice(&(*lon as f32).to_le_bytes()); line_blob.extend_from_slice(&(*lon as f32).to_le_bytes());
} }
// Triangulate for polygon types
if treat_as_building || treat_as_water_area || treat_as_landuse { if treat_as_building || treat_as_water_area || treat_as_landuse {
// Already checked closure above final_points = GeometryService::triangulate_polygon(&final_points);
final_points = triangulate_polygon(&final_points);
} }
if final_points.len() < 3 && (treat_as_building || treat_as_water_area || treat_as_landuse) { continue; } if final_points.len() < 3 && (treat_as_building || treat_as_water_area || treat_as_landuse) { continue; }
if simplified_points.len() < 2 && (is_highway || is_railway || treat_as_water_line) { continue; } if simplified_points.len() < 2 && (is_highway || is_railway || treat_as_water_line) { continue; }
let (first_lat, first_lon) = simplified_points[0]; let (first_lat, first_lon) = simplified_points[0];
let (x, y) = lat_lon_to_tile(first_lat, first_lon, zoom); let (x, y) = TileService::lat_lon_to_tile(first_lat, first_lon, zoom);
let zoom_i32 = zoom as i32; let zoom_i32 = zoom as i32;
// Create polygon blob from triangulated points
let mut polygon_blob = Vec::with_capacity(final_points.len() * 8); let mut polygon_blob = Vec::with_capacity(final_points.len() * 8);
for (lat, lon) in &final_points { for (lat, lon) in &final_points {
polygon_blob.extend_from_slice(&(*lat as f32).to_le_bytes()); polygon_blob.extend_from_slice(&(*lat as f32).to_le_bytes());
polygon_blob.extend_from_slice(&(*lon as f32).to_le_bytes()); polygon_blob.extend_from_slice(&(*lon as f32).to_le_bytes());
} }
// Use line_blob for highways/railways/water_lines, polygon_blob for others
if is_highway || treat_as_water_line { if is_highway || treat_as_water_line {
let task = DbTask::Way { zoom: zoom_i32, table: "ways", id, tags: tags.clone(), points: line_blob.clone(), x, y }; let task = DbTask::Way { zoom: zoom_i32, table: "ways", id, tags: tags.clone(), points: line_blob.clone(), x, y };
let _ = tx.blocking_send(task); let _ = tx.blocking_send(task);
@@ -717,7 +279,6 @@ async fn main() -> Result<()> {
} }
if is_railway { if is_railway {
// Store for deferred insertion - colors will be applied from relations
let (first_lat, first_lon) = simplified_points[0]; let (first_lat, first_lon) = simplified_points[0];
railway_store.insert_way(id, tags.clone(), line_blob.clone(), first_lat, first_lon); railway_store.insert_way(id, tags.clone(), line_blob.clone(), first_lat, first_lon);
} }
@@ -734,40 +295,18 @@ async fn main() -> Result<()> {
relation_count += 1; relation_count += 1;
let tags: HashMap<String, String> = rel.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect(); let tags: HashMap<String, String> = rel.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect();
// Process route relations for transit colors if let Some(colour) = RailwayService::get_route_color(&tags) {
if tags.get("type").map(|t| t == "route").unwrap_or(false) {
let route_type = tags.get("route").map(|s| s.as_str());
let is_transit = match route_type {
Some("subway") | Some("tram") | Some("light_rail") => true,
Some("train") => {
// Only include S-Bahn and suburban trains
tags.get("network").map(|n| n.contains("S-Bahn")).unwrap_or(false) ||
tags.get("service").map(|s| s == "suburban").unwrap_or(false) ||
tags.get("ref").map(|r| r.starts_with("S")).unwrap_or(false)
},
_ => false,
};
if is_transit {
// Extract colour tag
if let Some(colour) = tags.get("colour").or(tags.get("color")) {
// Map colour to all member ways
for member in rel.members() { for member in rel.members() {
if let osmpbf::RelMemberType::Way = member.member_type { if let osmpbf::RelMemberType::Way = member.member_type {
railway_store.set_color(member.member_id, colour.clone()); railway_store.set_color(member.member_id, colour.clone());
} }
} }
} }
}
}
// Process multipolygon relations (existing code)
if tags.get("type").map(|t| t == "multipolygon").unwrap_or(false) { if tags.get("type").map(|t| t == "multipolygon").unwrap_or(false) {
// Check if it's a water or landuse multipolygon
// IMPORTANT: Rivers like the Isar are tagged waterway=river on the relation itself!
let is_water = tags.get("natural").map(|v| v == "water" || v == "wetland" || v == "bay").unwrap_or(false) || let is_water = tags.get("natural").map(|v| v == "water" || v == "wetland" || v == "bay").unwrap_or(false) ||
tags.get("waterway").map(|v| v == "riverbank" || v == "river" || v == "canal").unwrap_or(false) || tags.get("waterway").map(|v| v == "riverbank" || v == "river" || v == "canal").unwrap_or(false) ||
tags.get("water").is_some() || // Also check water=* tag tags.get("water").is_some() ||
tags.get("landuse").map(|v| v == "basin" || v == "reservoir").unwrap_or(false); tags.get("landuse").map(|v| v == "basin" || v == "reservoir").unwrap_or(false);
let is_landuse = tags.get("landuse").is_some() || let is_landuse = tags.get("landuse").is_some() ||
@@ -775,7 +314,6 @@ async fn main() -> Result<()> {
tags.get("natural").map(|v| v == "wood" || v == "scrub" || v == "heath").unwrap_or(false); tags.get("natural").map(|v| v == "wood" || v == "scrub" || v == "heath").unwrap_or(false);
if is_water || is_landuse { if is_water || is_landuse {
// Collect outer way members
let mut outer_ways: Vec<i64> = Vec::new(); let mut outer_ways: Vec<i64> = Vec::new();
for member in rel.members() { for member in rel.members() {
if member.role().unwrap_or("") == "outer" { if member.role().unwrap_or("") == "outer" {
@@ -786,11 +324,9 @@ async fn main() -> Result<()> {
} }
if !outer_ways.is_empty() { if !outer_ways.is_empty() {
// Assemble ALL rings from the outer ways (rivers have multiple rings!) let rings = MultipolygonService::assemble_rings(&outer_ways, &way_store);
let rings = assemble_rings(&outer_ways, &way_store);
for ring_node_ids in rings { for ring_node_ids in rings {
// Resolve node coordinates
let mut points: Vec<(f64, f64)> = Vec::new(); let mut points: Vec<(f64, f64)> = Vec::new();
for node_id in &ring_node_ids { for node_id in &ring_node_ids {
if let Some((lat, lon)) = node_store.get(*node_id) { if let Some((lat, lon)) = node_store.get(*node_id) {
@@ -802,17 +338,16 @@ async fn main() -> Result<()> {
let id = rel.id(); let id = rel.id();
let (first_lat, first_lon) = points[0]; let (first_lat, first_lon) = points[0];
for &zoom in &ZOOM_LEVELS { for &zoom in &FilteringService::ZOOM_LEVELS {
if !should_include(&tags, zoom) { continue; } if !FilteringService::should_include(&tags, zoom) { continue; }
// No simplification for multipolygons // No simplification for multipolygons
let final_points = triangulate_polygon(&points); let final_points = GeometryService::triangulate_polygon(&points);
if final_points.len() < 3 { continue; } if final_points.len() < 3 { continue; }
let (x, y) = lat_lon_to_tile(first_lat, first_lon, zoom); let (x, y) = TileService::lat_lon_to_tile(first_lat, first_lon, zoom);
let zoom_i32 = zoom as i32; let zoom_i32 = zoom as i32;
// Create polygon blob
let mut polygon_blob = Vec::with_capacity(final_points.len() * 8); let mut polygon_blob = Vec::with_capacity(final_points.len() * 8);
for (lat, lon) in &final_points { for (lat, lon) in &final_points {
polygon_blob.extend_from_slice(&(*lat as f32).to_le_bytes()); polygon_blob.extend_from_slice(&(*lat as f32).to_le_bytes());
@@ -837,27 +372,26 @@ async fn main() -> Result<()> {
} }
})?; })?;
// Deferred railway insertion - now with colors from route relations let (railways, colors) = railway_store.into_data();
println!("Inserting {} railway ways with colors...", railway_store.ways.len()); println!("Inserting {} railway ways with colors...", railways.len());
for (way_id, railway) in &railway_store.ways { for (id, railway) in railways {
let mut tags = railway.tags.clone(); let mut tags = railway.tags;
// Apply color from route relation if available if let Some(colour) = colors.get(&id) {
if let Some(colour) = railway_store.get_color(*way_id) {
tags.insert("colour".to_string(), colour.clone()); tags.insert("colour".to_string(), colour.clone());
} }
// Insert for all applicable zoom levels // Insert for all applicable zoom levels
for &zoom in &ZOOM_LEVELS { for &zoom in &FilteringService::ZOOM_LEVELS {
if !should_include(&tags, zoom) { continue; } if !FilteringService::should_include(&tags, zoom) { continue; }
let (x, y) = lat_lon_to_tile(railway.first_lat, railway.first_lon, zoom); let (x, y) = TileService::lat_lon_to_tile(railway.first_lat, railway.first_lon, zoom);
let zoom_i32 = zoom as i32; let zoom_i32 = zoom as i32;
let task = DbTask::Way { let task = DbTask::Way {
zoom: zoom_i32, zoom: zoom_i32,
table: "railways", table: "railways",
id: railway.id, id,
tags: tags.clone(), tags: tags.clone(),
points: railway.points.clone(), points: railway.points.clone(),
x, x,
@@ -890,12 +424,9 @@ async fn main() -> Result<()> {
for table in &tables { for table in &tables {
println!("Compacting map_data.{}...", table); println!("Compacting map_data.{}...", table);
let query = format!("ALTER TABLE map_data.{} WITH gc_grace_seconds = 0", table); let query = format!("ALTER TABLE map_data.{} WITH gc_grace_seconds = 0", table);
let _ = session.query(query, &[]).await; let _ = scylla_repo.get_session().query(query, &[]).await;
} }
// Force a flush to ensure all data is on disk before compaction
// Note: In ScyllaDB, compaction happens automatically, but we set gc_grace_seconds=0
// to allow immediate tombstone cleanup. For manual compaction, use nodetool externally.
println!("Compaction settings updated. Tombstones will be cleaned during next compaction cycle."); println!("Compaction settings updated. Tombstones will be cleaned during next compaction cycle.");
println!("For immediate compaction, run: docker exec scylla nodetool compact map_data"); println!("For immediate compaction, run: docker exec scylla nodetool compact map_data");
@@ -903,11 +434,3 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn lat_lon_to_tile(lat: f64, lon: f64, zoom: u32) -> (i32, i32) {
let n = 2.0f64.powi(zoom as i32);
let x = (lon + 180.0) / 360.0 * n;
let lat_rad = lat.to_radians();
let y = (1.0 - (lat_rad.tan() + (1.0 / lat_rad.cos())).ln() / std::f64::consts::PI) / 2.0 * n;
(x as i32, y as i32)
}

View File

@@ -0,0 +1 @@
// Parsing logic is currently in main.rs loop

View File

@@ -0,0 +1,5 @@
pub mod way_store;
pub mod railway_store;
pub mod node_store;
pub mod scylla_repository;

View File

@@ -0,0 +1,77 @@
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use memmap2::Mmap;
use anyhow::Result;
pub struct NodeStore {
writer: Option<BufWriter<File>>,
mmap: Option<Mmap>,
path: PathBuf,
last_id: i64,
}
impl NodeStore {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
let writer = BufWriter::with_capacity(10 * 1024 * 1024, file); // 10MB buffer
Ok(Self {
writer: Some(writer),
mmap: None,
path,
last_id: -1,
})
}
pub fn insert(&mut self, id: i64, lat: f64, lon: f64) -> Result<()> {
if let Some(writer) = &mut self.writer {
if id > self.last_id + 1 {
let gap = id - self.last_id - 1;
writer.seek(SeekFrom::Current(gap * 8))?;
} else if id <= self.last_id {
writer.seek(SeekFrom::Start(id as u64 * 8))?;
}
let lat_i32 = (lat * 1e7) as i32;
let lon_i32 = (lon * 1e7) as i32;
writer.write_all(&lat_i32.to_le_bytes())?;
writer.write_all(&lon_i32.to_le_bytes())?;
self.last_id = id;
}
Ok(())
}
pub fn prepare_for_reading(&mut self) -> Result<()> {
self.writer = None; // Flush and close writer
let file = File::open(&self.path)?;
let mmap = unsafe { Mmap::map(&file)? };
self.mmap = Some(mmap);
Ok(())
}
pub fn get(&self, id: i64) -> Option<(f64, f64)> {
if let Some(mmap) = &self.mmap {
let offset = id as usize * 8;
if offset + 8 <= mmap.len() {
let chunk = &mmap[offset..offset+8];
let lat_i32 = i32::from_le_bytes(chunk[0..4].try_into().unwrap());
let lon_i32 = i32::from_le_bytes(chunk[4..8].try_into().unwrap());
if lat_i32 == 0 && lon_i32 == 0 { return None; }
return Some((lat_i32 as f64 / 1e7, lon_i32 as f64 / 1e7));
}
}
None
}
}

View File

@@ -0,0 +1,41 @@
use std::collections::HashMap;
// Store railway ways for deferred insertion (after relation processing for colors)
pub struct RailwayWay {
pub id: i64,
pub tags: HashMap<String, String>,
pub points: Vec<u8>, // Serialized line blob
pub first_lat: f64,
pub first_lon: f64,
}
pub struct RailwayStore {
ways: HashMap<i64, RailwayWay>, // way_id -> railway data
way_colors: HashMap<i64, String>, // way_id -> colour from route relation
}
impl RailwayStore {
pub fn new() -> Self {
Self {
ways: HashMap::new(),
way_colors: HashMap::new(),
}
}
pub fn insert_way(&mut self, id: i64, tags: HashMap<String, String>, points: Vec<u8>, first_lat: f64, first_lon: f64) {
self.ways.insert(id, RailwayWay { id, tags, points, first_lat, first_lon });
}
pub fn set_color(&mut self, way_id: i64, color: String) {
// Only set if not already set (first route relation wins)
self.way_colors.entry(way_id).or_insert(color);
}
pub fn get_color(&self, way_id: i64) -> Option<&String> {
self.way_colors.get(&way_id)
}
pub fn into_data(self) -> (HashMap<i64, RailwayWay>, HashMap<i64, String>) {
(self.ways, self.way_colors)
}
}

View File

@@ -0,0 +1,101 @@
use scylla::{Session, SessionBuilder};
use std::sync::Arc;
use std::collections::HashMap;
use tokio::task::JoinSet;
use anyhow::Result;
pub struct ScyllaRepository {
session: Arc<Session>,
insert_node: scylla::statement::prepared_statement::PreparedStatement,
insert_ways: scylla::statement::prepared_statement::PreparedStatement,
insert_buildings: scylla::statement::prepared_statement::PreparedStatement,
insert_water: scylla::statement::prepared_statement::PreparedStatement,
insert_landuse: scylla::statement::prepared_statement::PreparedStatement,
insert_railways: scylla::statement::prepared_statement::PreparedStatement,
}
impl ScyllaRepository {
pub async fn connect(uri: &str) -> Result<Self> {
let session = loop {
match SessionBuilder::new().known_node(uri).build().await {
Ok(session) => break session,
Err(e) => {
println!("Failed to connect to ScyllaDB: {}. Retrying in 5 seconds...", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
};
let session = Arc::new(session);
// Ensure schema exists
session.query("CREATE KEYSPACE IF NOT EXISTS map_data WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }", &[]).await?;
// Create tables
session.query("CREATE TABLE IF NOT EXISTS map_data.nodes (zoom int, tile_x int, tile_y int, id bigint, lat double, lon double, tags map<text, text>, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.ways (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.buildings (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.water (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.landuse (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
session.query("CREATE TABLE IF NOT EXISTS map_data.railways (zoom int, tile_x int, tile_y int, id bigint, tags map<text, text>, points blob, PRIMARY KEY ((zoom, tile_x, tile_y), id))", &[]).await?;
// Prepare statements
let insert_node = session.prepare("INSERT INTO map_data.nodes (zoom, tile_x, tile_y, id, lat, lon, tags) VALUES (?, ?, ?, ?, ?, ?, ?)").await?;
let insert_ways = session.prepare("INSERT INTO map_data.ways (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_buildings = session.prepare("INSERT INTO map_data.buildings (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_water = session.prepare("INSERT INTO map_data.water (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_landuse = session.prepare("INSERT INTO map_data.landuse (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
let insert_railways = session.prepare("INSERT INTO map_data.railways (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
Ok(Self {
session,
insert_node,
insert_ways,
insert_buildings,
insert_water,
insert_landuse,
insert_railways,
})
}
pub async fn truncate_tables(&self) -> Result<()> {
println!("Truncating tables...");
self.session.query("TRUNCATE map_data.nodes", &[]).await?;
self.session.query("TRUNCATE map_data.ways", &[]).await?;
self.session.query("TRUNCATE map_data.buildings", &[]).await?;
self.session.query("TRUNCATE map_data.water", &[]).await?;
self.session.query("TRUNCATE map_data.landuse", &[]).await?;
self.session.query("TRUNCATE map_data.railways", &[]).await?;
println!("Tables truncated.");
Ok(())
}
pub async fn insert_node(&self, zoom: i32, id: i64, lat: f64, lon: f64, tags: HashMap<String, String>, x: i32, y: i32) -> Result<()> {
self.session.execute(
&self.insert_node,
(zoom, x, y, id, lat, lon, tags),
).await?;
Ok(())
}
pub async fn insert_way(&self, table: &str, zoom: i32, id: i64, tags: HashMap<String, String>, points: Vec<u8>, x: i32, y: i32) -> Result<()> {
let statement = match table {
"ways" => &self.insert_ways,
"buildings" => &self.insert_buildings,
"water" => &self.insert_water,
"landuse" => &self.insert_landuse,
"railways" => &self.insert_railways,
_ => panic!("Unknown table: {}", table),
};
self.session.execute(
statement,
(zoom, x, y, id, tags, points),
).await?;
Ok(())
}
pub fn get_session(&self) -> Arc<Session> {
self.session.clone()
}
}

View File

@@ -0,0 +1,20 @@
use std::collections::HashMap;
// Store way geometries for multipolygon assembly
pub struct WayStore {
ways: HashMap<i64, Vec<i64>>, // way_id -> node_id list
}
impl WayStore {
pub fn new() -> Self {
Self { ways: HashMap::new() }
}
pub fn insert(&mut self, way_id: i64, node_refs: Vec<i64>) {
self.ways.insert(way_id, node_refs);
}
pub fn get(&self, way_id: i64) -> Option<&Vec<i64>> {
self.ways.get(&way_id)
}
}

View File

@@ -0,0 +1,98 @@
use std::collections::HashMap;
pub struct FilteringService;
impl FilteringService {
pub const ZOOM_LEVELS: [u32; 6] = [2, 4, 6, 9, 12, 14];
pub fn should_include(tags: &HashMap<String, String>, zoom: u32) -> bool {
if zoom >= 14 { return true; }
let highway = tags.get("highway").map(|s| s.as_str());
let place = tags.get("place").map(|s| s.as_str());
let natural = tags.get("natural").map(|s| s.as_str());
let railway = tags.get("railway").map(|s| s.as_str());
let waterway = tags.get("waterway").map(|s| s.as_str());
match zoom {
2 => {
// Space View: Continents and Countries
matches!(place, Some("continent" | "country" | "sea" | "ocean")) ||
matches!(natural, Some("water" | "bay" | "strait")) || // Major water bodies
matches!(highway, Some("motorway")) || // Added motorway
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential")) || // Added more green + farmland/residential
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve")) || // Added parks
matches!(natural, Some("wood" | "scrub")) // Added wood/scrub
},
4 => {
// Regional View (NEW)
matches!(highway, Some("motorway" | "trunk")) ||
matches!(place, Some("city" | "town" | "sea" | "ocean")) ||
matches!(natural, Some("water" | "wood" | "scrub" | "heath" | "wetland" | "bay" | "strait")) ||
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential")) ||
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve")) ||
matches!(waterway, Some("river"))
},
6 => {
// Enterprise Grade: ONLY Motorways and Trunk roads. No primary/secondary.
// ONLY Cities. No nature/landuse.
matches!(highway, Some("motorway" | "trunk" | "primary")) || // Added primary
matches!(place, Some("city" | "sea" | "ocean")) ||
matches!(railway, Some("rail")) || // Major rail lines
matches!(natural, Some("water" | "wood" | "scrub" | "heath" | "wetland" | "bay" | "strait")) ||
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential")) ||
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve")) ||
matches!(waterway, Some("river"))
},
9 => {
// Enterprise Grade: Add Primary roads.
// Add Towns.
// Limited nature.
matches!(highway, Some("motorway" | "trunk" | "primary")) ||
matches!(place, Some("city" | "town" | "sea" | "ocean" | "island" | "islet")) || // Islands!
matches!(railway, Some("rail" | "subway" | "light_rail" | "narrow_gauge")) || // Added urban transit
matches!(natural, Some("water" | "wood" | "scrub" | "bay" | "strait" | "wetland" | "heath" | "sand" | "beach" | "shingle" | "bare_rock")) || // Sand/Beaches!
matches!(tags.get("landuse").map(|s| s.as_str()), Some("forest" | "grass" | "meadow" | "farmland" | "residential" | "basin" | "reservoir" | "allotments")) ||
matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve" | "garden")) || // Gardens
matches!(waterway, Some("river" | "riverbank" | "canal")) // Added canal
},
12 => {
matches!(highway, Some("motorway" | "trunk" | "primary" | "secondary" | "tertiary" | "residential" | "unclassified" | "pedestrian" | "service" | "track")) || // Added minor roads
matches!(place, Some("city" | "town" | "village")) ||
matches!(railway, Some("rail" | "subway" | "light_rail" | "narrow_gauge" | "tram")) || // Added tram
tags.contains_key("building") ||
tags.contains_key("landuse") ||
tags.contains_key("leisure") ||
matches!(natural, Some("water" | "wood" | "scrub" | "wetland" | "heath" | "bay" | "strait" | "sand" | "beach" | "bare_rock")) ||
matches!(waterway, Some("river" | "riverbank" | "stream" | "canal" | "drain" | "ditch")) // Added canal/drain/ditch
},
_ => false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_include_motorway_zoom_2() {
let mut tags = HashMap::new();
tags.insert("highway".to_string(), "motorway".to_string());
assert!(FilteringService::should_include(&tags, 2));
}
#[test]
fn test_should_not_include_path_zoom_2() {
let mut tags = HashMap::new();
tags.insert("highway".to_string(), "path".to_string());
assert!(!FilteringService::should_include(&tags, 2));
}
#[test]
fn test_should_include_park_zoom_12() {
let mut tags = HashMap::new();
tags.insert("leisure".to_string(), "park".to_string());
assert!(FilteringService::should_include(&tags, 12));
}
}

View File

@@ -0,0 +1,91 @@
#[derive(Clone, Copy, Debug)]
pub struct Point {
pub x: f64,
pub y: f64,
}
impl Point {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
pub struct GeometryService;
impl GeometryService {
// Ramer-Douglas-Peucker simplification
pub fn simplify_points(points: &[(f64, f64)], epsilon: f64) -> Vec<(f64, f64)> {
if points.len() < 3 {
return points.to_vec();
}
let start = points[0];
let end = points[points.len() - 1];
let mut max_dist = 0.0;
let mut index = 0;
for i in 1..points.len() - 1 {
let dist = Self::perpendicular_distance(points[i], start, end);
if dist > max_dist {
max_dist = dist;
index = i;
}
}
if max_dist > epsilon {
let mut left = Self::simplify_points(&points[..=index], epsilon);
let mut right = Self::simplify_points(&points[index..], epsilon);
// Remove duplicate point at split
left.pop();
left.extend(right);
left
} else {
vec![start, end]
}
}
fn perpendicular_distance(p: (f64, f64), line_start: (f64, f64), line_end: (f64, f64)) -> f64 {
let (x, y) = p;
let (x1, y1) = line_start;
let (x2, y2) = line_end;
let dx = x2 - x1;
let dy = y2 - y1;
if dx == 0.0 && dy == 0.0 {
return ((x - x1).powi(2) + (y - y1).powi(2)).sqrt();
}
let num = (dy * x - dx * y + x2 * y1 - y2 * x1).abs();
let den = (dx.powi(2) + dy.powi(2)).sqrt();
num / den
}
pub fn triangulate_polygon(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut flat_points = Vec::with_capacity(points.len() * 2);
for (lat, lon) in points {
flat_points.push(*lat);
flat_points.push(*lon);
}
// We assume simple polygons (no holes) for now as we are just processing ways
// Complex multipolygons with holes are handled via relation processing which naturally closes rings
// But for triangulation, earcutr handles holes if we provide hole indices.
// For basic way polygons, we assume no holes.
let triangles = earcutr::earcut(&flat_points, &[], 2).unwrap_or_default();
let mut result = Vec::with_capacity(triangles.len());
for i in triangles {
let lat = flat_points[i * 2];
let lon = flat_points[i * 2 + 1];
result.push((lat, lon));
}
result
}
}

View File

@@ -0,0 +1,6 @@
pub mod geometry_service;
pub mod multipolygon_service;
pub mod filtering_service;
pub mod tile_service;
pub mod railway_service;

View File

@@ -0,0 +1,105 @@
use crate::repositories::way_store::WayStore;
pub struct MultipolygonService;
impl MultipolygonService {
// Assemble ways into MULTIPLE rings (connect end-to-end)
// Rivers like the Isar have multiple separate channels/rings
pub fn assemble_rings(way_ids: &[i64], way_store: &WayStore) -> Vec<Vec<i64>> {
if way_ids.is_empty() { return Vec::new(); }
// Get all way geometries
let mut segments: Vec<Vec<i64>> = Vec::new();
for &way_id in way_ids {
if let Some(nodes) = way_store.get(way_id) {
if nodes.len() >= 2 {
segments.push(nodes.clone());
}
}
}
if segments.is_empty() { return Vec::new(); }
let mut completed_rings: Vec<Vec<i64>> = Vec::new();
// Keep assembling rings until we run out of segments
while !segments.is_empty() {
// Start a new ring with the first available segment
let mut ring = segments.remove(0);
// Try to extend this ring
let max_iterations = segments.len() * segments.len() + 100;
let mut iterations = 0;
loop {
iterations += 1;
if iterations > max_iterations { break; }
let mut connected = false;
for i in 0..segments.len() {
let seg = &segments[i];
if seg.is_empty() { continue; }
let ring_start = *ring.first().unwrap();
let ring_end = *ring.last().unwrap();
let seg_start = *seg.first().unwrap();
let seg_end = *seg.last().unwrap();
if ring_end == seg_start {
// Connect: ring + seg (skip first node of seg)
ring.extend(seg[1..].iter().cloned());
segments.remove(i);
connected = true;
break;
} else if ring_end == seg_end {
// Connect: ring + reversed seg
let reversed: Vec<i64> = seg.iter().rev().cloned().collect();
ring.extend(reversed[1..].iter().cloned());
segments.remove(i);
connected = true;
break;
} else if ring_start == seg_end {
// Connect: seg + ring
let mut new_ring = seg.clone();
new_ring.extend(ring[1..].iter().cloned());
ring = new_ring;
segments.remove(i);
connected = true;
break;
} else if ring_start == seg_start {
// Connect: reversed seg + ring
let mut reversed: Vec<i64> = seg.iter().rev().cloned().collect();
reversed.extend(ring[1..].iter().cloned());
ring = reversed;
segments.remove(i);
connected = true;
break;
}
}
// Check if ring is now closed
if ring.len() >= 4 && ring.first() == ring.last() {
completed_rings.push(ring);
break; // Move to next ring
}
// If no connection was made and ring isn't closed,
// we can't extend this ring anymore
if !connected {
// Still save partial rings if they have enough points
// This helps with incomplete data - at least show something
if ring.len() >= 4 {
// Force-close the ring
let first = ring[0];
ring.push(first);
completed_rings.push(ring);
}
break;
}
}
}
completed_rings
}
}

View File

@@ -0,0 +1,28 @@
use std::collections::HashMap;
pub struct RailwayService;
impl RailwayService {
// Determine if a relation is a transit route and return its color
pub fn get_route_color(tags: &HashMap<String, String>) -> Option<String> {
if tags.get("type").map(|t| t == "route").unwrap_or(false) {
let route_type = tags.get("route").map(|s| s.as_str());
let is_transit = match route_type {
Some("subway") | Some("tram") | Some("light_rail") => true,
Some("train") => {
// Only include S-Bahn and suburban trains
tags.get("network").map(|n| n.contains("S-Bahn")).unwrap_or(false) ||
tags.get("service").map(|s| s == "suburban").unwrap_or(false) ||
tags.get("ref").map(|r| r.starts_with("S")).unwrap_or(false)
},
_ => false,
};
if is_transit {
// Extract colour tag
return tags.get("colour").or(tags.get("color")).cloned();
}
}
None
}
}

View File

@@ -0,0 +1,13 @@
use std::f64::consts::PI;
pub struct TileService;
impl TileService {
pub fn lat_lon_to_tile(lat: f64, lon: f64, zoom: u32) -> (i32, i32) {
let n = 2.0_f64.powi(zoom as i32);
let x = ((lon + 180.0) / 360.0 * n).floor() as i32;
let lat_rad = lat.to_radians();
let y = ((1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / PI) / 2.0 * n).floor() as i32;
(x, y)
}
}

9
run.sh
View File

@@ -1,9 +0,0 @@
#!/bin/bash
# Load .env variables
if [ -f .env ]; then
export $(cat .env | grep -v '#' | awk '/=/ {print $1}')
fi
echo "Using PBF file: ${HOST_PBF_PATH:-./europe-latest.osm.pbf}"
docker compose --profile import up --build importer
docker compose build --no-cache