Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

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>
    }
}