diff --git a/CHANGELOG.md b/CHANGELOG.md index 4357a03..76a7111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 6ce087f..fcfb1be 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -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"] } diff --git a/ui/src/canvas.rs b/ui/src/canvas.rs index 84aa4d2..a4ffb7f 100644 --- a/ui/src/canvas.rs +++ b/ui/src/canvas.rs @@ -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) { - 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, + 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 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 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 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), diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 60be41d..de564ea 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -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::); let sel = RwSignal::new(None::); let dragging = RwSignal::new(false); + // `None` until the map is first drawn, then fitted to the canvas. + let viewport = RwSignal::new(None::); + // 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::("/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::::new(); + let map_panel = NodeRef::::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| { + if attached == Some(true) { + return true; + } + let Some(c) = canvas.get() else { + return false; + }; + let on_wheel = + Closure::::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,14 +249,29 @@ fn App() -> impl IntoView { view! {
-
+
{ + 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() @@ -337,6 +481,11 @@ fn App() -> impl IntoView { "gold square: market, coloured square: workplace"
"flat bar: cart, dashed line: shard border"
+

"Map controls"

+
+ "left drag: select, right click: clear selection"
+ "wheel: zoom, middle drag: pan" +
} diff --git a/ui/src/model.rs b/ui/src/model.rs index 5e3c4fe..199c73b 100644 --- a/ui/src/model.rs +++ b/ui/src/model.rs @@ -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 { diff --git a/ui/style.css b/ui/style.css index 513f332..e95cbee 100644 --- a/ui/style.css +++ b/ui/style.css @@ -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 {