src/pages/estate_board.rs
//! **The artifact flow, mounted** (lane HOL-SVG-RON, owner 2026-10-30; was HBOARD-1's estate
//! board; HOL-FLOW-SMART): holger's drawing of its RON and the estate it runs in, with the
//! counters on each, as `AdminService.FlowSvg` serves it at the zoom a page asks for. The
//! console draws nothing: it shows holger's bytes as an image, lays a plain link over each
//! place holger says is clickable (a machine that unfolds, the full RON), and puts the estate
//! board's line under it. ONE page shows it: the Flow page (`/flow`), at the zoom its address
//! opens (lane HOL-UI-FIX, owner 2026-10-31: the Overview showed the same picture again; it
//! shows the estate's state in cards now and links here). `tests/the_board_is_mounted.rs`
//! holds it.
//!
//! **It redraws itself** (F14, the field test of 3.1.0: the picture never changed without a
//! click or a reload): every `refresh_secs` of holger's model, the interval holger's own
//! `/-/flow` page refreshes at, while the page is in sight; never when holger says 0. A redraw
//! holger refuses keeps the drawing and says so in the line under it.
//! `browser-tests/tests/flow_refresh.rs` proves it in a browser.
//!
//! **It zooms by the mouse wheel** (F14b, Henrik 2026-11-01): how large the drawing is shown,
//! not what holger draws (`?open=`). The drawing sits in a frame of its own that scrolls; the
//! wheel lays it out wider or narrower around the pointer (never a CSS transform: holger's
//! links are laid out with it, as shares of it, so each stays on its box), a drag pans it, `+`
//! `-` `0` on the focused frame and the tools' buttons step it, a double click resets it. The
//! zoom stays through holger's redraws; another `?open=` is drawn at holger's own size again.
//! `browser-tests/tests/flow_zoom.rs` proves it in a browser.
use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use crate::components::feedback::{EmptyState, Loading};
use crate::security::{self as sec, GrpcDoor};
/// Is the page out of sight (another tab in front, the window minimised)?
fn out_of_sight() -> bool {
web_sys::window().and_then(|w| w.document()).is_some_and(|d| d.hidden())
}
/// The board as one card: the drawing at the zoom `open` names, holger's links over it, its
/// words under it, the server's refusal in its own code and words. Whether it may be read
/// is holger's call. The drawing shows at holger's own size in a frame of its own that
/// scrolls, and the wheel zooms it there (F14b).
///
/// It asks again every `refresh_secs` holger's answer names, for the zoom `open` names then,
/// while the page is in sight: one ask at a time (the next is armed when an answer came), none
/// while the page is hidden (shown again, at once if the interval passed meanwhile), none once
/// the card is gone.
#[component]
pub fn EstateBoard(#[prop(into)] open: Signal<String>) -> impl IntoView {
let card = RwSignal::new(sec::FlowCard::default());
let zoom = Zoom { view: NodeRef::new(), level: RwSignal::new(1.0), own: RwSignal::new(None) };
// The redraw timer's beat: each one asks again.
let beat = RwSignal::new(0u64);
// The ask whose answer is shown (an earlier one's is dropped), when it went, the timer.
let asked = StoredValue::new(0u64);
let asked_at = StoredValue::new(0f64);
let timer = StoredValue::new(None::<TimeoutHandle>);
let stop = move || {
if let Some(t) = timer.try_update_value(Option::take).flatten() {
t.clear();
}
};
// The next redraw `ms` from now; none while the page is out of sight.
let arm = move |ms: u32| {
stop();
if !out_of_sight() {
_ = timer.try_set_value(set_timeout_with_handle(move || beat.update(|n| *n += 1), Duration::from_millis(ms.into())).ok());
}
};
Effect::new(move |_| {
let open = open.get();
beat.track();
stop();
let Some(me) = asked.try_update_value(|n| {
*n += 1;
*n
}) else {
return;
};
asked_at.set_value(js_sys::Date::now());
spawn_local(async move {
let got = sec::load_board(&GrpcDoor, &open).await;
// Asked again since, or the card is gone: this answer is no longer the one shown.
if asked.try_get_value() != Some(me) {
return;
}
// Another zoom of holger's is drawn at holger's own size again.
let refit = card.with_untracked(|c| c.open != open);
card.update(|c| *c = c.answered(&open, got, &crate::webio::now_hms()));
if refit {
zoom.refit();
}
if let Some(every) = card.with_untracked(sec::FlowCard::every) {
arm(sec::redraw_in_ms(every, 0.0));
}
});
});
// Out of sight: no redraw. In sight again: the rest of the interval, or at once.
let sight = window_event_listener_untyped("visibilitychange", move |_| match card.with_untracked(sec::FlowCard::every) {
Some(every) if !out_of_sight() => arm(sec::redraw_in_ms(every, js_sys::Date::now() - asked_at.get_value())),
_ => stop(),
});
on_cleanup(move || {
stop();
sight.remove();
});
Effect::new(move |_| {
if let Some(view) = zoom.view.get() {
gestures(view.into(), zoom);
}
});
// The drawing is laid out again only when holger's answer changed; the line under it
// follows every answer.
let answer = Memo::new(move |_| card.with(|c| c.answer.clone()));
let drawn = move || answer.with(|a| matches!(a, Some(Ok(Some(_)))));
view! {
<div class="card board" data-board="estate">
<h3>"Flow"</h3>
<div class="board-tools" hidden=move || !drawn()>
<button type="button" class="quiet" data-zoom="out" title="Zoom out (−)" aria-label="zoom out" on:click=move |_| _ = zoom.by(1.0 / sec::ZOOM_STEP, None)>"−"</button>
<span class="count" data-zoom-level="">{move || sec::zoom_words(zoom.level.get())}</span>
<button type="button" class="quiet" data-zoom="in" title="Zoom in (+)" aria-label="zoom in" on:click=move |_| _ = zoom.by(sec::ZOOM_STEP, None)>"+"</button>
<button type="button" class="quiet" data-zoom="reset" title="Back to holger's own size (0)" on:click=move |_| zoom.reset()>"Reset zoom"</button>
<span class="count">"Wheel to zoom, drag to pan."</span>
</div>
<div class="board-view" node_ref=zoom.view tabindex="0" aria-label="the flow drawing: the wheel or + and − zoom it, a drag pans it, 0 resets it">
{move || match answer.get() {
None => view! { <Loading what="the flow"/> }.into_any(),
Some(Err(r)) => view! {
<div class="failbox" role="alert">
<p class="fail-summary"><strong>"holger refused the flow"</strong></p>
<p class="fail-detail mono"><span class="badge badge-bad">{r.code}</span>{r.text}</p>
</div>
}.into_any(),
Some(Ok(None)) => view! { <EmptyState message="holger drew no flow"/> }.into_any(),
Some(Ok(Some(b))) => {
let alt = if b.estate.is_empty() { format!("artifact flow, drawn by holger from its RON: {}", b.summary) } else { format!("estate {} artifact flow, drawn by holger from its RON: {}", b.estate, b.summary) };
let links = b.links.clone();
view! {
<div class="board-frame">
<img class="board-img" src=b.src alt=alt draggable="false" style:width=move || zoom.size().0 style:height=move || zoom.size().1/>
{links.into_iter().map(|l| view! {
<a class="board-hit" href=l.href style=l.style title=l.label.clone() aria-label=l.label draggable="false"></a>
}).collect_view()}
</div>
}.into_any()
}
}}
</div>
{move || answer.with(|a| match a {
Some(Ok(Some(b))) => Some(b.summary.clone()),
_ => None,
}).map(|line| view! { <p class="count board-summary">{line}</p> })}
{move || card.with(sec::FlowCard::fresh).map(|(stale, words)| view! {
<p class="count board-fresh" data-fresh=if stale { "stale" } else { "drawn" }>{words}</p>
})}
</div>
}
}
/// A listener on `el` that may keep the browser from its default (`passive: false`: a wheel
/// over the drawing leaves the page still); `capture` hears it before the element under the
/// pointer does. The closure lives as long as the element (the garbage collector takes both).
fn listen<E: wasm_bindgen::convert::FromWasmAbi + 'static>(el: &web_sys::HtmlElement, name: &str, capture: bool, f: impl FnMut(E) + 'static) {
let opts = web_sys::AddEventListenerOptions::new();
opts.set_passive(false);
opts.set_capture(capture);
let f = Closure::<dyn FnMut(E)>::new(f).into_js_value();
_ = el.add_event_listener_with_callback_and_add_event_listener_options(name, f.unchecked_ref(), &opts);
}
/// **How large the drawing is shown** (F14b): its frame (`.board-view`, which scrolls), the
/// zoom (1 is holger's own size) and the drawing's own size, px, once it was laid out.
#[derive(Clone, Copy)]
struct Zoom {
view: NodeRef<leptos::html::Div>,
level: RwSignal<f64>,
own: RwSignal<Option<(f64, f64)>>,
}
impl Zoom {
/// The drawing's laid-out width and height at the zoom: its own size times the zoom, set
/// even at 1 once known, so a redraw's image (decoded a moment later) keeps the frame's
/// scroll; `auto` before the first is laid out.
fn size(&self) -> (String, String) {
match self.own.get() {
Some((w, h)) => {
let z = self.level.get();
(format!("{}px", w * z), format!("{}px", h * z))
}
None => ("auto".into(), "auto".into()),
}
}
fn frame(&self) -> Option<(web_sys::HtmlElement, web_sys::HtmlImageElement)> {
let view: web_sys::HtmlElement = self.view.get_untracked()?.into();
let img = view.query_selector("img.board-img").ok()??.dyn_into::<web_sys::HtmlImageElement>().ok()?;
Some((view, img))
}
/// Zoom by `factor` around (`x`, `y`) on the page, the frame's middle when `None`: the
/// drawing laid out at the new size at once and the frame scrolled so the point under the
/// pointer stays under it. False when no drawing is laid out to zoom.
fn by(&self, factor: f64, at: Option<(f64, f64)>) -> bool {
let Some((view, img)) = self.frame() else {
return false;
};
let (w, h) = (f64::from(img.natural_width()), f64::from(img.natural_height()));
let Some(old) = self.level.try_get_untracked() else {
return false;
};
if w <= 0.0 || h <= 0.0 {
return false;
}
let new = sec::zoom_by(old, factor, f64::from(view.client_width()) / w);
if new == old {
return true;
}
let (v, d) = (view.get_bounding_client_rect(), img.get_bounding_client_rect());
let (left, top) = (v.left() + f64::from(view.client_left()), v.top() + f64::from(view.client_top()));
let (x, y) = at.unwrap_or((left + f64::from(view.client_width()) / 2.0, top + f64::from(view.client_height()) / 2.0));
let (sx, sy) = (f64::from(view.scroll_left()), f64::from(view.scroll_top()));
let style = web_sys::HtmlElement::style(&img);
_ = style.set_property("width", &format!("{}px", w * new));
_ = style.set_property("height", &format!("{}px", h * new));
let ratio = new / old;
view.set_scroll_left(sec::anchored_scroll(sx, x - left, d.left() - left + sx, ratio).round() as i32);
view.set_scroll_top(sec::anchored_scroll(sy, y - top, d.top() - top + sy, ratio).round() as i32);
self.own.set(Some((w, h)));
self.level.set(new);
true
}
/// Back to holger's own size, the frame at its start.
fn reset(&self) {
// `try_set` hands the value back when the card is gone.
if self.level.try_set(1.0).is_some() {
return;
}
if let Some((view, img)) = self.frame() {
let (w, h) = self.own.get_untracked().map_or(("auto".to_string(), "auto".to_string()), |(w, h)| (format!("{w}px"), format!("{h}px")));
let style = web_sys::HtmlElement::style(&img);
_ = style.set_property("width", &w);
_ = style.set_property("height", &h);
view.set_scroll_left(0);
view.set_scroll_top(0);
}
}
/// Another drawing of holger's (another `?open=`): at its own size, whatever it is.
fn refit(&self) {
if self.own.try_set(None).is_none() {
self.reset();
}
}
}
/// **The frame's gestures** (F14b): the wheel zooms around the pointer and keeps the page
/// still (a sideways turn scrolls the frame as ever), a drag pans a drawing larger than its
/// frame and is then no press on the link it started on, a double click resets, `+` `-` `0`
/// step it, and each image laid out tells the zoom its own size.
fn gestures(view: web_sys::HtmlElement, zoom: Zoom) {
listen(&view, "wheel", false, move |e: web_sys::WheelEvent| {
if e.delta_y() != 0.0 && zoom.by(sec::wheel_factor(e.delta_y(), e.delta_mode()), Some((f64::from(e.client_x()), f64::from(e.client_y())))) {
e.prevent_default();
}
});
// Where a press began (pointer and scroll), and whether it moved far enough to be a drag.
let held = Rc::new(Cell::new(None::<(i32, i32, i32, i32)>));
let dragged = Rc::new(Cell::new(false));
{
let (frame, held, dragged) = (view.clone(), held.clone(), dragged.clone());
listen(&view, "pointerdown", false, move |e: web_sys::PointerEvent| {
let larger = frame.scroll_width() > frame.client_width() || frame.scroll_height() > frame.client_height();
// A press on the frame's own scrollbars is theirs.
let r = frame.get_bounding_client_rect();
let inside = f64::from(e.client_x()) - r.left() - f64::from(frame.client_left()) < f64::from(frame.client_width())
&& f64::from(e.client_y()) - r.top() - f64::from(frame.client_top()) < f64::from(frame.client_height());
dragged.set(false);
held.set((e.button() == 0 && e.is_primary() && larger && inside).then(|| (e.client_x(), e.client_y(), frame.scroll_left(), frame.scroll_top())));
});
}
{
let (frame, held, dragged) = (view.clone(), held.clone(), dragged.clone());
listen(&view, "pointermove", false, move |e: web_sys::PointerEvent| {
let Some((x, y, left, top)) = held.get() else {
return;
};
let (dx, dy) = (e.client_x() - x, e.client_y() - y);
if !dragged.get() && dx.abs() + dy.abs() < 5 {
return;
}
dragged.set(true);
frame.set_scroll_left(left - dx);
frame.set_scroll_top(top - dy);
e.prevent_default();
});
}
for end in ["pointerup", "pointercancel", "pointerleave"] {
let held = held.clone();
listen(&view, end, false, move |_: web_sys::PointerEvent| held.set(None));
}
// Heard before the link is: a drag's click follows nothing.
listen(&view, "click", true, move |e: web_sys::MouseEvent| {
if dragged.replace(false) {
e.prevent_default();
e.stop_propagation();
}
});
listen(&view, "dblclick", false, move |_: web_sys::MouseEvent| zoom.reset());
listen(&view, "keydown", false, move |e: web_sys::KeyboardEvent| {
if e.ctrl_key() || e.meta_key() || e.alt_key() {
return;
}
match e.key().as_str() {
"+" | "=" => _ = zoom.by(sec::ZOOM_STEP, None),
"-" | "_" => _ = zoom.by(1.0 / sec::ZOOM_STEP, None),
"0" => zoom.reset(),
_ => return,
}
e.prevent_default();
});
// `load` does not bubble; heard on the way down.
listen(&view, "load", true, move |e: web_sys::Event| {
if let Some(img) = e.target().and_then(|t| t.dyn_into::<web_sys::HtmlImageElement>().ok()) {
let own = (f64::from(img.natural_width()), f64::from(img.natural_height()));
if own.0 > 0.0 && own.1 > 0.0 && zoom.own.try_get_untracked() != Some(Some(own)) {
_ = zoom.own.try_set(Some(own));
}
}
});
}
/// **The Flow page** (`/flow`): the machines its address opens (`?open=main,lab-1`, `all` for
/// the full RON; main when it names none), at holger's own size in the board's frame, which
/// scrolls and zooms. A click on a machine is holger's link to the same page with that machine
/// unfolded or folded.
#[component]
pub fn Flow() -> impl IntoView {
crate::webio::set_title("Flow");
let query = leptos_router::hooks::use_query_map();
let open = Signal::derive(move || sec::flow_open(query.read().get("open").as_deref()));
view! {
<section class="page">
<header class="page-head">
<div>
<h2>"Flow"</h2>
<p class="lede">"How artifacts flow through this estate, drawn by holger from its RON. Click a machine to unfold it in place, or show the full RON."</p>
</div>
</header>
<EstateBoard open=open/>
</section>
}
}