diff --git a/CLAUDE.md b/CLAUDE.md index fb1ef72..55463c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,6 +185,139 @@ revisions were fetched only *after* its basemap loaded, behind an early return, so a floor whose only revisions were candidates listed none of them and **the first promotion on any floor could never be made from a browser**. +## Fleet: editing a candidate's zones + +The write half of that pane (`ui/zone_editor.mjs`, route `POST /v1/sites// +floors//zones`, operator flow `docs/fleet/README.md` §11, contract +`fleet-api.md`): drag vertices, poses and whole zones on the map, double-click +an edge or vertex to add or remove one, and name the places. **The whole design +is one rule: editing is a derivation, never a mutation.** Saving re-packs *the revision under review* with the submitted zones +and accepts the result as an ordinary candidate — same `accept()` as a robot's +upload, inert until promoted — so a stored revision's bytes never change, which +is what the announced digests depend on, and promotion stays the only write that +moves a floor. Five things are load-bearing. **The edit names a source +revision**, which is what makes an *unpromoted* map editable: `segment-map` +hands over `zone_01`..`zone_07`, and deriving only from the canonical revision +meant promoting those placeholders in order to be allowed to fix them — +publishing a map because it was wrong — besides rebinding coordinates drawn on +one frame onto another's. **A derivation is held to `promote`'s bar, not the +upload's** (`accept(require_posegraph=…)`): a revision with no posegraph cannot +be extended, which is an error for a robot's upload where the session can be +re-run and a *warning* on something already stored, so the strict bar put an +`edit zones` button beside a `promotable` verdict that could only ever fail +(found in a browser; every sim bundle is such a revision). **It lives in the +review pane and nowhere else** — the operations canvas draws the *published* +basemap, so an editor there could only ever edit the published revision, and +"which map are these coordinates against" must have one answer. That also +retires the MVP's frozen-overlay workaround: after a save the pane selects the +new candidate and re-reads its zones, so what is on screen is the saved set from +the server rather than a held-over copy of what was typed. **`navigable` is +written only when it deviates from the kind**, because every zone arrives from +the server with the field filled in (`bundle.zone_term` defaults it) and writing +it back verbatim would carry a `keepout`'s `false` onto a zone just changed to +`room` — a room nothing can be dispatched to, with nothing on screen to say why. +And the editor **refuses client-side exactly what the robot's loader refuses**: +a non-dispatchable name, and two zones answering one query (`ambiguities` +mirrors `bundle.ambiguities` — names and aliases, not display names), since a +stored candidate no robot will load is worse than a rejected save. Editing is a +*mode*: the floor picker, the revision list and promote are disabled while it is +up, there is no autosave, and `cancel` discards. One pre-existing defect fell +out and is fixed: the map pane's review button was hidden unless the floor on +screen had candidates, and above 760 px the tab bar is hidden too — so the pane +built for floors no robot is reporting was reachable only through a floor a robot +was reporting. + +**What the operator can see is the other half of it**, and the first build of +this editor failed it in four ways an operator found in one sitting. **One +`hitTest` answers what a press will take** — the drag reads it, the cursor reads +it, and the hover highlight draws it — because three targets (vertex, pose, zone +body) plus a fourth meaning "this drag pans the map" is unguessable from a +static canvas, and three copies of that ordering would eventually disagree with +each other. Its highlight ring is *ink*, not white: a canvas gets no cascade, and +the surface under it is not the theme's background but the basemap, whose free +space is white in both themes (measured: white moved 1.5% of the pixels around a +handle, ink moves 12%). **A row is a list, not a form** — it carries what is +compared *across* zones (name, kind, shape) and the rest of zone/v0 +(`display_name`, `aliases`, `navigable`, `parent`, `tags`, `description`) edits +in a panel for the *selected* zone, so a new spec field costs no column, and a +2560 px monitor no longer stretches a twelve-character zone name into a text box +the size of a paragraph (rows cap at 640 px). **There is one list, not two**: +one renderer draws a revision's zones read-only and editable alike, `edit zones` +putting controls into the same cells — two renderers were two layouts that +drifted, and the read-only one had stranded the kind half a screen from the +name. Nothing in that column moves when editing opens (asserted in +`browser_check.mjs`), the editing surface has no border of its own, and a zone +is always selected: an empty panel needs a caption, and any caption for it +("select a zone to name it") names one of the several things it is for. And a control exists only where dragging cannot +reach — `⌖` (place a pose) appears **only** for a zone that has none, a +`segment-map` room being an outline with no `x`/`y` and so no cross to drag; a +row's name is a *button*, because in a list the name is what you select by and +an input there put a caret where a click meant "this one" (renaming moved into +the panel, beside the zone's other fields). Two controls were built and then cut +for failing that test: a `+ area`/`− area` cell, and a paragraph of instructions +standing in for the hover feedback before it existed. **A control sits at the +level of the thing it acts on**, which three buttons in one row denied: `save as +candidate` and `cancel` end the mode, so they take the place of `edit zones` +above the list rather than standing beside a control that adds one zone, and +`add zone` is the list's last line. The save's message moved with them, out +of the column of fields for one zone and under the list it is about — with no +height until there is something to say, since a strip reserved for the longest +message (the refusal quoting both zones that answer one query) is a gap over the +first zone that nothing explains. The room comes out of the list instead, the +one thing in that box which scrolls, so nothing above the message moves. It +carries what a save *did*, not that one is happening: the outcome used to be set +on the pane's own line in the far column while this one was cleared, so the only +thing appearing where the work was happening was `saving…`, lasting as long as +the request. The mode's banner is gone with it — `save as candidate` says what +saving does, and the one thing a banner was needed for, the shift modifier, is a +`title` on the surface it applies to. + +**A place is named once.** While a zone's machine name is still one nobody +chose (`zone_03`), typing its display name sets it through `slugify` — "The +Kitchen" gives `the_kitchen`, "Café" gives `cafe` (the letter survives, not just +the accent). It is a proposal in a visible field, and it never rewrites a name +an operator has chosen, since `goto` takes that name and a fetch may be scripted +against it; a spelling that cannot become a name at all ("3rd floor") proposes +nothing rather than mangling one. The field also marks an invalid name *as it is +typed*: the rule is the loader's and the save enforces it, but a field that looks +like free text until a save fails does not look like a field with a rule. That +leaves three naming fields doing two jobs — an identifier, a label, and the +other spellings `zones.resolve` will also match. + +A zone is drawn under `map.zoneLabel` — `display_name` if it has one, else the +machine name — by the operations map and the editor's own overlay alike, so a +place cannot answer to one name in the list and another while it is being +edited. `color-scheme` is declared per theme in the stylesheet for the same +class of reason: a `select`'s dropdown, a checkbox and a scrollbar are the +browser's to paint, and left to the *system* preference while the page follows +its own, a dark page grows a white dropdown list. + +**Every coordinate an edit writes lands on a pixel centre** (`snapToPixel`; +shift is the way off it, chosen over alt because a desktop's window manager +takes alt-drag and a modifier the page never receives is no modifier at all). +The map's resolution is the precision available, so a free-hand vertex is digits +nothing can back — and two zones meant to share a wall land millimetres apart, +differently every time. Three consequences: a *body* drag snaps its delta rather +than each vertex (`snapDelta`), so a room traced onto its walls keeps its shape, +and it is measured from the grab rather than accumulated per move, which would +drift the zone behind the pointer by whatever each rounding threw away; the +outline `withKind` invents starts on the grid, while the **pose stays where it +was taught** — that number was measured by driving a robot there; and nothing +re-snaps a coordinate the operator did not touch. + +**The kind decides whether a zone is a point or an area, and `withKind` makes +the geometry follow** (`bundle.POINT_KINDS`: dock, charger, pickup, dropoff, +home). Editing the two separately is what leaves a `dropoff` carrying an outline +nothing reads and a `room` with no extent `zones.containing` can never match — +so naming a bare pose an area gives it a square to drag onto the walls (which is +how an area is drawn in the UI at all), and naming an outlined zone a point +drops the outline and keeps the pose. Two things fall out. The classification is +**guidance, not validation**: `bundle` does not refuse an outline on a charger, +because that would refuse maps taught before the rule existed. And the +point-ward move is **refused** when the outline's centroid lies outside it (a +concave hallway) rather than putting the pose in a wall — `poseFor` returns null +and the select reverts. + ## Fleet: the zone vocabulary The API served the roster, the basemaps and dispatch, but not the one thing a diff --git a/docs/fleet/README.md b/docs/fleet/README.md index 516ae34..8d48324 100644 --- a/docs/fleet/README.md +++ b/docs/fleet/README.md @@ -663,12 +663,15 @@ outline for a `polygon`, a cross for a bare waypoint, each labelled — so the `goto ` targets you can type are the ones you can see. They come from the canonical revision, in that revision's map frame. -Beside the map's floor label is the **canonical revision** it is showing, and, -when the floor has candidates waiting, a button into the **review** pane — -which is where a candidate is looked at and promoted (§11). The map pane keeps -no promote control of its own: this canvas draws robots on the *published* -basemap, so promoting from beside it would mean promoting a map you have not -seen. +Beside the map's floor label is the **canonical revision** it is showing, and a +button into the **review** pane — which is where a candidate is looked at, its +zones named, and the map promoted (§11). It says how many candidates the floor +on screen has when it knows, and it is there either way: above 760 px the tab +bar is hidden, so a button that appeared only for a floor with candidates was +the sole door to a pane whose whole point is the floors *no robot is reporting*. +The map pane keeps no promote control of its own: this canvas draws robots on +the *published* basemap, so promoting from beside it would mean promoting a map +you have not seen. ![The dashboard on a phone](../images/fleet-ui-phone.webp) @@ -893,16 +896,123 @@ which appears whenever the floor on screen has something waiting. It shows: revision came from, when it was mapped, its size and resolution, the free/ occupied/unknown split, whether it carries a posegraph (i.e. whether mapping can be continued in this frame), its bytes and digest. -- **The zones in it**, and — the part that is easy to miss — whether they are - the revision's own or **inherited from the floor**. Inherited zones were - taught in a previous session's frame: they draw perfectly over the new map and - are wrong by however far the two origins differ. The pane says so in words, - because the canvas cannot. +- **The zones in it**, and — the part that is easy to miss — an `inherited` + mark beside the heading when they are not the revision's own. A revision that + carries no zones is drawn with the floor's, taught in a previous session's + frame: they draw perfectly over the new map and are out by however far the two + origins differ, which the canvas cannot show. Zones that belong to the map + they are drawn on are marked nothing at all — that is what "zones" means. - **The promote button**, which is the same audited flip `fleetctl promote` makes. -Everything except that button is a read. Nothing you do here changes any floor -until you promote. +Everything except that button and the zone editor below is a read. Nothing you +do here changes any floor until you promote. + +### Naming the rooms on it, before you promote it + +`pixi run segment-map` finds the rooms of a map but cannot know what they are +called, so a fresh revision arrives with `zone_01`..`zone_07`. **`edit zones`**, +beside the zone list, is where they get their names — on the candidate's own map, +where you can see which room is which. + +The controls are the map and the list together: + +- **On the map**: drag a vertex to follow a wall, drag a zone to move footprint + and pose together, drag a pose cross to move where the robot is sent, + double-click an edge to add a vertex or a vertex to remove it. A polygon needs + three, so the last removal is refused rather than quietly making a line. + **Whatever the next press would take is highlighted under the pointer**, and + the cursor says which it is: a crosshair over a vertex, a move cursor over a + pose or a zone body, and the map's own grab cursor everywhere else — where a + drag pans instead of editing. +- **Everything you drag lands on a pixel centre** — the map's own grid, so two + zones meant to share a wall share the same numbers, and a coordinate never + claims precision the map does not have. A whole zone moves by whole pixels, so + a traced room keeps its shape. **Hold shift to move freely**, for the rare + case that wants a coordinate between two pixels. Only what you drag is + snapped: a pose taught by driving a robot there is a measurement, and it is + left exactly where the robot said, while an outline this editor invents starts + on the grid. +- **It is the same list either way.** The zones of a revision are listed under + the map whether or not you are editing them — name, kind, shape — and + `edit zones` puts controls into those rows rather than replacing them with a + second list. Nothing moves when you click it: the rows stay where they are and + the fields open beside them. +- **A row is a list you pick from**: the name selects that zone, beside it the + **kind**, then the shape it has. `⌖` appears only for a zone with no pose at + all (a segmented room is an outline, so there is no cross to drag) and arms + the next map click as its pose. `×` deletes the zone; **`add zone`**, the last + line of the list, drops a square at the view centre to be dragged into shape + and named. +- **Where a control sits says what it acts on.** `save as candidate` and + `cancel` take the place of `edit zones` above the list, because they end the + edit that button began; `add zone` is in the list, because that is what it + adds to. What the save says — a refusal, or the candidate it wrote — appears + under the list, in room the list gives up as the message arrives, and stays + until another revision is opened. +- **Selecting a row** opens that zone's own fields beside the list — and a zone + is always selected, so they are always showing something: its **name** + (renaming is a deliberate act, not a side effect of clicking the list), the + **display name** an operator reads — and which the map is labelled with, here + and in the operations view, as soon as it is set — **also called** (the other spellings + `goto` should accept — an MCP dispatcher turning "the galley" into a command + matches these), **navigable**, the zone it is **inside**, **tags**, and a + **description**. They live here rather than in the row because they belong to + one zone at a time, and because a column each would make the list unreadable + long before zone/v0 ran out of fields. + +**The kind decides whether a zone is a point or an area, and the geometry +follows it.** A `charger`, `dock`, `pickup`, `dropoff` or `home` is a pose to +drive to; everything else — `room`, `corridor`, `keepout`, `slow`, a plain +`area` — is a place with extent, and "am I in it" is the question it exists to +answer. So changing the kind changes the shape: call a taught waypoint a `room` +and it gets an outline to drag onto the walls; call an outlined zone a `charger` +and the outline goes, leaving the pose. That is how an area is drawn here, and +it is one decision rather than two that can contradict each other. + +The one refusal: an outline whose centre falls outside it (a concave hallway) +cannot become a point on its own, because there is no pose to fall back on — +place one with `⌖` first. + +A zone taught by driving reads as an `area` until you say otherwise: `save-zone` +writes no kind, and `bundle.zone_term` defaults a missing one to `area` rather +than inventing one. Beyond geometry, three kinds change what a robot does today +— `keepout` and `slow` are not destinations (`goto` and `fetch` both refuse +them), and `segment-map` writes `room` — the rest are vocabulary a planner may +read over `/v1/zones`. + +**You should not have to name a place twice.** A machine name is what `goto` +takes (lowercase, digits, `_`, and the field says so as you type rather than at +save), and a display name is what a person reads — so while the machine name is +still one nobody chose (`zone_03`, as `segment-map` and `add zone` mint them), +typing a display name sets it: "The Kitchen" gives `the_kitchen`, "Café" gives +`cafe`. It is a proposal, in the field, editable; a name you have already chosen +is never rewritten, because `goto` takes it and a `fetch` may be scripted +against it. **Aliases** are the third naming field and a different job: other +spellings a dispatcher may *say* for the same place, which `goto` also matches. + +Two names the same is refused before it is saved — the robot's loader refuses a +vocabulary where one query answers to two zones rather than picking by luck, so +the editor must not produce one. A name a dispatcher cannot type (`Café`, `Drop +Off`) is refused the same way. + +**Saving derives a new candidate**: `save as candidate` sends the edited set, +and the server re-packs the revision you were editing with those zones in place +of its own. The revision you edited is untouched — including when it is the +published one — and the new candidate is selected in the pane, so the zones on +screen afterwards are the saved ones read back from the server. Promote it when +it looks right. Two consequences worth knowing: + +- **Iterating costs a candidate each save.** Editing a candidate derives from + *it*, so a floor's list grows while you work; the registry keeps the canonical + revision plus the five newest candidates, so the intermediates fall off on + their own. +- **A revision that inherited the floor's zones stops inheriting.** The saved + candidate carries them, which is what you want: inherited zones were taught in + another session's frame, and dragging them onto this map is the correction. + +`cancel` discards the edit. There is no autosave and nothing is written until +you save, so an edit you are unsure about costs nothing to abandon. **A floor with nothing published yet works the same way** — which was not always true: the dashboard used to fetch a floor's revisions only after its basemap had diff --git a/docs/fleet/fleet-api.md b/docs/fleet/fleet-api.md index fb42e4e..4cf23e2 100644 --- a/docs/fleet/fleet-api.md +++ b/docs/fleet/fleet-api.md @@ -101,6 +101,8 @@ GET …/revisions//map.json that revision's own transform + size GET …/revisions//map.png that revision's own image GET …/revisions//zones.json that revision's own zone binding POST …/revisions//promote make it canonical (an operator) +POST /v1/sites//floors//zones edited zones of a revision -> + a new candidate (an operator) GET / the operator UI (static files) ``` @@ -499,6 +501,69 @@ frame. All three are reads, so like every other read route they take no operator token; M7 changes that for all of them at once. +### `POST /v1/sites//floors//zones` + +Operator token required. The edited zone set, and the revision it was edited +against: + +```json +{"schema": 1, "revision": "20260802T145731", + "zones": {"kitchen": {"x": 1.0, "y": -3.0, "yaw": 0.0, "kind": "room", + "display_name": "The Kitchen", "aliases": ["galley"], + "polygon": [[0.0,-4.0],[2.0,-4.0],[2.0,-2.0],[0.0,-2.0]]}}} +``` + +`zones` is the `zones.yaml` shape — keyed by name, both halves of zone/v0 in one +entry — and it **replaces** that revision's set rather than patching it. An entry +may echo its key as `name`, which is dropped: the file keys by name and carries +no second copy. + +```json +{"schema":1,"site":"home","floor":"ground","revision":"20260812T211029", + "derived_from":"20260802T145731","promoted":false,"warnings":[],"audit_id":31} +``` + +| Status | Meaning | +|---|---| +| `201` | stored as a candidate — `revision` is the new one, `derived_from` the edited one | +| `400` | `zones` is not a mapping, or a name is not a directory name | +| `401` | no usable operator token (recorded as an anonymous attempt) | +| `404` | no such floor, or no such `revision` on it | +| `409` | no `revision` given and the floor has nothing published to edit | +| `422` | the edited set is not readable as a `zones.yaml`; `errors` says why | + +**Editing is a derivation, not a mutation.** The named revision's map bytes are +re-packed with the submitted zones in place of its own and accepted as an +ordinary candidate — validated by the same code as a robot's upload, listed by +the floor route, promotable through the route above. Nothing already stored is +written: a promoted revision's bytes back a digest the fleet has been told, and a +candidate is immutable for the same reason an id is never reused. The response +carries `promoted: false` because that stays a separate, audited decision. + +**`revision` is what makes an unpromoted map editable**, and that is the point +rather than a convenience. A fresh build arrives carrying `zone_01`..`zone_07` +from `segment-map`; without it the only thing an edit could derive from was the +canonical revision, so renaming those placeholders meant promoting them first — +publishing a map *because* it was wrong. It also keeps the coordinates in the +frame they were drawn in: the operator is looking at that revision's own map. +Omitted, the canonical revision is edited, which is the same thing for a floor +whose published map is what is on screen. + +**The bar is the source's, not the upload's.** A revision with no posegraph is +one mapping cannot be continued from — an error for a robot's upload, where the +session can be re-run, and a *warning* on a stored revision, which navigates +perfectly and which `promote` will accept. A derivation is therefore validated +the way `promote` validates: holding an edit to a stricter bar than the revision +it derives from would put an `edit zones` button beside a `promotable` verdict +that could only ever fail. + +Two things this route deliberately does not do. It does not slugify or otherwise +repair a name — a zone a dispatcher cannot type is stored with a warning, as +everywhere else in the vocabulary (`problems` are reported, not enforced; the +*robot's* loader is what refuses one). And it never publishes: the audit row +names the actor, the floor and the revision edited, and the floor's `map` +symlink is untouched. + --- ## What the browser is allowed to do @@ -511,7 +576,8 @@ omission, not by intention. M7 makes that structural on the broker side too, with a subscribe-only credential. **Reviewing a candidate is all GETs.** The review pane reads a revision's -`map.json`, `map.png` and `zones.json` and writes nothing; its one write is the -`promote` M4 already had, which is authorized and audited like any other. Zone -*editing* is a separate write (`POST …/floors//zones`) that derives a new -candidate and still changes no published floor. +`map.json`, `map.png` and `zones.json`; the two writes beside them are the +`promote` M4 already had and the zone edit above, both operator-authorized and +both audited. The edit is the only write in the fleet that produces a revision, +and it produces an inert one: a candidate nobody is running, on a floor that has +not moved. diff --git a/mote_bringup/mote_bringup/bundle.py b/mote_bringup/mote_bringup/bundle.py index afb4a25..531d4c1 100644 --- a/mote_bringup/mote_bringup/bundle.py +++ b/mote_bringup/mote_bringup/bundle.py @@ -104,6 +104,20 @@ #: honoured — otherwise the flag would mean whatever the file last said. CONSTRAINT_KINDS = frozenset(("keepout", "slow")) +#: Kinds that name a **pose** rather than a region: a charger is where the robot +#: docks, not an area it may be anywhere inside of, and "am I in the dropoff" is +#: not a question about it. Everything else in :data:`ZONE_KINDS` is a place with +#: extent — a room, a corridor, a keepout — whose footprint is the point of it. +#: +#: This is a fact about the vocabulary and so lives beside it, but it is +#: **guidance, not validation**: a bundle that carries an outline on a `charger` +#: still loads, because a rule that refused one would refuse maps taught before +#: the rule existed. What reads it is the zone editor, where changing a zone's +#: kind is how an operator says which of the two a place is — and the geometry +#: follows, rather than being toggled separately as though the two were +#: unrelated. +POINT_KINDS = frozenset(("dock", "charger", "pickup", "dropoff", "home")) + #: A dispatchable zone name. The shared token a dispatcher types, so it is a #: machine name rather than a label: lowercase, no spaces, no punctuation to #: guess at. Anything an operator wants to *see* belongs in ``display_name``. diff --git a/mote_fleet/README.md b/mote_fleet/README.md index 0ae2542..fddb2ab 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, pan/pinch), `mqtt.mjs` (a subscribe-only MQTT client), `layout.mjs` (one pane at a time on a phone) | +| [`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), `review.mjs` (see a candidate before promoting it), `zone_editor.mjs` (name its zones, saved as a new candidate) | | [`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/bundle_store.py b/mote_fleet/server/bundle_store.py index eecd31b..315a8d3 100644 --- a/mote_fleet/server/bundle_store.py +++ b/mote_fleet/server/bundle_store.py @@ -354,26 +354,44 @@ def read_vocabulary(self, site: str, floor: str) -> dict: raise StoreError(f"no zones for {site}/{floor}", 404) return bundle.vocabulary(bundle.read_zones(path), site, floor) - def derive_zones(self, site: str, floor: str, zones: dict, *, by: str): - """A new candidate revision: the canonical map's bytes, edited zones. + def derive_zones( + self, site: str, floor: str, zones: dict, *, by: str, source: str = "" + ): + """A new candidate revision: one revision's map bytes, edited zones. An operator's zone edit never touches a stored revision — a promoted revision's bytes back a digest the fleet has been told, and a candidate is immutable for the same reason one id is never reused. So an edit is - a *derivation*: pack the canonical revision with the submitted zones in + a *derivation*: pack the source revision with the submitted zones in place of its own, and accept the result as an ordinary candidate — validated by the same code as any upload, inert until promoted. + + ``source`` is the revision being edited, defaulting to the canonical + one. Naming a candidate is what makes an unpromoted map editable: the + first build of a floor arrives with `zone_01`..`zone_07` from + `segment-map`, and defaulting to the canonical would have meant + promoting placeholder names in order to be allowed to fix them — + publishing a map *because* it was wrong. It also puts the edit in the + frame it was drawn in: the operator renaming rooms is looking at the + candidate's own map in the review pane, and rebinding those names onto + the published map's frame is exactly the trap the pane exists to name. Returns ``(stored_revision, report, derived_from)``. """ - canonical = self.canonical(site, floor) - if not canonical: + source = source or self.canonical(site, floor) + if not source: raise StoreError( f"{site}/{floor} has no published map to edit zones on", 409 ) - rev_dir = self.revision_dir(site, floor, canonical) + rev_dir = self.revision_dir(site, floor, source) previous = {} - zones_file = rev_dir / bundle.ZONES_YAML - if zones_file.is_file(): + # The zones the operator was shown, which is what the edit is a delta + # of: `_zones_file` falls back to the floor's file for a revision + # carrying none, and so does the review pane that fed the editor. The + # `frame_id` and `vocabulary_revision` come from there for the same + # reason — an edit that silently reset the vocabulary revision would + # make a later carry-forward unable to tell which naming is newer. + zones_file = self._zones_file(site, floor, revision=source) + if zones_file is not None: try: previous = bundle.read_zones(zones_file) except bundle.BundleError: @@ -400,8 +418,9 @@ def derive_zones(self, site: str, floor: str, zones: dict, *, by: str): datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S"), blob, uploaded_by=by, + require_posegraph=False, ) - return stored, report, canonical + return stored, report, source def vocabularies(self) -> list: """Every floor's vocabulary, for a dispatcher bootstrapping a whole @@ -468,6 +487,7 @@ def accept( *, robot_id: str = "", uploaded_by: str = "", + require_posegraph: bool = True, ) -> tuple[str, bundle.Report]: """Store an uploaded revision as a **candidate**. Returns ``(id, report)``. @@ -475,6 +495,16 @@ def accept( robot is running. That separation is the milestone's conflict story: a second robot's map of the same floor is kept beside the first, and an operator promotes one. + + ``require_posegraph`` is what a *robot's upload* is held to and what a + derivation is not: a mapping session that produced no posegraph produced + a map nothing can extend, and that is worth refusing at the source. A + revision already in the registry has been judged by the looser rule + :meth:`promote` uses, so a derivation of one must not be held to a + stricter bar than the revision it came from — the review pane calls such + a revision promotable, and an `edit zones` button beside that verdict + that could only ever fail is worse than no button (observed: the sim's + own bundles carry no posegraph). """ if not self.root: raise StoreError("this server stores no site bundles", 404) @@ -491,7 +521,7 @@ def accept( except bundle.BundleError as exc: raise StoreError(str(exc), 400) from exc try: - report = bundle.validate(staging) + report = bundle.validate(staging, require_posegraph=require_posegraph) except bundle.BundleError as exc: # validate() documents that it reports rather than raises, and # it is tested that way. Belt and braces: a validator that diff --git a/mote_fleet/server/fleet_server.py b/mote_fleet/server/fleet_server.py index 005a01a..6985d87 100644 --- a/mote_fleet/server/fleet_server.py +++ b/mote_fleet/server/fleet_server.py @@ -37,7 +37,8 @@ GET .../revisions//map.png that revision's own image GET .../revisions//zones.json that revision's own zone binding POST .../revisions//promote make it canonical (operator) - POST /v1/sites//floors//zones edited zones -> new candidate (operator) + POST /v1/sites//floors//zones edited zones of a revision -> + a new candidate (operator) GET / the operator UI (static files) pixi run fleet-server -- --db ~/fleet/registry.db --broker-host fleet-box @@ -816,13 +817,19 @@ def _promote(self, rest: str, body: dict): ) def _edit_zones(self, rest: str, body: dict): - """An operator's zone edit: derive a candidate from the canonical map. + """An operator's zone edit: derive a candidate from a revision. The edit writes nothing the fleet can see — the result is an ordinary candidate (same map bytes, new zones), validated like any upload and inert until the operator promotes it through the existing route. That keeps promoted revisions immutable, which the announced digests rely on, and keeps promotion the only write that changes a floor. + + The body's optional ``revision`` is the revision being edited; without + one the canonical map is edited, as before. It is a body field rather + than a path segment because the edited revision is an *input* to the + derivation and never the thing written — the route's own resource is + the floor's zones, and the result is a revision id neither end chose. """ parts = rest.split("/") if len(parts) != 3 or parts[1] != "floors": @@ -844,23 +851,27 @@ def _edit_zones(self, rest: str, body: dict): ) self._error(401, str(exc)) return - if not self._names(site, floor): + source = str(body.get("revision") or "") + if not self._names(site, floor, *([source] if source else [])): return zones = body.get("zones") if not isinstance(zones, dict): self._error(400, "a zones mapping is required: {zones: {name: {...}}}") return actor = operator["name"] + # The audit row names what was edited, not only which floor: two + # candidates of one floor are two different maps, and "who renamed the + # rooms on this map" is unanswerable from the floor alone. entry = registry.record( actor=actor, action="map.zones", - command=target, + command=f"{target}/{source}" if source else target, result="editing", remote=self.address_string(), ) try: stored, report, derived_from = self.server.store.derive_zones( - site, floor, zones, by=actor + site, floor, zones, by=actor, source=source ) except StoreError as exc: registry.finish(entry["id"], "rejected", str(exc)) @@ -876,7 +887,9 @@ def _edit_zones(self, rest: str, body: dict): traceback.print_exc() self._error(500, "the zone edit could not be stored") return - registry.finish(entry["id"], "stored", f"candidate {stored}") + registry.finish( + entry["id"], "stored", f"candidate {stored} from {derived_from}" + ) print( f"zone edit {target} by {actor}: candidate {stored} (from {derived_from})", file=sys.stderr, diff --git a/mote_fleet/server/ui/app.mjs b/mote_fleet/server/ui/app.mjs index 6d10675..4a5ef3e 100644 --- a/mote_fleet/server/ui/app.mjs +++ b/mote_fleet/server/ui/app.mjs @@ -14,7 +14,6 @@ import { BrokerReader, parseTopic } from './mqtt.mjs'; import { MapView } from './map.mjs'; -import { ZoneEditor } from './zone_editor.mjs'; import { ReviewView } from './review.mjs'; import { setupPanes } from './layout.mjs'; @@ -42,7 +41,6 @@ const state = { const dom = {}; let mapView = null; -let editor = null; let review = null; let panes = null; let pending = false; @@ -182,10 +180,6 @@ async function ensureMap(record) { if (key === state.mapKey) return; state.mapKey = key; state.floor = null; - if (editor) { - editor.end(); - dom.zonesEdit.disabled = false; - } setZones([]); renderRevisions(); if (!key) { @@ -255,10 +249,16 @@ function renderRevisions() { const candidates = floor ? floor.revisions.filter((revision) => !revision.canonical) : []; - dom.reviewJump.hidden = candidates.length === 0; - dom.reviewJump.textContent = `${candidates.length} candidate${ - candidates.length === 1 ? '' : 's' - } — review`; + // Always reachable, and *not* conditional on this floor having candidates. + // The tab bar it shares the job with is hidden above 760 px, so while this + // was, a desk-width operator could reach the review pane only through a + // floor a robot was reporting — which is precisely the floor review is least + // needed for. The floor mapped last week by a robot since switched off, and + // the floor a build lands on with no robot near it, were both unreachable. + dom.reviewJump.hidden = false; + dom.reviewJump.textContent = candidates.length + ? `${candidates.length} candidate${candidates.length === 1 ? '' : 's'} — review` + : 'review maps'; } // The zones of the floor on screen, as a `goto` the operator does not have to @@ -295,42 +295,6 @@ function onPromoted() { scheduleRender(); } -// Editing starts from the zones on screen — the canonical floor's — and never -// writes back to them: saving derives a fresh candidate revision server-side, -// which the picker then promotes like any robot-published map. -function onEditZones() { - if (!state.mapKey || !mapView.map) return; - editor.begin(state.zones); - dom.zonesEdit.disabled = true; -} - -async function onSaveZones() { - const problem = editor.problems(); - if (problem) { - dom.zoneNote.textContent = problem; - dom.zoneNote.className = 'note error'; - return; - } - const [site, floor] = state.mapKey.split('/'); - dom.zoneNote.textContent = 'saving\u2026'; - dom.zoneNote.className = 'note'; - try { - const body = await api(`/v1/sites/${site}/floors/${floor}/zones`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ schema: 1, zones: editor.payload() }), - }); - editor.finish(); - dom.zonesEdit.disabled = false; - dom.promoteNote.textContent = `zone candidate ${body.revision} saved \u2014 the zones shown are the candidate's; open review to see it and promote it`; - dom.promoteNote.className = 'note'; - await loadFloor(site, floor, state.mapKey); - } catch (error) { - dom.zoneNote.textContent = error.message; - dom.zoneNote.className = 'note error'; - } -} - // -- rendering ----------------------------------------------------------- function scheduleRender() { @@ -597,7 +561,6 @@ function bind() { mapLabel: 'map-label', mapRevision: 'map-revision', reviewJump: 'review-jump', - promoteNote: 'promote-note', canvas: 'map-canvas', follow: 'follow', fit: 'fit', @@ -609,7 +572,6 @@ function bind() { reviewVerdictNotes: 'review-verdict-notes', reviewNotesLabel: 'review-notes-label', reviewProvenance: 'review-provenance', - reviewZones: 'review-zones', reviewZoneSource: 'review-zone-source', reviewCanvas: 'review-canvas', reviewMapLabel: 'review-map-label', @@ -619,7 +581,7 @@ function bind() { zonesEdit: 'zones-edit', zoneEditor: 'zone-editor', zoneRows: 'zone-rows', - zoneAdd: 'zone-add', + zoneDetail: 'zone-detail', zoneSave: 'zone-save', zoneCancel: 'zone-cancel', zoneNote: 'zone-note', @@ -655,12 +617,20 @@ export async function boot() { verdictNotes: dom.reviewVerdictNotes, notesLabel: dom.reviewNotesLabel, provenance: dom.reviewProvenance, - zones: dom.reviewZones, zoneSource: dom.reviewZoneSource, mapLabel: dom.reviewMapLabel, promote: dom.reviewPromote, fit: dom.reviewFit, note: dom.reviewNote, + // The zone editor's own controls. It lives in this pane because it edits + // the *selected revision's* zones over that revision's own map. + zonesEdit: dom.zonesEdit, + editor: dom.zoneEditor, + editorRows: dom.zoneRows, + editorDetail: dom.zoneDetail, + editorNote: dom.zoneNote, + zoneSave: dom.zoneSave, + zoneCancel: dom.zoneCancel, }, }); // A canvas has no size until its pane is on screen, so each map is told when @@ -686,18 +656,6 @@ export async function boot() { mapView.draw(); }); dom.dispatch.addEventListener('submit', onDispatch); - editor = new ZoneEditor(mapView, { - panel: dom.zoneEditor, - rows: dom.zoneRows, - note: dom.zoneNote, - }); - dom.zonesEdit.addEventListener('click', onEditZones); - dom.zoneAdd.addEventListener('click', () => editor.addZone()); - dom.zoneSave.addEventListener('click', onSaveZones); - dom.zoneCancel.addEventListener('click', () => { - editor.end(); - dom.zonesEdit.disabled = false; - }); document.getElementById('token-form').addEventListener('submit', onToken); state.config = await api('/v1/config'); diff --git a/mote_fleet/server/ui/index.html b/mote_fleet/server/ui/index.html index 1a7bcdf..2749f03 100644 --- a/mote_fleet/server/ui/index.html +++ b/mote_fleet/server/ui/index.html @@ -44,27 +44,17 @@

