This commit is contained in:
Dongho Kim
2025-12-15 23:10:38 +09:00
parent 169997eecd
commit 45807e3a90
19 changed files with 3438 additions and 2256 deletions

View File

@@ -9,6 +9,7 @@ WORKDIR /app/frontend
# Build frontend # Build frontend
RUN wasm-pack build --target web --out-name wasm --out-dir ../backend/static RUN wasm-pack build --target web --out-name wasm --out-dir ../backend/static
RUN cp index.html ../backend/static/index.html RUN cp index.html ../backend/static/index.html
RUN cp favicon.svg ../backend/static/favicon.svg
# Build Backend # Build Backend
FROM rust:latest AS backend-builder FROM rust:latest AS backend-builder

266
build.log
View File

@@ -1,266 +0,0 @@
time="2025-11-26T11:51:45+01:00" level=warning msg="/Users/ekstrah/Desktop/git/map/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion"
#1 [internal] load local bake definitions
#1 reading from stdin 520B done
#1 DONE 0.0s
#2 [internal] load build definition from Dockerfile
#2 transferring dockerfile: 1.41kB done
#2 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 2)
#2 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 13)
#2 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 21)
#2 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 28)
#2 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 39)
#2 DONE 0.0s
#3 [internal] load metadata for docker.io/library/debian:forky-slim
#3 ...
#4 [internal] load metadata for docker.io/library/rust:latest
#4 DONE 0.6s
#3 [internal] load metadata for docker.io/library/debian:forky-slim
#3 DONE 0.6s
#5 [internal] load .dockerignore
#5 transferring context: 2B done
#5 DONE 0.0s
#6 [backend 1/5] FROM docker.io/library/debian:forky-slim@sha256:7c8d9645032d8b0e0afa9f95d2cd34f7eedd2915562f5d19bf9c20dec1bf25fc
#6 resolve docker.io/library/debian:forky-slim@sha256:7c8d9645032d8b0e0afa9f95d2cd34f7eedd2915562f5d19bf9c20dec1bf25fc done
#6 DONE 0.0s
#7 [backend 2/5] WORKDIR /app
#7 CACHED
#8 [frontend-builder 1/7] FROM docker.io/library/rust:latest@sha256:4a29b0db5c961cd530f39276ece3eb6e66925b59599324c8c19723b72a423615
#8 resolve docker.io/library/rust:latest@sha256:4a29b0db5c961cd530f39276ece3eb6e66925b59599324c8c19723b72a423615 0.0s done
#8 DONE 0.0s
#9 [internal] load build context
#9 transferring context: 746B done
#9 DONE 0.0s
#10 [frontend-builder 2/7] WORKDIR /app
#10 CACHED
#11 [backend-builder 3/6] COPY backend ./backend
#11 CACHED
#12 [frontend-builder 5/7] RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
#12 CACHED
#13 [frontend-builder 3/7] COPY frontend ./frontend
#13 CACHED
#14 [frontend-builder 4/7] COPY backend/static ./backend/static
#14 CACHED
#15 [frontend-builder 6/7] WORKDIR /app/frontend
#15 CACHED
#16 [frontend-builder 7/7] RUN wasm-pack build --target web --out-name wasm --out-dir ../backend/static
#16 5.805 [INFO]: Checking for the Wasm target...
#16 6.004 info: downloading component 'rust-std' for 'wasm32-unknown-unknown'
#16 6.830 info: installing component 'rust-std' for 'wasm32-unknown-unknown'
#16 8.136 [INFO]: Compiling to Wasm...
#16 8.381 Compiling unicode-ident v1.0.22
#16 8.381 Compiling proc-macro2 v1.0.103
#16 8.381 Compiling quote v1.0.42
#16 8.381 Compiling wasm-bindgen-shared v0.2.105
#16 8.381 Compiling rustversion v1.0.22
#16 8.383 Compiling cfg-if v1.0.4
#16 8.383 Compiling bumpalo v3.19.0
#16 8.384 Compiling stable_deref_trait v1.2.1
#16 8.384 Compiling once_cell v1.21.3
#16 8.729 Compiling cfg_aliases v0.1.1
#16 8.736 Compiling smallvec v1.15.1
#16 8.743 Compiling writeable v0.6.2
#16 8.760 Compiling autocfg v1.5.0
#16 8.779 Compiling litemap v0.8.1
#16 8.866 Compiling icu_normalizer_data v2.1.1
#16 8.895 Compiling version_check v0.9.5
#16 8.934 Compiling serde_core v1.0.228
#16 9.065 Compiling icu_properties_data v2.1.1
#16 9.134 Compiling log v0.4.28
#16 9.159 Compiling bitflags v2.10.0
#16 9.180 Compiling num-traits v0.2.19
#16 9.197 Compiling parking_lot_core v0.9.12
#16 9.251 Compiling slotmap v1.0.7
#16 9.255 Compiling thiserror v1.0.69
#16 9.437 Compiling unicode-width v0.1.14
#16 9.463 Compiling scopeguard v1.2.0
#16 9.465 Compiling hashbrown v0.16.1
#16 9.506 Compiling serde v1.0.228
#16 9.506 Compiling bit-vec v0.6.3
#16 9.562 Compiling equivalent v1.0.2
#16 9.575 Compiling termcolor v1.4.1
#16 9.627 Compiling wasm-bindgen v0.2.105
#16 9.636 Compiling lock_api v0.4.14
#16 9.675 Compiling bit-set v0.5.3
#16 9.749 Compiling codespan-reporting v0.11.1
#16 9.764 Compiling syn v2.0.111
#16 9.800 Compiling wgpu-hal v0.19.5
#16 9.819 Compiling unicode-xid v0.2.6
#16 9.947 Compiling indexmap v2.12.1
#16 9.947 Compiling rustc-hash v1.1.0
#16 9.969 Compiling hexf-parse v0.2.1
#16 9.979 Compiling itoa v1.0.15
#16 9.990 Compiling percent-encoding v2.3.2
#16 10.03 Compiling raw-window-handle v0.6.2
#16 10.05 Compiling form_urlencoded v1.2.2
#16 10.08 Compiling parking_lot v0.12.5
#16 10.13 Compiling wgpu-core v0.19.4
#16 10.16 Compiling utf8_iter v1.0.4
#16 10.17 Compiling serde_json v1.0.145
#16 10.21 Compiling profiling v1.0.17
#16 10.24 Compiling ryu v1.0.20
#16 10.25 Compiling arrayvec v0.7.6
#16 10.25 Compiling wgpu v0.19.4
#16 10.29 Compiling winit v0.29.15
#16 10.34 Compiling memchr v2.7.6
#16 10.38 Compiling futures-core v0.3.31
#16 10.43 Compiling either v1.15.0
#16 10.44 Compiling futures-task v0.3.31
#16 10.47 Compiling bytes v1.11.0
#16 10.49 Compiling fnv v1.0.7
#16 10.51 Compiling pin-utils v0.1.0
#16 10.54 Compiling pin-project-lite v0.2.16
#16 10.55 Compiling itertools v0.11.0
#16 10.56 Compiling futures-util v0.3.31
#16 10.58 Compiling cursor-icon v1.2.0
#16 10.67 Compiling static_assertions v1.1.0
#16 10.68 Compiling smol_str v0.2.2
#16 10.69 Compiling sync_wrapper v0.1.2
#16 10.70 Compiling tower-service v0.3.3
#16 10.72 Compiling base64 v0.21.7
#16 10.74 Compiling atomic-waker v1.1.2
#16 10.89 Compiling http v0.2.12
#16 11.32 Compiling earcutr v0.4.3
#16 11.81 Compiling synstructure v0.13.2
#16 11.81 Compiling wasm-bindgen-macro-support v0.2.105
#16 12.04 Compiling zerofrom-derive v0.1.6
#16 12.04 Compiling yoke-derive v0.8.1
#16 12.04 Compiling zerovec-derive v0.11.2
#16 12.04 Compiling displaydoc v0.2.5
#16 12.04 Compiling thiserror-impl v1.0.69
#16 12.04 Compiling serde_derive v1.0.228
#16 12.04 Compiling bytemuck_derive v1.10.2
#16 12.80 Compiling zerofrom v0.1.6
#16 12.80 Compiling bytemuck v1.24.0
#16 12.85 Compiling naga v0.19.2
#16 12.86 Compiling yoke v0.8.1
#16 12.94 Compiling zerovec v0.11.5
#16 12.94 Compiling zerotrie v0.2.3
#16 12.96 Compiling wasm-bindgen-macro v0.2.105
#16 13.35 Compiling tinystr v0.8.2
#16 13.35 Compiling potential_utf v0.1.4
#16 13.39 Compiling icu_collections v2.1.1
#16 13.42 Compiling icu_locale_core v2.1.1
#16 13.87 Compiling js-sys v0.3.82
#16 13.87 Compiling console_error_panic_hook v0.1.7
#16 13.88 Compiling icu_provider v2.1.1
#16 14.07 Compiling serde_urlencoded v0.7.1
#16 14.17 Compiling icu_properties v2.1.1
#16 14.17 Compiling icu_normalizer v2.1.1
#16 15.02 Compiling idna_adapter v1.2.1
#16 15.05 Compiling idna v1.1.0
#16 15.21 Compiling url v2.5.7
#16 17.02 Compiling web-sys v0.3.82
#16 17.08 Compiling wasm-bindgen-futures v0.4.55
#16 17.12 Compiling web-time v0.2.4
#16 25.07 Compiling wgpu-types v0.19.2
#16 25.07 Compiling glow v0.13.1
#16 25.07 Compiling console_log v1.0.0
#16 25.07 Compiling reqwest v0.11.27
#16 31.14 Compiling frontend v0.1.0 (/app/frontend)
#16 31.21 warning: unused variable: `railways_data`
#16 31.21 --> src/lib.rs:806:33
#16 31.21 |
#16 31.21 806 | ... let railways_data = if let Some(json) = fetch_cached(&url_railways).await {
#16 31.21 | ^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_railways_data`
#16 31.21 |
#16 31.21 = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
#16 31.21
#16 31.22 warning: value captured by `camera_uniform` is never read
#16 31.22 --> src/lib.rs:871:21
#16 31.22 |
#16 31.22 871 | camera_uniform = camera_uniform_data;
#16 31.22 | ^^^^^^^^^^^^^^
#16 31.22 |
#16 31.22 = help: did you mean to capture by reference instead?
#16 31.22 = note: `#[warn(unused_assignments)]` (part of `#[warn(unused)]`) on by default
#16 31.22
#16 31.22 warning: unused variable: `window_clone`
#16 31.22 --> src/lib.rs:461:9
#16 31.22 |
#16 31.22 461 | let window_clone = window.clone();
#16 31.22 | ^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_window_clone`
#16 31.22
#16 31.23 error[E0382]: use of moved value
#16 31.23 --> src/lib.rs:855:41
#16 31.23 |
#16 31.23 848 | ... if let Some(railways) = railways_data {
#16 31.23 | -------- value moved here
#16 31.23 ...
#16 31.23 855 | ... if let Some(railways) = railways_data {
#16 31.23 | ^^^^^^^^ value used here after move
#16 31.23 |
#16 31.23 = note: move occurs because value has type `Vec<MapWay>`, which does not implement the `Copy` trait
#16 31.23 help: borrow this binding in the pattern to avoid moving the value
#16 31.23 |
#16 31.23 848 | if let Some(ref railways) = railways_data {
#16 31.23 | +++
#16 31.23
#16 31.24 warning: variable does not need to be mutable
#16 31.24 --> src/lib.rs:521:25
#16 31.24 |
#16 31.24 521 | let mut cam = camera.lock().unwrap();
#16 31.24 | ----^^^
#16 31.24 | |
#16 31.24 | help: remove this `mut`
#16 31.24 |
#16 31.24 = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
#16 31.24
#16 31.27 For more information about this error, try `rustc --explain E0382`.
#16 31.27 warning: `frontend` (lib) generated 4 warnings
#16 31.27 error: could not compile `frontend` (lib) due to 1 previous error; 4 warnings emitted
#16 31.32 Error: Compiling your crate to WebAssembly failed
#16 31.32 Caused by: Compiling your crate to WebAssembly failed
#16 31.32 Caused by: failed to execute `cargo build`: exited with exit status: 101
#16 31.32 full command: cd "/app/frontend" && "cargo" "build" "--lib" "--release" "--target" "wasm32-unknown-unknown"
#16 ERROR: process "/bin/sh -c wasm-pack build --target web --out-name wasm --out-dir ../backend/static" did not complete successfully: exit code: 1
------
> [frontend-builder 7/7] RUN wasm-pack build --target web --out-name wasm --out-dir ../backend/static:
31.24 |
31.24 = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
31.24
31.27 For more information about this error, try `rustc --explain E0382`.
31.27 warning: `frontend` (lib) generated 4 warnings
31.27 error: could not compile `frontend` (lib) due to 1 previous error; 4 warnings emitted
31.32 Error: Compiling your crate to WebAssembly failed
31.32 Caused by: Compiling your crate to WebAssembly failed
31.32 Caused by: failed to execute `cargo build`: exited with exit status: 101
31.32 full command: cd "/app/frontend" && "cargo" "build" "--lib" "--release" "--target" "wasm32-unknown-unknown"
------
Dockerfile:10
--------------------
8 | WORKDIR /app/frontend
9 | # Build frontend
10 | >>> RUN wasm-pack build --target web --out-name wasm --out-dir ../backend/static
11 |
12 | # Build Backend
--------------------
failed to solve: process "/bin/sh -c wasm-pack build --target web --out-name wasm --out-dir ../backend/static" did not complete successfully: exit code: 1
View build details: docker-desktop://dashboard/build/default/default/ojyxf9sq9vbhjusaaq2tnqkq5

