diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5396b5bbf..974011ad6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5539,7 +5539,7 @@ request viewbox and continues searching without that geographic preference. ### Manual Whiteboard Drawing (September 2026) - 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, and stays in the list. 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. +- **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. - 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. diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index fb2febbf4..5985730ec 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -58,18 +58,10 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** A clip of the globe canvas, clear of the panels on every side. */ const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 }; -/** - * How many amber pixels the clip contains. The drawn area's stroke and fill are - * the only amber in this scene, so the count going UP after a shape is finished - * is the shape actually painting — which is what CESIUM_3D_TILE-only - * classification failed to do on a keyless boot while still reporting a mark on - * the board. Decoding happens in the page because Node here has no image - * decoder. - */ -/** The canvas clip as a PNG, for comparing two moments of the same view. */ // Clear of the open UTILITIES panel (x 100–380), whose own amber swatch // dims when its row's shape is hidden. const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 }; +/** The canvas clip as a PNG, for comparing two moments of the same view. */ const canvasShot = () => page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' }); @@ -107,6 +99,14 @@ function amberOnlyIn(shotA, shotB) { ); } +/** + * How many amber pixels the clip contains. The drawn area's stroke and fill are + * the only amber in this scene, so the count going UP after a shape is finished + * is the shape actually painting — which is what CESIUM_3D_TILE-only + * classification failed to do on a keyless boot while still reporting a mark on + * the board. Decoding happens in the page because Node here has no image + * decoder. + */ async function amberPixels() { const shot64 = await page.screenshot({ clip: CANVAS_CLIP, diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index a8ab18b63..811142193 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -183,6 +183,7 @@ function fakeAnnotations() { return { calls, marks, + listeners, changed, async annotate(specs, options) { calls.annotate.push({ specs, options }); @@ -459,6 +460,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act h.tool.addVertex(-97.75, 30.26); assert.equal(pointerOwner(), 'draw'); assert.ok(h.tool.diagnostics().domListeners > 0); + assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board'); await h.tool.destroy(); await Promise.resolve(); @@ -476,6 +478,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act 'the viewer got its selecting click back', ); assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached'); + assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board'); // Idempotent: a second destroy is harmless. await h.tool.destroy(); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 70641642c..64d03914b 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1385,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => { assert.match(state, /'contacts-inactive'/); assert.match(state, /'no-tracked-aircraft'/); }); + +test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => { + assert.match( + ui, + /if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/, + 'opening the second left panel keeps the fold the first recorded', + ); + const { PanelChrome } = await import('./ui/panelChrome.js'); + const previous = globalThis.document; + const panels = {}; + globalThis.document = { getElementById: (id) => panels[id] ?? null }; + const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } }); + try { + const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id); + panels['data-panel'] = panel('collapsed'); + assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore'); + panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed'); + assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded'); + panels['data-panel'] = panel(); + assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded'); + } finally { + globalThis.document = previous; + } +}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index e83083865..439c1d77c 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -52,6 +52,23 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ /** Own panel disclosure, docking, persistence and Cockpit rail restoration. */ export class PanelChrome { + /** + * Whether another left panel Cockpit keeps is open, or only folded aside + * for this one and coming back when it closes. + * @param {string} panelId The panel closing. + */ + _otherCockpitLeftPanelOpen(panelId) { + return [...COCKPIT_LEFT_PANEL_IDS].some((id) => { + if (id === panelId) return false; + const panel = document.getElementById(id); + return ( + !!panel && + (!panel.classList.contains('collapsed') || + panel.classList.contains('layout-auto-collapsed')) + ); + }); + } + constructor({ elements, operations, @@ -504,8 +521,11 @@ export class PanelChrome { this.cockpitView?.active && COCKPIT_LEFT_PANEL_IDS.has(panelId) ) { - this._cockpitContextCollapsedForDataPanel = - !this.cockpitView.contextCollapsed; + // Record the fold once: opening the other left panel after the first + // must not forget that Contact was folded for them. + if (!this._cockpitContextCollapsedForDataPanel) + this._cockpitContextCollapsedForDataPanel = + !this.cockpitView.contextCollapsed; if (this._cockpitContextCollapsedForDataPanel) { this.cockpitView.setContextCollapsed(true); } @@ -534,7 +554,8 @@ export class PanelChrome { nextCollapsed && this.cockpitView?.active && COCKPIT_LEFT_PANEL_IDS.has(panelId) && - this._cockpitContextCollapsedForDataPanel + this._cockpitContextCollapsedForDataPanel && + !this._otherCockpitLeftPanelOpen(panelId) ) { this._cockpitContextCollapsedForDataPanel = false; this.cockpitView.setContextCollapsed(false); diff --git a/src/ui/views/panels/DrawMarkList.test.mjs b/src/ui/views/panels/DrawMarkList.test.mjs index bf174ec7a..64cc998a8 100644 --- a/src/ui/views/panels/DrawMarkList.test.mjs +++ b/src/ui/views/panels/DrawMarkList.test.mjs @@ -101,6 +101,9 @@ test('a name is saved on Enter or leaving the field; an unchanged one costs noth name.value = ' Ridge walk '; key(name, 'Enter'); assert.deepEqual(calls, [['renameDrawMark', 'a1', 'Ridge walk']]); + assert.equal(document.activeElement, name, 'Enter keeps focus in the field'); + name.blur(); + assert.equal(calls.length, 1, 'the blur after Enter saves nothing more'); const pinName = control('a2', 'name'); pinName.focus(); pinName.blur(); @@ -122,6 +125,7 @@ test('Escape drops the draft and stays in the list, even with the row\'s colours const event = key(name, 'Escape'); assert.equal(event.defaultPrevented, true); assert.equal(name.value, 'Camp', 'the saved name comes back'); + assert.equal(document.activeElement, name, 'focus stays in the field'); await nextTick(); assert.deepEqual(calls, [], 'the draft is not saved'); // Escape in the open colours closes them and gives focus to the swatch. diff --git a/src/ui/views/panels/DrawMarkList.vue b/src/ui/views/panels/DrawMarkList.vue index 52e15b07b..19811c761 100644 --- a/src/ui/views/panels/DrawMarkList.vue +++ b/src/ui/views/panels/DrawMarkList.vue @@ -42,15 +42,16 @@ function onNameKey(row, event) { const input = event.currentTarget; if (event.key === 'Enter') { event.preventDefault(); + // Saved, and focus stays here: blurring would drop it to the page. commit(row, input); - input.blur(); + input.select(); } else if (event.key === 'Escape') { // Escape drops the draft before anything else, an open colour picker // included, and stays in the list: it must not leave Draw. event.preventDefault(); event.stopPropagation(); input.value = rowOf(row.id)?.name ?? row.name; - input.blur(); + input.select(); } }