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

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

View File

@@ -781,6 +781,13 @@
<span class="menu-label">Recents</span>
</a>
<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) -->
<div class="recent-tiles">
<div class="recent-tile" title="Recent Location">
@@ -1168,6 +1175,15 @@
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 {
font-size: 10px;
font-weight: 500;
@@ -1229,8 +1245,9 @@
}
.icon-btn {
width: 32px;
height: 32px;
width: 24px;
height: 24px;
fill: #333;
display: flex;
align-items: center;
justify-content: center;
@@ -1324,18 +1341,17 @@
</style>
<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 {
await init();
console.log("WASM initialized");
// run() is auto-called via #[wasm_bindgen(start)] in lib.rs
} catch (e) {
console.error("Failed to initialize WASM:", e);
console.error("Wasm failed:", e);
}
}
run();
main();
// Hamburger menu toggle
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -1,5 +1,3 @@
//! Camera and input state management
/// GPU-compatible camera uniform data
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
@@ -19,7 +17,16 @@ pub struct 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
// We want to map world coordinates to clip space [-1, 1]
// zoom controls how much of the world we see.
@@ -39,14 +46,9 @@ impl Camera {
],
theme: [
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::sync::Arc;
use crate::types::{MapNode, MapWay, TileBuffers};
use crate::geo::KalmanFilter;
@@ -20,6 +17,8 @@ pub struct AppState {
pub user_location: Option<(f64, f64)>,
pub kalman_filter: Option<KalmanFilter>,
pub watch_id: Option<i32>,
pub show_transit: bool,
pub cursor_position: Option<[f64; 2]>,
}
impl AppState {
@@ -37,6 +36,8 @@ impl AppState {
user_location: None,
kalman_filter: None,
watch_id: None,
show_transit: false,
cursor_position: None,
}
}
}
@@ -46,3 +47,18 @@ impl Default for AppState {
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
use wasm_bindgen::JsCast;
use crate::camera::Camera;
use crate::state::AppState;
use crate::domain::camera::Camera;
use crate::domain::state::AppState;
use crate::geo::project;
use crate::tiles::get_visible_tiles;
use crate::services::tile_service::TileService;
/// A candidate label for rendering
pub struct LabelCandidate {
@@ -43,9 +43,9 @@ pub fn update_labels(
// Clear existing labels
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 uniforms = camera.to_uniform(is_dark);
let uniforms = camera.to_uniform(is_dark, state.show_transit);
let mut candidates: Vec<LabelCandidate> = Vec::new();
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)
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);
// 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);
}
"#)),
@@ -136,8 +142,14 @@ pub fn create_colored_building_pipeline(
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let is_dark = camera.theme.x;
// 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);
// 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);
}
"#)),

View File

@@ -63,6 +63,48 @@ pub struct RoadVertex {
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 {
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {

View File

@@ -34,8 +34,14 @@ pub fn create_landuse_green_pipeline(
@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.804, 0.922, 0.690), vec3<f32>(0.18, 0.29, 0.18), is_dark);
// Green: Light #E8F5E3 (Apple Maps pale green), Dark #2d4a2d
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);
}
"#)),
@@ -75,8 +81,14 @@ pub fn create_landuse_residential_pipeline(
@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);
// Light: #EDE8E1 (Apple Maps beige), Dark: #1a1a1a
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);
}
"#)),
@@ -118,6 +130,12 @@ pub fn create_sand_pipeline(
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);
// 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);
}
"#)),

View File

@@ -7,7 +7,7 @@ pub mod roads;
pub mod landuse;
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 water::{create_water_pipeline, create_water_line_pipeline};
pub use roads::{
@@ -22,4 +22,4 @@ pub use roads::{
generate_road_geometry,
};
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
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(
device: &wgpu::Device,
@@ -18,13 +127,16 @@ pub fn create_railway_pipeline(
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) color: vec3<f32>,
@location(0) center: vec2<f32>,
@location(1) normal: vec2<f32>,
@location(2) color: vec3<f32>,
@location(3) type_id: f32,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec3<f32>,
@location(1) type_id: f32,
};
@vertex
@@ -33,28 +145,72 @@ pub fn create_railway_pipeline(
) -> 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 y = world_pos.y * camera.params.y + camera.params.w;
out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
out.color = model.color;
out.type_id = model.type_id;
return out;
}
@fragment
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
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) {
return vec4<f32>(in.color, 1.0);
final_color = in.color;
} else {
// Fallback: Light: #808080 (grey), Dark: #5a5a5a (darker grey)
let is_dark = camera.theme.x;
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);
final_color = mix(vec3<f32>(0.5, 0.5, 0.5), vec3<f32>(0.35, 0.35, 0.35), is_dark);
}
// 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,
entry_point: "vs_main",
buffers: &[
ColoredVertex::desc(),
RailwayVertex::desc(),
],
},
fragment: Some(wgpu::FragmentState {
@@ -81,12 +237,12 @@ pub fn create_railway_pipeline(
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: *format,
blend: Some(wgpu::BlendState::REPLACE),
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
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
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; }
// Computes normals for each segment
@@ -166,6 +166,12 @@ pub fn create_road_motorway_outline_pipeline(
let is_dark = camera.theme.x;
// 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);
// 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);
}
"#)),
@@ -217,6 +223,12 @@ pub fn create_road_motorway_pipeline(
let is_dark = camera.theme.x;
// 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);
// 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);
}
"#)),
@@ -272,6 +284,12 @@ pub fn create_road_primary_outline_pipeline(
let is_dark = camera.theme.x;
// Outline: Subtle Grey
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);
}
"#)),
@@ -324,6 +342,12 @@ pub fn create_road_primary_pipeline(
// Fill
// Fill
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);
}
"#)),
@@ -380,6 +404,12 @@ pub fn create_road_secondary_outline_pipeline(
// 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);
// 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);
}
"#)),
@@ -432,6 +462,12 @@ pub fn create_road_secondary_pipeline(
// 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);
// 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);
}
"#)),
@@ -488,6 +524,12 @@ pub fn create_road_residential_outline_pipeline(
// 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);
// 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);
}
"#)),
@@ -540,6 +582,12 @@ pub fn create_road_residential_pipeline(
// 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);
// 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);
}
"#)),

View File

@@ -46,9 +46,15 @@ pub fn create_water_pipeline(
@fragment
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 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);
}
"#)),
@@ -140,8 +146,14 @@ pub fn create_water_line_pipeline(
@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.647, 0.749, 0.867), vec3<f32>(0.29, 0.44, 0.65), is_dark);
// Light: #B8DAFF (lighter for streams), Dark: #4a6fa5
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);
}
"#)),

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