use bevy::asset::RenderAssetUsages;
use bevy::camera::ScalingMode;
use bevy::input::mouse::AccumulatedMouseScroll;
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_a5::prelude::*;
use bevy_a5::query::grid_disk;
use bevy_slippy_tiles::*;
use std::collections::HashSet;
const CENTER_LAT: f64 = 48.8566;
const CENTER_LON: f64 = 2.3522;
const REFERENCE_ZOOM: ZoomLevel = ZoomLevel::L8;
const REFERENCE_ZOOM_INT: i32 = 8;
const DEFAULT_TILE_ZOOM: ZoomLevel = ZoomLevel::L8;
const MIN_TILE_ZOOM_INT: i32 = 4;
const MAX_TILE_ZOOM_INT: i32 = 17;
const DEFAULT_RESOLUTION: i32 = 5;
const TILE_SIZE: f32 = 256.0;
const VIEWPORT_HEIGHT_MIN: f32 = 80.0;
const VIEWPORT_HEIGHT_MAX: f32 = 4096.0;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(BevyA5Plugin)
.add_plugins(SlippyTilesPlugin)
.insert_resource(SlippyTilesSettings {
auto_render: false,
..default()
})
.insert_resource(
PlanetSettings::earth(),
)
.insert_resource(MapState::default())
.insert_resource(HoveredCell::default())
.insert_resource(GridResolution(DEFAULT_RESOLUTION))
.insert_resource(GridMeshState::default())
.insert_resource(TileTracker::default())
.insert_resource(ActiveTileZoom(DEFAULT_TILE_ZOOM))
.insert_resource(DropdownState::default())
.add_systems(Startup, setup)
.add_systems(
Update,
(
pan_camera_keyboard,
pan_camera_drag,
zoom_camera,
resolution_toggle_button,
resolution_option_button,
keyboard_resolution_shortcut,
update_dropdown_visibility,
update_resolution_dropdown_label,
update_active_tile_zoom,
request_visible_tiles,
handle_tile_downloads,
rebuild_grid_mesh,
update_hovered_cell,
draw_grid_overlay,
update_cell_id_text,
)
.chain(),
)
.run();
}
#[derive(Resource, Default)]
struct MapState {
center_tile: Option<SlippyTileCoordinates>,
}
#[derive(Resource, Default)]
struct HoveredCell(Option<GeoCell>);
#[derive(Resource)]
struct GridResolution(i32);
#[derive(Resource)]
struct ActiveTileZoom(ZoomLevel);
#[derive(Resource, Default)]
struct DropdownState {
open: bool,
}
#[derive(Component)]
struct MapCamera;
#[derive(Component)]
struct CellIdText;
#[derive(Component)]
struct GridMesh;
#[derive(Component)]
struct TileQuad;
#[derive(Component)]
struct ResolutionToggleButton;
#[derive(Component)]
struct ResolutionToggleLabel;
#[derive(Component)]
struct ResolutionOptionsPanel;
#[derive(Component)]
struct ResolutionOption(i32);
#[derive(Resource, Default)]
struct GridMeshState {
built_resolution: Option<i32>,
built_center_world: Option<Vec2>,
built_radius_m: f64,
}
#[derive(Resource, Default)]
struct TileTracker {
requested: HashSet<(u8, u32, u32)>,
spawned: HashSet<(u8, u32, u32)>,
}
#[derive(Resource, Default)]
struct DragState {
last_cursor: Option<Vec2>,
dragging: bool,
}
fn setup(mut commands: Commands, mut map_state: ResMut<MapState>) {
commands.insert_resource(DragState::default());
commands.spawn((
MapCamera,
Camera3d::default(),
Projection::from(OrthographicProjection {
scaling_mode: ScalingMode::FixedVertical {
viewport_height: TILE_SIZE * 5.0,
},
..OrthographicProjection::default_3d()
}),
Transform::from_translation(Vec3::new(0.0, 500.0, 0.0))
.looking_at(Vec3::ZERO, Vec3::NEG_Z),
));
let center_tile =
SlippyTileCoordinates::from_latitude_longitude(CENTER_LAT, CENTER_LON, REFERENCE_ZOOM);
map_state.center_tile = Some(center_tile);
commands.spawn((
DirectionalLight {
illuminance: 20_000.0,
shadows_enabled: false,
..default()
},
Transform::from_rotation(Quat::from_euler(
EulerRot::XYZ,
-std::f32::consts::FRAC_PI_2,
0.0,
0.0,
)),
));
let origin_cell = GeoCell::from_lon_lat(CENTER_LON, CENTER_LAT, DEFAULT_RESOLUTION)
.expect("Center coordinates should be valid");
commands.spawn((FloatingOrigin::default(), origin_cell, Transform::default()));
commands.spawn((
CellIdText,
Text::new("Cell: —"),
TextFont {
font_size: 16.0,
..default()
},
TextColor(Color::WHITE),
Node {
position_type: PositionType::Absolute,
top: Val::Px(8.0),
right: Val::Px(12.0),
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
..default()
},
BackgroundColor(Color::linear_rgba(0.0, 0.0, 0.0, 0.55)),
));
commands
.spawn(Node {
position_type: PositionType::Absolute,
top: Val::Px(8.0),
left: Val::Px(12.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(2.0),
..default()
})
.with_children(|parent| {
parent
.spawn((
ResolutionToggleButton,
Button,
Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(6.0)),
min_width: Val::Px(160.0),
..default()
},
BackgroundColor(TOGGLE_BG_IDLE),
))
.with_children(|btn| {
btn.spawn((
ResolutionToggleLabel,
Text::new(format!("Resolution: {} ▾", DEFAULT_RESOLUTION)),
TextFont {
font_size: 16.0,
..default()
},
TextColor(Color::WHITE),
));
});
parent
.spawn((
ResolutionOptionsPanel,
Node {
display: Display::None,
flex_direction: FlexDirection::Column,
min_width: Val::Px(160.0),
..default()
},
BackgroundColor(PANEL_BG),
))
.with_children(|panel| {
for res in MIN_RESOLUTION..=MAX_RESOLUTION {
panel
.spawn((
ResolutionOption(res),
Button,
Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
..default()
},
BackgroundColor(option_bg(res, DEFAULT_RESOLUTION, false)),
))
.with_children(|opt| {
opt.spawn((
Text::new(format!("Resolution {}", res)),
TextFont {
font_size: 15.0,
..default()
},
TextColor(Color::WHITE),
));
});
}
});
});
info!("Overhead map example");
info!("Slippy zoom adapts to camera viewport_height; tiles are re-fetched on zoom change.");
info!("Controls: WASD or drag to pan, mouse-wheel or Q/E to zoom, dropdown or 1..0/[ ] for A5 resolution");
}
const TOGGLE_BG_IDLE: Color = Color::linear_rgba(0.10, 0.10, 0.10, 0.85);
const TOGGLE_BG_HOVER: Color = Color::linear_rgba(0.20, 0.20, 0.20, 0.90);
const TOGGLE_BG_PRESSED: Color = Color::linear_rgba(0.25, 0.25, 0.25, 0.95);
const PANEL_BG: Color = Color::linear_rgba(0.08, 0.08, 0.08, 0.95);
fn option_bg(option_res: i32, active_res: i32, hovered: bool) -> Color {
if option_res == active_res {
if hovered {
Color::linear_rgba(0.55, 0.40, 0.10, 0.95)
} else {
Color::linear_rgba(0.45, 0.30, 0.05, 0.85)
}
} else if hovered {
Color::linear_rgba(0.30, 0.30, 0.30, 0.95)
} else {
Color::linear_rgba(0.0, 0.0, 0.0, 0.0)
}
}
fn request_visible_tiles(
map_state: Res<MapState>,
active_zoom: Res<ActiveTileZoom>,
cameras: Query<(&Projection, &GlobalTransform), With<MapCamera>>,
windows: Query<&Window, With<PrimaryWindow>>,
mut tracker: ResMut<TileTracker>,
mut tile_requests: MessageWriter<DownloadSlippyTilesMessage>,
) {
let Some(center_tile_ref) = &map_state.center_tile else {
return;
};
let Ok((projection, gt)) = cameras.single() else {
return;
};
let Ok(window) = windows.single() else {
return;
};
let Projection::Orthographic(ortho) = projection else {
return;
};
let ScalingMode::FixedVertical { viewport_height } = ortho.scaling_mode else {
return;
};
let win_size = window.size();
if win_size.x <= 0.0 || win_size.y <= 0.0 {
return;
}
let aspect = win_size.x / win_size.y;
let half_h = viewport_height * 0.5;
let half_w = half_h * aspect;
let cam = gt.translation();
let center_ll = center_tile_ref.to_latitude_longitude(REFERENCE_ZOOM);
let (lon_min, lat_max) =
map_pos_to_lonlat(cam.x - half_w, cam.z - half_h, center_ll.longitude, center_ll.latitude);
let (lon_max, lat_min) =
map_pos_to_lonlat(cam.x + half_w, cam.z + half_h, center_ll.longitude, center_ll.latitude);
let zoom = active_zoom.0;
let zoom_byte = zoom.to_u8();
let tl = SlippyTileCoordinates::from_latitude_longitude(lat_max, lon_min, zoom);
let br = SlippyTileCoordinates::from_latitude_longitude(lat_min, lon_max, zoom);
let min_tx = tl.x.min(br.x) as i64;
let max_tx = tl.x.max(br.x) as i64;
let min_ty = tl.y.min(br.y) as i64;
let max_ty = tl.y.max(br.y) as i64;
let max_idx = (1i64 << zoom_byte) - 1;
const MAX_NEW_PER_FRAME: usize = 32;
let mut queued = 0usize;
for tx in min_tx..=max_tx {
if tx < 0 || tx > max_idx {
continue;
}
for ty in min_ty..=max_ty {
if ty < 0 || ty > max_idx {
continue;
}
let key = (zoom_byte, tx as u32, ty as u32);
if tracker.requested.contains(&key) {
continue;
}
tracker.requested.insert(key);
tile_requests.write(DownloadSlippyTilesMessage {
tile_size: TileSize::Normal,
zoom_level: zoom,
coordinates: Coordinates::SlippyTile(SlippyTileCoordinates {
x: key.1,
y: key.2,
}),
radius: Radius(0),
use_cache: true,
});
queued += 1;
if queued >= MAX_NEW_PER_FRAME {
return;
}
}
}
}
fn handle_tile_downloads(
mut commands: Commands,
mut tile_messages: MessageReader<SlippyTileDownloadedMessage>,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut tracker: ResMut<TileTracker>,
map_state: Res<MapState>,
active_zoom: Res<ActiveTileZoom>,
) {
let Some(center_tile_ref) = &map_state.center_tile else {
return;
};
let center_ll = center_tile_ref.to_latitude_longitude(REFERENCE_ZOOM);
let center_lon = center_ll.longitude;
let center_lat = center_ll.latitude;
let active_byte = active_zoom.0.to_u8();
for message in tile_messages.read() {
let tile_coords = message.get_slippy_tile_coordinates();
let zoom_byte = message.zoom_level.to_u8();
if zoom_byte != active_byte {
continue;
}
let key = (zoom_byte, tile_coords.x, tile_coords.y);
if tracker.spawned.contains(&key) {
continue;
}
tracker.spawned.insert(key);
let tl = tile_coords.to_latitude_longitude(message.zoom_level);
let br = SlippyTileCoordinates {
x: tile_coords.x + 1,
y: tile_coords.y + 1,
}
.to_latitude_longitude(message.zoom_level);
let world_tl = lonlat_to_map_pos(tl.longitude, tl.latitude, center_lon, center_lat);
let world_br = lonlat_to_map_pos(br.longitude, br.latitude, center_lon, center_lat);
let world_x = (world_tl.x + world_br.x) * 0.5;
let world_z = (world_tl.z + world_br.z) * 0.5;
let tile_size_x = (world_br.x - world_tl.x).abs();
let tile_size_z = (world_br.z - world_tl.z).abs();
let image_handle: Handle<Image> = asset_server.load(message.path.clone());
commands.spawn((
TileQuad,
Mesh3d(meshes.add(Plane3d::new(Vec3::Y, Vec2::splat(0.5)))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color_texture: Some(image_handle),
unlit: true,
..default()
})),
Transform {
translation: Vec3::new(world_x, 0.0, world_z),
scale: Vec3::new(tile_size_x, 1.0, tile_size_z),
..default()
},
));
}
}
fn update_active_tile_zoom(
cameras: Query<&Projection, With<MapCamera>>,
mut active_zoom: ResMut<ActiveTileZoom>,
mut tracker: ResMut<TileTracker>,
mut commands: Commands,
tiles_q: Query<Entity, With<TileQuad>>,
) {
let Ok(projection) = cameras.single() else {
return;
};
let Projection::Orthographic(ortho) = projection else {
return;
};
let ScalingMode::FixedVertical { viewport_height } = ortho.scaling_mode else {
return;
};
let current_int = active_zoom.0.to_u8() as i32;
let current_tile_world =
TILE_SIZE / 2.0_f32.powi(current_int - REFERENCE_ZOOM_INT);
let target_tile_world = (viewport_height / 4.0).max(1.0);
let new_int = if target_tile_world > current_tile_world * 2.0 {
let steps = (target_tile_world / current_tile_world).log2().floor() as i32;
(current_int - steps).max(MIN_TILE_ZOOM_INT)
} else if target_tile_world < current_tile_world * 0.5 {
let steps = (current_tile_world / target_tile_world).log2().floor() as i32;
(current_int + steps).min(MAX_TILE_ZOOM_INT)
} else {
current_int
};
if new_int == current_int {
return;
}
let Ok(new_zoom) = ZoomLevel::try_from(new_int as u8) else {
return;
};
info!("Slippy tile zoom: {:?} -> {:?}", active_zoom.0, new_zoom);
active_zoom.0 = new_zoom;
tracker.requested.clear();
tracker.spawned.clear();
for entity in tiles_q.iter() {
commands.entity(entity).despawn();
}
}
fn pan_camera_keyboard(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform, &Projection), With<MapCamera>>,
) {
let Ok((mut transform, projection)) = query.single_mut() else {
return;
};
let viewport_h = match projection {
Projection::Orthographic(o) => match o.scaling_mode {
ScalingMode::FixedVertical { viewport_height } => viewport_height,
_ => 512.0,
},
_ => 512.0,
};
let speed = viewport_h * 0.6 * time.delta_secs();
let mut delta = Vec3::ZERO;
if keys.pressed(KeyCode::KeyW) {
delta.z -= speed;
}
if keys.pressed(KeyCode::KeyS) {
delta.z += speed;
}
if keys.pressed(KeyCode::KeyA) {
delta.x -= speed;
}
if keys.pressed(KeyCode::KeyD) {
delta.x += speed;
}
transform.translation += delta;
}
fn pan_camera_drag(
mouse_buttons: Res<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &Projection, &GlobalTransform), With<MapCamera>>,
mut camera_transforms: Query<&mut Transform, With<MapCamera>>,
button_interactions: Query<&Interaction, With<Button>>,
mut drag: ResMut<DragState>,
) {
let Ok(window) = windows.single() else {
return;
};
let Ok((camera, projection, _camera_global)) = cameras.single() else {
return;
};
let Ok(mut transform) = camera_transforms.single_mut() else {
return;
};
let cursor = window.cursor_position();
let over_ui = button_interactions
.iter()
.any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
if mouse_buttons.just_pressed(MouseButton::Left) && !over_ui {
drag.dragging = true;
drag.last_cursor = cursor;
}
if mouse_buttons.just_released(MouseButton::Left) {
drag.dragging = false;
drag.last_cursor = None;
}
if !drag.dragging {
drag.last_cursor = cursor;
return;
}
let (Some(cur), Some(prev)) = (cursor, drag.last_cursor) else {
drag.last_cursor = cursor;
return;
};
if cur == prev {
return;
}
let viewport_h = match projection {
Projection::Orthographic(o) => match o.scaling_mode {
ScalingMode::FixedVertical { viewport_height } => viewport_height,
_ => 512.0,
},
_ => 512.0,
};
let Some(viewport_size) = camera.logical_viewport_size() else {
return;
};
if viewport_size.y <= 0.0 {
return;
}
let world_per_pixel = viewport_h / viewport_size.y;
let pixel_delta = cur - prev;
transform.translation.x -= pixel_delta.x * world_per_pixel;
transform.translation.z -= pixel_delta.y * world_per_pixel;
drag.last_cursor = cursor;
}
fn zoom_camera(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
scroll: Res<AccumulatedMouseScroll>,
mut query: Query<&mut Projection, With<MapCamera>>,
) {
let Ok(mut projection) = query.single_mut() else {
return;
};
let Projection::Orthographic(ref mut ortho) = *projection else {
return;
};
let ScalingMode::FixedVertical {
ref mut viewport_height,
} = ortho.scaling_mode
else {
return;
};
let mut zoom_steps: f32 = 0.0;
if scroll.delta.y != 0.0 {
zoom_steps -= scroll.delta.y;
}
if keys.pressed(KeyCode::KeyE) {
zoom_steps -= 4.0 * time.delta_secs();
}
if keys.pressed(KeyCode::KeyQ) {
zoom_steps += 4.0 * time.delta_secs();
}
if zoom_steps == 0.0 {
return;
}
let factor = 1.15_f32.powf(zoom_steps);
*viewport_height = (*viewport_height * factor).clamp(VIEWPORT_HEIGHT_MIN, VIEWPORT_HEIGHT_MAX);
}
fn update_hovered_cell(
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform), With<MapCamera>>,
map_state: Res<MapState>,
resolution: Res<GridResolution>,
mut hovered: ResMut<HoveredCell>,
) {
let Some(center_tile) = &map_state.center_tile else {
if hovered.0.is_some() {
hovered.0 = None;
}
return;
};
let Ok(window) = windows.single() else {
return;
};
let Ok((camera, camera_transform)) = cameras.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
if hovered.0.is_some() {
hovered.0 = None;
}
return;
};
let Ok(ray) = camera.viewport_to_world(camera_transform, cursor) else {
return;
};
let dir = ray.direction.as_vec3();
if dir.y.abs() < 1e-6 {
return;
}
let t = -ray.origin.y / dir.y;
if t < 0.0 {
if hovered.0.is_some() {
hovered.0 = None;
}
return;
}
let hit = ray.origin + dir * t;
let center_ll = center_tile.to_latitude_longitude(REFERENCE_ZOOM);
let (lon, lat) = map_pos_to_lonlat(hit.x, hit.z, center_ll.longitude, center_ll.latitude);
let new_cell = GeoCell::from_lon_lat(lon, lat, resolution.0);
if hovered.0 != new_cell {
hovered.0 = new_cell;
}
}
fn rebuild_grid_mesh(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut state: ResMut<GridMeshState>,
resolution: Res<GridResolution>,
map_state: Res<MapState>,
camera_query: Query<(&Camera, &Projection, &GlobalTransform), With<MapCamera>>,
grid_q: Query<Entity, With<GridMesh>>,
) {
let Some(center_tile) = &map_state.center_tile else {
return;
};
let Ok((_, _, camera_gt)) = camera_query.single() else {
return;
};
let center_ll = center_tile.to_latitude_longitude(REFERENCE_ZOOM);
let center_lon = center_ll.longitude;
let center_lat = center_ll.latitude;
let scale = lonlat_scale(center_lat);
let visible_radius_m = visible_radius_meters(&camera_query, scale);
let build_radius_m = visible_radius_m * 2.0;
let cam_world = camera_gt.translation();
let cam_xz = Vec2::new(cam_world.x, cam_world.z);
let needs_resolution_rebuild =
resolution.is_changed() || state.built_resolution != Some(resolution.0);
let needs_pan_rebuild = match state.built_center_world {
None => true,
Some(prev) => {
let drift_world = (cam_xz - prev).length() as f64;
let metres_per_world = 110_574.0 / scale.1.max(1e-6);
let drift_m = drift_world * metres_per_world;
drift_m > (build_radius_m - visible_radius_m) * 0.5
|| visible_radius_m * 2.0 > state.built_radius_m
}
};
if !needs_resolution_rebuild && !needs_pan_rebuild {
return;
}
let (cam_lon, cam_lat) =
map_pos_to_lonlat(cam_world.x, cam_world.z, center_lon, center_lat);
let Some(cam_cell) = GeoCell::from_lon_lat(cam_lon, cam_lat, resolution.0) else {
return;
};
const MAX_CELLS: usize = 200_000;
let max_k_by_budget =
(((MAX_CELLS as f64 - 1.0) * 2.0 / 5.0).sqrt() as usize).max(1);
let cell_area_m2 = cam_cell.area().max(1e-3);
let cell_edge_m = cell_area_m2.sqrt();
let k_for_coverage =
((build_radius_m / cell_edge_m).ceil() as usize).max(1) + 1;
let k = k_for_coverage.min(max_k_by_budget);
if k_for_coverage > max_k_by_budget {
info!(
"Grid disk capped at k={} (would need k={} for full coverage at resolution {}); centre region only",
max_k_by_budget, k_for_coverage, resolution.0
);
}
let cells = grid_disk(&cam_cell, k).unwrap_or_default();
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut offset: u32 = 0;
for cell in &cells {
let Some(boundary) = cell.boundary() else {
continue;
};
let verts: Vec<_> = if boundary.len() > 1 && boundary.first() == boundary.last() {
boundary[..boundary.len() - 1].to_vec()
} else {
boundary
};
if verts.is_empty() {
continue;
}
for ll in &verts {
let p = lonlat_to_map_pos(ll.longitude(), ll.latitude(), center_lon, center_lat);
positions.push([p.x, p.y, p.z]);
}
let n = verts.len() as u32;
for i in 0..n {
indices.push(offset + i);
indices.push(offset + (i + 1) % n);
}
offset += n;
}
let mut new_mesh = Mesh::new(
PrimitiveTopology::LineList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
);
new_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
new_mesh.insert_indices(Indices::U32(indices));
let mut despawned = 0;
for e in grid_q.iter() {
commands.entity(e).despawn();
despawned += 1;
}
let (base_color, emissive) = grid_color_for_resolution(resolution.0);
commands.spawn((
GridMesh,
Mesh3d(meshes.add(new_mesh)),
MeshMaterial3d(materials.add(StandardMaterial {
base_color,
emissive,
unlit: true,
..default()
})),
Transform::default(),
));
info!(
"Rebuilt A5 grid mesh: resolution={}, cells={}, despawned={}",
resolution.0,
cells.len(),
despawned
);
state.built_resolution = Some(resolution.0);
state.built_center_world = Some(cam_xz);
state.built_radius_m = build_radius_m;
}
const MIN_RESOLUTION: i32 = 1;
const MAX_RESOLUTION: i32 = 15;
fn keyboard_resolution_shortcut(
keys: Res<ButtonInput<KeyCode>>,
mut resolution: ResMut<GridResolution>,
mut grid_state: ResMut<GridMeshState>,
mut origin_q: Query<&mut GeoCell, With<FloatingOrigin>>,
) {
let direct = [
(KeyCode::Digit1, 1),
(KeyCode::Digit2, 2),
(KeyCode::Digit3, 3),
(KeyCode::Digit4, 4),
(KeyCode::Digit5, 5),
(KeyCode::Digit6, 6),
(KeyCode::Digit7, 7),
(KeyCode::Digit8, 8),
(KeyCode::Digit9, 9),
(KeyCode::Digit0, 10),
];
let mut target: Option<i32> = None;
for (key, value) in direct {
if keys.just_pressed(key) {
target = Some(value);
break;
}
}
if target.is_none() {
if keys.just_pressed(KeyCode::BracketRight) {
target = Some((resolution.0 + 1).min(MAX_RESOLUTION));
} else if keys.just_pressed(KeyCode::BracketLeft) {
target = Some((resolution.0 - 1).max(MIN_RESOLUTION));
}
}
let Some(new) = target else {
return;
};
if resolution.0 == new {
return;
}
info!("Resolution change requested: {}", new);
resolution.0 = new;
grid_state.built_resolution = None;
if let Ok(mut cell) = origin_q.single_mut() {
if let Some(new_cell) = GeoCell::from_lon_lat(CENTER_LON, CENTER_LAT, new) {
*cell = new_cell;
}
}
}
fn draw_grid_overlay(mut gizmos: Gizmos, map_state: Res<MapState>, hovered: Res<HoveredCell>) {
let Some(center_tile) = &map_state.center_tile else {
return;
};
let Some(cell) = hovered.0 else {
return;
};
let center_ll = center_tile.to_latitude_longitude(REFERENCE_ZOOM);
let center_lon = center_ll.longitude;
let center_lat = center_ll.latitude;
let Some(boundary) = cell.boundary() else {
return;
};
let verts: Vec<_> = if boundary.len() > 1 && boundary.first() == boundary.last() {
boundary[..boundary.len() - 1].to_vec()
} else {
boundary
};
if verts.is_empty() {
return;
}
let color = Color::linear_rgb(1.0, 1.0, 0.0);
let projected: Vec<Vec3> = verts
.iter()
.map(|ll| lonlat_to_map_pos(ll.longitude(), ll.latitude(), center_lon, center_lat))
.collect();
for offset in [Vec3::new(0.0, 0.5, 0.0), Vec3::new(0.0, 1.0, 0.0)] {
for i in 0..projected.len() {
let next = (i + 1) % projected.len();
gizmos.line(projected[i] + offset, projected[next] + offset, color);
}
}
if let Some(ll) = cell.center() {
let pos = lonlat_to_map_pos(ll.longitude(), ll.latitude(), center_lon, center_lat);
gizmos.sphere(Isometry3d::from_translation(pos), 2.0, color);
}
}
fn visible_radius_meters(
camera_query: &Query<(&Camera, &Projection, &GlobalTransform), With<MapCamera>>,
scale: (f64, f64),
) -> f64 {
let Ok((camera, projection, _)) = camera_query.single() else {
return 200_000.0;
};
let viewport_h = match projection {
Projection::Orthographic(o) => match o.scaling_mode {
ScalingMode::FixedVertical { viewport_height } => viewport_height,
_ => 512.0,
},
_ => 512.0,
};
let aspect = camera
.logical_viewport_size()
.map(|s| if s.y > 0.0 { s.x / s.y } else { 1.0 })
.unwrap_or(1.0);
let viewport_w = viewport_h * aspect;
let half_diag_world = ((viewport_w * viewport_w + viewport_h * viewport_h).sqrt() * 0.5) as f64;
let metres_per_world = 110_574.0 / scale.1.max(1e-6);
half_diag_world * metres_per_world
}
fn update_cell_id_text(
hovered: Res<HoveredCell>,
mut text_query: Query<&mut Text, With<CellIdText>>,
) {
if !hovered.is_changed() {
return;
}
let Ok(mut text) = text_query.single_mut() else {
return;
};
text.0 = match hovered.0 {
Some(cell) => format!("Cell: 0x{:016x} (res {})", cell.raw(), cell.resolution()),
None => "Cell: —".to_string(),
};
}
fn grid_color_for_resolution(resolution: i32) -> (Color, LinearRgba) {
let span = (MAX_RESOLUTION - MIN_RESOLUTION).max(1) as f32;
let t = ((resolution - MIN_RESOLUTION).clamp(0, MAX_RESOLUTION - MIN_RESOLUTION)) as f32
/ span;
let hue = t * 360.0;
let base = Color::hsl(hue, 0.95, 0.55);
let emissive_color = Color::hsl(hue, 0.95, 0.65);
let emissive = LinearRgba::from(emissive_color) * 1.5;
(base, emissive)
}
fn resolution_toggle_button(
mut q: Query<
(&Interaction, &mut BackgroundColor),
(Changed<Interaction>, With<ResolutionToggleButton>),
>,
mut state: ResMut<DropdownState>,
) {
for (interaction, mut bg) in q.iter_mut() {
match *interaction {
Interaction::Pressed => {
state.open = !state.open;
bg.0 = TOGGLE_BG_PRESSED;
}
Interaction::Hovered => bg.0 = TOGGLE_BG_HOVER,
Interaction::None => bg.0 = TOGGLE_BG_IDLE,
}
}
}
fn resolution_option_button(
interaction_q: Query<
(&Interaction, &ResolutionOption),
Changed<Interaction>,
>,
mut style_q: Query<(&ResolutionOption, &Interaction, &mut BackgroundColor)>,
mut resolution: ResMut<GridResolution>,
mut grid_state: ResMut<GridMeshState>,
mut origin_q: Query<&mut GeoCell, With<FloatingOrigin>>,
mut dropdown: ResMut<DropdownState>,
) {
for (interaction, opt) in interaction_q.iter() {
if matches!(*interaction, Interaction::Pressed) && resolution.0 != opt.0 {
info!("Resolution selected: {}", opt.0);
resolution.0 = opt.0;
grid_state.built_resolution = None;
if let Ok(mut cell) = origin_q.single_mut() {
if let Some(new_cell) = GeoCell::from_lon_lat(CENTER_LON, CENTER_LAT, opt.0) {
*cell = new_cell;
}
}
dropdown.open = false;
}
}
for (opt, interaction, mut bg) in style_q.iter_mut() {
let hovered = matches!(*interaction, Interaction::Hovered | Interaction::Pressed);
bg.0 = option_bg(opt.0, resolution.0, hovered);
}
}
fn update_dropdown_visibility(
state: Res<DropdownState>,
mut panel_q: Query<&mut Node, With<ResolutionOptionsPanel>>,
) {
if !state.is_changed() {
return;
}
let Ok(mut node) = panel_q.single_mut() else {
return;
};
node.display = if state.open {
Display::Flex
} else {
Display::None
};
}
fn update_resolution_dropdown_label(
resolution: Res<GridResolution>,
mut label_q: Query<&mut Text, With<ResolutionToggleLabel>>,
) {
if !resolution.is_changed() {
return;
}
let Ok(mut label) = label_q.single_mut() else {
return;
};
label.0 = format!("Resolution: {} ▾", resolution.0);
}
fn lonlat_to_map_pos(lon: f64, lat: f64, center_lon: f64, center_lat: f64) -> Vec3 {
let scale = lonlat_scale(center_lat);
let dx = (lon - center_lon) * scale.0;
let dy = (lat - center_lat) * scale.1;
Vec3::new(dx as f32, 1.0, -(dy) as f32)
}
fn map_pos_to_lonlat(x: f32, z: f32, center_lon: f64, center_lat: f64) -> (f64, f64) {
let scale = lonlat_scale(center_lat);
let lon = center_lon + (x as f64) / scale.0;
let lat = center_lat + (-(z as f64)) / scale.1;
(lon, lat)
}
fn lonlat_scale(center_lat: f64) -> (f64, f64) {
let meters_per_deg_lon = 111_320.0 * center_lat.to_radians().cos();
let meters_per_deg_lat = 110_574.0;
let n = 2.0_f64.powi(REFERENCE_ZOOM.to_u8() as i32);
let tile_deg_lon = 360.0 / n;
let tile_meters = tile_deg_lon * meters_per_deg_lon;
let meters_to_world = TILE_SIZE as f64 / tile_meters;
(
meters_per_deg_lon * meters_to_world,
meters_per_deg_lat * meters_to_world,
)
}