diff --git a/CLAUDE.md b/CLAUDE.md index fb09e20..eedd4ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Milestone M2 of `docs/design/fleet.md`; the operator flow is `docs/fleet/README. ## Fleet: the operator view + dispatch API (M3) -Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots//dispatch` authorizes an operator token (`fleetctl operator new --name `; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test. +Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots//dispatch` authorizes an operator token (`fleetctl operator new --name `; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test. **The phone is the realistic off-LAN client**, so below 760 px the three panes become one at a time behind a bottom tab bar (`server/ui/layout.mjs`), selecting a robot in the roster navigates to the map — what the desktop layout gets for free by showing both — and the canvas gained pinch-to-zoom (`pinchSpan`/`pinchUpdate` in `map.mjs`, pure and tested, because a division by a zero span puts NaN in the view scale and blanks the map for good) plus a fingertip-sized hit target. The breakpoint is a **silent** seam — CSS decides what is displayed, JS decides when a selection navigates, and disagreement yields a tab bar over stacked panes rather than an error — so it lives in `layout.mjs` and `ui_test.mjs` reads the stylesheet and holds it there, as it does for every pane having a tab and for `touch-action: none` on the canvas (without which the browser eats the drag and the pinch before a single pointer event arrives). Dispatch gained a **zone picker that writes `goto ` into the command box rather than sending it**: the grammar stays the robot's, and the keyboard leaves the common case. Three pre-existing bugs fell out, all of which a desk hides: `hidden` does not hide an element whose class sets `display` (the empty promote picker), the canvas backing store was resized on width alone so a height change left the previous frame's scale bar under the new one, and the scale bar was drawn in the dark theme's near-white on a white basemap — a canvas gets no cascade, so it now reads `--dim` off the element. Measurements, including `browser_check.mjs`'s phone pass, are `m3-verification.md` §9; **a real device is still the acceptance** — emulation gets the viewport and the touch points right and the thumb wrong. ## Fleet: the map registry (M4) diff --git a/docs/fleet/README.md b/docs/fleet/README.md index 45c4662..189320b 100644 --- a/docs/fleet/README.md +++ b/docs/fleet/README.md @@ -625,8 +625,9 @@ broker credential that can publish**, and its MQTT client implements no PUBLISH packet at all. **The map.** A floor's PNG basemap with live robot markers on it: pan by -dragging, zoom with the wheel, click a robot to select it, `follow` to keep the -selected one centred, `fit` to see the whole floor. The scale bar is metres. +dragging, zoom with the wheel or by pinching, click a robot to select it, +`follow` to keep the selected one centred, `fit` to see the whole floor. The +scale bar is metres. Only robots on the *same* site and floor as the selected one are drawn — a pose from another floor is a different map frame, and drawing it here would place a robot somewhere it is not. @@ -646,6 +647,32 @@ Beside the map's floor label is the **canonical revision** it is showing, and, when a robot has published one, a picker to promote a candidate onto the floor (§11). Both need the operator token; without one the pane is read-only. +![The dashboard on a phone](../images/fleet-ui-phone.webp) + +**On a phone.** The realistic off-LAN client is a phone — it is what an operator +has in a corridor, and "where is the robot and what is it doing" is exactly the +question you ask from one. Below 760 px the three panes become **one at a time** +behind a tab bar at the bottom of the screen, within thumb reach, so the map +gets the whole display instead of a couple of hundred pixels between the roster +and the detail pane. Two things follow from losing the side-by-side view: + +- **Picking a robot in the roster takes you to the map.** On a desk that + happens for free, both panes being visible; on a phone it has to be done. + The third tab is labelled with the selected robot's id, so the selection is + legible without switching to it. +- **Pinch to zoom**, since there is no wheel. One finger pans, two zoom about + the point between them, and a two-finger drag carries the map along. Robot + markers get a larger hit target when the pointer is a fingertip rather than + a cursor. + +**Dispatch has a zone picker** beside the command box, listing the taught zones +of the floor on screen. It *writes* `goto ` into the box rather than +sending it — the grammar is still the robot's, parsed only by its task layer — +which on a touchscreen removes the keyboard from the common case without adding +a second command language for the fleet server to keep in step. + +Between 760 and 1100 px the panes stack and scroll, as before. + **What it does not do**, deliberately: no marker clustering, no basemap tiling, no 3D, no camera, no teleop. The first two are what `fleet.md` Q5 describes for large sites and would be unmeasured complexity at this fleet size; the last diff --git a/docs/fleet/m3-verification.md b/docs/fleet/m3-verification.md index 102776c..5ebf20f 100644 --- a/docs/fleet/m3-verification.md +++ b/docs/fleet/m3-verification.md @@ -320,7 +320,7 @@ why the bug survived M1. - **A phone-sized layout.** The panes stack below 1100 px, which is not the same as being usable one-handed on a 390 px screen — and the map canvas is the part - that suffers. Observed, not designed for. + that suffers. Observed, not designed for. **Addressed later — §9 below.** - **The Foxglove deep link.** The button is rendered from the configured template and opens `foxglove://…`, which needs both the Foxglove desktop app on the operator's machine and a `foxglove_bridge` on the robot. Neither exists @@ -333,3 +333,76 @@ why the bug survived M1. - **A robot dropping off while the dashboard watches.** The Last Will is tested against a real broker (`m1-verification.md` §2) and the UI renders `offline` from the same retained payload, but the two have not been observed together. + +--- + +## 9. The phone layout (2026-07-31) + +Closing §6's "the one thing the run did not enjoy is the small screen" and the +first bullet of §8. The network path was already proven from a phone on +cellular; what was missing was a layout for the screen it arrived on. + +### What was changed + +Below 760 px the three panes become one at a time behind a bottom tab bar; +selecting a robot in the roster navigates to the map; the map canvas gained +pinch-to-zoom and a fingertip-sized hit target; and the dispatch box gained a +zone picker that writes `goto ` rather than sending it. The operator flow +is [`README.md` §9](README.md); the breakpoint lives in `server/ui/layout.mjs` +and the stylesheet is held to it by `ui_test.mjs`, because the two failing to +agree produces a tab bar over stacked panes rather than an error. + +### How it was measured + +`ui_test.mjs` grew the pinch arithmetic (`pinchSpan`, `pinchUpdate`) and the +seams that fail silently: the CSS/JS breakpoint, every pane having a tab, and +`touch-action: none` on the canvas. 24/24 under node. + +`browser_check.mjs` grew a **phone pass** — 390x844 at device scale 3, mobile +metrics and touch emulation on, driven with real `Input.dispatchTouchEvent` +gestures rather than synthesised DOM events. Against a live stack (container +broker, fleet server, three scripted robots on the `office_world` bundle): + +``` +ok a coarse pointer is what the page thinks it has +ok one pane at a time, with a tab bar to move between them — {"tabs":"flex","shown":1} +ok no pane scrolls sideways on a phone +ok the canvas backing store follows the pane it is in — 1170x1674 for 1170x1674 +ok picking a robot in the roster shows it on the map — map +ok two fingers zoom the map +``` + +Separately, in a Playwright context with `hasTouch` (so `pointer: coarse` +actually matches, which CDP touch events alone do not cause): a tap landing +**20 px** off a robot marker selected it — inside the touch target, outside the +14 px one a mouse gets — and after a pinch centred on that marker, a tap at the +same screen point still selected it, which is what shows the zoom anchors where +the fingers are rather than drifting. A `goto dropoff` chosen from the zone +picker dispatched through the API and wrote the expected audit row. + +### Three bugs this found, all pre-existing + +- **`hidden` did not hide.** `.revisions` and `.dispatch` set `display: flex`, + which outranks the attribute's UA-stylesheet `display: none` — so the promote + picker rendered empty on every floor with no candidate. Invisible on a desk; + a wasted row of a phone. Fixed with a `[hidden] { display: none !important }` + rule, which also covers anything added later. +- **The canvas backing store was resized on width alone.** A height change + without a width change — a tab switch, the toolbar rewrapping, a mobile URL + bar sliding away — left `clearRect` unable to reach the bottom of the store, + and the previous frame's scale bar stayed on screen under the new one. +- **The scale bar was drawn in the dark theme's near-white**, on a basemap whose + free space is white. A canvas gets no cascade, so the stylesheet's light + theme could not reach it; it now reads `--dim` off the canvas element and is + legible in both. + +### Not verified here + +- **A real device.** Everything above is emulation: it gets the viewport, the + device pixel ratio and the touch points right, and the thumb wrong. Reach, + one-handed grip, the on-screen keyboard covering the dispatch box, and iOS + Safari's own chrome are what a phone in a corridor tests and this does not. + The `100dvh` height, the `env(safe-area-inset-bottom)` padding under the tab + bar and the 16 px input font (which is what stops Safari zooming the page on + focus) are all written for that device and confirmed only in Chrome. +- **Landscape**, and tablets between 760 and 1100 px, which still stack. diff --git a/docs/images/fleet-ui-phone.webp b/docs/images/fleet-ui-phone.webp new file mode 100644 index 0000000..119167a Binary files /dev/null and b/docs/images/fleet-ui-phone.webp differ diff --git a/mote_fleet/README.md b/mote_fleet/README.md index 6bfba1a..d825d4b 100644 --- a/mote_fleet/README.md +++ b/mote_fleet/README.md @@ -63,7 +63,7 @@ pixi run -e fleet fleetctl -- dispatch mote-01 goto kitchen | [`server/registry.py`](server/registry.py) | the SQLite row store: robots, enrollment tokens, operators, the audit log, transactional id allocation | | [`server/bundle_store.py`](server/bundle_store.py) | the map registry's byte store: candidate revisions, validation on the way in, the atomic flip that publishes one | | [`server/fleetctl.py`](server/fleetctl.py) | operator CLI: tokens, roster, dispatch, audit, watch | -| [`server/ui/`](server/ui/) | the dashboard: `index.html`, `app.mjs`, `map.mjs` (basemap + the Q5 transform), `mqtt.mjs` (a subscribe-only MQTT client) | +| [`server/ui/`](server/ui/) | the dashboard: `index.html`, `app.mjs`, `map.mjs` (basemap + the Q5 transform, pan/pinch), `mqtt.mjs` (a subscribe-only MQTT client), `layout.mjs` (one pane at a time on a phone) | | [`server/mosquitto.conf`](server/mosquitto.conf), [`broker.sh`](server/broker.sh) | the broker, its WebSocket listener, and where its state goes | | [`deploy/`](deploy/) | the deployed shape: an image for the API+UI, a compose file that runs it beside the broker, and `fleet-deploy.sh` (gated update, rollback, backup, restore) | diff --git a/mote_fleet/server/ui/app.mjs b/mote_fleet/server/ui/app.mjs index 5569729..ab73dc2 100644 --- a/mote_fleet/server/ui/app.mjs +++ b/mote_fleet/server/ui/app.mjs @@ -14,6 +14,7 @@ import { BrokerReader, parseTopic } from './mqtt.mjs'; import { MapView } from './map.mjs'; +import { setupPanes } from './layout.mjs'; const TOKEN_KEY = 'mote.operator.token'; @@ -34,10 +35,12 @@ const state = { operator: null, mapKey: null, floor: null, // the registry's view of the floor on screen: revisions, candidates + zones: [], // the floor's taught places, for the map and the dispatch picker }; const dom = {}; let mapView = null; +let panes = null; let pending = false; // -- small helpers ------------------------------------------------------- @@ -175,6 +178,7 @@ async function ensureMap(record) { if (key === state.mapKey) return; state.mapKey = key; state.floor = null; + setZones([]); renderRevisions(); if (!key) { mapView.clearMap(); @@ -197,11 +201,19 @@ async function ensureMap(record) { // Taught places, in the same frame as the basemap. A floor may have none, // which is a 404 and not an error worth showing. api(`/v1/maps/${site}/${floor}/zones.json`) - .then((body) => state.mapKey === key && mapView.setZones(body.zones)) - .catch(() => state.mapKey === key && mapView.setZones([])); + .then((body) => state.mapKey === key && setZones(body.zones)) + .catch(() => state.mapKey === key && setZones([])); loadFloor(site, floor, key); } +// Taught places go two ways: onto the basemap, and into the dispatch picker. +// Both are the floor's, so they arrive and are cleared together. +function setZones(zones) { + state.zones = zones || []; + mapView.setZones(state.zones); + renderZones(); +} + // -- the map registry ---------------------------------------------------- async function loadFloor(site, floor, key) { @@ -240,6 +252,26 @@ function renderRevisions() { ); } +// The zones of the floor on screen, as a `goto` the operator does not have to +// type. It writes the command rather than sending it: the send button stays the +// one place a task leaves this page. +function renderZones() { + const names = state.zones.map((zone) => zone.name).sort((a, b) => a.localeCompare(b)); + dom.zone.hidden = names.length === 0; + dom.zone.replaceChildren( + el('option', { value: '', text: 'go to a taught zone…' }), + ...names.map((name) => el('option', { value: name, text: name })), + ); +} + +function onZone() { + const name = dom.zone.value; + if (!name) return; + dom.command.value = `goto ${name}`; + dom.dispatchNote.textContent = ''; + dom.dispatchNote.className = 'note'; +} + async function onPromote(event) { event.preventDefault(); const revision = dom.revision.value; @@ -322,6 +354,9 @@ function renderRoster(records) { onclick: () => { state.selected = record.id; state.mapKey = null; // re-resolve: the new robot may be on another floor + // Where the desktop layout has three panes at once, a phone has to + // be taken there: picking a robot means asking where it is. + panes.show('map'); scheduleRender(); }, }, @@ -358,6 +393,9 @@ function renderRoster(records) { } function renderDetail(record) { + // On a phone the detail pane is behind a tab, so the tab is where the + // selection is visible at all. + dom.tabDetail.textContent = record ? record.id : 'robot'; if (!record) { dom.detailName.textContent = 'no robot selected'; dom.detailMeta.replaceChildren(); @@ -515,6 +553,8 @@ function bind() { statusLog: 'status-log', dispatch: 'dispatch', command: 'command', + zone: 'zone', + tabDetail: 'tab-detail', dispatchNote: 'dispatch-note', foxglove: 'foxglove', brokerState: 'broker-state', @@ -547,6 +587,10 @@ export async function boot() { scheduleRender(); }, }); + // The canvas has no size until its pane is on screen, so the map is told when + // it becomes visible rather than fitting into a hidden 0x0 box. + panes = setupPanes({ onShow: (name) => name === 'map' && mapView.shown() }); + dom.zone.addEventListener('change', onZone); dom.fit.addEventListener('click', () => { mapView.follow(null); dom.follow.checked = false; diff --git a/mote_fleet/server/ui/index.html b/mote_fleet/server/ui/index.html index 5d9efdb..3409e2d 100644 --- a/mote_fleet/server/ui/index.html +++ b/mote_fleet/server/ui/index.html @@ -2,7 +2,15 @@ - + + mote fleet @@ -25,12 +33,12 @@

mote/fleet

-
+

roster

-
+

map

@@ -48,7 +56,7 @@

map

-
+

no robot selected

+ @@ -90,6 +106,19 @@

task status

+ + + diff --git a/mote_fleet/server/ui/layout.mjs b/mote_fleet/server/ui/layout.mjs new file mode 100644 index 0000000..f6fffb6 --- /dev/null +++ b/mote_fleet/server/ui/layout.mjs @@ -0,0 +1,51 @@ +// One pane at a time, on a viewport too narrow for three. +// +// The desktop layout puts roster, map and detail side by side, and gets one +// thing for free by doing so: picking a robot in the roster shows it on the map +// without the operator asking. A phone cannot show three panes, so this module +// supplies the missing half — a tab bar, and the rule that selecting a robot +// moves you to the map. +// +// The breakpoint is the seam. CSS decides which panes are *displayed*; this +// decides when a selection should navigate. Disagree and nothing fails: you get +// a tab bar over three stacked panes, or a phone where choosing a robot appears +// to do nothing because the map it selected on is off screen. So the number +// lives here, the stylesheet is required to match it, and `ui_test.mjs` reads +// both files and asserts they do. + +export const NARROW_MAX_PX = 760; + +export const NARROW_MEDIA = `(max-width: ${NARROW_MAX_PX}px)`; + +// Wire the tab bar to the panes. Returns `show(name)`, which is a no-op on a +// wide viewport in effect but not in fact: it still moves `.active`, so a +// window narrowed later opens on the pane the operator last chose. +export function setupPanes({ root = document, onShow = () => {} } = {}) { + const tabs = [...root.querySelectorAll('.panes [data-pane]')]; + const panes = [...root.querySelectorAll('.pane[data-pane]')]; + + function show(name) { + if (!panes.some((pane) => pane.dataset.pane === name)) return; + for (const pane of panes) pane.classList.toggle('active', pane.dataset.pane === name); + for (const tab of tabs) { + const selected = tab.dataset.pane === name; + tab.classList.toggle('active', selected); + tab.setAttribute('aria-selected', String(selected)); + } + onShow(name); + } + + for (const tab of tabs) tab.addEventListener('click', () => show(tab.dataset.pane)); + + const narrow = window.matchMedia(NARROW_MEDIA); + // Crossing the breakpoint changes which panes exist on screen. The map is the + // one that cares: its canvas has no size while hidden, so it needs telling. + narrow.addEventListener('change', () => onShow(current())); + + function current() { + const active = panes.find((pane) => pane.classList.contains('active')); + return active ? active.dataset.pane : null; + } + + return { show, current, isNarrow: () => narrow.matches }; +} diff --git a/mote_fleet/server/ui/map.mjs b/mote_fleet/server/ui/map.mjs index 9093932..0c89e86 100644 --- a/mote_fleet/server/ui/map.mjs +++ b/mote_fleet/server/ui/map.mjs @@ -48,6 +48,37 @@ export function pixelToWorld(map, px, py) { }; } +// -- pinch --------------------------------------------------------------- + +// Two touches, as one gesture: how far apart they are and where their midpoint +// is. Pure, because the alternative to testing this is discovering on a phone +// that the map flies off screen. +export function pinchSpan(a, b) { + return { + distance: Math.hypot(a.x - b.x, a.y - b.y), + centre: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, + }; +} + +// What one move of a two-finger gesture asks for: a zoom factor, the point to +// zoom about, and how far the fingers carried the map with them. +// +// The zero guard is load-bearing rather than defensive. Two pointers reported +// at the same position give distance 0, and a factor of 0/0 or n/0 puts NaN or +// Infinity into the view scale — from which every subsequent draw is blank, +// with nothing on screen to say why. +export function pinchUpdate(before, after) { + const factor = before.distance > 0 ? after.distance / before.distance : 1; + return { + factor, + centre: after.centre, + pan: { + x: after.centre.x - before.centre.x, + y: after.centre.y - before.centre.y, + }, + }; +} + // The view that shows the whole basemap, centred, with a small margin. export function fitView(map, width, height, margin = 16) { const scale = Math.min( @@ -89,6 +120,12 @@ export class MapView { this.followId = null; this.selectedId = null; this._drag = null; + this._pointers = new Map(); + this._pinch = null; + // A fit needs a sized canvas, and on a narrow viewport this one spends its + // early life in a hidden pane with no size at all. Track whether the map on + // screen has ever actually been fitted, so `shown()` can finish the job. + this._fitted = false; this._bind(); } @@ -98,7 +135,10 @@ export class MapView { const changed = !this.map || this.map.site !== map.site || this.map.floor !== map.floor; this.map = map; this.image = image; - if (changed) this.fit(); + if (changed) { + this._fitted = false; + this.fit(); + } this.draw(); } @@ -106,6 +146,7 @@ export class MapView { this.map = null; this.image = null; this.zones = []; + this._fitted = false; this.draw(); } @@ -137,7 +178,19 @@ export class MapView { fit() { if (!this.map) return; const { width, height } = this._size(); + // A hidden pane's canvas measures 0x0, and fitting into that yields a scale + // of 0 or NaN — a blank map that stays blank once the pane is shown. Leave + // the view alone and let `shown()` fit when there is something to fit into. + if (!width || !height) return; this.view = fitView(this.map, width, height); + this._fitted = true; + } + + // The map pane has become visible (a tab switch, or the viewport crossing the + // breakpoint). This is the first moment its canvas has a size. + shown() { + if (!this._fitted) this.fit(); + this.draw(); } zoomBy(factor, screenX, screenY) { @@ -160,10 +213,31 @@ export class MapView { return this._screenOf(worldToPixel(this.map, worldX, worldY)); } + // Canvas-relative coordinates. Not `offsetX`: during a pointer capture — and + // for the second finger of a pinch — that is measured against whatever the + // event happens to be over, which is not always this canvas. + _local(event) { + const rect = this.canvas.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + } + _bind() { const canvas = this.canvas; canvas.addEventListener('pointerdown', (event) => { - const hit = this._robotAt(event.offsetX, event.offsetY); + canvas.setPointerCapture(event.pointerId); + this._pointers.set(event.pointerId, this._local(event)); + + // A second finger means a pinch, whatever the first one had started. + if (this._pointers.size === 2) { + this._drag = null; + canvas.classList.remove('dragging'); + this._pinch = this._span(); + return; + } + if (this._pointers.size > 2) return; + + const point = this._local(event); + const hit = this._robotAt(point.x, point.y); if (hit) { this.onSelect(hit.id); return; @@ -171,23 +245,51 @@ export class MapView { // Dragging the map is a deliberate pan, so it stops following: an // operator who grabs the canvas wants to look somewhere else. this.followId = null; - this._drag = { x: event.clientX, y: event.clientY }; - canvas.setPointerCapture(event.pointerId); + this._drag = point; canvas.classList.add('dragging'); }); canvas.addEventListener('pointermove', (event) => { + if (!this._pointers.has(event.pointerId)) return; + const point = this._local(event); + this._pointers.set(event.pointerId, point); + + if (this._pinch) { + const span = this._span(); + if (!span) return; + const { factor, centre, pan } = pinchUpdate(this._pinch, span); + this._pinch = span; + // Fingers carry the map with them as well as scaling it, so the pan + // lands before the zoom is taken about where they now are. + this.view.tx += pan.x; + this.view.ty += pan.y; + this.zoomBy(factor, centre.x, centre.y); + return; + } + if (!this._drag) return; - this.view.tx += event.clientX - this._drag.x; - this.view.ty += event.clientY - this._drag.y; - this._drag = { x: event.clientX, y: event.clientY }; + this.view.tx += point.x - this._drag.x; + this.view.ty += point.y - this._drag.y; + this._drag = point; this.draw(); }); const release = (event) => { - this._drag = null; - canvas.classList.remove('dragging'); + this._pointers.delete(event.pointerId); if (canvas.hasPointerCapture(event.pointerId)) { canvas.releasePointerCapture(event.pointerId); } + if (this._pointers.size < 2 && this._pinch) { + this._pinch = null; + // One finger left: hand it the pan rather than freezing until it lifts. + // Its position is where the drag resumes, so the map does not jump. + const [remaining] = [...this._pointers.values()]; + this._drag = remaining || null; + if (this._drag) canvas.classList.add('dragging'); + return; + } + if (!this._pointers.size) { + this._drag = null; + canvas.classList.remove('dragging'); + } }; canvas.addEventListener('pointerup', release); canvas.addEventListener('pointercancel', release); @@ -199,15 +301,27 @@ export class MapView { }, { passive: false }, ); - window.addEventListener('resize', () => this.draw()); + window.addEventListener('resize', () => { + if (!this._fitted) this.fit(); + this.draw(); + }); + } + + _span() { + const [a, b] = [...this._pointers.values()]; + return a && b ? pinchSpan(a, b) : null; } _robotAt(screenX, screenY) { if (!this.map) return null; + // A fingertip is not a mouse pointer: the same 14 px target that is + // comfortable with a cursor is most of the way to unhittable by touch. + const reach = + typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 24 : 14; for (const robot of this.robots) { if (robot.x === null || robot.x === undefined) continue; const point = this._toScreen(robot.x, robot.y); - if (Math.hypot(point.x - screenX, point.y - screenY) <= 14) return robot; + if (Math.hypot(point.x - screenX, point.y - screenY) <= reach) return robot; } return null; } @@ -217,9 +331,16 @@ export class MapView { draw() { const { width, height } = this._size(); const ratio = window.devicePixelRatio || 1; - if (this.canvas.width !== Math.round(width * ratio)) { - this.canvas.width = Math.round(width * ratio); - this.canvas.height = Math.round(height * ratio); + // Height as well as width: the two change independently, and on a phone the + // height changes often — a tab switch, the toolbar rewrapping, the URL bar + // sliding away. Resizing on width alone leaves the backing store at its old + // height, and `clearRect` (which works in CSS pixels) then cannot reach the + // bottom of it: the previous frame's scale bar stays on screen under the + // new one. + const backing = { w: Math.round(width * ratio), h: Math.round(height * ratio) }; + if (this.canvas.width !== backing.w || this.canvas.height !== backing.h) { + this.canvas.width = backing.w; + this.canvas.height = backing.h; } const ctx = this.context; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); @@ -366,7 +487,12 @@ export class MapView { const length = metres * pixelsPerMetre; const x = 16; const y = height - 18; - ctx.strokeStyle = 'rgba(230, 237, 243, 0.8)'; + // A canvas gets no cascade, so the stylesheet's light theme cannot reach in + // here: drawn in the dark theme's near-white this is invisible on a phone + // that has asked for light, over a map whose free space is white. `--dim` + // is chosen for legibility against either background. + const ink = getComputedStyle(this.canvas).getPropertyValue('--dim').trim() || '#8b949e'; + ctx.strokeStyle = ink; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y - 5); @@ -376,7 +502,7 @@ export class MapView { ctx.stroke(); ctx.font = '11px ui-monospace, monospace'; ctx.textAlign = 'left'; - ctx.fillStyle = 'rgba(230, 237, 243, 0.8)'; + ctx.fillStyle = ink; ctx.fillText(`${metres} m`, x + 4, y - 8); } } diff --git a/mote_fleet/server/ui/style.css b/mote_fleet/server/ui/style.css index e9e53b8..19205b6 100644 --- a/mote_fleet/server/ui/style.css +++ b/mote_fleet/server/ui/style.css @@ -35,9 +35,24 @@ box-sizing: border-box; } +/* The `hidden` attribute is how this page hides things it has nothing to say + with — the promote picker on a floor with no candidates, the dispatch form + with no robot selected. It is only a UA-stylesheet `display: none`, so any + rule of ours that sets `display` silently outranks it and the element stays + on screen: empty, but taking a row. This is the standard way to give the + attribute the last word. */ +[hidden] { + display: none !important; +} + body { margin: 0; height: 100vh; + /* Mobile browsers count `vh` against the viewport with the URL bar hidden, so + a 100vh page is taller than the window and the bottom tab bar starts off + screen. `dvh` is the visible height, and the declaration order leaves the + fallback for anything that does not know it. */ + height: 100dvh; display: flex; flex-direction: column; background: var(--bg); @@ -213,6 +228,10 @@ main { min-height: 0; display: block; cursor: grab; + /* The canvas handles its own pan and pinch. Without this the browser claims + both gestures first — a drag scrolls the page and a pinch zooms the whole + document — and the pointer events the map is listening for never arrive. */ + touch-action: none; /* Everything outside the basemap is unmapped, not empty: a flat backdrop distinct from both the panel and the map's own free space. */ background: var(--void); @@ -225,6 +244,11 @@ main { .check { color: var(--dim); font-size: 12px; + /* A label wrapping between its box and its word reads as two controls. */ + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; } /* -- roster ------------------------------------------------------------ */ @@ -375,6 +399,11 @@ main { min-width: 160px; } +.zone-picker { + flex: 1 1 100%; + min-width: 0; +} + .note { width: 100%; margin: 4px 0 0; @@ -386,6 +415,43 @@ main { color: var(--fault); } +/* -- the tab bar -------------------------------------------------------- */ + +/* Hidden until the viewport is too narrow for three panes. It is defined here, + *before* that media query, deliberately: `display: none` and the query's + `display: flex` have the same specificity, so the one written later wins and + a bar defined below the query would never appear. */ +.panes { + display: none; + border-top: 1px solid var(--line); + background: var(--panel); + /* Below the home indicator on a notched phone, which the page reaches under + thanks to `viewport-fit=cover`. */ + padding: 6px 6px calc(6px + env(safe-area-inset-bottom, 0px)); + gap: 6px; +} + +.panes button { + flex: 1; + background: transparent; + border-color: transparent; + color: var(--dim); + padding: 10px 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panes button.active { + color: var(--accent); + border-color: var(--accent); + background: var(--bg); +} + +/* -- narrower than a desk ---------------------------------------------- */ + +/* Between a phone and three columns: stack the panes and scroll. A pane is + still wide enough to read here, so all three stay on screen. */ @media (max-width: 1100px) { main { grid-template-columns: 1fr; @@ -398,6 +464,111 @@ main { } } +/* -- one pane at a time ------------------------------------------------- */ + +/* A phone. Three stacked panes here means a map a couple of hundred pixels tall + with the roster above it and the detail below the fold, so instead the tab + bar appears and exactly one pane is on screen — the map getting the whole of + it, which is the point of having one. + + `.active` is set by layout.mjs, which must switch at the same width this does + (NARROW_MAX_PX; ui_test.mjs holds the two together). Note that `.pane` sets + `display: flex`, which beats the `hidden` attribute — hiding a pane has to go + through this class, not through `hidden`. */ +@media (max-width: 760px) { + main { + grid-template-columns: 1fr; + grid-auto-rows: auto; + grid-template-rows: minmax(0, 1fr); + } + + .pane { + display: none; + border: none; + } + + .pane.active { + display: flex; + } + + .panes { + display: flex; + } + + header { + padding: 8px 12px; + gap: 8px; + } + + /* The token form is the widest thing in the header and the least often used + — a token is pasted once and kept in localStorage — so it goes last and + takes its own row, leaving the name, the broker light and the operator + state to share the one above it. */ + .token-form { + order: 3; + flex: 1 1 100%; + margin-left: 0; + } + + .token-form input { + flex: 1; + min-width: 0; + } + + /* The map's controls are the pane's whole toolbar: promote, follow, fit. + Unwrapped they run off the side of a phone, and `overflow: auto` turns + that into a horizontal scrollbar with the buttons hidden beyond it. */ + .pane-head { + flex-wrap: wrap; + } + + .revisions { + flex: 1 1 100%; + } + + .revisions select { + flex: 1; + max-width: none; + min-width: 0; + } + + /* Safari zooms the page when a text field under 16px takes focus, and never + zooms back out. */ + input, + select, + button { + font-size: 16px; + } + + .fact-key, + .subsystem-name { + width: 72px; + } +} + +/* -- touch -------------------------------------------------------------- */ + +/* Sized for a fingertip rather than a cursor, on any device driven by one — + which is the pointer, not the screen width: a tablet is neither narrow nor + a mouse. The map's own hit target follows the same rule in map.mjs. */ +@media (pointer: coarse) { + button, + .button, + input, + select { + min-height: 40px; + } + + .robot { + padding: 12px; + } + + .check input { + width: 20px; + height: 20px; + } +} + /* -- state that is no longer current ----------------------------------- */ /* Retained state is the last thing a robot said, not a claim about now. An diff --git a/mote_fleet/test/browser_check.mjs b/mote_fleet/test/browser_check.mjs index d4164e0..19ef9e1 100644 --- a/mote_fleet/test/browser_check.mjs +++ b/mote_fleet/test/browser_check.mjs @@ -172,6 +172,121 @@ try { writeFileSync(shot, Buffer.from(screenshot.data, 'base64')); console.log(`screenshot: ${shot}`); + // -- the phone ---------------------------------------------------------- + // + // The off-LAN client is a phone, and a phone is not a narrow desktop: it has + // one pane's worth of screen and no wheel to zoom the map with. Emulated + // here — a real device is still the acceptance (README §9), because emulation + // gets the viewport and the touch points right and the thumb wrong. + + await session.send('Emulation.setDeviceMetricsOverride', { + width: 390, + height: 844, + deviceScaleFactor: 3, + mobile: true, + }); + await session.send('Emulation.setTouchEmulationEnabled', { + enabled: true, + maxTouchPoints: 5, + }); + await session.send('Page.navigate', { url }); + await sleep(4000); + + check( + 'a coarse pointer is what the page thinks it has', + await session.evaluate(`window.matchMedia('(pointer: coarse)').matches`), + ); + + const onePane = await session.evaluate(`(() => ({ + tabs: getComputedStyle(document.querySelector('.panes')).display, + shown: [...document.querySelectorAll('.pane')] + .filter(p => getComputedStyle(p).display !== 'none').length, + }))()`); + check( + 'one pane at a time, with a tab bar to move between them', + onePane.tabs === 'flex' && onePane.shown === 1, + JSON.stringify(onePane), + ); + + // Nothing may scroll sideways: a pane wider than the screen hides whatever is + // off its right edge — which is where the map's follow and fit buttons are. + const sideways = await session.evaluate(`(() => { + const out = []; + for (const tab of document.querySelectorAll('.panes [data-pane]')) { + tab.click(); + const pane = document.querySelector('.pane.active'); + if (pane.scrollWidth > pane.clientWidth || + document.documentElement.scrollWidth > window.innerWidth) out.push(tab.dataset.pane); + } + return out.join(','); + })()`); + check('no pane scrolls sideways on a phone', sideways === '', sideways); + + // Switching panes changes the canvas's height but not its width. Resizing on + // width alone leaves a backing store `clearRect` cannot fully reach, and the + // previous frame stays visible along the bottom. + const backing = await session.evaluate(`(() => { + document.querySelector('.panes [data-pane="detail"]').click(); + document.querySelector('.panes [data-pane="map"]').click(); + const c = document.getElementById('map-canvas'); + const r = c.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + return { w: c.width, h: c.height, want: [Math.round(r.width * ratio), Math.round(r.height * ratio)] }; + })()`); + check( + 'the canvas backing store follows the pane it is in', + backing.w === backing.want[0] && backing.h === backing.want[1], + `${backing.w}x${backing.h} for ${backing.want.join('x')}`, + ); + + const jumped = await session.evaluate(`(() => { + document.querySelector('.panes [data-pane="roster"]').click(); + document.querySelector('.robot').click(); + return document.querySelector('.pane.active').dataset.pane; + })()`); + check('picking a robot in the roster shows it on the map', jumped === 'map', jumped); + + // Pinch. The wheel handler has no touch equivalent, so this is the gesture + // that decides whether the map is usable at all on a phone. + const canvas = await session.evaluate(`(() => { + const r = document.getElementById('map-canvas').getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`); + const paint = () => + session.evaluate(`(() => { + const c = document.getElementById('map-canvas'); + const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; + let h = 0; + for (let i = 0; i < d.length; i += 997) h = (h * 31 + d[i]) >>> 0; + return h; + })()`); + const touch = (type, points) => + session.send('Input.dispatchTouchEvent', { + type, + touchPoints: points.map(([x, y], id) => ({ x, y, id })), + }); + const before = await paint(); + await touch('touchStart', [ + [canvas.x - 40, canvas.y], + [canvas.x + 40, canvas.y], + ]); + await touch('touchMove', [ + [canvas.x - 100, canvas.y], + [canvas.x + 100, canvas.y], + ]); + await touch('touchMove', [ + [canvas.x - 160, canvas.y], + [canvas.x + 160, canvas.y], + ]); + await touch('touchEnd', []); + await sleep(400); + check('two fingers zoom the map', (await paint()) !== before); + + const phoneShot = shot.replace(/(\.png)?$/, '-phone.png'); + const phonePng = await session.send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(phoneShot, Buffer.from(phonePng.data, 'base64')); + console.log(`screenshot: ${phoneShot}`); + const errors = await session.evaluate(`window.__errors ? window.__errors.length : 0`); check('no uncaught page errors', errors === 0, String(errors)); } finally { diff --git a/mote_fleet/test/ui_test.mjs b/mote_fleet/test/ui_test.mjs index e5e379d..797a4fc 100644 --- a/mote_fleet/test/ui_test.mjs +++ b/mote_fleet/test/ui_test.mjs @@ -10,6 +10,7 @@ // node --test mote_fleet/test/ui_test.mjs import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { @@ -22,7 +23,17 @@ import { parsePackets, parseTopic, } from '../server/ui/mqtt.mjs'; -import { fitView, pixelToWorld, worldToPixel, zoneOutline } from '../server/ui/map.mjs'; +import { + fitView, + pinchSpan, + pinchUpdate, + pixelToWorld, + worldToPixel, + zoneOutline, +} from '../server/ui/map.mjs'; +import { NARROW_MAX_PX } from '../server/ui/layout.mjs'; + +const read = (name) => readFileSync(new URL(`../server/ui/${name}`, import.meta.url), 'utf8'); // -- the MQTT codec ------------------------------------------------------ @@ -184,3 +195,76 @@ test('a bare waypoint has no outline to draw', () => { const map = { resolution: 0.05, origin: [0, 0, 0], width: 100, height: 100 }; assert.equal(zoneOutline(map, { name: 'pickup', x: 1, y: 1 }), null); }); + +// -- pinch to zoom ------------------------------------------------------- + +// The touch gesture the wheel handler has no equivalent for. Its arithmetic is +// pure for the same reason the transform above is: on a phone, a sign or a +// division wrong here is a map that leaps off screen with no way to tell why. + +test('fingers moving apart zoom in, and together zoom out', () => { + const before = pinchSpan({ x: 100, y: 100 }, { x: 200, y: 100 }); + const wider = pinchSpan({ x: 50, y: 100 }, { x: 250, y: 100 }); + assert.equal(pinchUpdate(before, wider).factor, 2); + assert.equal(pinchUpdate(wider, before).factor, 0.5); +}); + +test('the zoom is taken about the midpoint of the two fingers', () => { + const span = pinchSpan({ x: 10, y: 20 }, { x: 30, y: 60 }); + assert.deepEqual(span.centre, { x: 20, y: 40 }); + assert.equal(span.distance, Math.hypot(20, 40)); +}); + +test('fingers that move together carry the map with them', () => { + const before = pinchSpan({ x: 0, y: 0 }, { x: 100, y: 0 }); + const after = pinchSpan({ x: 30, y: 12 }, { x: 130, y: 12 }); + const update = pinchUpdate(before, after); + assert.equal(update.factor, 1); // same span: a pan, not a zoom + assert.deepEqual(update.pan, { x: 30, y: 12 }); +}); + +test('two pointers reported at one place leave the scale alone', () => { + // Not defensive: a factor of n/0 is Infinity and 0/0 is NaN, and either one + // in the view scale blanks the map permanently. + const together = pinchSpan({ x: 5, y: 5 }, { x: 5, y: 5 }); + assert.equal(together.distance, 0); + assert.equal(pinchUpdate(together, pinchSpan({ x: 0, y: 0 }, { x: 40, y: 0 })).factor, 1); +}); + +// -- the narrow layout --------------------------------------------------- + +// Below the breakpoint the stylesheet shows one pane at a time and app.mjs +// navigates to the map on a selection. Neither half fails loudly if they +// disagree about where "narrow" starts, so the seams are checked here. + +test('the stylesheet switches to one pane at the width layout.mjs uses', () => { + const css = read('style.css'); + const widths = [...css.matchAll(/@media \(max-width: (\d+)px\)/g)].map((m) => Number(m[1])); + assert.ok( + widths.includes(NARROW_MAX_PX), + `style.css has no @media (max-width: ${NARROW_MAX_PX}px); found ${widths.join(', ')}`, + ); +}); + +test('the tab bar and the panes address each other by the same names', () => { + const html = read('index.html'); + const panes = [...html.matchAll(/class="pane[^"]*" data-pane="([^"]+)"/g)].map((m) => m[1]); + const tabs = [...html.matchAll(/]*data-pane="([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual(panes, ['roster', 'map', 'detail']); + // A pane with no tab is simply unreachable on a phone, and nothing says so. + assert.deepEqual([...tabs].sort(), [...panes].sort()); +}); + +test('exactly one pane starts active, so a phone opens on something', () => { + const html = read('index.html'); + const active = [...html.matchAll(/class="pane [^"]*\bactive\b[^"]*"/g)]; + assert.equal(active.length, 1); +}); + +test('the map canvas takes its own touch gestures', () => { + // Without `touch-action: none` the browser consumes the drag and the pinch + // before the canvas sees a single pointer event. + const css = read('style.css'); + const canvas = css.slice(css.indexOf('#map-canvas {')); + assert.match(canvas.slice(0, canvas.indexOf('}')), /touch-action:\s*none/); +});