3
frontend/favicon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="500" height="500" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M 11.27 100.00 A 42.54 42.54 0 0 1 96.35 100.00 Z" fill="#FF5656" style="mix-blend-mode: multiply"></path><path d="M 100.00 7.85 A 42.47 42.47 0 0 0 100.00 92.79 Z" fill="#8CE4FF" style="mix-blend-mode: multiply"></path><path d="M 18.87 0.00 A 38.03 38.03 0 0 0 94.94 0.00 Z" fill="#FFA239" style="mix-blend-mode: multiply"></path><path d="M 0.00 23.22 A 35.33 35.33 0 0 1 0.00 93.88 Z" fill="#FEEE91" style="mix-blend-mode: multiply"></path><circle cx="76.43" cy="75.68" r="2.31" fill="#FFFFFF"></circle></svg>

After

Width:  |  Height:  |  Size: 637 B

File diff suppressed because it is too large Load Diff

52
frontend/src/camera.rs Normal file
View File

@@ -0,0 +1,52 @@
//! Camera and input state management
/// GPU-compatible camera uniform data
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CameraUniform {
/// x: scale_x, y: scale_y, z: translate_x, w: translate_y
pub params: [f32; 4],
/// x: is_dark (1.0 for dark, 0.0 for light), y,z,w: padding
pub theme: [f32; 4],
}
/// Camera state for 2D map view
pub struct Camera {
pub x: f32,
pub y: f32,
pub zoom: f32,
pub aspect: f32,
}
impl Camera {
pub fn to_uniform(&self, is_dark: bool) -> CameraUniform {
// Simple 2D orthographic projection-like transform
// We want to map world coordinates to clip space [-1, 1]
// zoom controls how much of the world we see.
// aspect ratio correction is needed for non-square windows.
// Scale:
// If zoom is 1.0, we see 2.0 units of world height (from -1 to 1).
// scale_y = zoom
// scale_x = zoom / aspect
CameraUniform {
params: [
self.zoom / self.aspect, // scale_x
-self.zoom, // scale_y (flipped for North-Up)
-self.x * (self.zoom / self.aspect), // translate_x
self.y * self.zoom, // translate_y (flipped sign)
],
theme: [
if is_dark { 1.0 } else { 0.0 },
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)>,
}

60
frontend/src/geo.rs Normal file
View File

@@ -0,0 +1,60 @@
//! Geographic projection and location filtering utilities
/// Web Mercator Projection
/// Returns (x, y) in range [0.0, 1.0] for the whole world
pub fn project(lat: f64, lon: f64) -> (f32, f32) {
let x = (lon + 180.0) / 360.0;
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;
// Validate results - clamp to valid range and handle NaN/Infinity
let x = if x.is_finite() { (x as f32).clamp(0.0, 1.0) } else { 0.5 };
let y = if y.is_finite() { (y as f32).clamp(0.0, 1.0) } else { 0.5 };
(x, y)
}
/// Kalman filter for smoothing GPS location updates
#[derive(Debug, Clone)]
pub struct KalmanFilter {
pub lat: f64,
pub lon: f64,
pub variance: f64,
pub timestamp: f64,
}
impl KalmanFilter {
pub fn new(lat: f64, lon: f64, timestamp: f64) -> Self {
Self {
lat,
lon,
variance: 0.0,
timestamp,
}
}
pub fn process(&mut self, lat: f64, lon: f64, accuracy: f64, timestamp: f64) -> (f64, f64) {
if accuracy <= 0.0 { return (self.lat, self.lon); }
let dt = timestamp - self.timestamp;
if dt < 0.0 { return (self.lat, self.lon); }
// Process noise variance (meters per second)
let q_metres_per_sec = 3.0;
let variance_process = q_metres_per_sec * q_metres_per_sec * dt / 1000.0;
// Prediction step
let variance = self.variance + variance_process;
// Update step
let measurement_variance = accuracy * accuracy;
let k = variance / (variance + measurement_variance);
self.lat = self.lat + k * (lat - self.lat);
self.lon = self.lon + k * (lon - self.lon);
self.variance = (1.0 - k) * variance;
self.timestamp = timestamp;
(self.lat, self.lon)
}
}

360
frontend/src/labels.rs Normal file
View File

@@ -0,0 +1,360 @@
//! Label rendering for map features
use wasm_bindgen::JsCast;
use crate::camera::Camera;
use crate::state::AppState;
use crate::geo::project;
use crate::tiles::get_visible_tiles;
/// A candidate label for rendering
pub struct LabelCandidate {
pub name: String,
pub x: f64,
pub y: f64,
pub priority: i32,
#[allow(dead_code)]
pub is_country: bool,
pub rotation: f64,
pub label_type: LabelType,
pub category: String,
}
/// Type of label for styling
#[derive(Clone, Copy, PartialEq)]
pub enum LabelType {
Country,
City,
Street,
Poi,
}
/// Update DOM labels based on current camera and state
pub fn update_labels(
window: &web_sys::Window,
camera: &Camera,
state: &AppState,
width: f64,
height: f64,
_scale_factor: f64,
) {
let document = window.document().unwrap();
let container = document.get_element_by_id("labels").unwrap();
// Clear existing labels
container.set_inner_html("");
let visible_tiles = 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 uniforms = camera.to_uniform(is_dark);
let mut candidates: Vec<LabelCandidate> = Vec::new();
let zoom = camera.zoom;
for tile in &visible_tiles {
if let Some(nodes) = state.nodes.get(&tile) {
for node in nodes {
let place: Option<&str> = node.tags.get("place").map(|s| s.as_str());
let name: Option<&str> = node.tags.get("name").map(|s| s.as_str());
if let (Some(place), Some(name)) = (place, name) {
// 1. Zoom Level Filtering
let should_show = match place {
"continent" | "country" => true,
"city" => zoom > 20.0,
"town" => zoom > 500.0,
"village" | "hamlet" => zoom > 2000.0,
"suburb" => zoom > 5000.0,
_ => false,
};
if !should_show { continue; }
// 2. Priority Calculation
let mut priority: i32 = match place {
"continent" => 1000,
"country" => 100,
"city" => 80,
"town" => 60,
"village" => 40,
"hamlet" => 20,
_ => 0,
};
// Capital bonus
if let Some(capital) = node.tags.get("capital") {
if capital == "yes" {
priority += 10;
}
}
// Population bonus (logarithmic)
if let Some(pop_str) = node.tags.get("population") {
if let Ok(pop) = pop_str.parse::<f64>() {
priority += (pop.log10() * 2.0) as i32;
}
}
// 3. Projection & Screen Coordinates
let (x, y) = project(node.lat, node.lon);
let cx = x * uniforms.params[0] + uniforms.params[2];
let cy = y * uniforms.params[1] + uniforms.params[3];
// Clip check (NDC)
if cx < -1.2 || cx > 1.2 || cy < -1.2 || cy > 1.2 { continue; }
// Direct NDC to CSS Pixel mapping
let client_width = window.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(width);
let client_height = window.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(height);
let css_x = (cx as f64 + 1.0) * 0.5 * client_width;
let css_y = (1.0 - cy as f64) * 0.5 * client_height;
let name_string: String = name.to_string();
let label_type = if place == "country" || place == "continent" {
LabelType::Country
} else {
LabelType::City
};
candidates.push(LabelCandidate {
name: name_string,
x: css_x,
y: css_y,
priority,
is_country: place == "country" || place == "continent",
rotation: 0.0,
label_type,
category: "place".to_string(),
});
}
// POI Labels (amenity, leisure, tourism)
let amenity: Option<&str> = node.tags.get("amenity").map(|s| s.as_str());
let leisure: Option<&str> = node.tags.get("leisure").map(|s| s.as_str());
let tourism: Option<&str> = node.tags.get("tourism").map(|s| s.as_str());
let poi_name: Option<&str> = node.tags.get("name").map(|s| s.as_str());
if let Some(poi_name) = poi_name {
if poi_name.is_empty() { continue; }
// Determine POI type and set zoom threshold
let (min_zoom, priority) = if let Some(amenity_type) = amenity {
match amenity_type {
"hospital" => (500.0, 45),
"university" | "college" => (800.0, 40),
"school" => (2000.0, 25),
"pharmacy" | "doctors" => (3000.0, 20),
"restaurant" | "cafe" => (5000.0, 15),
"fuel" | "parking" => (4000.0, 18),
"bank" | "atm" => (4000.0, 17),
_ => (6000.0, 10),
}
} else if let Some(leisure_type) = leisure {
match leisure_type {
"park" | "garden" => (800.0, 38),
"sports_centre" | "stadium" => (1500.0, 32),
"playground" => (4000.0, 15),
_ => (5000.0, 12),
}
} else if let Some(tourism_type) = tourism {
match tourism_type {
"attraction" | "museum" => (500.0, 42),
"hotel" => (2000.0, 28),
"viewpoint" => (1500.0, 30),
_ => (3000.0, 20),
}
} else {
continue;
};
// Zoom filter
if zoom < min_zoom { continue; }
// Project coordinates
let (x, y) = project(node.lat, node.lon);
let cx = x * uniforms.params[0] + uniforms.params[2];
let cy = y * uniforms.params[1] + uniforms.params[3];
if cx < -1.2 || cx > 1.2 || cy < -1.2 || cy > 1.2 { continue; }
let client_width = window.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(width);
let client_height = window.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(height);
let css_x = (cx as f64 + 1.0) * 0.5 * client_width;
let css_y = (1.0 - cy as f64) * 0.5 * client_height;
candidates.push(LabelCandidate {
name: poi_name.to_string(),
x: css_x,
y: css_y,
priority,
is_country: false,
rotation: 0.0,
label_type: LabelType::Poi,
category: if let Some(t) = amenity { t.to_string() }
else if let Some(t) = leisure { t.to_string() }
else if let Some(t) = tourism { t.to_string() }
else { "generic".to_string() },
});
}
}
}
}
// Process ways for street labels
let client_width = window.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(width);
let client_height = window.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(height);
for tile in &visible_tiles {
if let Some(ways) = state.ways.get(tile) {
for way in ways {
// Check if road has a name
let name: Option<&str> = way.tags.get("name").map(|s| s.as_str());
let highway: Option<&str> = way.tags.get("highway").map(|s| s.as_str());
if let (Some(name), Some(highway_type)) = (name, highway) {
// Skip unnamed or minor roads
if name.is_empty() { continue; }
// Zoom filtering
let min_zoom = match highway_type {
"motorway" | "trunk" => 200.0,
"primary" => 500.0,
"secondary" => 1500.0,
"tertiary" => 3000.0,
"residential" | "unclassified" => 6000.0,
_ => 10000.0,
};
if zoom < min_zoom { continue; }
// Priority based on road type
let priority: i32 = match highway_type {
"motorway" | "trunk" => 50,
"primary" => 40,
"secondary" => 30,
"tertiary" => 20,
_ => 10,
};
// Parse road points to find midpoint and angle
let points = &way.points;
if points.len() < 16 { continue; }
let mut parsed_points: Vec<[f64; 2]> = Vec::new();
for chunk in points.chunks(8) {
if chunk.len() < 8 { break; }
let lat = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0u8; 4])) as f64;
let lon = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0u8; 4])) as f64;
parsed_points.push([lat, lon]);
}
if parsed_points.len() < 2 { continue; }
// Calculate midpoint
let mid_idx = parsed_points.len() / 2;
let mid_point = parsed_points[mid_idx];
// Calculate angle from road direction
let p1 = if mid_idx > 0 { parsed_points[mid_idx - 1] } else { parsed_points[0] };
let p2 = if mid_idx + 1 < parsed_points.len() { parsed_points[mid_idx + 1] } else { parsed_points[mid_idx] };
let (x1, y1) = project(p1[0], p1[1]);
let (x2, y2) = project(p2[0], p2[1]);
let dx = x2 - x1;
let dy = -(y2 - y1);
let mut angle_deg = (dy as f64).atan2(dx as f64).to_degrees();
// Keep text readable
if angle_deg > 90.0 { angle_deg -= 180.0; }
if angle_deg < -90.0 { angle_deg += 180.0; }
// Project midpoint to screen
let (mx, my) = project(mid_point[0], mid_point[1]);
let cx = mx * uniforms.params[0] + uniforms.params[2];
let cy = my * uniforms.params[1] + uniforms.params[3];
// Clip check
if cx < -1.5 || cx > 1.5 || cy < -1.5 || cy > 1.5 { continue; }
let css_x = (cx as f64 + 1.0) * 0.5 * client_width;
let css_y = (1.0 - cy as f64) * 0.5 * client_height;
candidates.push(LabelCandidate {
name: name.to_string(),
x: css_x,
y: css_y,
priority,
is_country: false,
rotation: angle_deg,
label_type: LabelType::Street,
category: "street".to_string(),
});
}
}
}
}
// 4. Sort by Priority (High to Low)
candidates.sort_by(|a, b| b.priority.cmp(&a.priority));
// 5. Collision Detection & Placement
let mut placed_rects: Vec<(f64, f64, f64, f64)> = Vec::new();
for candidate in candidates {
// Estimate dimensions based on label type
let (est_w, est_h) = match candidate.label_type {
LabelType::Country => (candidate.name.len() as f64 * 12.0 + 20.0, 24.0),
LabelType::City => (candidate.name.len() as f64 * 8.0 + 10.0, 16.0),
LabelType::Street => (candidate.name.len() as f64 * 6.0 + 8.0, 12.0),
LabelType::Poi => (candidate.name.len() as f64 * 6.5 + 10.0, 14.0),
};
// Centered label
let rect_x = candidate.x - est_w / 2.0;
let rect_y = candidate.y - est_h / 2.0;
// Check collision
let mut collision = false;
for (px, py, pw, ph) in &placed_rects {
let padding = if candidate.label_type == LabelType::Street { 12.0 } else { 20.0 };
if rect_x < px + pw + padding &&
rect_x + est_w + padding > *px &&
rect_y < py + ph + padding &&
rect_y + est_h + padding > *py {
collision = true;
break;
}
}
if !collision {
placed_rects.push((rect_x, rect_y, est_w, est_h));
let div = document.create_element("div").unwrap();
let class_name = match candidate.label_type {
LabelType::Country => "label label-country".to_string(),
LabelType::City => "label label-city".to_string(),
LabelType::Street => "label label-street".to_string(),
LabelType::Poi => format!("label label-poi label-poi-{}", candidate.category),
};
div.set_class_name(&class_name);
div.set_text_content(Some(&candidate.name));
let div_html: web_sys::HtmlElement = div.dyn_into().unwrap();
let style = div_html.style();
style.set_property("left", &format!("{}px", candidate.x)).unwrap();
style.set_property("top", &format!("{}px", candidate.y)).unwrap();
let transform = match candidate.label_type {
LabelType::Poi => "translate(-50%, 10px)",
LabelType::Street if candidate.rotation.abs() > 0.5 => &format!("translate(-50%, -50%) rotate({}deg)", candidate.rotation),
_ => "translate(-50%, -50%)"
};
style.set_property("transform", transform).unwrap();
container.append_child(&div_html).unwrap();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
//! Building render pipeline
use super::common::Vertex;
pub fn create_building_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(
model: VertexInput,
) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
// Globe Effect: Spherize
// let r2 = x*x + y*y;
// let w = 1.0 + r2 * 0.5;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Buildings: Light: #d9d9d9 (0.85), Dark: #333333 (0.2)
let is_dark = camera.theme.x;
let color = mix(vec3<f32>(0.85, 0.85, 0.85), vec3<f32>(0.2, 0.2, 0.2), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Building Pipeline Layout"),
bind_group_layouts: &[bind_group_layout],
push_constant_ranges: &[],
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[
Vertex::desc(),
],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: *format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
})
}