map

- + an operator promotes it. Promotion, and editing its zones, happen + in the review pane where that candidate's own map is on screen — + this is the way in. It is always here, and says how many + candidates the floor on screen has when it knows: above 760 px + the tab bar is hidden, so a conditional button was the only door + to a pane whose whole point is floors no robot is reporting. --> + - -

- - -

-
+ +
+
+

zones

+ + + + + + + +
+ +
+ +

+ +
diff --git a/mote_fleet/server/ui/map.mjs b/mote_fleet/server/ui/map.mjs index 96fd4ce..1f605dd 100644 --- a/mote_fleet/server/ui/map.mjs +++ b/mote_fleet/server/ui/map.mjs @@ -14,6 +14,15 @@ // an accident of where SLAM started, so metres from one floor mean nothing on // another. The second hop is pan/zoom and belongs to the viewer alone. +// What to call a zone on a map. `display_name` is the half meant for reading — +// "The Kitchen" — and the machine name is the half meant for typing, so a zone +// that has been given one is drawn with it. The editor draws its own overlay +// and reads this too: a zone should not answer to one name in the list and +// another the moment it is being edited. +export function zoneLabel(zone) { + return (zone && (zone.display_name || zone.name)) || ''; +} + // A zone as pixels on the basemap: a polygon's vertices, or a circle. Pure, so // the placement of a taught place is testable the same way a robot's is. export function zoneOutline(map, zone) { @@ -440,7 +449,7 @@ export class MapView { // display_name is what an operator calls the place; the machine name is // what they would type. Prefer the former on the map, where this is a // label rather than a thing to copy. - ctx.fillText(zone.display_name || zone.name, point.x, point.y + 14); + ctx.fillText(zoneLabel(zone), point.x, point.y + 14); } _screenOf(pixel) { diff --git a/mote_fleet/server/ui/review.mjs b/mote_fleet/server/ui/review.mjs index 2673824..525068d 100644 --- a/mote_fleet/server/ui/review.mjs +++ b/mote_fleet/server/ui/review.mjs @@ -22,10 +22,13 @@ // here. The zone list below is deliberately a row per zone with its own // cells, because that is where those controls go. // -// The write surface does not grow: everything this view reads is a GET, and the -// one thing it writes is the promote M4 already had. +// Two writes leave this pane, and both are audited operator actions: the +// promote M4 already had, and the zone edit beside it (`zone_editor.mjs`), +// which derives a *new* candidate rather than touching the revision on screen. +// Nothing here changes a floor until the promote. import { MapView } from './map.mjs'; +import { ZoneEditor } from './zone_editor.mjs'; // -- routes --------------------------------------------------------------- @@ -185,31 +188,28 @@ export function provenanceRows(revision) { ]; } -// Where the zones on screen came from, which the coordinates cannot say. A -// revision that carries none inherits the floor's — taught in a *previous* SLAM -// session's frame, and so wrong for this map by however far the two origins -// differ. That is a reason to look before promoting, which is what this pane is -// for, so it is said out loud rather than left to the validator's warning list. +// Where the zones on screen came from, which the coordinates cannot say — as a +// state beside the heading, in the idiom the rest of the page uses, rather than +// a sentence of prose under it. +// +// **The ordinary case says nothing.** Zones that belong to the revision they are +// drawn on are what "zones" already means; a caption announcing it is a label +// for the absence of a problem, in words ("taught in this revision's own +// frame") that only mean anything to someone who knows the problem. What is +// worth a word is the exception: a revision carrying no zones of its own is +// drawn with the *floor's*, taught in a previous SLAM session's frame and so +// out by however far the two origins differ. `null` is "nothing to say". export function zoneSource(source, count) { - if (!count) return 'this revision carries no zones'; + if (!count) return { tag: 'none', title: 'this revision carries no zones' }; if (source === 'floor') { - return 'inherited from the floor — this revision carries none, so these were taught on another map frame'; - } - return 'taught in this revision’s own frame'; -} - -// One zone's geometry in a phrase. The vocabulary half (kind, aliases) is shown -// in its own cells; this is the binding half, which is the half that is only -// true against the map beside it. -export function zoneSummary(zone) { - if (zone.polygon && zone.polygon.length >= 3) { - return `polygon, ${zone.polygon.length} vertices`; - } - if (typeof zone.radius === 'number') return `circle, r ${zone.radius} m`; - if (zone.x !== undefined && zone.y !== undefined) { - return `waypoint ${zone.x.toFixed(2)}, ${zone.y.toFixed(2)}`; + return { + tag: 'inherited', + title: + 'this revision carries no zones, so the floor’s are drawn: they were ' + + 'taught on another map and line up only as far as the two frames do', + }; } - return 'no footprint'; + return null; } // -- the view ------------------------------------------------------------- @@ -240,13 +240,26 @@ export class ReviewView { // and an image decode; without this, clicking through two candidates draws // whichever finished last rather than the one selected. this.epoch = 0; + // The zones last loaded for the selected revision, which is what an edit + // starts from and what the map goes back to if the edit is cancelled. + this.zones = []; + this.editing = false; this.map = new MapView(dom.canvas); + this.editor = new ZoneEditor(this.map, { + panel: dom.editor, + rows: dom.editorRows, + detail: dom.editorDetail, + note: dom.editorNote, + }); dom.floor.addEventListener('change', () => this.open(dom.floor.value)); dom.promote.addEventListener('click', () => this.promote()); dom.fit.addEventListener('click', () => { this.map.fit(); this.map.draw(); }); + dom.zonesEdit.addEventListener('click', () => this.beginEdit()); + dom.zoneSave.addEventListener('click', () => this.saveZones()); + dom.zoneCancel.addEventListener('click', () => this.endEdit()); } // The pane's canvas has no size until the pane is on screen, so a fit done @@ -276,10 +289,16 @@ export class ReviewView { async open(key, revision = null) { const parsed = parseFloorKey(key); if (!parsed) return; + // An edit in progress owns the pane: re-opening a floor would reload the + // zones under it and drop the edit on the floor. The controls that lead + // here are disabled while editing; this covers the ones that arrive from + // elsewhere (the map pane's jump button). + if (this.editing) return; const epoch = (this.epoch += 1); this.key = key; this.dom.floor.value = key; this.note(''); + this.editor.note(''); try { this.detail = await this.api(floorPath(parsed.site, parsed.floor)); } catch (error) { @@ -344,6 +363,93 @@ export class ReviewView { } } + // -- editing ---------------------------------------------------------- + + // Editable when there is a revision selected and its map is on screen: the + // coordinates being dragged mean nothing except against that image, and a + // revision whose map failed to load has none. + editable() { + return Boolean(this.selected && this.map.map); + } + + // Editing is a mode on the selected revision, so while it is on, the things + // that would swap that revision out from under it are disabled rather than + // racing it. There is no autosave: an unsaved edit is lost to `cancel`, and + // nothing else can reach it. + renderEditControls() { + // One control at a time in one place: `edit zones` is what is there to + // begin with, and while the edit is up its two endings stand in for it. + this.dom.zonesEdit.disabled = !this.editable(); + this.dom.zonesEdit.hidden = this.editing || !this.editable(); + this.dom.zoneSave.hidden = !this.editing; + this.dom.zoneCancel.hidden = !this.editing; + this.dom.floor.disabled = this.editing; + for (const row of this.dom.revisions.querySelectorAll('button')) { + row.disabled = this.editing; + } + if (this.editing) this.dom.promote.disabled = true; + } + + beginEdit() { + if (!this.editable() || this.editing) return; + this.editing = true; + // The editor draws its own zones, handles and pose crosses on the canvas; + // leaving the read-only set under them would double every outline. + this.map.setZones([]); + this.editor.begin(this.zones); + this.renderEditControls(); + // Not on the note line: that line is for what the operator has to read — + // a refusal, or what a save did — and a banner sitting in it for the whole + // edit would take its room from the list and say what `save as candidate` + // already says. The modifier has nowhere to be discovered but the surface + // it applies to. + this.dom.canvas.title = 'edits snap to the map’s pixels; hold shift to move freely'; + } + + endEdit() { + this.editing = false; + this.dom.canvas.title = ''; + this.editor.note(''); + this.editor.end(); + this.map.setZones(this.zones); + this.renderEditControls(); + this.renderVerdict(); + } + + // Saving does not write the revision on screen: the server packs that + // revision's map bytes with the submitted zones and stores the result as an + // ordinary candidate. So the pane then *selects* the new candidate, and the + // zones on screen afterwards are the saved ones read back from the server — + // rather than the frozen overlay this needed when the editor lived on the + // operations map, where the only thing to re-render was the stale set the + // edit was made from (which read as data loss, 2026-08-02). + async saveZones() { + if (!this.editing || !this.selected) return; + const problem = this.editor.problems(); + if (problem) { + this.editor.note(problem, true); + return; + } + const { site, floor } = parseFloorKey(this.key); + const from = this.selected.revision; + this.editor.note('saving…'); + let body; + try { + body = await this.api(`/v1/sites/${site}/floors/${floor}/zones`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ schema: 1, revision: from, zones: this.editor.payload() }), + }); + } catch (error) { + this.editor.note(error.message, true); + return; + } + this.endEdit(); + await this.loadFloors(); + await this.open(this.key, body.revision); + this.editor.note(`candidate ${body.revision} saved from ${from}`); + } + // -- rendering -------------------------------------------------------- renderFloors() { @@ -399,6 +505,7 @@ export class ReviewView { el('p', { class: 'empty', text: 'no revisions on this floor' }), ); } + this.renderEditControls(); } renderVerdict() { @@ -423,20 +530,17 @@ export class ReviewView { ); } - // One row per zone, three cells. Read-only here on purpose — the write half - // (rename, alias, kind, click-to-teach a pose) is task 346, and it edits - // exactly these rows. + // The zones of the selected revision, drawn by the editor whether or not it + // is editing them: `edit zones` puts controls in these rows rather than + // replacing them with a second list of its own. renderZones(zones, source = '') { - this.dom.zoneSource.textContent = zoneSource(source, zones.length); - this.dom.zones.replaceChildren( - ...zones.map((zone) => - el('div', { class: 'zone-row' }, [ - el('span', { class: 'zone-name', text: zone.display_name || zone.name }), - el('span', { class: 'zone-kind', text: zone.kind || 'area' }), - el('span', { class: 'dim', text: zoneSummary(zone) }), - ]), - ), - ); + this.zones = zones; + const origin = zoneSource(source, zones.length); + this.dom.zoneSource.hidden = !origin; + this.dom.zoneSource.textContent = origin ? origin.tag : ''; + this.dom.zoneSource.title = origin ? origin.title : ''; + this.editor.show(zones); + this.renderEditControls(); } note(text, bad = false) { @@ -469,7 +573,7 @@ export class ReviewView { await this.open(this.key, revision); this.note( body.announced - ? `${site}/${floor} is on ${body.revision}; robots will pull it` + ? `${site}/${floor} is on ${body.revision}` : `promoted, but not announced: ${body.detail}`, !body.announced, ); diff --git a/mote_fleet/server/ui/style.css b/mote_fleet/server/ui/style.css index 78b28ed..9f39023 100644 --- a/mote_fleet/server/ui/style.css +++ b/mote_fleet/server/ui/style.css @@ -3,6 +3,12 @@ roster (who), map (where), detail (what, and send it somewhere). */ :root { + /* The browser draws the parts of a control we do not: a `select`'s dropdown + list, a checkbox, a scrollbar. Without this it draws them from the *system* + preference while the page is on its own, so a dark page grows a white + dropdown. Declared per theme rather than as `light dark`, because these + blocks are what decides the theme. */ + color-scheme: dark; --bg: #0d1117; --panel: #161b22; --line: #30363d; @@ -20,6 +26,7 @@ @media (prefers-color-scheme: light) { :root { + color-scheme: light; --bg: #f6f8fa; --panel: #ffffff; --line: #d0d7de; @@ -144,6 +151,7 @@ h3 { } input, +select, button, .button { font: inherit; @@ -208,20 +216,6 @@ main { padding: 0 14px 10px; } -.revisions { - display: flex; - gap: 6px; - align-items: center; -} - -.revisions select { - max-width: 220px; -} - -#promote-note { - padding: 0 14px 8px; -} - #map-canvas { flex: 1; width: 100%; @@ -598,15 +592,14 @@ main { /* -- zone editor ---------------------------------------------------------- */ /* A class that sets display defeats the hidden attribute (the M3 promote- - picker lesson), so the guard is explicit. */ + picker lesson), so the guard is explicit. The editing surface is otherwise + plain: it had a border and a padded box of its own, which meant clicking + `edit zones` drew a frame around a part of the page that had not moved. */ .zone-editor { display: flex; flex-direction: column; - gap: 6px; - margin: 6px 0 0; - padding: 8px; - border: 1px solid var(--line); - border-radius: 6px; + gap: 8px; + min-width: 0; } .zone-editor[hidden] { display: none; @@ -616,10 +609,18 @@ main { display: flex; flex-direction: column; gap: 4px; - max-height: 180px; + min-height: 0; overflow-y: auto; } +/* The same lesson again, one element along: the read-only zone list is hidden + while the editable one is up, and a class that sets `display` outranks the + `hidden` attribute — leaving both lists on screen, each disagreeing with the + other about what the zones are. */ +.zone-rows[hidden] { + display: none; +} + .zone-row { display: grid; grid-template-columns: 1fr 1fr auto; @@ -629,8 +630,45 @@ main { border-radius: 4px; } -.zone-row.selected { - outline: 1px solid var(--accent); +/* A row is picked, so it has to look pickable and then look picked — but only + when there is something to pick it for. Without this the only thing that + changed on selection was a panel to the side and an outline on the map, + neither of which is where the pointer is. */ +.zone-rows.editing .zone-row { + cursor: pointer; +} + +.zone-rows.editing .zone-row:hover { + background: var(--bg); +} + +.zone-rows.editing .zone-row.selected { + outline: none; + border-color: var(--accent); + background: var(--bg); +} + +/* The name is the target: it fills its cell, reads as text rather than as a + control competing with the kind beside it, and takes the accent when its row + is the selected one. */ +.zone-name { + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border-color: transparent; +} + +.zone-row.selected .zone-name { + color: var(--accent); +} + +/* The row is the target, not the word in it: the shared `button:hover` rule + would draw a box around the name and make one cell look like the control. */ +.zone-name:hover { + border-color: transparent; + color: var(--accent); } .zone-row input { @@ -638,11 +676,158 @@ main { min-width: 0; } -.zone-actions { +/* One row shape, edited or not: name, kind, shape, place-pose, delete. The + cells do not move when the controls arrive in them — a list that relaid + itself on `edit zones` made looking and editing two different pages. + + `minmax(0, 1fr)` on the name rather than `1fr`, because a grid track's + automatic minimum is its content and a long name would otherwise widen the + row past the pane instead of ellipsing inside its cell. The `max-width` is + the same problem from the other end: on a 2560 px monitor the pane is wide + enough to strand the kind and the shape half a screen from the name, and to + stretch a twelve-character name into a text box the size of a paragraph. */ +.zone-rows .zone-row { + grid-template-columns: minmax(0, 1fr) 10ch minmax(0, 21ch) 4ch 4ch; + max-width: 640px; + border: 1px solid transparent; +} + +.zone-rows .zone-row > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The name cell, whichever it is: the button and the text line up, so entering + edit mode does not shift the word. */ +.zone-name, +.zone-name-text { + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 10px; + border: 1px solid transparent; +} + +/* The selected zone's own fields. */ +.zone-detail { display: flex; + flex-direction: column; gap: 6px; } +.zone-detail h4 { + margin: 2px 0; + font-size: 12px; + color: var(--dim); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.zone-field { + display: grid; + grid-template-columns: 13ch minmax(0, 1fr); + gap: 8px; + align-items: center; + font-size: 12px; +} + +.zone-field-name { + color: var(--dim); +} + +.zone-rename { + font-weight: 600; +} + +/* A name the robot's loader would refuse, said while it is typed. The save + refuses it too — this is so that the rule is visible before then. */ +.zone-rename.bad { + border-color: var(--fault); + color: var(--fault); +} + +.zone-field input[type='checkbox'] { + justify-self: start; + width: 16px; + height: 16px; +} + +/* Fixed, so the cells line up down the list: each row is its own grid, and an + `auto` track sized to "4 vertices" in one row and "waypoint" in the next + would step in and out. */ +/* Fixed, and held even when the cell is empty: `⌖` is offered only to a zone + with no pose to drag, and a column that collapsed when it was absent would + step the delete buttons in and out down the list. */ +.zone-editor .zone-row .place { + width: 4ch; + padding: 4px 0; + text-align: center; +} + +.zone-rows .zone-row select, +.zone-rows .zone-row .zone-del { + font: 12px ui-monospace, monospace; + min-width: 0; +} + +/* Armed: the next click on the map is this zone's pose. The state has to be + visible on the button that armed it — the pointer is about to be somewhere + else entirely, and a click that silently moved a pose would be worse than + no feature. */ +.zone-row button.armed { + border-color: var(--accent); + color: var(--accent); +} + +/* The last item in the list, and shaped like one: a full-width control at the + foot of what it adds to, dashed so it reads as a place for a zone rather + than a zone. */ +.zone-add { + flex: none; + border-style: dashed; + color: var(--dim); + text-align: left; +} + +/* Editing holds the selected revision still, so the controls that would swap it + are disabled — and have to *look* disabled, or the pane reads as ignoring + clicks. */ +.zones-head button:disabled, +.revision-row:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* The heading and the controls for the whole edit. They sit with the zones + rather than in the pane head, because they act on the list under them and not + on the pane — and `save as candidate`/`cancel` sit exactly where `edit zones` + was, so the mode begins and ends at one place. The details column carries the + same head, so the two columns are announced the same way and their headings + line up. */ +.zones-head { + display: flex; + align-items: baseline; + gap: 10px; + min-height: 28px; +} + +/* What the save is doing, or the rule it is refusing on. Under the list rather + than above it, and no height of its own: an empty message reserving two lines + is a gap over the first zone that nothing explains, while here the list — the + one thing in the box that scrolls — gives up the room as the message arrives + and nothing above it moves. */ +.zones-panel > .note { + grid-column: 1; + grid-row: 3; + margin: 0; +} + +.zones-head h3 { + margin-bottom: 0; +} + /* -- candidate review ----------------------------------------------------- */ /* Review is a *mode*, not a fourth column. The fleet-operations panes stand @@ -691,6 +876,48 @@ main:has(.review-pane.active) > .pane:not(.review-pane) { margin: 4px 0 0; } +/* The zones half of the pane, reserved at one height so that entering and + leaving edit mode does not resize the map above it. The editing surface is + the taller of the two, so it sets the height and the read-only list sits in + the same box with room to spare; a floor with more zones than fit scrolls + inside it rather than pushing the canvas about. */ +.zones-panel { + flex: none; + height: 300px; + overflow: auto; + display: grid; + grid-template-columns: minmax(0, 640px) minmax(0, 26em); + grid-template-rows: auto 1fr auto; + align-content: start; + gap: 6px 24px; +} + +.zones-head, +.zone-rows { + grid-column: 1; +} + +.zones-panel > .zone-rows { + grid-row: 2; + height: 100%; +} + +/* A pill in the page's own idiom, and the only one that is a warning: it says + these coordinates were taught somewhere else. */ +.pill.warn { + border-color: var(--degraded); + color: var(--degraded); +} + +/* Beside the list rather than under it, and spanning the rows above so that + nothing in the left column moves when it appears. */ +.zone-editor { + grid-column: 2; + grid-row: 1 / span 3; + border-left: 1px solid var(--line); + padding-left: 20px; +} + #review-canvas { flex: 1; width: 100%; @@ -794,20 +1021,6 @@ main:has(.review-pane.active) > .pane:not(.review-pane) { display: none; } -/* The editor's row grid ends in an `auto` column sized to a button; here the - third cell is a phrase of arbitrary length, which would push the row wider - than the pane. Task 346 replaces these cells with controls and inherits the - row shape either way. */ -#review-zones .zone-row { - grid-template-columns: minmax(0, 1fr) 72px minmax(0, 1fr); -} - -#review-zones .zone-row > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - button.promote { align-self: flex-start; } @@ -845,4 +1058,42 @@ button.promote:disabled { flex: none; height: 46vh; } + + /* The pane itself scrolls on a phone, so a second scrolling box inside it is + a trap: the height comes off the reservation and goes back to the content, + and the two columns go back to one. */ + .zones-panel { + height: auto; + overflow: visible; + display: flex; + flex-direction: column; + } + + .zone-editor { + border-left: none; + border-top: 1px solid var(--line); + padding: 8px 0 0; + } + + .zone-rows .zone-row { + display: flex; + flex-wrap: wrap; + } + + .zone-editor .zone-row input, + .zone-editor .zone-row select { + flex: 1 1 45%; + } + + /* Nor anything to reserve: with the pane scrolling, a message that arrives + can have the height it needs. */ + .zones-panel > .note { + min-height: 0; + } + + /* No reserved box here, so no height to fill: the list is as long as it is + and the pane scrolls, which is what a phone does with everything else. */ + .zones-panel > .zone-rows { + height: auto; + } } diff --git a/mote_fleet/server/ui/zone_editor.mjs b/mote_fleet/server/ui/zone_editor.mjs index 251e42b..296c291 100644 --- a/mote_fleet/server/ui/zone_editor.mjs +++ b/mote_fleet/server/ui/zone_editor.mjs @@ -1,16 +1,25 @@ -// Zone editing on the fleet map: drag a vertex, drag a zone, drag a pose, -// rename, add, delete — then save the lot as a *candidate* revision. +// Zone editing on a map revision: drag a vertex, drag a zone, place a pose, +// rename, name the kind, add aliases, add, delete — then save the lot as a +// *candidate* revision. // -// The editor never writes to the floor it is looking at. Saving POSTs the -// edited set to the server, which derives a new candidate from the canonical -// revision (same map bytes, new zones); the operator then promotes it through -// the picker exactly like a robot-published map. Promoted revisions stay -// immutable, which the announced digests rely on. +// The editor never writes to the revision it is looking at. Saving POSTs the +// edited set to the server, which derives a new candidate from that revision +// (same map bytes, new zones); the operator then promotes it exactly like a +// robot-published map. Stored revisions stay immutable, which the announced +// digests rely on, and promotion stays the only write that changes a floor. // -// Geometry lives in pure functions over zone objects in *world* metres, so -// every edit operation is testable under node with no canvas and no DOM. +// It lives in the review pane (`review.mjs`) because a zone is a coordinate in +// one map frame: the map under the zones has to be the map they belong to, and +// only that pane draws a *candidate's* own map. Editing a candidate is the +// point — a fresh build arrives with `zone_01`..`zone_07` from `segment-map`, +// and an editor that could only edit the published map would have required +// promoting those placeholder names in order to be allowed to fix them. +// +// Geometry and the vocabulary rules live in pure functions over zone objects in +// *world* metres, so every edit operation is testable under node with no canvas +// and no DOM. -import { pixelToWorld, worldToPixel } from './map.mjs'; +import { pixelToWorld, worldToPixel, zoneLabel } from './map.mjs'; // Ray-cast membership over [[x, y], ...]. Concave polygons are fine, which // matters because the hallway is one. @@ -57,6 +66,43 @@ export function nearestEdge(zone, x, y) { return best; } +// What the pointer is over, in the order a drag claims it: a vertex first (it +// is the smallest target and sits on top of its own zone), then a pose cross, +// then the zone's interior. `null` means the map — which is why the same +// function draws the hover: an operator who cannot tell a vertex drag from a +// zone drag from a map pan is guessing, and this is the one answer all three +// read. +export function hitTest(zones, x, y, reach) { + for (const zone of zones) { + const vertex = zone.polygon ? nearestVertex(zone, x, y) : null; + if (vertex && vertex.distance <= reach) { + return { kind: 'vertex', zone: zone.name, index: vertex.index }; + } + } + for (const zone of zones) { + if (typeof zone.x !== 'number') continue; + // A pose is a cross rather than a corner to aim at, so it is given a + // little more reach than a vertex. + if (Math.hypot(zone.x - x, zone.y - y) <= reach * 1.2) { + return { kind: 'pose', zone: zone.name }; + } + } + for (const zone of zones) { + if (zone.polygon && pointInPolygon(zone.polygon, x, y)) { + return { kind: 'zone', zone: zone.name }; + } + } + return null; +} + +// The cursor for a target — the half of the answer that arrives before the +// pointer has touched anything. +export function cursorFor(target, placing = false) { + if (placing) return 'crosshair'; + if (!target) return ''; // the stylesheet's `grab`: this drag pans the map + return target.kind === 'vertex' ? 'crosshair' : 'move'; +} + export function withVertex(zone, index, x, y) { const polygon = zone.polygon.map((point, i) => i === index ? [round(x), round(y)] : point, @@ -114,18 +160,237 @@ export function freshZone(existing, cx, cy, half = 1.0) { }; } +// A default outline, one metre either side of the pose, for a zone that has +// just been called a room and has no extent yet. It is a starting shape to drag +// onto the walls, not a guess at the room. +function squareAround(zone, map, half = 1.0) { + // On the grid, like every other coordinate this editor writes. The *pose* is + // left where it is — it was measured by driving a robot there, and moving it + // two centimetres to tidy a number would be inventing data — but the outline + // is this editor's own, so it starts where the next drag would put it. + const corner = (x, y) => { + const point = snapToPixel(map, x, y); + return [point.x, point.y]; + }; + return [ + corner(zone.x - half, zone.y - half), + corner(zone.x + half, zone.y - half), + corner(zone.x + half, zone.y + half), + corner(zone.x - half, zone.y + half), + ]; +} + +// A pose for a zone that has only an outline. The robot's loader derives one +// the same way when it loads a polygon-only zone; here it is needed when an +// outline is about to be dropped, so that the zone is left with a position at +// all. Concave outlines whose centroid falls outside them get `null`, and the +// caller refuses the change rather than inventing a pose in a wall. +export function poseFor(zone) { + if (typeof zone.x === 'number' && typeof zone.y === 'number') { + return { x: zone.x, y: zone.y }; + } + if (!zone.polygon || !zone.polygon.length) return null; + const sum = zone.polygon.reduce((acc, [x, y]) => [acc[0] + x, acc[1] + y], [0, 0]); + const centre = { x: round(sum[0] / zone.polygon.length), y: round(sum[1] / zone.polygon.length) }; + return pointInPolygon(zone.polygon, centre.x, centre.y) ? centre : null; +} + +// **The kind decides whether a zone is a point or an area, and the geometry +// follows it.** A `charger` is a pose to dock at; a `room` is a place with +// walls, and "am I in it" is the question it exists to answer. Editing them as +// two independent things — a kind here, an outline toggled over there — is what +// leaves a `dropoff` carrying a seven-vertex outline nothing reads, and a room +// with no extent that `zones.containing` can never match. +// +// So: naming a bare pose an area gives it a starting outline to drag onto the +// walls, and naming an outlined zone a point drops the outline and keeps the +// pose. Returns `null` if that second move would leave the zone with no +// position at all, which is a zone the robot's loader refuses. +export function withKind(zone, kind, map = null) { + const next = { ...zone, kind }; + if (isAreaKind(kind)) { + if (!next.polygon && typeof next.radius !== 'number' && typeof next.x === 'number') { + next.polygon = squareAround(next, map); + } + return next; + } + if (!next.polygon && typeof next.radius !== 'number') return next; + const pose = poseFor(next); + if (!pose) return null; + delete next.polygon; + delete next.radius; + return { ...next, x: pose.x, y: pose.y }; +} + +// The map's own pixel grid, in world metres. A vertex dropped anywhere inside a +// pixel covers exactly the same cells as one at its centre, so the free-hand +// coordinate is precision the map does not have — and two zones meant to share +// a wall end up a few millimetres apart, differently each time. Snapping to +// centres (half a pixel off the origin, which is a pixel *edge*) makes "the +// same place" the same number. +export function snapToPixel(map, x, y) { + if (!map || !map.resolution) return { x: round(x), y: round(y) }; + const axis = (value, origin) => + round(origin + (Math.floor((value - origin) / map.resolution) + 0.5) * map.resolution); + return { x: axis(x, map.origin[0]), y: axis(y, map.origin[1]) }; +} + +// A whole number of pixels, for dragging a zone bodily: snapping each vertex +// would pull the shape about, and snapping the *movement* keeps it rigid — a +// room traced onto its walls stays traced when it is nudged. +export function snapDelta(map, delta) { + if (!map || !map.resolution) return delta; + return round(Math.round(delta / map.resolution) * map.resolution); +} + +// One zone's geometry in a phrase: the binding half, which is the half that is +// only true against the map beside it. Shown in the list whether or not the +// list is being edited — the shape is a fact about the zone, not a control. +export function zoneSummary(zone) { + if (zone.polygon && zone.polygon.length >= 3) { + return `polygon, ${zone.polygon.length} vertices`; + } + if (typeof zone.radius === 'number') return `circle, r ${zone.radius} m`; + if (zone.x !== undefined && zone.y !== undefined) { + return `waypoint ${zone.x.toFixed(2)}, ${zone.y.toFixed(2)}`; + } + return 'no footprint'; +} + // A machine name a dispatcher can type — the same rule the robot's loader and // the bundle validator enforce, applied here so a bad rename fails in the // input rather than at save. export const NAME_RE = /^[a-z][a-z0-9_]*$/; +// The machine name a display name implies: "The Kitchen" -> `the_kitchen`, +// "Café" -> `cafe`. Two fields for one place is a chore, and the operator is +// naming a room, not authoring an identifier — so the identifier follows. +// +// It is a *proposal*, not a rule: the result is put in the name field where it +// can be seen and changed, and it is only ever offered while the name is still +// one nobody chose (see `isGeneratedName`). Renaming a zone an operator has +// already named would break the `goto` that names it, which is the opposite of +// a convenience. A spelling that cannot become a name at all (`3rd floor`) +// yields '' and nothing is proposed. +export function slugify(text) { + const slug = String(text || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // é -> e, rather than dropping the letter + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return NAME_RE.test(slug) ? slug : ''; +} + +// A name nobody has chosen: what `segment-map` mints for a room it found and +// what `add zone` mints for a new one. These are the names the editor exists to +// replace, so they are the ones it may replace on its own. +export function isGeneratedName(name) { + return /^zone_\d+$/.test(String(name || '')); +} + +// zone/v0's kinds, in the spec's order. Mirrored from `bundle.ZONE_KINDS` +// rather than fetched, because this is a `