feat(ui): zoom with the wheel, pan with the middle button

The map canvas now fills its panel and draws through a view transform,
so it stays sharp at every zoom level. The wheel zooms around the
pointer, dragging with the middle button pans, and right click clears
the selection. The wheel listener is attached natively and non-passive
because Leptos delegates `wheel` to the document, where browsers ignore
`preventDefault`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-12 21:02:35 +02:00
parent ec861de3ba
commit 2bb369fc88
6 changed files with 268 additions and 35 deletions

View File

@ -18,6 +18,7 @@ Notable changes to this project are documented here.
- Every shard keeps its own database, configured with `SHARD_DATABASE_URL` (default `sqlite://sim-shard-{shard}.db?mode=rwc`).
- The Picard console reaches the actors of the other shards as `@shard-N` (`peers`, `@shard-1 actors`, `send @shard-1 PATH JSON`); `/api/actors/ws?shard=N` connects to shard `N` directly.
- The UI draws markets, workplaces, carts, merchants, crop fields (gold when ripe), all homes and the shard borders, and shows each person's job and coins plus economy and employment statistics.
- The map fills its panel: the mouse wheel zooms around the pointer, dragging with the middle button pans, and right click clears the selection.
### Changed

View File

@ -11,7 +11,7 @@ leptos = { version = "0.8", features = ["csr"] }
wasm-bindgen = "=0.2.129"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "HtmlElement", "MouseEvent", "WebSocket", "MessageEvent", "Location", "Element", "DomRect", "HtmlDivElement", "Window", "Storage"] }
web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "HtmlElement", "MouseEvent", "WheelEvent", "AddEventListenerOptions", "EventTarget", "WebSocket", "MessageEvent", "Location", "Element", "DomRect", "HtmlDivElement", "Window", "Storage"] }
gloo-net = { version = "0.6", default-features = false, features = ["http", "json"] }
gloo-timers = { version = "0.3", features = ["futures"] }
serde = { version = "1", features = ["derive"] }

View File

