makepad/examples/map/src/main.rs
Admin 184a768c84 examples/map: NL-wide bridge overlay; drop superseded Amsterdam bake
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:41 +02:00

1772 lines
69 KiB
Rust

//! Offline GPS navigator on top of MapView + makepad-map-nav.
//!
//! - Search box (places, streets, addresses, "supermarkt"-style category
//! queries) over the `region.search` index, queried on a worker thread.
//! - Long-press: first sets your position (blue puck), then sets a
//! destination and routes to it (drive / bike / walk).
//! - Start begins a simulated drive along the route with live turn-by-turn
//! banner, ETA bar, follow camera and recenter button.
//!
//! Data: build `local/maps/noord-holland.{search,graph}` first:
//! cargo run --release -p makepad-map-tiles -- nav-build \
//! local/maps/noord-holland-latest.osm.pbf local/maps/noord-holland
pub use makepad_widgets;
pub mod dem;
pub mod elev_graph;
use makepad_map_nav::geo::{bearing_deg, bearing_delta_deg, LonLat};
use makepad_map_nav::graph::{Route, RouteGraph, TravelMode};
use makepad_map_nav::nav::{NavSession, NavState};
use makepad_map_nav::search::{SearchIndex, SearchResult};
use makepad_widgets::*;
use std::sync::mpsc;
app_main!(App);
const NAV_DATA_BASENAME: &str = "local/maps/noord-holland";
/// Continent-wide settlement index (cities/towns/villages) merged into
/// search so any European place is a fly-to target.
const EUROPE_PLACES_PATH: &str = "local/maps/europe-places.search";
const EUROPE_SEARCHDB_PATH: &str = "local/maps/europe.searchdb";
/// Long-haul fallback graph: Europe major roads (motorway..secondary),
/// used when a route endpoint lies outside the detailed regional graph.
const EUROPE_MAJOR_GRAPH_PATH: &str = "local/maps/europe-major.graph";
/// Simulated drive runs this much faster than real time.
const SIM_SPEED_MULT: f64 = 6.0;
const MAX_RESULTS: usize = 8;
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
let ResultButton = ButtonFlat{
width: Fill
height: Fit
align: Align{x: 0.0 y: 0.5}
label_walk: Walk{width: Fill, height: Fit}
padding: Inset{left: 10, right: 10, top: 6, bottom: 6}
visible: false
text: ""
draw_text +: {
color: #x223038
color_hover: #x000000
color_focus: #x223038
color_down: #x000000
text_style: theme.font_regular{font_size: 9.5}
}
}
let PanelText = Label{
width: Fill
draw_text +: {
color: #x22303c
text_style: theme.font_regular{font_size: 9}
}
}
// Light panel checkbox: the desktop theme's label text is white.
let LayerCheck = CheckBox{
draw_text +: {
color: #x223038
color_hover: #x000000
color_down: #x000000
color_active: #x223038
color_focus: #x223038
}
}
// The app's floating panels are light; the desktop theme's button text
// is white, so pin dark label colors.
let AppButton = Button{
draw_text +: {
color: #x223038
color_hover: #x000000
color_focus: #x223038
color_down: #x000000
}
}
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(1280, 840)
pass.clear_color: vec4(0.08, 0.10, 0.12, 1.0)
body +: {
View{
width: Fill
height: Fill
flow: Overlay
map := MapView{
width: Fill
height: Fill
center_lon: 4.8952
center_lat: 52.3702
zoom: 13.0
min_zoom: 3.0
mbtiles_path: "local/maps/europe-shortbread.mbtiles"
detail_mbtiles_path: "local/maps/europe-osm-detail.mbtiles"
bridge_dz_mbtiles_path: "local/maps/nl-bridge-dz.mbtiles"
buildings_3d: true
// shiny.md: baked AO/shadows/water/foliage ride
// the widget theme defaults; the showcase app
// also turns on the specular sheen.
style_light +: {
shiny +: {
building_sheen: true
}
}
}
// --- Search panel (top-left) ---
View{
width: Fill
height: Fit
search_panel := RoundedView{
flow: Down
width: 360
height: Fit
margin: Inset{left: 12, top: 12}
padding: 8
draw_bg +: {
color: #xfffffff2
border_radius: 9.0
border_size: 1.0
border_color: #x00000022
}
search_input := TextInput{
width: Fill
empty_text: "Search places, streets, addresses…"
}
results_view := View{
flow: Down
width: Fill
height: Fit
visible: false
margin: Inset{top: 4}
result_0 := ResultButton{}
result_1 := ResultButton{}
result_2 := ResultButton{}
result_3 := ResultButton{}
result_4 := ResultButton{}
result_5 := ResultButton{}
result_6 := ResultButton{}
result_7 := ResultButton{}
}
status_label := PanelText{
margin: Inset{top: 6, left: 2}
text: "Loading navigation data…"
}
}
}
// --- Turn banner (top-center) ---
View{
width: Fill
height: Fit
align: Align{x: 0.5 y: 0.0}
banner := RoundedView{
flow: Down
width: Fit
height: Fit
visible: false
margin: Inset{top: 12}
padding: Inset{left: 18, right: 18, top: 10, bottom: 10}
draw_bg +: {
color: #x1a7a3cf0
border_radius: 9.0
}
banner_text := Label{
draw_text +: {
color: #xffffff
text_style: theme.font_regular{font_size: 13}
}
text: ""
}
banner_dist := Label{
margin: Inset{top: 2}
draw_text +: {
color: #xd8f2df
text_style: theme.font_regular{font_size: 10}
}
text: ""
}
}
}
// --- Route elevation profile (self-pinned above the route bar) ---
elevation_graph := ElevationGraph{}
// --- Route / nav bar (bottom-center) ---
View{
width: Fill
height: Fill
align: Align{x: 0.5 y: 1.0}
route_bar := RoundedView{
flow: Right
width: Fit
height: Fit
visible: false
align: Align{x: 0.0 y: 0.5}
margin: Inset{bottom: 18}
padding: Inset{left: 14, right: 10, top: 8, bottom: 8}
draw_bg +: {
color: #xfffffff2
border_radius: 9.0
border_size: 1.0
border_color: #x00000022
}
route_label := Label{
margin: Inset{right: 10, top: 3}
draw_text +: {
color: #x22303c
text_style: theme.font_regular{font_size: 11}
}
text: ""
}
mode_dropdown := DropDown{
margin: Inset{right: 6}
labels: ["Drive" "Bike" "Walk"]
draw_text +: {
color: #x223038
color_hover: #x000000
color_focus: #x223038
color_down: #x000000
}
}
go_button := AppButton{
margin: Inset{right: 4}
text: "Start"
}
end_button := AppButton{
text: "End"
}
}
}
// --- Layers panel (above the bottom-right buttons) ---
View{
width: Fill
height: Fill
align: Align{x: 1.0 y: 1.0}
layers_panel := RoundedView{
visible: false
flow: Down
width: Fit
height: Fit
margin: Inset{right: 16, bottom: 56}
padding: Inset{left: 12, right: 14, top: 10, bottom: 10}
spacing: 4
draw_bg +: {
color: #xfffffff2
border_radius: 9.0
border_size: 1.0
border_color: #x00000022
}
layer_chargers := LayerCheck{text: "EV fast chargers"}
layer_chargers_slow := LayerCheck{text: "EV slow chargers"}
layer_transit := LayerCheck{text: "Transit"}
layer_nature := LayerCheck{text: "Nature areas"}
layer_districts := LayerCheck{text: "Districts"}
layer_bag := LayerCheck{text: "Building age"}
layer_population := LayerCheck{text: "Population"}
layer_rain := LayerCheck{text: "Rain radar"}
layer_wind := LayerCheck{text: "Wind"}
layer_terrain := LayerCheck{text: "Terrain"}
Hr{}
layer_night := LayerCheck{text: "Night theme"}
layer_circuit := LayerCheck{text: "Circuit City"}
PanelText{
margin: Inset{top: 6}
text: "Noise · Flood: soon"
}
}
}
// --- Zoom + recenter (bottom-right) ---
View{
width: Fill
height: Fill
flow: Right
align: Align{x: 1.0 y: 1.0}
recenter_button := AppButton{
visible: false
margin: Inset{right: 6, bottom: 18}
text: "Detach"
}
layers_button := AppButton{
margin: Inset{right: 4, bottom: 18}
text: "Layers"
}
tilt_button := AppButton{
margin: Inset{right: 4, bottom: 18}
text: "3D"
}
zoom_in_button := AppButton{
margin: Inset{right: 4, bottom: 18}
text: " + "
}
zoom_out_button := AppButton{
margin: Inset{right: 16, bottom: 18}
text: " - "
}
}
// --- Set position at map center (bottom-left) ---
View{
width: Fill
height: Fill
align: Align{x: 0.0 y: 1.0}
position_button := AppButton{
margin: Inset{left: 16, bottom: 18}
text: "Set position here"
}
}
}
}
}
}
}
}
// --- Worker protocol ---
enum NavRequest {
Search {
id: u64,
query: String,
near: Option<LonLat>,
},
Route {
id: u64,
from: LonLat,
to: LonLat,
mode: TravelMode,
},
Elevation {
id: u64,
points: Vec<LonLat>,
},
}
enum NavResponse {
Ready {
docs: usize,
edges: usize,
},
LoadFailed {
error: String,
},
SearchDone {
id: u64,
results: Vec<SearchResult>,
},
RouteDone {
id: u64,
route: Box<Option<Route>>,
},
ElevationDone {
id: u64,
profile: Box<Option<dem::ElevationProfile>>,
},
}
#[derive(Clone)]
struct TerrainUpdate {
texels: Vec<u32>,
width: usize,
height: usize,
/// Elevation meters, terrarium-packed BGRA texels for the GPU
/// displacement sampler, plus the raw grid for CPU (pin/label) lifts.
elev_texels: Vec<u32>,
elev: Vec<f32>,
elev_width: usize,
elev_height: usize,
bbox: (f64, f64, f64, f64),
}
#[derive(Clone)]
struct WindUpdate {
nx: usize,
ny: usize,
u: Vec<f32>,
v: Vec<f32>,
bbox: (f64, f64, f64, f64),
}
#[derive(Clone)]
struct RainUpdate {
frames: Vec<Vec<u32>>,
width: usize,
height: usize,
/// Hi-res dual-radar composite for the "now" frame (BGRA, width, height).
now_hires: Option<(Vec<u32>, usize, usize)>,
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
nav_rx: ToUIReceiver<NavResponse>,
#[rust]
nav_tx: Option<mpsc::Sender<NavRequest>>,
#[rust]
next_request_id: u64,
#[rust]
active_search_id: u64,
#[rust]
active_route_id: u64,
#[rust]
data_ready: bool,
#[rust]
search_results: Vec<SearchResult>,
#[rust]
search_debounce: Timer,
#[rust]
pending_query: String,
#[rust]
position: Option<LonLat>,
#[rust]
heading: Option<f64>,
#[rust]
dest: Option<(LonLat, String)>,
#[rust]
route: Option<Route>,
#[rust]
session: Option<NavSession>,
#[rust]
navigating: bool,
#[rust]
follow: bool,
#[rust]
rain_on: bool,
#[rust]
rain_worker_started: bool,
#[rust]
rain_rx: ToUIReceiver<RainUpdate>,
/// Last decoded nowcast, kept so toggling rain back on is instant.
#[rust]
rain_cache: Option<RainUpdate>,
#[rust]
wind_on: bool,
#[rust]
wind_worker_started: bool,
#[rust]
wind_rx: ToUIReceiver<WindUpdate>,
#[rust]
wind_cache: Option<WindUpdate>,
#[rust]
terrain_on: bool,
#[rust]
terrain_worker_started: bool,
#[rust]
terrain_tx: Option<mpsc::Sender<((f64, f64, f64, f64), (f32, f32, f32), bool)>>,
#[rust]
terrain_rx: ToUIReceiver<TerrainUpdate>,
#[rust]
last_terrain_request: Option<(i64, i64, i64, i64)>,
#[rust]
program_moves: u32,
#[rust]
sim_progress_m: f64,
/// Per-frame tick: the sim advances on NextFrame, not a timer, so the
/// follow camera moves once per rendered frame instead of at 20 Hz.
#[rust]
sim_next_frame: NextFrame,
#[rust]
sim_last_tick: Option<std::time::Instant>,
/// Smoothed heading-up camera rotation during nav.
#[rust]
map_rotation: f64,
/// Eased 2.5D tilt animation: (current, target).
#[rust]
tilt_current: f64,
#[rust]
tilt_target: f64,
#[rust]
tilt_next_frame: NextFrame,
#[rust]
sim_started: Option<std::time::Instant>,
#[rust]
mode: TravelMode,
#[rust]
active_elevation_id: u64,
#[rust]
layers_open: bool,
#[rust]
layer_states: [bool; 7],
}
impl App {
fn map(&self, cx: &Cx) -> MapViewRef {
self.ui.map_view(cx, ids!(map))
}
fn request_id(&mut self) -> u64 {
self.next_request_id += 1;
self.next_request_id
}
fn set_status(&self, cx: &mut Cx, text: &str) {
self.ui.label(cx, ids!(status_label)).set_text(cx, text);
}
fn start_worker(&mut self) {
makepad_widgets::start_memory_watchdog(None);
let (tx, rx) = mpsc::channel::<NavRequest>();
self.nav_tx = Some(tx);
let sender = self.nav_rx.sender();
std::thread::spawn(move || {
let load = || -> Result<(SearchIndex, RouteGraph), String> {
let search_path = format!("{}.search", NAV_DATA_BASENAME);
let graph_path = format!("{}.graph", NAV_DATA_BASENAME);
let data = std::fs::read(&search_path)
.map_err(|err| format!("{}: {}", search_path, err))?;
let index = SearchIndex::deserialize(&data).map_err(|err| err.to_string())?;
let data = std::fs::read(&graph_path)
.map_err(|err| format!("{}: {}", graph_path, err))?;
let graph = RouteGraph::deserialize(&data).map_err(|err| err.to_string())?;
Ok((index, graph))
};
let (index, graph) = match load() {
Ok(loaded) => {
let _ = sender.send(NavResponse::Ready {
docs: loaded.0.doc_count(),
edges: loaded.1.edges.len(),
});
loaded
}
Err(error) => {
let _ = sender.send(NavResponse::LoadFailed { error });
return;
}
};
// The disk-backed all-of-Europe database supersedes the
// settlements-only places index when it exists on disk.
let searchdb = makepad_map_nav::searchdb::SearchDb::open(
std::path::Path::new(EUROPE_SEARCHDB_PATH),
)
.ok();
let places_index = if searchdb.is_some() {
None
} else {
std::fs::read(EUROPE_PLACES_PATH)
.ok()
.and_then(|data| SearchIndex::deserialize(&data).ok())
};
// Europe-wide major-roads graph: loaded lazily as the long-haul
// fallback (missing file = regional routing only).
let major_graph = std::fs::read(EUROPE_MAJOR_GRAPH_PATH)
.ok()
.and_then(|data| RouteGraph::deserialize(&data).ok());
if let Some(major) = &major_graph {
log!(
"nav: europe major-roads graph loaded ({} edges)",
major.edges.len()
);
}
let dem_cache = std::sync::Arc::new(std::sync::Mutex::new(dem::DemCache::new(
"local/maps/dem",
)));
while let Ok(request) = rx.recv() {
match request {
NavRequest::Search { id, query, near } => {
let mut results = index.query(&query, near, MAX_RESULTS);
if let Some(db) = &searchdb {
if let Ok(more) = db.query(&query, near, MAX_RESULTS) {
results.extend(more);
}
} else if let Some(places) = &places_index {
results.extend(places.query(&query, near, MAX_RESULTS));
}
// Merge the regional and continental hits: best score
// first, drop same-name near-duplicates (cities live
// in both indexes).
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut merged: Vec<SearchResult> = Vec::new();
for result in results {
let duplicate = merged.iter().any(|kept| {
kept.name.eq_ignore_ascii_case(&result.name)
&& makepad_map_nav::geo::haversine_m(kept.pos, result.pos)
< 2_000.0
});
if !duplicate {
merged.push(result);
}
if merged.len() >= MAX_RESULTS {
break;
}
}
let _ = sender.send(NavResponse::SearchDone {
id,
results: merged,
});
}
NavRequest::Route { id, from, to, mode } => {
// Detailed regional graph first; beyond its coverage
// (Paris!) fall back to the Europe major-roads graph.
let mut route = graph.route(from, to, mode);
if route.is_none() {
if let Some(major) = &major_graph {
route = major.route(from, to, mode);
}
}
let _ = sender.send(NavResponse::RouteDone {
id,
route: Box::new(route),
});
}
NavRequest::Elevation { id, points } => {
// Own thread: the first request may download DEM
// tiles and must not stall search/route requests.
let dem_cache = dem_cache.clone();
let sender = sender.clone();
std::thread::spawn(move || {
let mut cache = dem_cache.lock().unwrap();
let profile = dem::route_profile(&mut cache, &points, 200);
let _ = sender.send(NavResponse::ElevationDone {
id,
profile: Box::new(profile),
});
});
}
}
}
});
}
// --- Search ---
fn send_search(&mut self, cx: &mut Cx) {
// Debug camera: "@cam <lon> <lat> <zoom> [rot_deg] [tilt_deg]" in
// the search box drives the full camera — lets automated sessions
// frame arbitrary 3D angles without gesture synthesis.
if let Some(rest) = self.pending_query.trim().strip_prefix("@cam ") {
let vals: Vec<f64> = rest
.split_whitespace()
.filter_map(|v| v.parse::<f64>().ok())
.collect();
if vals.len() >= 3 {
let map = self.map(cx);
map.set_center(cx, vals[0], vals[1]);
map.set_map_zoom(cx, vals[2]);
map.set_rotation(cx, vals.get(3).copied().unwrap_or(0.0));
let tilt = vals.get(4).copied().unwrap_or(0.0);
map.set_tilt(cx, tilt);
self.tilt_target = tilt;
self.tilt_current = tilt;
self.sync_tilt_button(cx);
self.last_terrain_request = None;
self.request_terrain(cx);
}
// Clear so the next @cam doesn't append to stale text.
self.ui.text_input(cx, ids!(search_input)).set_text(cx, "");
self.pending_query.clear();
self.hide_results(cx);
return;
}
// A script hot-reload wipes #[rust] state including the worker
// channel; bring it back on demand.
if self.nav_tx.is_none() {
self.start_worker();
}
if !self.data_ready || self.pending_query.trim().is_empty() {
self.hide_results(cx);
return;
}
let near = self
.position
.or_else(|| self.map(cx).center().map(|(lon, lat)| LonLat::new(lon, lat)));
let id = self.request_id();
self.active_search_id = id;
if let Some(tx) = &self.nav_tx {
let _ = tx.send(NavRequest::Search {
id,
query: self.pending_query.clone(),
near,
});
}
}
fn result_button(&self, cx: &Cx, index: usize) -> ButtonRef {
match index {
0 => self.ui.button(cx, ids!(result_0)),
1 => self.ui.button(cx, ids!(result_1)),
2 => self.ui.button(cx, ids!(result_2)),
3 => self.ui.button(cx, ids!(result_3)),
4 => self.ui.button(cx, ids!(result_4)),
5 => self.ui.button(cx, ids!(result_5)),
6 => self.ui.button(cx, ids!(result_6)),
_ => self.ui.button(cx, ids!(result_7)),
}
}
fn show_results(&mut self, cx: &mut Cx) {
for i in 0..MAX_RESULTS {
let button = self.result_button(cx, i);
if let Some(result) = self.search_results.get(i) {
// Two lines: prominent name, then category · address · distance.
let mut sub = result.category.label().to_string();
if !result.secondary.is_empty() {
sub.push_str(" · ");
sub.push_str(&result.secondary);
}
if let Some(d) = result.distance_m {
sub.push_str(" · ");
sub.push_str(&fmt_dist(d));
}
button.set_text(cx, &format!("{}\n{}", result.name, sub));
button.set_visible(cx, true);
} else {
button.set_visible(cx, false);
}
}
self.ui
.view(cx, ids!(results_view))
.set_visible(cx, !self.search_results.is_empty());
}
fn hide_results(&mut self, cx: &mut Cx) {
self.search_results.clear();
self.ui.view(cx, ids!(results_view)).set_visible(cx, false);
}
/// Start the rain radar worker: polls the KNMI +2h nowcast through the
/// on-disk cache (RadarSync never hits the network more than once per
/// 4 min), decodes the 25-frame HDF5 and reprojects to mercator RGBA.
/// Also syncs both raw radar volumes and composites the hi-res "now"
/// image (250 m instead of the composite's 1 km).
fn ensure_rain_worker(&mut self) {
if self.rain_worker_started {
return;
}
self.rain_worker_started = true;
let sender = self.rain_rx.sender();
std::thread::spawn(move || {
use makepad_geodata::radar::{RadarConfig, RadarSync};
use makepad_geodata::radar_volume::{composite_volumes, RadarVolume};
use makepad_geodata::{knmi_hdf5, radar_raster};
let stamp_of = |filename: &str| -> String {
filename
.rsplit('_')
.next()
.unwrap_or("")
.trim_end_matches(".h5")
.to_string()
};
let sync = RadarSync::new(RadarConfig::new("local/overlays/radar"));
let (herwijnen_config, den_helder_config) =
RadarConfig::volume_pair("local/overlays/radar");
let herwijnen_sync = RadarSync::new(herwijnen_config);
let den_helder_sync = RadarSync::new(den_helder_config);
let projection = radar_raster::RadarProjection::new(1024, 1280);
let mut hires_projection: Option<radar_raster::RadarProjection> = None;
let mut last_decoded: Option<std::path::PathBuf> = None;
let mut last_volume_stamp = String::new();
let mut current: Option<RainUpdate> = None;
loop {
let mut changed = false;
if let Ok(state) = sync.sync() {
if let Some(newest) = state.frames.last() {
if last_decoded.as_deref() != Some(newest.path.as_path()) {
if let Ok(data) = std::fs::read(&newest.path) {
if let Ok(frames) = knmi_hdf5::decode_frames(&data) {
// Reproject the 25 frames in parallel —
// serial this was the multi-second wait
// blamed on "downloading".
let texels: Vec<Vec<u32>> =
std::thread::scope(|scope| {
let handles: Vec<_> = frames
.iter()
.map(|frame| {
let projection = &projection;
scope.spawn(move || {
radar_raster::rgba_to_bgra_texels(
&projection.frame_to_rgba(frame),
)
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().unwrap())
.collect()
});
let now_hires =
current.as_ref().and_then(|c| c.now_hires.clone());
current = Some(RainUpdate {
frames: texels,
width: 1024,
height: 1280,
now_hires,
});
last_decoded = Some(newest.path.clone());
changed = true;
}
}
}
}
}
// Volumes of both radars: composite the newest timestamp
// present on both sides.
let _ = herwijnen_sync.sync();
let _ = den_helder_sync.sync();
let herwijnen_state = herwijnen_sync.state();
let den_helder_state = den_helder_sync.state();
let pair = herwijnen_state.frames.iter().rev().find_map(|h| {
let stamp = stamp_of(&h.filename);
den_helder_state
.frames
.iter()
.rev()
.find(|d| stamp_of(&d.filename) == stamp)
.map(|d| (stamp, h.path.clone(), d.path.clone()))
});
if let Some((stamp, herwijnen_path, den_helder_path)) = pair {
if stamp != last_volume_stamp && current.is_some() {
let decode = |path: &std::path::Path| -> Option<RadarVolume> {
RadarVolume::decode(&std::fs::read(path).ok()?).ok()
};
if let (Some(herwijnen), Some(den_helder)) =
(decode(&herwijnen_path), decode(&den_helder_path))
{
let hires_projection = hires_projection.get_or_insert_with(|| {
radar_raster::RadarProjection::new(2048, 2560)
});
let frame = composite_volumes(&[herwijnen, den_helder], 4);
let rgba = hires_projection.composite_to_rgba(&frame);
if let Some(current) = &mut current {
current.now_hires = Some((
radar_raster::rgba_to_bgra_texels(&rgba),
hires_projection.width,
hires_projection.height,
));
last_volume_stamp = stamp;
changed = true;
}
}
}
}
if changed {
if let Some(current) = &current {
let _ = sender.send(current.clone());
}
}
std::thread::sleep(std::time::Duration::from_secs(60));
}
});
}
/// GFS wind worker: 30 min disk-gated NOMADS polls (US public domain,
/// ~3 KB per fetch), cached GRIB2 on disk, decoded in-process.
fn ensure_wind_worker(&mut self) {
if self.wind_worker_started {
return;
}
self.wind_worker_started = true;
let sender = self.wind_rx.sender();
std::thread::spawn(move || {
use makepad_geodata::wind::{WindSync, WIND_EAST, WIND_NORTH, WIND_SOUTH, WIND_WEST};
let sync = WindSync::new("local/overlays/wind");
loop {
let field = sync.sync().ok().flatten().or_else(|| sync.cached());
if let Some(field) = field {
let _ = sender.send(WindUpdate {
nx: field.nx,
ny: field.ny,
u: field.u,
v: field.v,
bbox: (WIND_WEST, WIND_SOUTH, WIND_EAST, WIND_NORTH),
});
}
std::thread::sleep(std::time::Duration::from_secs(300));
}
});
}
/// Terrain worker: renders hillshade textures for requested view
/// bboxes from the local terrarium mbtiles (no network at all).
fn ensure_terrain_worker(&mut self) {
if self.terrain_worker_started {
return;
}
self.terrain_worker_started = true;
let (tx, rx) = mpsc::channel::<((f64, f64, f64, f64), (f32, f32, f32), bool)>();
self.terrain_tx = Some(tx);
let sender = self.terrain_rx.sender();
std::thread::spawn(move || {
use makepad_geodata::terrain_shade::TerrainShader;
let Ok(mut shader) =
TerrainShader::open(std::path::Path::new("local/overlays/nl-terrain.mbtiles"))
else {
return;
};
let mut land_reader =
makepad_mbtile_reader::MbtilesReader::open(std::path::Path::new(
"local/maps/europe-shortbread.mbtiles",
))
.ok();
while let Ok(mut request) = rx.recv() {
// Only the newest pending request matters.
while let Ok(newer) = rx.try_recv() {
request = newer;
}
let (bbox, sun, cast_shadows) = request;
// One SceneSun: the hillshade lights with the same sun as
// the building walls and (optionally) casts terrain shadows.
shader.sun = sun;
shader.cast_shadows = cast_shadows;
let (w, h) = (4096usize, 3072usize);
let (mut rgba, elev_full, shade) =
shader.shade_region(bbox.0, bbox.1, bbox.2, bbox.3, w, h);
if let Some(reader) = land_reader.as_mut() {
makepad_widgets::map::drape::drape_landcover(reader, bbox, w, h, &elev_full, &shade, &mut rgba);
}
let texels = makepad_geodata::radar_raster::rgba_to_bgra_texels(&rgba);
// Displacement grid matches the renderer's 48x36 terrain
// mesh EXACTLY (49x37 corners): GPU bilinear over this
// texture then equals the mesh's linear interpolation, so
// roads ride the surface without poking through on slopes.
let (ew, eh) = (289usize, 217usize);
let mut elev = vec![0f32; ew * eh];
let mut elev_texels = vec![0u32; ew * eh];
for y in 0..eh {
for x in 0..ew {
let sx = (x * (w - 1)) / (ew - 1);
let sy = (y * (h - 1)) / (eh - 1);
let m = elev_full[sy * w + sx].max(0.0);
elev[y * ew + x] = m;
// Terrarium pack: m + 32768 in R*256 + G + B/256.
let v = ((m + 32768.0) * 256.0) as u32;
let (r, g, b) = (v >> 16 & 255, v >> 8 & 255, v & 255);
elev_texels[y * ew + x] = b | (g << 8) | (r << 16) | (255 << 24);
}
}
let _ = sender.send(TerrainUpdate {
texels,
width: w,
height: h,
elev_texels,
elev,
elev_width: ew,
elev_height: eh,
bbox,
});
}
});
}
/// Request a hillshade render for the current viewport (debounced by
/// bbox identity).
fn request_terrain(&mut self, cx: &mut Cx) {
if !self.terrain_on {
return;
}
self.ensure_terrain_worker();
let Some((lon, lat, zoom)) = self
.map(cx)
.center()
.map(|(lon, lat)| (lon, lat, self.map(cx).map_zoom().unwrap_or(10.0)))
else {
return;
};
let world_px = 256.0 * 2f64.powf(zoom);
// Cover ~3 viewports (plus tilt horizon) in one render: pans and
// small zooms reuse the texture instead of re-rezzing every nudge.
let margin = 3.0 + 1.6 * self.tilt_current.to_radians().sin();
let half_w = 1000.0 * margin / world_px;
let half_h = 750.0 * margin / world_px;
let center = makepad_map_nav::geo::LonLat::new(lon, lat);
// normalized mercator center
let nx = (center.lon + 180.0) / 360.0;
let lat_rad = center.lat.to_radians();
let ny = (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0;
let bbox = (nx - half_w, ny - half_h, nx + half_w, ny + half_h);
// Re-render only when the view really leaves the current render:
// center snapped to half-viewport steps + integer zoom bucket. In
// between, the renderer keeps magnifying the last texture.
let step = half_w / 3.0;
let key = (
(nx / step).round() as i64,
(ny / step).round() as i64,
zoom.floor() as i64,
(self.tilt_current > 0.5) as i64,
);
if self.last_terrain_request == Some(key) {
return;
}
self.last_terrain_request = Some(key);
let shiny = self.map(cx).shiny().unwrap_or_default();
if let Some(tx) = &self.terrain_tx {
let _ = tx.send((
bbox,
(shiny.sun.dir.x, shiny.sun.dir.y, shiny.sun.dir.z),
shiny.terrain_shadows,
));
}
}
fn apply_wind(&mut self, cx: &mut Cx) {
if self.wind_on {
if let Some(update) = &self.wind_cache {
self.map(cx).set_wind_field(
cx,
update.nx,
update.ny,
update.u.clone(),
update.v.clone(),
update.bbox,
);
}
} else {
self.map(cx)
.set_wind_field(cx, 0, 0, Vec::new(), Vec::new(), (0.0, 0.0, 0.0, 0.0));
}
}
fn apply_rain(&mut self, cx: &mut Cx) {
let bbox = (
makepad_geodata::radar_raster::RASTER_WEST,
makepad_geodata::radar_raster::RASTER_SOUTH,
makepad_geodata::radar_raster::RASTER_EAST,
makepad_geodata::radar_raster::RASTER_NORTH,
);
if self.rain_on {
if let Some(update) = &self.rain_cache {
self.map(cx).set_rain_frames(
cx,
update.frames.clone(),
update.width,
update.height,
bbox,
);
self.map(cx).set_rain_now_hires(cx, update.now_hires.clone());
}
} else {
self.map(cx).set_rain_frames(cx, Vec::new(), 0, 0, bbox);
}
}
/// The 3D button shows the mode a press switches TO ("3D" when flat,
/// "2D" when tilted) so the current mode is always visible.
fn sync_tilt_button(&mut self, cx: &mut Cx) {
let label = if self.tilt_target > 0.0 { "2D" } else { "3D" };
self.ui.button(cx, ids!(tilt_button)).set_text(cx, label);
}
fn pick_result(&mut self, cx: &mut Cx, index: usize) {
let Some(result) = self.search_results.get(index).cloned() else {
return;
};
self.hide_results(cx);
// A fresh search must start from an empty box, not append.
self.ui.text_input(cx, ids!(search_input)).set_text(cx, "");
self.pending_query.clear();
self.set_destination(cx, result.pos, &result.name, true);
}
// --- Routing ---
fn set_destination(&mut self, cx: &mut Cx, pos: LonLat, name: &str, fly: bool) {
self.dest = Some((pos, name.to_string()));
let map = self.map(cx);
map.set_markers(
cx,
vec![MapMarker::new(1, pos.lon, pos.lat, vec4(0.86, 0.24, 0.24, 1.0))],
);
if fly {
self.program_moves += 1;
map.fly_to(cx, pos.lon, pos.lat, 16.0);
}
if self.position.is_some() {
self.request_route(cx);
} else {
self.set_status(
cx,
&format!("{} — double-click the map to set your position first", name),
);
}
}
fn request_route(&mut self, cx: &mut Cx) {
let (Some(from), Some((to, name))) = (self.position, self.dest.clone()) else {
return;
};
if self.nav_tx.is_none() {
self.start_worker();
}
if !self.data_ready {
return;
}
let id = self.request_id();
self.active_route_id = id;
if let Some(tx) = &self.nav_tx {
let _ = tx.send(NavRequest::Route {
id,
from,
to,
mode: self.mode,
});
}
self.set_status(cx, &format!("Routing to {}", name));
}
/// Rebuild the overlay path list from the app-tracked layer states.
fn apply_overlay_selection(&mut self, cx: &mut Cx) {
const LAYER_PATHS: [&str; 7] = [
"local/overlays/nl-chargers.mbtiles?fast",
"local/overlays/nl-transit.mbtiles",
"local/overlays/nl-nature.mbtiles",
"local/overlays/nl-wijkbuurt.mbtiles",
"local/overlays/nl-buildings-age.mbtiles",
"local/overlays/nl-demographics.mbtiles",
"local/overlays/nl-chargers.mbtiles?slow",
];
let paths: Vec<&str> = LAYER_PATHS
.iter()
.zip(self.layer_states.iter())
.filter(|(_, on)| **on)
.map(|(path, _)| *path)
.collect();
self.map(cx).set_overlay_paths(cx, &paths.join(";"));
}
fn apply_route(&mut self, cx: &mut Cx, route: Route) {
let points: Vec<(f64, f64)> = route.points.iter().map(|p| (p.lon, p.lat)).collect();
let map = self.map(cx);
map.set_route(cx, &points);
let name = self.dest.as_ref().map(|d| d.1.clone()).unwrap_or_default();
self.ui.label(cx, ids!(route_label)).set_text(
cx,
&format!(
"{}{} · {}",
name,
fmt_dist(route.length_m),
fmt_dur(route.duration_s)
),
);
self.ui.view(cx, ids!(route_bar)).set_visible(cx, true);
self.set_status(
cx,
&format!(
"{} route: {} · {} — press Start to navigate",
self.mode.label(),
fmt_dist(route.length_m),
fmt_dur(route.duration_s)
),
);
if self.navigating {
// Reroute mid-drive: restart the session on the new route.
self.session = Some(NavSession::new(route.clone()));
self.sim_progress_m = 0.0;
}
let id = self.request_id();
self.active_elevation_id = id;
if let Some(tx) = &self.nav_tx {
let _ = tx.send(NavRequest::Elevation {
id,
points: route.points.clone(),
});
}
self.route = Some(route);
}
// --- Navigation (simulated drive) ---
fn start_nav(&mut self, cx: &mut Cx) {
let Some(route) = self.route.clone() else {
return;
};
self.session = Some(NavSession::new(route.clone()));
self.navigating = true;
self.follow = true;
self.ui
.button(cx, ids!(recenter_button))
.set_visible(cx, true);
self.ui
.button(cx, ids!(recenter_button))
.set_text(cx, "Detach");
self.sim_progress_m = 0.0;
self.sim_last_tick = Some(std::time::Instant::now());
self.sim_started = Some(std::time::Instant::now());
self.ui.view(cx, ids!(banner)).set_visible(cx, true);
self.ui.button(cx, ids!(go_button)).set_visible(cx, false);
if let Some(start) = route.points.first() {
self.position = Some(*start);
self.program_moves += 1;
self.map(cx).fly_to(cx, start.lon, start.lat, 17.0);
}
self.sim_next_frame = cx.new_next_frame();
}
fn end_nav(&mut self, cx: &mut Cx) {
self.navigating = false;
self.session = None;
self.route = None;
self.dest = None;
self.map_rotation = 0.0;
let map = self.map(cx);
map.set_rotation(cx, 0.0);
map.clear_route(cx);
map.set_markers(cx, Vec::new());
self.ui.view(cx, ids!(banner)).set_visible(cx, false);
self.ui.view(cx, ids!(route_bar)).set_visible(cx, false);
if let Some(mut graph) = self
.ui
.widget(cx, ids!(elevation_graph))
.borrow_mut::<crate::elev_graph::ElevationGraph>()
{
graph.set_profile(cx, None);
}
self.ui.button(cx, ids!(go_button)).set_visible(cx, true);
self.ui
.button(cx, ids!(recenter_button))
.set_visible(cx, false);
self.set_status(cx, "Search a place or double-click the map to route");
}
fn tick_sim(&mut self, cx: &mut Cx) {
if !self.navigating {
return;
}
let Some(route) = self.route.clone() else {
return;
};
let now = std::time::Instant::now();
let dt = self
.sim_last_tick
.map(|last| now.duration_since(last).as_secs_f64())
.unwrap_or(0.05)
.min(0.5);
self.sim_last_tick = Some(now);
// Advance along the route at the route's average speed, sped up.
let avg_speed = if route.duration_s > 0.0 {
route.length_m / route.duration_s
} else {
10.0
};
self.sim_progress_m =
(self.sim_progress_m + avg_speed * SIM_SPEED_MULT * dt).min(route.length_m);
let pos = point_at(&route, self.sim_progress_m);
let ahead = point_at(&route, (self.sim_progress_m + 12.0).min(route.length_m));
let heading = if self.sim_progress_m + 1.0 < route.length_m {
Some(bearing_deg(pos, ahead))
} else {
self.heading
};
self.heading = heading;
self.position = Some(pos);
let now_s = self
.sim_started
.map(|s| now.duration_since(s).as_secs_f64())
.unwrap_or(0.0);
let status = self.session.as_mut().map(|s| s.update(pos, now_s));
let map = self.map(cx);
map.set_puck(cx, Some(MapPuck::new(pos.lon, pos.lat, heading, 12.0)));
if self.follow {
map.set_center(cx, pos.lon, pos.lat);
// Heading-up: ease the camera onto the travel bearing with a
// shortest-arc exponential so curves sweep instead of snapping.
if let Some(target) = heading {
let delta = bearing_delta_deg(self.map_rotation, target);
let blend = 1.0 - (-dt * 3.0).exp();
self.map_rotation = (self.map_rotation + delta * blend).rem_euclid(360.0);
map.set_rotation(cx, self.map_rotation);
}
}
if let Some(status) = status {
let index = route.cum_dist_m.partition_point(|&c| c < status.progress_m);
map.set_route_progress(cx, index);
match status.state {
NavState::Arrived => {
self.ui
.label(cx, ids!(banner_text))
.set_text(cx, "You have arrived");
self.ui.label(cx, ids!(banner_dist)).set_text(cx, "");
self.navigating = false;
return;
}
_ => {
if let Some(idx) = status.next_maneuver {
let maneuver = &route.maneuvers[idx];
self.ui
.label(cx, ids!(banner_text))
.set_text(cx, &maneuver.text());
self.ui.label(cx, ids!(banner_dist)).set_text(
cx,
&format!(
"in {} · {} · {} left",
fmt_dist(status.dist_to_next_m),
fmt_dist(status.remaining_m),
fmt_dur(status.remaining_s / SIM_SPEED_MULT)
),
);
}
if status.needs_reroute {
self.request_route(cx);
}
}
}
}
self.sim_next_frame = cx.new_next_frame();
}
}
/// Route point at a given distance from the start (linear interpolation).
fn point_at(route: &Route, dist_m: f64) -> LonLat {
let cum = &route.cum_dist_m;
let points = &route.points;
if points.is_empty() {
return LonLat::default();
}
let idx = cum.partition_point(|&c| c < dist_m);
if idx == 0 {
return points[0];
}
if idx >= points.len() {
return points[points.len() - 1];
}
let seg = cum[idx] - cum[idx - 1];
let f = if seg > 0.0 {
(dist_m - cum[idx - 1]) / seg
} else {
0.0
};
LonLat::new(
points[idx - 1].lon + (points[idx].lon - points[idx - 1].lon) * f,
points[idx - 1].lat + (points[idx].lat - points[idx - 1].lat) * f,
)
}
fn fmt_dist(m: f64) -> String {
if m < 950.0 {
format!("{:.0} m", (m / 10.0).round() * 10.0)
} else {
format!("{:.1} km", m / 1000.0)
}
}
fn fmt_dur(s: f64) -> String {
let minutes = (s / 60.0).round() as i64;
if minutes < 60 {
format!("{} min", minutes.max(1))
} else {
format!("{} h {:02} min", minutes / 60, minutes % 60)
}
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
self.start_worker();
self.set_status(cx, "Loading navigation data…");
// Debug: /tmp/mp_start_cam holds "lon lat zoom [rot] [tilt]" — boot
// straight into a benchmark viewport (env vars don't reach
// studio-launched runs; the file does).
if let Ok(text) = std::fs::read_to_string("/tmp/mp_start_cam") {
let vals: Vec<f64> = text
.split_whitespace()
.filter_map(|v| v.parse::<f64>().ok())
.collect();
if vals.len() >= 3 {
let map = self.map(cx);
map.set_center(cx, vals[0], vals[1]);
map.set_map_zoom(cx, vals[2]);
map.set_rotation(cx, vals.get(3).copied().unwrap_or(0.0));
let tilt = vals.get(4).copied().unwrap_or(0.0);
map.set_tilt(cx, tilt);
self.tilt_target = tilt;
self.tilt_current = tilt;
}
}
}
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
// Search box
if let Some(text) = self.ui.text_input(cx, ids!(search_input)).changed(actions) {
self.pending_query = text;
cx.stop_timer(self.search_debounce);
self.search_debounce = cx.start_timeout(0.18);
}
if let Some((text, _)) = self.ui.text_input(cx, ids!(search_input)).returned(actions) {
self.pending_query = text;
self.send_search(cx);
}
for i in 0..MAX_RESULTS {
if self.result_button(cx, i).clicked(actions) {
self.pick_result(cx, i);
}
}
// Map actions
let map = self.map(cx);
if let Some((lon, lat)) = map.long_pressed(actions) {
if self.position.is_none() {
self.position = Some(LonLat::new(lon, lat));
map.set_puck(cx, Some(MapPuck::new(lon, lat, None, 15.0)));
if self.dest.is_some() {
self.request_route(cx);
} else {
self.set_status(
cx,
"Position set — search a place or double-click to route there",
);
}
} else if !self.navigating {
self.set_destination(cx, LonLat::new(lon, lat), "Dropped pin", false);
}
}
if let Some((_lon, _lat, info)) = map.pin_tapped(actions) {
let get = |key: &str| {
info.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
.unwrap_or("")
};
let title = if !get("operator").is_empty() {
get("operator").to_string()
} else if !get("name").is_empty() {
get("name").to_string()
} else {
"Charger".to_string()
};
let mut lines: Vec<String> = Vec::new();
if !get("max_kw").is_empty() {
lines.push(format!("{} kW max", get("max_kw")));
}
if !get("evses").is_empty() || !get("connectors").is_empty() {
lines.push(format!(
"{} bays · {} connectors",
get("evses"),
get("connectors")
));
}
if !get("name").is_empty() && get("name") != title {
lines.push(get("name").to_string());
}
if !get("city").is_empty() {
lines.push(get("city").to_string());
}
self.ui.label(cx, ids!(pin_info_title)).set_text(cx, &title);
self.ui
.label(cx, ids!(pin_info_body))
.set_text(cx, &lines.join("\n"));
self.ui.view(cx, ids!(pin_info)).set_visible(cx, true);
}
if map.tapped(actions).is_some() {
self.hide_results(cx);
self.ui.view(cx, ids!(pin_info)).set_visible(cx, false);
}
// Manual tilt gesture = entering/leaving 3D mode: track it so the
// 3D button toggles from the REAL camera state (no snap-to-flat
// then re-animate) and dragging upright returns cleanly to 2D.
if let Some(tilt) = map.tilt_changed(actions) {
self.tilt_target = tilt;
self.tilt_current = tilt;
self.sync_tilt_button(cx);
// A tilted camera sees far beyond the flat viewport: refresh
// the terrain bbox for the new frustum.
self.last_terrain_request = None;
self.request_terrain(cx);
}
if map.viewport_changed(actions).is_some() {
if self.program_moves > 0 {
self.program_moves -= 1;
}
self.request_terrain(cx);
// Gestures (pan/tilt/rotate) do NOT detach the follow camera —
// attach/detach is an explicit toggle in the UI.
}
// Route bar
if let Some(index) = self.ui.drop_down(cx, ids!(mode_dropdown)).selected(actions) {
self.mode = match index {
1 => TravelMode::Bike,
2 => TravelMode::Foot,
_ => TravelMode::Car,
};
if self.dest.is_some() && self.position.is_some() {
self.request_route(cx);
}
}
if self.ui.button(cx, ids!(position_button)).clicked(actions) {
if let Some((lon, lat)) = self.map(cx).center() {
self.position = Some(LonLat::new(lon, lat));
self.heading = None;
self.map(cx)
.set_puck(cx, Some(MapPuck::new(lon, lat, None, 15.0)));
if self.dest.is_some() {
self.request_route(cx);
} else {
self.set_status(
cx,
"Position set — search a place or double-click to route there",
);
}
}
}
if self.ui.button(cx, ids!(go_button)).clicked(actions) {
self.start_nav(cx);
}
if self.ui.button(cx, ids!(end_button)).clicked(actions) {
self.end_nav(cx);
}
if self.ui.button(cx, ids!(layers_button)).clicked(actions) {
self.layers_open = !self.layers_open;
self.ui
.view(cx, ids!(layers_panel))
.set_visible(cx, self.layers_open);
}
// Track layer state app-side: reading .active(cx) in the same
// event pass as changed() races the widget state (same class of
// bug as the layers panel visibility).
let mut layers_changed = false;
if let Some(value) = self.ui.check_box(cx, ids!(layer_chargers)).changed(actions) {
self.layer_states[0] = value;
layers_changed = true;
}
if let Some(value) = self
.ui
.check_box(cx, ids!(layer_chargers_slow))
.changed(actions)
{
self.layer_states[6] = value;
layers_changed = true;
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_transit)).changed(actions) {
self.layer_states[1] = value;
layers_changed = true;
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_nature)).changed(actions) {
self.layer_states[2] = value;
layers_changed = true;
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_districts)).changed(actions) {
self.layer_states[3] = value;
layers_changed = true;
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_bag)).changed(actions) {
self.layer_states[4] = value;
layers_changed = true;
}
if let Some(value) = self
.ui
.check_box(cx, ids!(layer_population))
.changed(actions)
{
self.layer_states[5] = value;
layers_changed = true;
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_rain)).changed(actions) {
self.rain_on = value;
if value {
self.ensure_rain_worker();
}
self.apply_rain(cx);
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_wind)).changed(actions) {
self.wind_on = value;
if value {
self.ensure_wind_worker();
}
self.apply_wind(cx);
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_terrain)).changed(actions) {
self.terrain_on = value;
if value {
self.request_terrain(cx);
} else {
self.map(cx)
.set_terrain_overlay(cx, TerrainOverlayData::default());
}
}
if layers_changed {
self.apply_overlay_selection(cx);
}
// Theme rows behave as radio buttons: circuit wins, then night.
if let Some(value) = self.ui.check_box(cx, ids!(layer_night)).changed(actions) {
if value {
self.ui.check_box(cx, ids!(layer_circuit)).set_active(cx, false, Animate::No);
self.map(cx).set_theme(cx, 1);
} else {
self.map(cx).set_theme(cx, 0);
}
}
if let Some(value) = self.ui.check_box(cx, ids!(layer_circuit)).changed(actions) {
if value {
self.ui.check_box(cx, ids!(layer_night)).set_active(cx, false, Animate::No);
self.map(cx).set_theme(cx, 2);
} else {
self.map(cx).set_theme(cx, 0);
}
}
if self.ui.button(cx, ids!(tilt_button)).clicked(actions) {
self.tilt_target = if self.tilt_target > 0.0 { 0.0 } else { 42.0 };
self.tilt_next_frame = cx.new_next_frame();
self.sync_tilt_button(cx);
}
if self.ui.button(cx, ids!(zoom_in_button)).clicked(actions) {
if let Some(zoom) = self.map(cx).map_zoom() {
self.map(cx).set_map_zoom(cx, zoom + 1.0);
}
}
if self.ui.button(cx, ids!(zoom_out_button)).clicked(actions) {
if let Some(zoom) = self.map(cx).map_zoom() {
self.map(cx).set_map_zoom(cx, zoom - 1.0);
}
}
if self.ui.button(cx, ids!(recenter_button)).clicked(actions) {
// Attach/detach toggle: follow the puck, or roam freely.
self.follow = !self.follow;
let label = if self.follow { "Detach" } else { "Follow" };
self.ui
.button(cx, ids!(recenter_button))
.set_text(cx, label);
if self.follow {
if let Some(pos) = self.position {
self.map(cx).set_center(cx, pos.lon, pos.lat);
}
}
}
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
crate::elev_graph::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
// Worker responses
while let Ok(update) = self.terrain_rx.try_recv() {
if self.terrain_on {
self.map(cx).set_terrain_overlay(
cx,
TerrainOverlayData {
texels: update.texels,
width: update.width,
height: update.height,
elev_texels: update.elev_texels,
elev: update.elev,
elev_width: update.elev_width,
elev_height: update.elev_height,
bbox: update.bbox,
},
);
}
}
while let Ok(update) = self.wind_rx.try_recv() {
self.wind_cache = Some(update);
if self.wind_on {
self.apply_wind(cx);
}
}
while let Ok(update) = self.rain_rx.try_recv() {
self.rain_cache = Some(update);
if self.rain_on {
self.apply_rain(cx);
}
}
while let Ok(response) = self.nav_rx.try_recv() {
match response {
NavResponse::Ready { docs, edges } => {
self.data_ready = true;
self.set_status(
cx,
&format!(
"{} places · {} road edges — search, or double-click to set position",
group_thousands(docs),
group_thousands(edges)
),
);
}
NavResponse::LoadFailed { error } => {
self.set_status(
cx,
&format!(
"Nav data missing ({}). Run: makepad-map-tiles nav-build \
local/maps/noord-holland-latest.osm.pbf {}",
error, NAV_DATA_BASENAME
),
);
}
NavResponse::SearchDone { id, results } => {
if id == self.active_search_id {
self.search_results = results;
self.show_results(cx);
}
}
NavResponse::RouteDone { id, route } => {
if id == self.active_route_id {
match *route {
Some(route) => self.apply_route(cx, route),
None => self.set_status(cx, "No route found for this mode"),
}
}
}
NavResponse::ElevationDone { id, profile } => {
if id == self.active_elevation_id && self.route.is_some() {
if let Some(mut graph) = self
.ui
.widget(cx, ids!(elevation_graph))
.borrow_mut::<crate::elev_graph::ElevationGraph>()
{
graph.set_profile(cx, *profile);
}
}
}
}
}
if self.search_debounce.is_event(event).is_some() {
self.send_search(cx);
}
if self.sim_next_frame.is_event(event).is_some() {
self.tick_sim(cx);
}
if self.tilt_next_frame.is_event(event).is_some() {
let delta = self.tilt_target - self.tilt_current;
if delta.abs() < 0.1 {
self.tilt_current = self.tilt_target;
// Animation settled: fetch terrain for the tilted frustum.
self.last_terrain_request = None;
self.request_terrain(cx);
} else {
self.tilt_current += delta * 0.12;
self.tilt_next_frame = cx.new_next_frame();
}
self.map(cx).set_tilt(cx, self.tilt_current);
}
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}
/// Drape-lite: rasterize base-map landcover polygons into the terrain
/// shade texture, lit by the same hillshade factor, so regional 3D reads
/// as green forested mountainsides instead of bare hypsometric rock.
fn group_thousands(n: usize) -> String {
let digits = n.to_string();
let mut out = String::new();
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out
}