View File

@@ -0,0 +1,71 @@
//! Common pipeline utilities and vertex types
/// GPU vertex with 2D position
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vertex {
pub position: [f32; 2],
}
impl Vertex {
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
}
]
}
}
}
/// Create a simple render pipeline with standard configuration
pub fn create_simple_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout,
shader: &wgpu::ShaderModule,
label: &str,
topology: wgpu::PrimitiveTopology,
) -> wgpu::RenderPipeline {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[bind_group_layout],
push_constant_ranges: &[],
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: "vs_main",
buffers: &[Vertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: *format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
})
}

View File

@@ -0,0 +1,126 @@
//! Landuse render pipelines (green, residential, sand)
use super::common::create_simple_pipeline;
pub fn create_landuse_green_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Green: Light #cdebb0, Dark #2d4a2d
let color = mix(vec3<f32>(0.80, 0.92, 0.69), vec3<f32>(0.18, 0.29, 0.18), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Green Landuse Pipeline", wgpu::PrimitiveTopology::TriangleList)
}
pub fn create_landuse_residential_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Light: #e0dfdf, Dark: #1a1a1a (very dark grey for residential areas)
let color = mix(vec3<f32>(0.88, 0.87, 0.87), vec3<f32>(0.1, 0.1, 0.1), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Residential Landuse Pipeline", wgpu::PrimitiveTopology::TriangleList)
}
pub fn create_sand_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// 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);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Sand Pipeline", wgpu::PrimitiveTopology::TriangleList)
}

