diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 851ead891..ea4e143a5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6017,7 +6017,8 @@ request viewbox and continues searching without that geographic preference. - UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone. - **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too. -- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam. +- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam. +- **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one. - Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". diff --git a/src/annotations/drawMarks.js b/src/annotations/drawMarks.js index 8a51500b0..725c07aa1 100644 --- a/src/annotations/drawMarks.js +++ b/src/annotations/drawMarks.js @@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) { : shape; } +/** The kind of shape a row action is offered for: `area` or `line`. */ +const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' }); + +const lonLat = (point) => + Array.isArray(point) + ? [Number(point[0]), Number(point[1])] + : [Number(point?.lon), Number(point?.lat)]; +const finitePairs = (points) => + (Array.isArray(points) ? points : []) + .map(lonLat) + .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)); +/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */ +const wrapLon = (lon) => + lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180; +/** Whether consecutive positions step across the antimeridian once wrapped. */ +const crossesAntimeridian = (points) => + points.some( + (point, index) => + index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180, + ); +/** Positions wrapped into range, or null when the sequence crosses ±180°. */ +const inRange = (points) => { + if (points.some(([, lat]) => Math.abs(lat) > 90)) return null; + const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]); + return crossesAntimeridian(wrapped) ? null : wrapped; +}; +const closed = (ring) => { + if (ring.length < 3) return ring; + const [first, last] = [ring[0], ring[ring.length - 1]]; + return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first]; +}; + +/** + * A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in + * [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area + * handle has several parts), a line a `LineString`. A pin, a shape too small + * to be one, or one that crosses the antimeridian has none, so it is offered + * no action rather than a shape that wraps the wrong way round the world. + */ +export function drawMarkGeometry(mark) { + if (mark?.type === 'route') { + const path = inRange(finitePairs(mark.path)); + return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null; + } + if (mark?.type !== 'area') return null; + const ringOf = (points) => { + const ring = inRange(closed(finitePairs(points))); + return ring?.length >= 4 ? ring : null; + }; + if (Array.isArray(mark.polygons) && mark.polygons.length) { + const polygons = []; + for (const rings of mark.polygons) { + const parts = (Array.isArray(rings) ? rings : []).map(ringOf); + // A part that cannot be given whole is no part: never a hole dropped. + if (!parts.length || parts.some((ring) => !ring)) return null; + polygons.push(parts); + } + return polygons.length > 1 + ? { type: 'MultiPolygon', coordinates: polygons } + : { type: 'Polygon', coordinates: polygons[0] }; + } + const ring = ringOf(mark.ring); + return ring ? { type: 'Polygon', coordinates: [ring] } : null; +} + +/** + * The actions an application adds to Draw's rows, checked once: each has an + * `id`, a Material Symbols `icon`, a `label(name)` worded by the application, + * the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and + * `run({ id, shape, name, title, geometry })` (`name` the person's own, `title` + * as the row shows it). Malformed or repeated ids are dropped. + */ +export function drawMarkActions(actions = []) { + const kept = new Map(); + for (const action of Array.isArray(actions) ? actions : []) { + const wellFormed = + typeof action?.id === 'string' && + /^[a-z][a-z0-9-]{0,39}$/.test(action.id) && + typeof action.icon === 'string' && + typeof action.label === 'function' && + typeof action.run === 'function' && + Array.isArray(action.shapes); + if (!wellFormed || kept.has(action.id)) continue; + kept.set( + action.id, + Object.freeze({ + id: action.id, + icon: action.icon, + label: action.label, + shapes: Object.freeze( + action.shapes.filter((shape) => + Object.values(ACTION_SHAPES).includes(shape), + ), + ), + accepts: + typeof action.accepts === 'function' ? action.accepts : () => true, + run: action.run, + }), + ); + } + return Object.freeze([...kept.values()]); +} + +/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */ +export function drawMarkActionsFor(mark, actions = []) { + const shape = ACTION_SHAPES[mark?.type]; + if (!shape || !actions.length) return { shape, geometry: null, actions: [] }; + const geometry = drawMarkGeometry(mark); + if (!geometry) return { shape, geometry, actions: [] }; + return { + shape, + geometry, + actions: actions.filter((action) => { + if (!action.shapes.includes(shape)) return false; + try { + return action.accepts(geometry, shape) === true; + } catch { + return false; + } + }), + }; +} + +/** An action's words for a row; an application's label that throws shows its id. */ +function actionLabel(action, title) { + try { + const text = action.label(title); + return typeof text === 'string' && text.trim() ? text.trim() : action.id; + } catch { + return action.id; + } +} + /** * The rows for the hand-drawn marks on the board, in board order. Text is * worded here, in the current language; the view only lays it out. * @param {object[]} marks The engine's `list()`. + * @param {ReturnType} [actions] The application's row actions. + * @param {Map} [notes] What an action said on a shape. */ -export function presentDrawMarks(marks = []) { +export function presentDrawMarks(marks = [], actions = [], notes = new Map()) { return marks .filter((mark) => mark?.manual === true) .map((mark) => { const fallback = drawMarkFallbackName(mark); const title = mark.name || fallback; const visible = !mark.hidden; + const offered = drawMarkActionsFor(mark, actions).actions; return { id: mark.id, name: mark.name || '', placeholder: fallback, color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary', hidden: !visible, + busy: notes.get(mark.id)?.busy === true, + note: offered.length ? notes.get(mark.id)?.text || '' : '', + actions: offered.map((action) => ({ + id: action.id, + icon: action.icon, + label: actionLabel(action, title), + })), labels: { name: t('Name of {name}', { name: title }), swatch: t('Change the colour of {name}', { name: title }), diff --git a/src/annotations/drawMarks.test.mjs b/src/annotations/drawMarks.test.mjs index 60662abf8..3c5b70377 100644 --- a/src/annotations/drawMarks.test.mjs +++ b/src/annotations/drawMarks.test.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import { DRAW_COLORS, DRAW_COLOR_CSS, + drawMarkActions, + drawMarkActionsFor, drawMarkFallbackName, + drawMarkGeometry, presentDrawMarks, } from './drawMarks.js'; @@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () => test('every colour has its swatch', () => { assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]); }); + +test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => { + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + }), + { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }, + ); + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: [ + [0, 0], + [1, 0], + [1, 1], + ], + }).coordinates[0].at(-1), + [0, 0], + 'an open ring is closed', + ); + assert.equal( + drawMarkGeometry({ + type: 'area', + ring: [ + [0, 0], + [1, 0], + ], + }), + null, + ); + assert.deepEqual( + drawMarkGeometry({ + type: 'route', + path: [ + { lon: 1, lat: 2, height: 9 }, + { lon: 3, lat: 4, height: 0 }, + ], + }), + { + type: 'LineString', + coordinates: [ + [1, 2], + [3, 4], + ], + }, + ); + assert.equal( + drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }), + null, + ); + const square = (x) => [ + [x, 0], + [x + 1, 0], + [x + 1, 1], + [x, 0], + ]; + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: square(0), + polygons: [[square(0)], [square(5)]], + }), + { type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] }, + 'an area handle with several parts keeps them all', + ); + assert.equal( + drawMarkGeometry({ + type: 'area', + polygons: [ + [ + square(0), + [ + [0.2, 0.2], + [0.3, 0.2], + ], + ], + ], + }), + null, + 'a hole that cannot be given whole is never dropped silently', + ); +}); + +test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => { + assert.deepEqual( + drawMarkGeometry({ + type: 'route', + path: [ + [370, 1], + [371, 1], + ], + }).coordinates, + [ + [10, 1], + [11, 1], + ], + ); + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: [ + [180, 0], + [179, 0], + [179, 1], + ], + }).coordinates[0][0], + [180, 0], + 'the antimeridian itself stays put', + ); + assert.equal( + drawMarkGeometry({ + type: 'area', + ring: [ + [179.9, 0], + [-179.9, 0], + [-179.9, 1], + ], + }), + null, + ); + assert.equal( + drawMarkGeometry({ + type: 'route', + path: [ + [179.5, 0], + [180.5, 0], + ], + }), + null, + ); + assert.equal( + drawMarkGeometry({ + type: 'route', + path: [ + [0, 91], + [1, 0], + ], + }), + null, + ); +}); + +test('row actions are checked once: malformed ones and repeated ids are dropped', () => { + const run = () => {}; + const label = () => 'x'; + const actions = drawMarkActions([ + { id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] }, + { id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] }, + { + id: 'tag', + icon: 'sell', + label, + run, + shapes: ['area', 'line', 'pin'], + }, + { id: 'tag', icon: 'other', label, run, shapes: ['area'] }, + null, + ]); + assert.deepEqual( + actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })), + [{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }], + ); + assert.deepEqual(drawMarkActions('nope'), []); + const area = { + type: 'area', + ring: [ + [0, 0], + [1, 0], + [1, 1], + ], + }; + const offer = drawMarkActionsFor(area, actions); + assert.equal(offer.shape, 'area'); + assert.deepEqual( + offer.actions.map((a) => a.id), + ['tag'], + ); + const refusing = drawMarkActions([ + { + id: 'measure', + icon: 'a', + label, + run, + shapes: ['area'], + accepts: () => { + throw new Error('no'); + }, + }, + ]); + assert.deepEqual( + drawMarkActionsFor(area, refusing).actions, + [], + 'an accepts that throws offers nothing', + ); + assert.deepEqual( + presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0] + .actions, + [], + ); +}); diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index a022fdcc9..f44d30753 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -50,9 +50,28 @@ import { removeLastVertex, } from './drawMode.js'; -import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js'; +import { + DRAW_COLORS, + DRAW_COLOR_CSS, + drawMarkActions, + drawMarkActionsFor, + drawMarkFallbackName, + presentDrawMarks, +} from './drawMarks.js'; import { onLocaleChange, t, translateKnown } from '../i18n/index.js'; +/** The longest note a row action leaves under its row, in characters. */ +const NOTE_MAX = 200; +/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */ +function noteText(text) { + if (typeof text !== 'string') return ''; + const trimmed = text.trim(); + if (trimmed.length <= NOTE_MAX) return trimmed; + const cut = trimmed.slice(0, NOTE_MAX - 1); + const space = cut.lastIndexOf(' '); + return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`; +} + /** The id this tool claims the pointer under. */ const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; @@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS; * through the actions it gives `connect`, which returns their disconnect. * A row's fly-to is an immediate navigation through `navigation`, like * Import's, so Cockpit can refuse it and a running orbit or follow lets go. - * @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps + * `markActions` are the application's own buttons on an area's or a line's + * row (drawMarks.js `drawMarkActions`); a press runs the action with the + * shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`). + * @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps * @returns {{destroy: Function}|null} */ export function initDrawTool({ @@ -76,7 +98,12 @@ export function initDrawTool({ navigation = null, publish = () => {}, connect = () => () => {}, + markActions = [], }) { + const rowActions = drawMarkActions(markActions); + // What a row action said last on each drawn shape: `{ run, busy, text }`. + const notes = new Map(); + let noteRun = 0; // A page without the Draw controls (the Display controls view omitted) has // no draw tool. if (!viewer || !annotations || !document.getElementById('draw-toggle')) @@ -115,14 +142,19 @@ export function initDrawTool({ () => null, ); - const present = () => + const present = () => { + const marks = annotations.list?.() || []; + // A note goes with its shape. + for (const id of notes.keys()) + if (!marks.some((mark) => mark.id === id)) notes.delete(id); publish({ active, shape, color, hint, - marks: presentDrawMarks(annotations.list?.() || []), + marks: presentDrawMarks(marks, rowActions, notes), }); + }; // The hint is kept as the call that words it, so a language switch can // word it again. let hintText = () => ''; @@ -450,6 +482,56 @@ export function initDrawTool({ return annotations.frame?.(id) ?? false; } + /** + * Run the application's row action `actionId` on drawn shape `id`, if the + * shape is still on the board and still offered it. Returns whether it ran. + * What the action returns, or resolves to, is the row's note (a measurement, + * "saved"): the row is busy until it settles, a newer press or the shape's + * removal makes a late answer moot, and a failure clears the note, so an + * action words its own refusals and resolves with them. + */ + function runMarkAction(id, actionId) { + const mark = (annotations.list?.() || []).find( + (entry) => entry.id === id && entry.manual === true, + ); + const offer = drawMarkActionsFor(mark, rowActions); + const action = offer.actions.find((entry) => entry.id === actionId); + if (!action || notes.get(mark.id)?.busy) return false; + const run = ++noteRun; + const settle = (text) => { + if (destroyed || notes.get(mark.id)?.run !== run) return; + notes.set(mark.id, { + run, + busy: false, + text: noteText(text), + }); + present(); + }; + const fail = (error) => { + console.warn(`[draw] ${actionId} failed`, error); + settle(''); + }; + notes.set(mark.id, { run, busy: true, text: '' }); + let answer; + try { + answer = action.run({ + id: mark.id, + shape: offer.shape, + name: mark.name || '', + title: mark.name || drawMarkFallbackName(mark), + geometry: offer.geometry, + }); + } catch (error) { + fail(error); + return false; + } + if (typeof answer?.then === 'function') { + present(); + Promise.resolve(answer).then(settle, fail); + } else settle(answer); + return true; + } + /** Draw `next` from now on; an open shape starts over in it. */ function chooseShape(next) { shape = normalizeShape(next); @@ -478,6 +560,7 @@ export function initDrawTool({ }, flyToDrawMark: flyToMark, removeDrawMark: (id) => annotations.remove?.(id), + runDrawMarkAction: runMarkAction, }); // The rows follow the board: a shape drawn, renamed, hidden or removed // here, by voice or by a clear. @@ -500,13 +583,15 @@ export function initDrawTool({ setActive, setShape: chooseShape, /** The hand-drawn marks the list shows, as its rows. */ - marks: () => presentDrawMarks(annotations.list?.() || []), + marks: () => + presentDrawMarks(annotations.list?.() || [], rowActions, notes), setColor(next) { color = DRAW_COLORS.includes(next) ? next : 'primary'; present(); syncPreview(); }, flyToMark, + runMarkAction, /** Test seam: add a vertex from lon/lat as if it had been clicked. */ addVertex(lon, lat, height = 0) { if (!session) return false; diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 811142193..4cef4d4fe 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -223,7 +223,7 @@ function fakeAnnotations() { } /** Stand up a tool against fresh doubles, with globals restored afterwards. */ -function harness({ navigation = null } = {}) { +function harness({ navigation = null, markActions = [] } = {}) { resetPointerOwnership(); const { byId, documentDouble } = fakeDom(); const viewer = fakeViewer(); @@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) { viewer, annotations, navigation, + markActions, publish: (view) => (shown.view = view), connect: (actions) => { controls.actions = actions; @@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n } }); +test('an application\'s row actions show on the shapes they are for and run with the shape', async () => { + const ran = []; + const h = harness({ + markActions: [ + { + id: 'tag', + icon: 'sell', + label: (name) => `Tag ${name}`, + shapes: ['area', 'line'], + run: (shape) => ran.push(['tag', shape]), + }, + { + id: 'measure', + icon: 'straighten', + label: (name) => `Measure ${name}`, + shapes: ['area'], + accepts: (geometry) => geometry.type === 'Polygon', + run: (shape) => ran.push(['measure', shape]), + }, + ], + }); + try { + const board = h.annotations; + board.marks.push( + { id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] }, + { id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] }, + { id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false }, + ); + board.changed(); + assert.deepEqual( + h.shown.view.marks.map(({ id, actions }) => [id, actions]), + [ + ['r1', [ + { id: 'tag', icon: 'sell', label: 'Tag Strait' }, + { id: 'measure', icon: 'straighten', label: 'Measure Strait' }, + ]], + ['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]], + ['p1', []], + ], + ); + assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true); + assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action'); + assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false); + assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false); + assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true); + assert.deepEqual(ran, [ + ['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }], + ['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }], + ]); + } finally { + h.restore(); + } +}); + +test('an action that throws is reported and leaves the row alone', async () => { + const h = harness({ + markActions: [ + { id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } }, + ], + }); + const warn = console.warn; + const warned = []; + console.warn = (...args) => warned.push(args[0]); + try { + h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] }); + h.annotations.changed(); + assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]); + assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false); + assert.deepEqual(warned, ['[draw] boom failed']); + } finally { + console.warn = warn; + h.restore(); + } +}); + +test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => { + const pending = []; + const h = harness({ + markActions: [ + { + id: 'measure', + icon: 'straighten', + label: (name) => `Measure ${name}`, + shapes: ['area'], + run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })), + }, + { id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' }, + { id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` }, + ], + }); + const warn = console.warn; + console.warn = () => {}; + try { + const board = h.annotations; + const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] }); + board.marks.push(area('r1'), area('r2')); + board.changed(); + const row = (id) => h.shown.view.marks.find((entry) => entry.id === id); + assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true); + assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']); + h.act('runDrawMarkAction', 'r1', 'long'); + assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word'); + assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true); + assert.deepEqual([row('r1').busy, row('r1').note], [true, '']); + assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press'); + pending[0].resolve('412 km², 38 km of edge'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']); + h.act('runDrawMarkAction', 'r1', 'measure'); + pending[1].reject(new Error('refused')); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note'); + h.act('runDrawMarkAction', 'r2', 'measure'); + board.marks.splice(1, 1); + board.changed(); + pending[2].resolve('late'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(row('r2'), undefined); + board.marks.push(area('r2')); + board.changed(); + assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went'); + } finally { + console.warn = warn; + h.restore(); + } +}); + test('Backspace on the page removes the last vertex', () => { const h = harness(); try { diff --git a/src/app/tools.js b/src/app/tools.js index 68033947d..8925151ad 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -35,6 +35,7 @@ export function createApplicationTools({ startChrome, onSceneDirector, sceneDataPacks, + drawMarkActions = [], signal, defer, }) { @@ -80,12 +81,15 @@ export function createApplicationTools({ // while a session is open, so its teardown belongs to the application // lifetime rather than to whoever last pressed the button. A row's fly-to // goes through the shell's navigation policy, as Import's does. + // `drawMarkActions` are an application's own buttons on a drawn area's or + // line's row (annotations/drawMarks.js); the standalone shell adds none. const drawTool = initDrawTool({ viewer, annotations, navigation: styleManager, publish: (view) => displayStore.publishDraw(view), connect: connectDisplay, + markActions: drawMarkActions, }); defer(() => drawTool?.destroy()); // UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index a7138b9b3..7d770d762 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -987,6 +987,26 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { .draw-mark-row.hidden-mark .draw-mark-swatch { opacity: 0.45; } +/* What an application's row action said (a measurement, a confirmation), under + the row; empty, it takes no room. */ +.draw-mark-note { + order: 2; + flex-basis: 100%; + padding: 0 0 2px 16px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.4px; + color: var(--text-dim); + overflow-wrap: anywhere; +} +.draw-mark-note:empty { + display: none; +} +/* Busy, but focusable: disabling the pressed button would drop focus. */ +.draw-mark-action[aria-disabled='true'] { + opacity: 0.5; + cursor: progress; +} .draw-mark-colors { /* Right after its swatch for keyboard and screen readers, shown last. */ order: 1; diff --git a/src/ui/views/panels/DrawMarkList.test.mjs b/src/ui/views/panels/DrawMarkList.test.mjs index 64cc998a8..9eaf952db 100644 --- a/src/ui/views/panels/DrawMarkList.test.mjs +++ b/src/ui/views/panels/DrawMarkList.test.mjs @@ -7,7 +7,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createApp, nextTick } from 'vue'; import { createPinia } from 'pinia'; -import { presentDrawMarks } from '../../../annotations/drawMarks.js'; +import { drawMarkActions, presentDrawMarks } from '../../../annotations/drawMarks.js'; import { useDisplayStore } from '../../stores/display.js'; import DrawMarkList from './DrawMarkList.vue'; @@ -34,8 +34,8 @@ function mount(t) { return { store, calls }; } -async function show(store, marks) { - store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks) }); +async function show(store, marks, actions = []) { + store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks, drawMarkActions(actions)) }); await nextTick(); } const row = (id) => document.querySelector(`#draw-mark-list [data-mark-id="${id}"]`); @@ -63,6 +63,32 @@ test('lists only hand-drawn marks, named or by kind, with Import-style actions', assert.equal(control('a2', 'remove').getAttribute('aria-label'), 'Remove Camp'); }); +test('an application\'s row actions sit before fly to, labelled, and act on their mark', async (t) => { + const { store, calls } = mount(t); + const tag = { id: 'tag', icon: 'sell', label: (name) => `Tag ${name}`, shapes: ['line'], run: () => {} }; + await show(store, [{ ...LINE, path: [[0, 0], [1, 0]] }, PIN], [tag]); + const button = control('a1', 'action-tag'); + assert.equal(button.getAttribute('aria-label'), 'Tag Line · 950 m'); + assert.equal(button.textContent, 'sell'); + assert.equal(button.nextElementSibling, control('a1', 'fly')); + assert.equal(control('a2', 'action-tag'), null, 'a pin is not offered a line action'); + button.click(); + assert.deepEqual(calls, [['runDrawMarkAction', 'a1', 'tag']]); + const note = row('a1').querySelector('.draw-mark-note'); + assert.equal(note.getAttribute('role'), 'status'); + assert.equal(note.textContent, ''); + assert.equal(row('a2').querySelector('.draw-mark-note'), null, 'a row without actions has no note'); + const line = { ...LINE, path: [[0, 0], [1, 0]] }; + store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) }); + await nextTick(); + assert.equal(control('a1', 'action-tag').getAttribute('aria-disabled'), 'true'); + assert.equal(row('a1').querySelector('.draw-mark-note').textContent, '…'); + store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: false, text: 'Tagged' }]])) }); + await nextTick(); + assert.equal(control('a1', 'action-tag').getAttribute('aria-disabled'), 'false'); + assert.equal(row('a1').querySelector('.draw-mark-note').textContent, 'Tagged'); +}); + test('show/hide, fly to and remove act on that one mark', async (t) => { const { store, calls } = mount(t); await show(store, [LINE, PIN]); diff --git a/src/ui/views/panels/DrawMarkList.vue b/src/ui/views/panels/DrawMarkList.vue index 19811c761..01ef121e3 100644 --- a/src/ui/views/panels/DrawMarkList.vue +++ b/src/ui/views/panels/DrawMarkList.vue @@ -10,10 +10,11 @@ import { useDisplayStore } from '../../stores/display.js'; // UTILITIES ▸ Draw's shapes, one row each like Import's files: the colour // swatch (it opens the five colours in the row), the name typed in place, -// and show/hide, fly to and remove. The draw tool publishes the rows and -// answers the controls. Rows are keyed, so a board change elsewhere updates a -// row in place and never takes the button under a click or the field being -// typed in. The list's template is one line: templates keep whitespace. +// and show/hide, the application's own row actions, fly to and remove. The +// draw tool publishes the rows and answers the controls. Rows are keyed, so a +// board change elsewhere updates a row in place and never takes the button +// under a click or the field being typed in. The list's template is one +// line: templates keep whitespace. const display = useDisplayStore(); const { draw } = storeToRefs(display); const rows = computed(() => draw.value?.marks ?? []); @@ -98,5 +99,5 @@ function leave() {