@ -1,4 +1,4 @@
use crate::model::{Sel, State, T, Terrain, canvas_len, tile_offset};
use crate::model::{Sel, State, T, Terrain, tile_offset};
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
@ -6,6 +6,68 @@ const COLORS: [&str; 7] = [
"#10305e", "#2a62a8", "#e0d08a", "#6aaa4a", "#3f7d3a", "#8a8a6a", "#aaaaaa",
];
const MAX_ZOOM: f64 = 8.0;
/// Visible part of the map: `zoom` canvas pixels per map pixel, with map pixel (`x`, `y`) at the
/// canvas's top-left corner.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
pub zoom: f64,
pub x: f64,
pub y: f64,
}
impl View {
/// Shows the whole map centred in a `width`×`height` canvas.
pub fn fit(t: &Terrain, width: f64, height: f64) -> Self {
let zoom = Self::fit_zoom(t, width, height);
Self {
zoom,
x: (tile_offset(t.w) - width / zoom) / 2.0,
y: (tile_offset(t.h) - height / zoom) / 2.0,
}
}
fn fit_zoom(t: &Terrain, width: f64, height: f64) -> f64 {
(width / tile_offset(t.w).max(1.0))
.min(height / tile_offset(t.h).max(1.0))
.max(f64::EPSILON)
}
/// Map pixel under canvas pixel (`px`, `py`).
pub fn to_map(self, px: f64, py: f64) -> (f64, f64) {
(self.x + px / self.zoom, self.y + py / self.zoom)
}
/// Zooms by `factor`, keeping the map point under canvas pixel (`px`, `py`) in place.
pub fn zoom_at(&mut self, t: &Terrain, size: (f64, f64), (px, py): (f64, f64), factor: f64) {
let (mx, my) = self.to_map(px, py);
let min = Self::fit_zoom(t, size.0, size.1) / 2.0;
self.zoom = (self.zoom * factor).clamp(min, MAX_ZOOM.max(min));
self.x = mx - px / self.zoom;
self.y = my - py / self.zoom;
self.clamp(t, size);
}
/// Moves the view by (`dx`, `dy`) canvas pixels.
pub fn pan(&mut self, t: &Terrain, size: (f64, f64), dx: f64, dy: f64) {
self.x -= dx / self.zoom;
self.y -= dy / self.zoom;
self.clamp(t, size);
}
/// Keeps at least half of the canvas covered by the map in each direction.
fn clamp(&mut self, t: &Terrain, (width, height): (f64, f64)) {
let (half_w, half_h) = (width / self.zoom / 2.0, height / self.zoom / 2.0);
self.x = self
.x
.clamp(-half_w, (tile_offset(t.w) - half_w).max(-half_w));
self.y = self
.y
.clamp(-half_h, (tile_offset(t.h) - half_h).max(-half_h));
}
}
fn trade_color(trade: &str) -> &'static str {
match trade {
"farm" => "#9acd32",
@ -18,9 +80,16 @@ fn trade_color(trade: &str) -> &'static str {
}
}
pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>) {
canvas.set_width(canvas_len(t.w));
canvas.set_height(canvas_len(t.h));
pub fn draw(
canvas: &HtmlCanvasElement,
t: &Terrain,
s: &State,
sel: Option<Sel>,
view: View,
(width, height): (u32, u32),
) {
canvas.set_width(width);
canvas.set_height(height);
let Some(ctx) = canvas
.get_context("2d")
.ok()
@ -29,6 +98,18 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
else {
return;
};
ctx.set_fill_style_str("#111");
ctx.fill_rect(0.0, 0.0, f64::from(width), f64::from(height));
let _ = ctx.set_transform(
view.zoom,
0.0,
0.0,
view.zoom,
-view.x * view.zoom,
-view.y * view.zoom,
);
// Lines that should stay the same width on screen at every zoom level.
let screen_px = 1.0 / view.zoom;
for y in 0..t.h {
for x in 0..t.w {
@ -144,8 +225,11 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
ctx.fill_rect(0.0, 0.0, tile_offset(t.w), tile_offset(t.h));
ctx.set_stroke_style_str("rgba(255,255,255,0.35)");
ctx.set_line_width(1.0);
let _ = ctx.set_line_dash(&js_sys::Array::of2(&6.0.into(), &4.0.into()));
ctx.set_line_width(screen_px);
let _ = ctx.set_line_dash(&js_sys::Array::of2(
&(6.0 * screen_px).into(),
&(4.0 * screen_px).into(),
));
for shard in s.shards.iter().skip(1) {
ctx.begin_path();
ctx.move_to(tile_offset(shard.0), 0.0);
@ -157,7 +241,7 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
if let Some(sel) = sel {
let (x0, y0, x1, y1) = sel.rect();
ctx.set_stroke_style_str("#fff");
ctx.set_line_width(2.0);
ctx.set_line_width(2.0 * screen_px);
ctx.stroke_rect(
tile_offset(x0),
tile_offset(y0),

View File

@ -3,12 +3,21 @@ mod canvas;
mod editor;
mod model;
use canvas::View;
use leptos::mount::mount_to_body;
use leptos::prelude::*;
use leptos::task::spawn_local;
use model::{Person, Sel, State, Terrain, tile_at};
use wasm_bindgen::prelude::*;
const LEFT_BUTTON: i16 = 0;
const MIDDLE_BUTTON: i16 = 1;
/// Zoom exponent per wheel pixel; one 100 px notch zooms by about 22 %.
const WHEEL_ZOOM_RATE: f64 = 0.002;
/// Edit of the view, given the terrain and the canvas size in device pixels.
type ViewChange<'a> = &'a dyn Fn(&mut View, &Terrain, (f64, f64));
#[wasm_bindgen(start)]
pub fn start() {
console_error_panic_hook::set_once();
@ -21,6 +30,12 @@ fn App() -> impl IntoView {
let terrain = RwSignal::new(None::<Terrain>);
let sel = RwSignal::new(None::<Sel>);
let dragging = RwSignal::new(false);
// `None` until the map is first drawn, then fitted to the canvas.
let viewport = RwSignal::new(None::<View>);
// Canvas size in device pixels, following the map panel.
let canvas_size = RwSignal::new((0u32, 0u32));
// Last pointer position while panning with the middle mouse button.
let pan_from = StoredValue::new(None::<(i32, i32)>);
let tool = RwSignal::new("inspect".to_string());
let param = RwSignal::new(String::new());
let amount = RwSignal::new("5".to_string());
@ -37,7 +52,15 @@ fn App() -> impl IntoView {
last_rev.set_value(Some(s.rev));
spawn_local(async move {
match api::get_json::<Terrain>("/api/terrain").await {
Some(t) => terrain.set(Some(t)),
Some(t) => {
let resized = terrain.with_untracked(|old| {
old.as_ref().is_none_or(|old| (old.w, old.h) != (t.w, t.h))
});
if resized {
viewport.set(None);
}
terrain.set(Some(t));
}
None => last_rev.set_value(None),
}
});
@ -46,35 +69,138 @@ fn App() -> impl IntoView {
});
let canvas = NodeRef::<leptos::html::Canvas>::new();
let map_panel = NodeRef::<leptos::html::Div>::new();
let measure = move || {
if let Some(panel) = map_panel.get_untracked() {
let ratio = window().device_pixel_ratio();
let device_px = |css_px: i32| {
// Rounded and clamped to `u32`, so the cast can't truncate.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let px = (f64::from(css_px) * ratio)
.round()
.clamp(0.0, f64::from(u32::MAX)) as u32;
px
};
canvas_size.set((
device_px(panel.client_width()),
device_px(panel.client_height()),
));
}
};
Effect::new(move |_| {
if map_panel.get().is_some() {
measure();
}
});
window_event_listener(leptos::ev::resize, move |_| measure());
Effect::new(move |_| {
let sel = sel.get();
let size = canvas_size.get();
if size.0 == 0 || size.1 == 0 {
return;
}
state.with(|s| {
terrain.with(|t| {
if let (Some(c), Some(s), Some(t)) = (canvas.get(), s.as_ref(), t.as_ref()) {
canvas::draw(&c, t, s, sel);
let view = viewport.get().unwrap_or_else(|| {
let fitted = View::fit(t, f64::from(size.0), f64::from(size.1));
viewport.update_untracked(|v| *v = Some(fitted));
fitted
});
canvas::draw(&c, t, s, sel, view, size);
}
});
});
});
let tile = move |ev: &web_sys::MouseEvent| -> Option<(usize, usize)> {
// Canvas pixel under the pointer and the canvas pixels per CSS pixel.
let canvas_point = move |ev: &web_sys::MouseEvent| -> Option<((f64, f64), f64)> {
let c = canvas.get_untracked()?;
// Map through the displayed size so CSS scaling or zoom can't skew the tile.
let r = c.get_bounding_client_rect();
let (sx, sy) = (
f64::from(c.width()) / r.width().max(1.0),
f64::from(c.height()) / r.height().max(1.0),
);
let px = (f64::from(ev.client_x()) - r.left()) * sx;
let py = (f64::from(ev.client_y()) - r.top()) * sy;
let scale = f64::from(c.width()) / r.width().max(1.0);
let px = (f64::from(ev.client_x()) - r.left()) * scale;
let py = (f64::from(ev.client_y()) - r.top()) * scale;
Some(((px, py), scale))
};
let tile = move |ev: &web_sys::MouseEvent| -> Option<(usize, usize)> {
let ((px, py), _) = canvas_point(ev)?;
let (mx, my) = viewport.get_untracked()?.to_map(px, py);
terrain.with_untracked(|t| {
t.as_ref()
.map(|t| (tile_at(px, t.w - 1), tile_at(py, t.h - 1)))
.map(|t| (tile_at(mx, t.w - 1), tile_at(my, t.h - 1)))
})
};
window_event_listener(leptos::ev::mouseup, move |_| {
if !dragging.get_untracked() {
// Applies `change` to the view with the current terrain and canvas size.
let adjust_view = move |change: ViewChange| {
let (w, h) = canvas_size.get_untracked();
terrain.with_untracked(|t| {
if let Some(t) = t {
viewport.update(|v| {
if let Some(v) = v {
change(v, t, (f64::from(w), f64::from(h)));
}
});
}
});
};
// Leptos delegates `wheel` to the document, where browsers make it passive, so a native,
// non-passive listener is needed for `prevent_default` to stop the page from scrolling.
Effect::new(move |attached: Option<bool>| {
if attached == Some(true) {
return true;
}
let Some(c) = canvas.get() else {
return false;
};
let on_wheel =
Closure::<dyn Fn(web_sys::WheelEvent)>::new(move |ev: web_sys::WheelEvent| {
ev.prevent_default();
let Some((point, _)) = canvas_point(&ev) else {
return;
};
// Line-based deltas (Firefox) are roughly 16 pixels each.
let delta = if ev.delta_mode() == web_sys::WheelEvent::DOM_DELTA_LINE {
ev.delta_y() * 16.0
} else {
ev.delta_y()
};
let factor = (-delta.clamp(-100.0, 100.0) * WHEEL_ZOOM_RATE).exp();
adjust_view(&|v, t, size| v.zoom_at(t, size, point, factor));
});
let options = web_sys::AddEventListenerOptions::new();
options.set_passive(false);
let _ = c.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
on_wheel.as_ref().unchecked_ref(),
&options,
);
// The canvas lives as long as the page.
on_wheel.forget();
true
});
window_event_listener(leptos::ev::mousemove, move |ev| {
let Some((lx, ly)) = pan_from.get_value() else {
return;
};
let Some((_, scale)) = canvas_point(&ev) else {
return;
};
pan_from.set_value(Some((ev.client_x(), ev.client_y())));
let dx = f64::from(ev.client_x() - lx) * scale;
let dy = f64::from(ev.client_y() - ly) * scale;
adjust_view(&|v, t, size| v.pan(t, size, dx, dy));
});
window_event_listener(leptos::ev::mouseup, move |ev| {
if ev.button() == MIDDLE_BUTTON {
pan_from.set_value(None);
}
if ev.button() != LEFT_BUTTON || !dragging.get_untracked() {
return;
}
dragging.set(false);
@ -110,7 +236,10 @@ fn App() -> impl IntoView {
state.with(|s| {
terrain.with(|t| match (sel, s.as_ref(), t.as_ref()) {
(Some(sel), Some(s), Some(t)) => model::describe(sel, s, t),
_ => vec!["click a tile or drag to select a region".to_string()],
_ => vec![
"click a tile or drag to select a region; right click clears the selection"
.to_string(),
],
})
})
};
@ -120,15 +249,30 @@ fn App() -> impl IntoView {
view! {
<style>{include_str!("../style.css")}</style>
<div class="app">
<div class="map">
<div class="map" node_ref=map_panel>
<canvas
node_ref=canvas
on:mousedown=move |ev| {
on:mousedown=move |ev| match ev.button() {
LEFT_BUTTON => {
if let Some((x, y)) = tile(&ev) {
sel.set(Some(Sel { ax: x, ay: y, bx: x, by: y }));
dragging.set(true);
}
}
MIDDLE_BUTTON => {
// Stops the browser's middle-click autoscroll.
ev.prevent_default();
pan_from.set_value(Some((ev.client_x(), ev.client_y())));
}
_ => {}
}
on:auxclick=move |ev| ev.prevent_default()
on:contextmenu=move |ev| {
ev.prevent_default();
dragging.set(false);
sel.set(None);
editing.update(|e| *e = (e.0 + 1, Vec::new()));
}
on:mousemove=move |ev| {
if dragging.get_untracked()
&& let Some((x, y)) = tile(&ev)
@ -337,6 +481,11 @@ fn App() -> impl IntoView {
"gold square: market, coloured square: workplace" <br />
"flat bar: cart, dashed line: shard border"
</div>
<h3>"Map controls"</h3>
<div>
"left drag: select, right click: clear selection" <br />
"wheel: zoom, middle drag: pan"
</div>
</div>
</div>
}

View File

@ -9,11 +9,6 @@ pub fn tile_offset(n: usize) -> f64 {
f64::from(u32::try_from(n).unwrap_or(u32::MAX)) * T
}
/// Canvas length in pixels of `n` tiles.
pub fn canvas_len(n: usize) -> u32 {
u32::try_from(n).map_or(u32::MAX, |n| n.saturating_mul(TILE_PX))
}
/// Tile index under canvas pixel offset `px`, clamped to `0..=max`.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn tile_at(px: f64, max: usize) -> usize {

View File

@ -12,12 +12,14 @@ body {
.map {
flex: 1;
min-width: 0;
overflow: auto;
max-height: 100vh;
height: 100vh;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 900px) {
@ -26,7 +28,9 @@ canvas {
}
.map {
max-height: none;
flex: none;
width: 100%;
height: 70vh;
}
.side {