View File

@@ -0,0 +1,21 @@
//! Render pipeline modules for different map features
pub mod common;
pub mod building;
pub mod water;
pub mod roads;
pub mod landuse;
pub mod railway;
pub use common::{Vertex, create_simple_pipeline};
pub use building::create_building_pipeline;
pub use water::{create_water_pipeline, create_water_line_pipeline};
pub use roads::{
create_road_motorway_pipeline,
create_road_primary_pipeline,
create_road_secondary_pipeline,
create_road_residential_pipeline,
create_road_mesh,
};
pub use landuse::{create_landuse_green_pipeline, create_landuse_residential_pipeline, create_sand_pipeline};
pub use railway::create_railway_pipeline;

View File

@@ -0,0 +1,95 @@
//! Railway render pipeline
use super::common::Vertex;
pub fn create_railway_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(
model: VertexInput,
) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
// Globe Effect: Spherize
// let r2 = x*x + y*y;
// let w = 1.0 + r2 * 0.5;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Light: #808080 (grey), Dark: #5a5a5a (darker grey)
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);
}
"#)),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Railway Pipeline Layout"),
bind_group_layouts: &[bind_group_layout],
push_constant_ranges: &[],
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[
Vertex::desc(),
],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: *format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
})
}

View File

@@ -0,0 +1,214 @@
//! Road render pipelines (motorway, primary, secondary, residential)
use super::common::{Vertex, create_simple_pipeline};
use crate::geo::project;
/// Create road mesh geometry (thick lines as triangles)
#[allow(dead_code)]
pub fn create_road_mesh(points: &[[f64; 2]], width: f32) -> Vec<Vertex> {
let mut vertices = Vec::new();
if points.len() < 2 { return vertices; }
for i in 0..points.len() - 1 {
let p1 = points[i];
let p2 = points[i+1];
// Convert to projected coordinates (0..1)
let (x1, y1) = project(p1[0], p1[1]);
let (x2, y2) = project(p2[0], p2[1]);
let dx = x2 - x1;
let dy = y2 - y1;
let len = (dx * dx + dy * dy).sqrt();
// Skip invalid segments:
// 1. Very short segments that would create degenerate geometry
// 2. Segments where width > length (creates giant rectangles instead of roads)
if len < 0.00001 || len < (width * 2.0) as f32 { continue; }
// Normal vector scaled by width
let nx = -dy / len * width;
let ny = dx / len * width;
// 4 corners
let v1 = Vertex { position: [x1 + nx, y1 + ny] };
let v2 = Vertex { position: [x1 - nx, y1 - ny] };
let v3 = Vertex { position: [x2 + nx, y2 + ny] };
let v4 = Vertex { position: [x2 - nx, y2 - ny] };
// Triangle 1
vertices.push(v1);
vertices.push(v2);
vertices.push(v3);
// Triangle 2
vertices.push(v2);
vertices.push(v4);
vertices.push(v3);
}
vertices
}
pub fn create_road_motorway_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Light: #e990a0, Dark: #d97080 (slightly darker/richer)
let color = mix(vec3<f32>(0.91, 0.56, 0.63), vec3<f32>(0.85, 0.44, 0.50), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Motorway Pipeline", wgpu::PrimitiveTopology::LineList)
}
pub fn create_road_primary_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Light: #fdbf6f, Dark: #e09f3f
let color = mix(vec3<f32>(0.99, 0.75, 0.44), vec3<f32>(0.88, 0.62, 0.25), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Primary Road Pipeline", wgpu::PrimitiveTopology::LineList)
}
pub fn create_road_secondary_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Light: White, Dark: #444444
let color = mix(vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(0.27, 0.27, 0.27), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Secondary Road Pipeline", wgpu::PrimitiveTopology::LineList)
}
pub fn create_road_residential_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Light: White, Dark: #333333
let color = mix(vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(0.2, 0.2, 0.2), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Residential Road Pipeline", wgpu::PrimitiveTopology::LineList)
}

View File

@@ -0,0 +1,146 @@
//! Water render pipelines
use super::common::{Vertex, create_simple_pipeline};
pub fn create_water_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(
model: VertexInput,
) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
// Globe Effect: Spherize
// let r2 = x*x + y*y;
// let w = 1.0 + r2 * 0.5;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Water: Light: #9ecaff, Dark: #1a2639
let is_dark = camera.theme.x;
let color = mix(vec3<f32>(0.62, 0.79, 1.0), vec3<f32>(0.1, 0.15, 0.22), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Water Pipeline Layout"),
bind_group_layouts: &[bind_group_layout],
push_constant_ranges: &[],
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Water Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
}
],
}
],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: *format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
})
}
pub fn create_water_line_pipeline(
device: &wgpu::Device,
format: &wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#"
struct CameraUniform {
params: vec4<f32>,
theme: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
};
@vertex
fn vs_main(model: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.position;
let x = world_pos.x * camera.params.x + camera.params.z;
let y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// Light: #a5bfdd (same/similar to water), Dark: #4a6fa5
let color = mix(vec3<f32>(0.66, 0.82, 0.96), vec3<f32>(0.29, 0.44, 0.65), is_dark);
return vec4<f32>(color, 1.0);
}
"#)),
});
create_simple_pipeline(device, format, bind_group_layout, &shader, "Water Line Pipeline", wgpu::PrimitiveTopology::LineList)
}

48
frontend/src/state.rs Normal file
View File

@@ -0,0 +1,48 @@
//! Application state management
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::types::{MapNode, MapWay, TileBuffers};
use crate::geo::KalmanFilter;
/// Global application state
pub struct AppState {
pub nodes: HashMap<(i32, i32, i32), Vec<MapNode>>,
pub ways: HashMap<(i32, i32, i32), Vec<MapWay>>,
pub buildings: HashMap<(i32, i32, i32), Vec<MapWay>>,
pub landuse: HashMap<(i32, i32, i32), Vec<MapWay>>,
pub water: HashMap<(i32, i32, i32), Vec<MapWay>>,
pub railways: HashMap<(i32, i32, i32), Vec<MapWay>>,
pub buffers: HashMap<(i32, i32, i32), Arc<TileBuffers>>,
pub loaded_tiles: HashSet<(i32, i32, i32)>,
pub pending_tiles: HashSet<(i32, i32, i32)>,
pub user_location: Option<(f64, f64)>,
pub kalman_filter: Option<KalmanFilter>,
pub watch_id: Option<i32>,
}
impl AppState {
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
ways: HashMap::new(),
buildings: HashMap::new(),
landuse: HashMap::new(),
water: HashMap::new(),
railways: HashMap::new(),
buffers: HashMap::new(),
loaded_tiles: HashSet::new(),
pending_tiles: HashSet::new(),
user_location: None,
kalman_filter: None,
watch_id: None,
}
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}

102
frontend/src/tiles.rs Normal file
View File

@@ -0,0 +1,102 @@
//! 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))
}

68
frontend/src/types.rs Normal file
View File

@@ -0,0 +1,68 @@
//! Data types for map features and tile data
use serde::Deserialize;
use std::collections::HashMap;
/// A map node (point feature with tags)
#[allow(dead_code)]
#[derive(Deserialize, Debug, Clone)]
pub struct MapNode {
pub id: i64,
pub lat: f64,
pub lon: f64,
pub tags: HashMap<String, String>,
}
/// A map way (line/polygon feature with tags and geometry)
#[allow(dead_code)]
#[derive(Deserialize, Debug, Clone)]
pub struct MapWay {
pub id: i64,
pub tags: HashMap<String, String>,
pub points: Vec<u8>,
}
/// Combined tile data from the backend
#[allow(dead_code)]
#[derive(Deserialize, Debug, Clone)]
pub struct TileData {
pub nodes: Vec<MapNode>,
pub ways: Vec<MapWay>,
pub buildings: Vec<MapWay>,
pub landuse: Vec<MapWay>,
pub water: Vec<MapWay>,
pub railways: Vec<MapWay>,
}
/// GPU buffers for a single tile's geometry
#[allow(dead_code)]
pub struct TileBuffers {
// Road Buffers
pub road_motorway_vertex_buffer: wgpu::Buffer,
pub road_motorway_vertex_count: u32,
pub road_primary_vertex_buffer: wgpu::Buffer,
pub road_primary_vertex_count: u32,
pub road_secondary_vertex_buffer: wgpu::Buffer,
pub road_secondary_vertex_count: u32,
pub road_residential_vertex_buffer: wgpu::Buffer,
pub road_residential_vertex_count: u32,
pub building_vertex_buffer: wgpu::Buffer,
pub building_index_count: u32,
// Landuse Buffers
pub landuse_green_vertex_buffer: wgpu::Buffer,
pub landuse_green_index_count: u32,
pub landuse_residential_vertex_buffer: wgpu::Buffer,
pub landuse_residential_index_count: u32,
pub landuse_sand_vertex_buffer: wgpu::Buffer,
pub landuse_sand_index_count: u32,
pub water_vertex_buffer: wgpu::Buffer,
pub water_index_count: u32,
pub railway_vertex_buffer: wgpu::Buffer,
pub railway_vertex_count: u32,
pub water_line_vertex_buffer: wgpu::Buffer,
pub water_line_vertex_count: u32,
}

View File

@@ -11,6 +11,125 @@ use memmap2::Mmap;
const ZOOM_LEVELS: [u32; 6] = [2, 4, 6, 9, 12, 14]; const ZOOM_LEVELS: [u32; 6] = [2, 4, 6, 9, 12, 14];
// Store way geometries for multipolygon assembly
struct WayStore {
ways: HashMap<i64, Vec<i64>>, // way_id -> node_id list
}
impl WayStore {
fn new() -> Self {
Self { ways: HashMap::new() }
}
fn insert(&mut self, way_id: i64, node_refs: Vec<i64>) {
self.ways.insert(way_id, node_refs);
}
fn get(&self, way_id: i64) -> Option<&Vec<i64>> {
self.ways.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 { struct NodeStore {
writer: Option<BufWriter<File>>, writer: Option<BufWriter<File>>,
mmap: Option<Mmap>, mmap: Option<Mmap>,
@@ -196,22 +315,22 @@ fn should_include(tags: &HashMap<String, String>, zoom: u32) -> bool {
// Add Towns. // Add Towns.
// Limited nature. // Limited nature.
matches!(highway, Some("motorway" | "trunk" | "primary")) || matches!(highway, Some("motorway" | "trunk" | "primary")) ||
matches!(place, Some("city" | "town" | "sea" | "ocean")) || matches!(place, Some("city" | "town" | "sea" | "ocean" | "island" | "islet")) || // Islands!
matches!(railway, Some("rail")) || matches!(railway, Some("rail")) ||
matches!(natural, Some("water" | "wood" | "bay" | "strait")) || 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")) || 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")) || matches!(tags.get("leisure").map(|s| s.as_str()), Some("park" | "nature_reserve" | "garden")) || // Gardens
matches!(waterway, Some("river" | "riverbank")) matches!(waterway, Some("river" | "riverbank" | "canal")) // Added canal
}, },
12 => { 12 => {
matches!(highway, Some("motorway" | "trunk" | "primary" | "secondary" | "tertiary")) || matches!(highway, Some("motorway" | "trunk" | "primary" | "secondary" | "tertiary" | "residential" | "unclassified" | "pedestrian" | "service" | "track")) || // Added minor roads
matches!(place, Some("city" | "town" | "village")) || matches!(place, Some("city" | "town" | "village")) ||
matches!(railway, Some("rail")) || matches!(railway, Some("rail")) ||
tags.contains_key("building") || tags.contains_key("building") ||
tags.contains_key("landuse") || tags.contains_key("landuse") ||
tags.contains_key("leisure") || tags.contains_key("leisure") ||
matches!(natural, Some("water" | "wood" | "scrub" | "wetland" | "heath" | "bay" | "strait")) || matches!(natural, Some("water" | "wood" | "scrub" | "wetland" | "heath" | "bay" | "strait" | "sand" | "beach" | "bare_rock")) ||
matches!(waterway, Some("river" | "riverbank" | "stream")) matches!(waterway, Some("river" | "riverbank" | "stream" | "canal" | "drain" | "ditch")) // Added canal/drain/ditch
}, },
_ => false _ => false
} }
@@ -249,6 +368,15 @@ async fn main() -> Result<()> {
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?; 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 // 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..."); 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_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_ways = session.prepare("INSERT INTO map_data.ways (zoom, tile_x, tile_y, id, tags, points) VALUES (?, ?, ?, ?, ?, ?)").await?;
@@ -337,11 +465,16 @@ async fn main() -> Result<()> {
// Run the PBF reader in a blocking task to allow blocking_send // Run the PBF reader in a blocking task to allow blocking_send
let tx_clone = tx.clone(); let tx_clone = tx.clone();
let reader_handle = tokio::task::spawn_blocking(move || -> Result<(usize, usize)> { let reader_handle = tokio::task::spawn_blocking(move || -> Result<(usize, usize, usize)> {
let tx = tx_clone; let tx = tx_clone;
let mut node_count = 0; let mut node_count = 0;
let mut way_count = 0; let mut way_count = 0;
let mut relation_count = 0;
let mut ways_pending = false; let mut ways_pending = false;
let mut relations_pending = false;
// Store way geometries for multipolygon assembly
let mut way_store = WayStore::new();
// We process sequentially: Nodes first, then Ways. // We process sequentially: Nodes first, then Ways.
// osmpbf yields nodes then ways. // osmpbf yields nodes then ways.
@@ -399,21 +532,32 @@ 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();
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 // 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");
let is_water = tags.get("natural").map(|v| v == "water" || v == "wetland" || v == "bay" || v == "strait").unwrap_or(false) ||
// 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) ||
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 == "stream" || v == "river").unwrap_or(false) || tags.get("waterway").map(|v| v == "riverbank" || v == "dock").unwrap_or(false) ||
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_water_line = tags.get("waterway").map(|v| v == "stream" || v == "river" || v == "canal" || v == "drain" || v == "ditch").unwrap_or(false);
let is_landuse = tags.contains_key("leisure") || let is_landuse = tags.contains_key("leisure") ||
tags.contains_key("landuse") || tags.contains_key("landuse") ||
tags.get("natural").map(|v| v == "wood" || v == "scrub" || v == "heath" || v == "wetland").unwrap_or(false); tags.get("natural").map(|v| v == "wood" || v == "scrub" || v == "heath" || v == "wetland").unwrap_or(false);
let is_railway = tags.contains_key("railway"); let is_railway = tags.contains_key("railway");
if is_highway || is_building || is_water || 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 // Resolve nodes from store
@@ -426,9 +570,24 @@ 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 // 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();
// Detect if we should treat this as an area
let mut treat_as_water_area = is_water_area && is_closed;
let mut treat_as_landuse = is_landuse && 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);
// If landuse/building is open, we skip it to avoid artifacts (giant triangles)
if (is_landuse || is_building) && !is_closed {
return;
}
for &zoom in &ZOOM_LEVELS { for &zoom in &ZOOM_LEVELS {
if !should_include(&tags, zoom) { continue; } if !should_include(&tags, zoom) { continue; }
@@ -442,11 +601,19 @@ async fn main() -> Result<()> {
_ => 0.0, _ => 0.0,
}; };
let epsilon = if is_water || is_landuse || is_highway { let epsilon = if treat_as_water_area || treat_as_landuse || is_highway || treat_as_water_line {
if zoom <= 4 && is_landuse { if zoom <= 4 && treat_as_landuse {
0.0 // Disable simplification for landuse at low zoom to prevent disappearing polygons 0.0 // Disable simplification for landuse at low zoom
} else if treat_as_water_area || treat_as_landuse {
// User requested "little more detail"
// Almost disable simplification for organic shapes
if zoom >= 9 {
0.0 // No simplification at zoom 9+
} else {
base_epsilon * 0.01 // 1% of standard simplification - high detail
}
} else { } else {
base_epsilon * 0.5 // Preserve more detail for natural features AND roads base_epsilon * 0.5 // Highways/Railways can handle some simplification
} }
} else { } else {
base_epsilon base_epsilon
@@ -469,17 +636,14 @@ async fn main() -> Result<()> {
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 (buildings, water, landuse) // Triangulate for polygon types
if is_building || is_water || is_landuse { if treat_as_building || treat_as_water_area || treat_as_landuse {
// Close the loop if not closed // Already checked closure above
if final_points.first() != final_points.last() {
final_points.push(final_points[0]);
}
final_points = triangulate_polygon(&final_points); final_points = triangulate_polygon(&final_points);
} }
if final_points.len() < 3 && (is_building || is_water || is_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) { 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) = lat_lon_to_tile(first_lat, first_lon, zoom);
@@ -492,23 +656,23 @@ async fn main() -> Result<()> {
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, polygon_blob for others // Use line_blob for highways/railways/water_lines, polygon_blob for others
if is_highway { 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);
} }
if is_building { if treat_as_building {
let task = DbTask::Way { zoom: zoom_i32, table: "buildings", id, tags: tags.clone(), points: polygon_blob.clone(), x, y }; let task = DbTask::Way { zoom: zoom_i32, table: "buildings", id, tags: tags.clone(), points: polygon_blob.clone(), x, y };
let _ = tx.blocking_send(task); let _ = tx.blocking_send(task);
} }
if is_water { if treat_as_water_area {
let task = DbTask::Way { zoom: zoom_i32, table: "water", id, tags: tags.clone(), points: polygon_blob.clone(), x, y }; let task = DbTask::Way { zoom: zoom_i32, table: "water", id, tags: tags.clone(), points: polygon_blob.clone(), x, y };
let _ = tx.blocking_send(task); let _ = tx.blocking_send(task);
} }
if is_landuse { if treat_as_landuse {
let task = DbTask::Way { zoom: zoom_i32, table: "landuse", id, tags: tags.clone(), points: polygon_blob.clone(), x, y }; let task = DbTask::Way { zoom: zoom_i32, table: "landuse", id, tags: tags.clone(), points: polygon_blob.clone(), x, y };
let _ = tx.blocking_send(task); let _ = tx.blocking_send(task);
} }
@@ -521,20 +685,97 @@ async fn main() -> Result<()> {
} }
} }
} }
Element::Relation(rel) => {
if !relations_pending {
println!("Switching to Relation processing...");
relations_pending = true;
}
relation_count += 1;
let tags: HashMap<String, String> = rel.tags().map(|(k, v)| (k.to_string(), v.to_string())).collect();
// Only process multipolygon relations
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) ||
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("landuse").map(|v| v == "basin" || v == "reservoir").unwrap_or(false);
let is_landuse = tags.get("landuse").is_some() ||
tags.get("leisure").map(|v| v == "park" || v == "nature_reserve" || v == "garden").unwrap_or(false) ||
tags.get("natural").map(|v| v == "wood" || v == "scrub" || v == "heath").unwrap_or(false);
if is_water || is_landuse {
// Collect outer way members
let mut outer_ways: Vec<i64> = Vec::new();
for member in rel.members() {
if member.role().unwrap_or("") == "outer" {
if let osmpbf::RelMemberType::Way = member.member_type {
outer_ways.push(member.member_id);
}
}
}
if !outer_ways.is_empty() {
// Assemble ALL rings from the outer ways (rivers have multiple rings!)
let rings = assemble_rings(&outer_ways, &way_store);
for ring_node_ids in rings {
// Resolve node coordinates
let mut points: Vec<(f64, f64)> = Vec::new();
for node_id in &ring_node_ids {
if let Some((lat, lon)) = node_store.get(*node_id) {
points.push((lat, lon));
}
}
if points.len() >= 4 {
let id = rel.id();
let (first_lat, first_lon) = points[0];
for &zoom in &ZOOM_LEVELS {
if !should_include(&tags, zoom) { continue; }
// No simplification for multipolygons
let final_points = triangulate_polygon(&points);
if final_points.len() < 3 { continue; }
let (x, y) = lat_lon_to_tile(first_lat, first_lon, zoom);
let zoom_i32 = zoom as i32;
// Create polygon blob
let mut polygon_blob = Vec::with_capacity(final_points.len() * 8);
for (lat, lon) in &final_points {
polygon_blob.extend_from_slice(&(*lat as f32).to_le_bytes());
polygon_blob.extend_from_slice(&(*lon as f32).to_le_bytes());
}
let table = if is_water { "water" } else { "landuse" };
let task = DbTask::Way { zoom: zoom_i32, table, id, tags: tags.clone(), points: polygon_blob.clone(), x, y };
let _ = tx.blocking_send(task);
}
}
}
}
}
}
}
_ => {} _ => {}
} }
if (node_count + way_count) % 100_000 == 0 { if (node_count + way_count + relation_count) % 100_000 == 0 {
println!("Processed {} nodes, {} ways...", node_count, way_count); println!("Processed {} nodes, {} ways, {} relations...", node_count, way_count, relation_count);
} }
})?; })?;
Ok((node_count, way_count)) Ok((node_count, way_count, relation_count))
}); });
let (node_count, way_count) = reader_handle.await??; let (node_count, way_count, relation_count) = reader_handle.await??;
println!("Finished reading PBF. Nodes: {}, Ways: {}. Waiting for consumer...", node_count, way_count); println!("Finished reading PBF. Nodes: {}, Ways: {}, Relations: {}. Waiting for consumer...", node_count, way_count, relation_count);
// Drop sender to signal consumer to finish // Drop sender to signal consumer to finish
drop(tx); drop(tx);