List drawn shapes under Draw, with icon shapes and colour swatches

Each shape drawn by hand gets a row under Draw, as Import's files do: its
colour swatch, its name, and show/hide, fly-to and remove. A shape is named
in its row, and its swatch opens the five colours there, so labelling and
colouring happen after a shape is drawn and can be seen, instead of in a
field filled in beforehand. Area, Line and Pin become icon buttons, named
for screen readers and tooltips, and the colour for the next shape is a row
of swatches instead of a dropdown.

The annotation engine gains per-mark remove, setHidden, restyle and frame,
and onChange so the list follows the board. A hidden mark stays on the board
at alpha 0, a renamed line keeps its length, and fly-to frames a line by its
whole path. Voice marks are not listed.

The Draw, form-field and segmented-control gates drive the new controls;
swatches are checked as chips, not segments.
This commit is contained in:
Said Turksever committed 2026-10-29 12:41:12 +00:00
1 parent 8543b7be43
commit 95ed686da5
23 files changed
+1044 -197

No files matched your search

+1 -1
View File
@@ -357,7 +357,7 @@ camera input stops rotation.
> 🗣️ _"Outline the state of Texas."_ · _"Annotate the Texas State Capitol and its grounds"_ — it draws the **actual enclosing boundary**, not a circle. · _"How far is the Eiffel Tower from the Louvre?"_ — a connector arrow appears and it speaks the distance. Everything persists until you say _"clear the map."_
**✍️ Or draw it yourself** — UTILITIES ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
**✍️ Or draw it yourself** — UTILITIES ▸ **Draw**: pick Area, Line or Pin and a colour, click the vertices on the real world, double-click to finish, then name, recolour, hide or fly to each shape from its row. Same whiteboard, same persistence, no microphone needed.
![Zilker Park and Lady Bird Lake drawing onto the 3D city as persistent vector annotations, by voice](docs/media/01-voice-annotate-zilker.gif)
+3 -2
View File
@@ -5412,7 +5412,8 @@ 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 (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, 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. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- 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/annotations/drawMarkList.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 name is typed in the row (Enter or leaving the field saves it, Esc restores it and stays in the field); an unnamed shape shows its kind, and an unnamed line its length. The swatch opens the five colours in the row. The list reads the engine's board and redraws on `onChange`; it asks for changes through the engine's per-mark `remove`, `setHidden`, `frame` and `restyle`. A hidden mark stays on the board at alpha 0, so it keeps its place and comes back at once; `restyle` re-adds the mark with the new name or colour, and a renamed line keeps its length (`Name — 1.2 km`). Fly-to frames a line by its whole path, not its first point.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` 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 markup lives in the UTILITIES panel's component template (`src/ui/templates/layer-panels.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `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, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
- **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.
@@ -5424,7 +5425,7 @@ request viewbox and continues searching without that geographic preference.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas, the page body and the tool's own label field. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,my_location,navigation,normal,on,open_in_full,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,timeline,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<!-- gev:template scene-chrome -->
+2
View File
@@ -29,6 +29,8 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarkList.js",
"src/annotations/drawMarkList.test.mjs",
"src/annotations/drawMode.js",
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
+1
View File
@@ -2371,6 +2371,7 @@
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarkList.js",
"src/annotations/drawMode.js",
"src/annotations/drawTool.js",
"src/data/geoFileImport.js",
+73 -15
View File
@@ -300,8 +300,6 @@ try {
// ── a pin, finished with Enter ──────────────────────────────────────────
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="pin"]');
await page.click('#draw-label-input');
await page.keyboard.type('Pin one');
await clickWorld(520, 330);
check(
'the pointer is claimed while a session is open',
@@ -328,16 +326,9 @@ try {
`${beforeBackspace} → ${afterBackspace}`,
);
await clickWorld(640, 500);
await page.click('#draw-label-input');
await page.keyboard.type('Market St');
await page.keyboard.press('Enter');
check(
'a line finished from the label field lands on the board',
await waitForMarks(2),
);
const lineMark = (await marks()).find((mark) =>
mark.label?.startsWith('Market St'),
);
check('a line finished with Enter lands on the board', await waitForMarks(2));
const lineMark = (await marks()).find((mark) => mark.type === 'route');
check(
'a drawn line reports its length and never a travel time',
Boolean(lineMark) &&
@@ -349,10 +340,8 @@ try {
// ── an area, coloured and labelled, finished with a double-click ────────
await page.click('.pp-mode-btn[data-shape="area"]');
await page.select('#draw-color-select', 'amber');
await page.click('.draw-color[data-color="amber"]');
const amberBefore = await amberPixels();
await page.click('#draw-label-input');
await page.keyboard.type('Civic Center block');
await clickWorld(400, 280);
await clickWorld(700, 290);
await clickWorld(720, 520);
@@ -375,14 +364,34 @@ try {
afterDoubleClick === 3,
`${afterDoubleClick} marks`,
);
// ── each drawn shape gets a row: named, recoloured, hidden from there ──
const rowIds = () =>
page.evaluate(() =>
[...document.querySelectorAll('#draw-mark-list [data-mark-id]')].map(
(row) => row.dataset.markId,
),
);
const ids = await rowIds();
check('every drawn shape has a row under Draw', ids.length === 3, ids);
const areaId = ids[2];
const rowControl = (id, control) =>
`#draw-mark-list [data-mark-id="${id}"] [data-control="${control}"]`;
await page.click(rowControl(areaId, 'name'));
await page.keyboard.type('Civic Center block');
await page.keyboard.press('Enter');
await wait(300);
const areaMark = (await marks()).find(
(mark) => mark.label === 'Civic Center block',
);
check(
'the label and colour chosen in the panel are what is drawn',
'the name typed in its row and the colour picked before drawing are what is drawn',
areaMark?.color === 'amber',
JSON.stringify(areaMark),
);
check(
'naming a shape keeps Draw on and the shape in progress untouched',
(await drawState()).active === true,
);
await wait(1200);
const amberAfter = await amberPixels();
check(
@@ -391,6 +400,49 @@ try {
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
);
await shot('01-area-with-label');
// ── the row's own controls: recolour, hide and show, fly to ─────────────
const markState = (id) =>
page.evaluate((id) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark && { color: mark.color, hidden: mark.hidden, alpha: mark.alpha }
);
}, id);
await page.click(rowControl(areaId, 'swatch'));
await page.click(rowControl(areaId, 'color-green'));
await wait(300);
check(
'a colour picked in the row recolours that shape',
(await markState(areaId))?.color === 'green',
);
await page.click(rowControl(areaId, 'toggle'));
await wait(600);
const hidden = await markState(areaId);
check(
'hiding a shape keeps it on the board but draws it at zero alpha',
hidden?.hidden === true && hidden.alpha === 0,
JSON.stringify(hidden),
);
await page.click(rowControl(areaId, 'toggle'));
await wait(600);
check(
'showing it again brings it straight back',
(await markState(areaId))?.alpha === 1,
);
const cameraAt = () =>
page.evaluate(() => {
const p = window.__godsEyeView.viewer.camera.positionWC;
return [p.x, p.y, p.z];
});
const beforeFly = await cameraAt();
await page.click(rowControl(ids[1], 'fly'));
await wait(2600);
const afterFly = await cameraAt();
check(
'fly to frames that shape',
Math.hypot(...afterFly.map((v, i) => v - beforeFly[i])) > 1,
);
await shot('03-pins-and-shapes');
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
@@ -421,6 +473,12 @@ try {
afterSecondEscape.marks === 3,
`${afterSecondEscape.marks} marks`,
);
await page.click(rowControl(ids[0], 'remove'));
await waitForMarks(2);
check(
'remove in a row takes that one shape off the board and out of the list',
(await rowIds()).join() === ids.slice(1).join(),
);
// ── a click that would select something selects nothing while drawing ───
const target = await findClickableTarget();
+19 -82
View File
@@ -39,14 +39,13 @@ try {
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
'radio-filter',
'sdr-gain-select',
].map((id) => document.getElementById(id));
return {
ids: selects.map((node) => node.id),
shared:
selects.length === 6 &&
selects.length === 5 &&
selects.every((node) => node.classList.contains('gev-field')),
native: selects.every(
(node) =>
@@ -58,7 +57,7 @@ try {
};
});
check(
'all six panel dropdowns retain native select semantics and shared surface',
'all five panel dropdowns retain native select semantics and shared surface',
inventory.shared && inventory.native,
inventory,
);
@@ -275,76 +274,33 @@ try {
await page.evaluate(() => window.__qaFormCalls.length === 2),
);
// The left rail fits one tall panel at a time, so Scenes and Utilities
// take turns: each field is measured with its own panel open.
const LEFT_PANEL_FIELDS = [
[
'scene-panel',
['scene-select', 'cctv-camera-select', 'hud-layout-select'],
],
['utilities-panel', ['draw-color-select']],
];
const showLeftPanel = (open) =>
page.evaluate((open) => {
for (const panel of ['scene-panel', 'utilities-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(
panel,
panel !== open,
{ persist: false, syncShare: false },
);
}, open);
await page.evaluate(() => {
for (const panel of [
'pp-toggles',
'utilities-panel',
'scene-panel',
'cctv-panel',
])
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
await showLeftPanel('utilities-panel');
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
for (const lang of ['en']) {
await page.evaluate((lang) => {
document.documentElement.lang = lang;
}, lang);
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
const layout = [];
for (const [open, ids] of LEFT_PANEL_FIELDS) {
await showLeftPanel(open);
layout.push(
...(await page.evaluate(
(ids) =>
ids.map((id) => {
const layout = await page.evaluate(() => {
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
return ids.map((id) => {
const node = document.getElementById(id),
rect = node.getBoundingClientRect(),
panel = node
.closest('.panel-collapsible')
?.getBoundingClientRect();
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
return {
id,
width: rect.width,
fits:
rect.width > 0 &&
rect.width <= (panel?.width || innerWidth),
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
radius: getComputedStyle(node).borderRadius,
};
}),
ids,
)),
);
}
});
});
check(
`${lang} fields fit their panels at ${width}px`,
layout.every((item) => item.fits),
@@ -386,7 +342,6 @@ try {
);
// Scene selection can restore application settings. Exercise it after the
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
await showLeftPanel('scene-panel');
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
await page.click('#scene-new-btn');
await page.waitForFunction(() =>
@@ -451,42 +406,25 @@ try {
{ timeout: 60000 },
);
await page.evaluate(() => {
for (const panel of [
'pp-toggles',
'utilities-panel',
'scene-panel',
'cctv-panel',
])
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
await page.evaluate(() => {
for (const panel of [
'pp-toggles',
'utilities-panel',
'scene-panel',
'cctv-panel',
])
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
await showLeftPanel('utilities-panel');
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
let translated = true;
for (const [open, ids] of LEFT_PANEL_FIELDS) {
await showLeftPanel(open);
translated &&= await page.evaluate((ids) => {
const nodes = ids.map((id) => document.getElementById(id));
const translated = await page.evaluate(() => {
const nodes = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
].map((id) => document.getElementById(id));
return (
document.documentElement.lang === 'ru' &&
nodes.every(
@@ -496,8 +434,7 @@ try {
node.closest('.panel-collapsible').getBoundingClientRect().width,
)
);
}, ids);
}
});
check('real Russian labels and fields fit at 390px', translated);
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
check('no uncaught browser errors', errors.length === 0, errors);
+30 -1
View File
@@ -45,7 +45,11 @@ try {
});
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
// Colour swatches are one-of-many too, but chips, not segments: they are
// checked on their own below.
const groups = [
...document.querySelectorAll('[role="radiogroup"]:not(.draw-colors)'),
];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
@@ -71,6 +75,31 @@ try {
JSON.stringify(surface),
);
const swatches = await page.evaluate(() => {
const group = document.getElementById('draw-colors');
const chips = [...(group?.querySelectorAll('[role="radio"]') || [])];
return {
count: chips.length,
round: chips.every(
(chip) => getComputedStyle(chip).borderRadius === '50%',
),
colours: new Set(
chips.map((chip) => getComputedStyle(chip).backgroundColor),
).size,
checked: chips.filter(
(chip) => chip.getAttribute('aria-checked') === 'true',
).length,
};
});
check(
"Draw's colour swatches are five round chips, one checked",
swatches.count === 5 &&
swatches.round &&
swatches.colours === 5 &&
swatches.checked === 1,
JSON.stringify(swatches),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
+111
View File
@@ -144,6 +144,17 @@ export function createAnnotationEngine({
// task with the FINAL outcome — resolved or failed — so narration that said
// "tracing the boundary now" can later honestly confirm or correct itself.
const outlineListeners = new Set();
// Told whenever the set of marks, or one mark's look, changes (Draw's list).
const changeListeners = new Set();
const notifyChange = () => {
for (const listener of changeListeners) {
try {
listener();
} catch {
/* one listener's failure must not stop the others */
}
}
};
function emitOutlineEvent(evt) {
for (const listener of outlineListeners) {
@@ -219,6 +230,7 @@ export function createAnnotationEngine({
const now = performance.now();
let changed = false;
let pending = false; // any mark still fading in / out, or with a TTL fade ahead
let expired = false;
for (const anno of annotations.values()) {
const next = computeAlpha(anno, now);
if (next !== anno.alpha) {
@@ -229,6 +241,7 @@ export function createAnnotationEngine({
annotations.delete(anno.id);
renderer.remove(anno);
changed = true;
expired = true;
continue;
}
if (pendingAnimation(anno, now)) pending = true;
@@ -237,6 +250,7 @@ export function createAnnotationEngine({
renderer.sync(annotations);
syncAnnotationHold();
}
if (expired) notifyChange();
// Stop once the board is STABLE — persistent marks (persist=true is the default)
// finish fading in after ~260ms and never change again, so without this the loop
// would spin O(N) every frame forever. ensureTicking() restarts on the next
@@ -466,6 +480,7 @@ export function createAnnotationEngine({
if (ids.length) {
renderer.sync(annotations);
ensureTicking();
notifyChange();
}
if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor);
else if (ids.length) ensureMarksVisible(ids);
@@ -885,6 +900,11 @@ export function createAnnotationEngine({
alpha: 0, // animate in
bornAt: now,
expiring: false,
// Drawn by hand (drawTool.js): listed under Draw, renamed and recoloured
// there. `name` is the person's own label, before any distance is added.
manual: spec?.manual === true,
name: spec?.manual === true ? cleanLabel(spec?.label) || null : null,
hidden: false,
};
if (type === 'route') {
@@ -970,6 +990,85 @@ export function createAnnotationEngine({
annotations.clear();
renderer.sync(annotations);
syncAnnotationHold();
notifyChange();
}
/** Take one mark off the board at once. */
function remove(id) {
const anno = annotations.get(id);
if (!anno) return false;
annotations.delete(id);
renderer.remove(anno);
renderer.sync(annotations);
syncAnnotationHold();
notifyChange();
return true;
}
/** Hide or show one mark; a hidden mark stays on the board at alpha 0. */
function setHidden(id, hidden) {
const anno = annotations.get(id);
if (!anno) return false;
anno.hidden = Boolean(hidden);
// Skip the fade-in, so showing it again is immediate.
anno.bornAt = Math.min(anno.bornAt, performance.now() - 260);
ensureTicking();
notifyChange();
return true;
}
/**
* Rename or recolour a hand-drawn mark. The renderers build a mark's look
* when it is added, so the mark is added again with the new look.
*/
function restyle(id, { name, color } = {}) {
const anno = annotations.get(id);
if (!anno?.manual) return false;
if (name !== undefined) {
anno.name = cleanLabel(name) || null;
anno.label =
anno.type === 'route'
? composeRouteLabel(anno.name, anno.distanceM, null, 'manual', false)
: anno.name;
}
if (color !== undefined && COLORS.has(color)) anno.color = color;
renderer.remove(anno);
try {
renderer.add(anno);
} catch (error) {
rollbackRendererState(anno);
annotations.delete(id);
syncAnnotationHold();
notifyChange();
throw error;
}
renderer.sync(annotations);
ensureTicking();
notifyChange();
return true;
}
/** Fly the camera to one mark; a line is framed whole, not by its start. */
function frame(id) {
const anno = annotations.get(id);
if (!anno) return false;
if (anno.type === 'route' && anno.path?.length) {
const ring = anno.path.map((p) => [p.lon, p.lat]);
const lons = ring.map(([lon]) => lon);
const lats = ring.map(([, lat]) => lat);
frameAnnotation({
...anno,
to: null,
ring,
polygons: null,
anchor: {
lon: (Math.min(...lons) + Math.max(...lons)) / 2,
lat: (Math.min(...lats) + Math.max(...lats)) / 2,
height: 0,
},
});
} else frameAnnotation(anno);
return true;
}
/** Begin a graceful fade-out of everything, then remove. */
@@ -1100,6 +1199,16 @@ export function createAnnotationEngine({
const engine = {
annotate,
clear,
remove,
setHidden,
restyle,
frame,
/** Subscribe to board changes; returns an unsubscribe fn. */
onChange(listener) {
if (destroyed || typeof listener !== 'function') return () => {};
changeListeners.add(listener);
return () => changeListeners.delete(listener);
},
destroy() {
if (destroyed) return;
destroyed = true;
@@ -1109,6 +1218,7 @@ export function createAnnotationEngine({
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
changeListeners.clear();
releaseContinuousRender('annotations');
renderer.destroy();
}
@@ -1364,6 +1474,7 @@ function normalizeType(type) {
}
function computeAlpha(anno, now) {
if (anno.hidden) return 0;
const inT = (now - anno.bornAt) / 260;
const fadeIn = inT >= 1 ? 1 : Math.max(0, inT);
+73
View File
@@ -730,3 +730,76 @@ test('only an explicit navigation request permits resolving distant annotation t
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
engine.destroy();
});
// Draw's list works one hand-drawn mark at a time: hide, rename, recolour,
// frame and remove it, and hear about every change to the board.
test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and removed', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
renderer,
});
let changes = 0;
const unsubscribe = engine.onChange(() => { changes += 1; });
const drawn = await engine.annotate([
{ type: 'route', manual: true, path: [[-97.75, 30.26], [-97.73, 30.27]], label: '' },
{ type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp', color: 'amber' },
], { persist: true });
assert.equal(drawn.drawn, 2);
assert.equal(changes, 1, 'one annotate, one change');
const [line, pin] = engine.list();
assert.equal(line.manual, true);
assert.equal(line.name, null);
assert.match(line.label, /km|m$/, 'an unnamed line is labelled by its length');
assert.equal(pin.name, 'Camp');
assert.equal(engine.setHidden(pin.id, true), true);
assert.equal(pin.hidden, true);
assert.equal(changes, 2);
const addsBefore = calls.add;
assert.equal(engine.restyle(line.id, { name: 'Ridge walk', color: 'green' }), true);
assert.equal(line.name, 'Ridge walk');
assert.match(line.label, /^Ridge walk — /, 'a renamed line keeps its length');
assert.equal(line.color, 'green');
assert.equal(calls.add, addsBefore + 1, 'the new look is drawn again');
assert.equal(engine.restyle(line.id, { color: 'not-a-colour' }), true);
assert.equal(line.color, 'green', 'an unknown colour is ignored');
assert.equal(engine.frame(line.id), true);
assert.equal(flights.length, 1);
assert.equal(engine.frame('anno-missing'), false);
assert.equal(engine.remove(pin.id), true);
assert.deepEqual(engine.list().map(({ id }) => id), [line.id]);
assert.equal(engine.remove(pin.id), false);
engine.clear();
assert.equal(engine.list().length, 0);
const seen = changes;
unsubscribe();
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
assert.equal(changes, seen, 'an unsubscribed listener hears nothing');
});
test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const [voiceMark] = engine.list();
assert.equal(voiceMark.manual, false);
assert.equal(engine.restyle(voiceMark.id, { name: 'Renamed' }), false);
assert.equal(voiceMark.label, 'Capitol');
});
+259
View File
@@ -0,0 +1,259 @@
/**
* The shapes drawn by hand, listed under Draw the way Import lists its files:
* one row per shape with its colour, its name, and show/hide, fly-to and
* remove. The name is edited in place and the colour picked from the row, so
* a shape is labelled and coloured after it is drawn, when it can be seen.
*
* The board is the annotation engine's: this list only reads `list()`, asks
* for changes through `remove`, `setHidden`, `frame` and `restyle`, and
* re-renders on `onChange`. Voice marks are not listed; only `manual` ones.
*/
import { msg, t } from '../i18n/index.js';
export const DRAW_COLORS = Object.freeze([
'primary',
'amber',
'cyan',
'green',
'red',
]);
/** The swatch for each colour, matching the preview and the drawn shape. */
export const DRAW_COLOR_CSS = Object.freeze({
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
});
const COLOR_NAMES = Object.freeze({
primary: msg('Primary'),
amber: msg('Amber'),
cyan: msg('Cyan'),
green: msg('Green'),
red: msg('Red'),
});
const SHAPE_NAMES = Object.freeze({
area: msg('Area'),
route: msg('Line'),
pin: msg('Pin'),
});
/** What a row calls a shape that has no name of its own. */
export function drawMarkFallbackName(mark) {
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
// An unnamed line's label is its length; keep it, it is the useful part.
return mark?.type === 'route' && !mark.name && mark.label
? `${shape} · ${mark.label}`
: shape;
}
/**
* @param {{
* listEl: HTMLElement,
* annotations: {list: Function, remove: Function, setHidden: Function,
* frame: Function, restyle: Function, onChange?: Function},
* listen: (target: EventTarget, type: string, listener: Function, options?: object) => void,
* }} deps
* @returns {{render: Function, marks: Function, destroy: Function}}
*/
export function createDrawMarkList({ listEl, annotations, listen }) {
const doc = listEl.ownerDocument;
let destroyed = false;
let picking = null; // the id whose colour picker is open
const marks = () =>
(annotations.list?.() || []).filter((mark) => mark?.manual === true);
const find = (id) => marks().find((mark) => mark.id === id) || null;
/** Which row control has focus, so a re-render can give it back. */
function focusedControl() {
const active = doc.activeElement;
if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-mark-id]');
return row
? { id: row.dataset.markId, control: active.dataset?.control }
: null;
}
function restoreFocus(was) {
if (!was) return;
const row = [...listEl.querySelectorAll('[data-mark-id]')].find(
(node) => node.dataset.markId === was.id,
);
row?.querySelector?.(`[data-control="${was.control}"]`)?.focus?.();
}
function actionButton(icon, label, control) {
const button = doc.createElement('button');
button.type = 'button';
button.className = 'geo-import-action';
button.dataset.control = control;
button.title = label;
button.setAttribute('aria-label', label);
const glyph = doc.createElement('span');
glyph.className = 'material-symbols-outlined';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = icon;
button.append(glyph);
return button;
}
function colorPicker(mark) {
const picker = doc.createElement('div');
picker.className = 'draw-colors draw-mark-colors';
picker.setAttribute('role', 'radiogroup');
picker.setAttribute(
'aria-label',
t('Colour of {name}', { name: label(mark) }),
);
for (const color of DRAW_COLORS) {
const swatch = doc.createElement('button');
swatch.type = 'button';
swatch.className = `draw-color${color === mark.color ? ' active' : ''}`;
swatch.dataset.color = color;
swatch.dataset.control = `color-${color}`;
swatch.setAttribute('role', 'radio');
swatch.setAttribute('aria-checked', String(color === mark.color));
const name = t(COLOR_NAMES[color]);
swatch.title = name;
swatch.setAttribute('aria-label', name);
picker.append(swatch);
}
return picker;
}
const label = (mark) => mark.name || drawMarkFallbackName(mark);
function render() {
if (destroyed) return;
const keep = focusedControl();
const list = marks();
if (picking && !list.some((mark) => mark.id === picking)) picking = null;
listEl.replaceChildren(
...list.map((mark) => {
const row = doc.createElement('li');
row.className = 'geo-import-row draw-mark-row';
row.dataset.markId = mark.id;
if (mark.hidden) row.classList.add('hidden-mark');
const swatch = doc.createElement('button');
swatch.type = 'button';
swatch.className = 'geo-import-swatch draw-mark-swatch';
swatch.dataset.control = 'swatch';
swatch.style.background = DRAW_COLOR_CSS[mark.color] || '';
const swatchLabel = t('Change the colour of {name}', {
name: label(mark),
});
swatch.title = swatchLabel;
swatch.setAttribute('aria-label', swatchLabel);
swatch.setAttribute('aria-expanded', String(picking === mark.id));
// textContent and value only, never markup: the name is typed text.
const name = doc.createElement('input');
name.type = 'text';
name.className = 'geo-import-name draw-mark-name';
name.dataset.control = 'name';
name.maxLength = 120;
name.autocomplete = 'off';
name.spellcheck = false;
name.value = mark.name || '';
name.placeholder = drawMarkFallbackName(mark);
name.setAttribute(
'aria-label',
t('Name of {name}', { name: label(mark) }),
);
const visible = !mark.hidden;
const toggle = actionButton(
visible ? 'visibility' : 'visibility_off',
visible
? t('Hide {name}', { name: label(mark) })
: t('Show {name}', { name: label(mark) }),
'toggle',
);
toggle.setAttribute('aria-pressed', String(visible));
row.append(
swatch,
name,
toggle,
actionButton(
'my_location',
t('Fly to {name}', { name: label(mark) }),
'fly',
),
actionButton(
'close',
t('Remove {name}', { name: label(mark) }),
'remove',
),
);
if (picking === mark.id) row.append(colorPicker(mark));
return row;
}),
);
listEl.classList.toggle('visible', list.length > 0);
restoreFocus(keep);
}
const rowId = (node) => node?.closest?.('[data-mark-id]')?.dataset.markId;
/** Commit a typed name; an unchanged one costs nothing. */
function commitName(input) {
const id = rowId(input);
const mark = id && find(id);
if (!mark) return;
const next = input.value.trim();
if (next === (mark.name || '')) return;
annotations.restyle(id, { name: next });
}
listen(listEl, 'click', (event) => {
const control = event.target?.closest?.('[data-control]');
const id = rowId(control);
if (!control || !id) return;
const kind = control.dataset.control;
if (kind === 'swatch') {
picking = picking === id ? null : id;
render();
} else if (kind.startsWith('color-')) {
picking = null;
annotations.restyle(id, { color: control.dataset.color });
render();
} else if (kind === 'toggle') annotations.setHidden(id, !find(id)?.hidden);
else if (kind === 'fly') annotations.frame(id);
else if (kind === 'remove') annotations.remove(id);
});
listen(listEl, 'keydown', (event) => {
const input = event.target;
if (input?.dataset?.control !== 'name') return;
if (event.key === 'Enter') {
event.preventDefault();
commitName(input);
input.blur?.();
} else if (event.key === 'Escape') {
// Escape undoes the edit; it must not also leave Draw or cancel a shape.
event.preventDefault();
event.stopPropagation();
input.value = find(rowId(input))?.name || '';
input.blur?.();
}
});
listen(listEl, 'focusout', (event) => {
if (event.target?.dataset?.control === 'name') commitName(event.target);
});
const unsubscribe = annotations.onChange?.(render) || (() => {});
render();
return {
render,
marks,
destroy() {
if (destroyed) return;
destroyed = true;
unsubscribe();
listEl.replaceChildren();
listEl.classList.remove('visible');
},
};
}
+280
View File
@@ -0,0 +1,280 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDrawMarkList, drawMarkFallbackName } from './drawMarkList.js';
/** Just enough DOM for the list: rows, inputs, buttons, attributes, focus. */
function fakeDocument() {
const doc = { activeElement: null };
const matches = (node, selector) => {
const attr = /^\[data-([a-z-]+)(?:="([^"]*)")?\]$/.exec(selector);
if (!attr) return false;
const key = attr[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
return attr[2] === undefined
? node.dataset[key] !== undefined
: node.dataset[key] === attr[2];
};
doc.createElement = (tag) => {
const classes = new Set();
const node = {
tagName: tag.toUpperCase(),
ownerDocument: doc,
parent: null,
children: [],
dataset: {},
style: {},
attributes: new Map(),
value: '',
placeholder: '',
title: '',
textContent: '',
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
contains: (name) => classes.has(name),
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
},
set className(value) {
classes.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
classes.add(name);
},
get className() {
return [...classes].join(' ');
},
setAttribute(key, value) {
this.attributes.set(key, String(value));
},
getAttribute(key) {
return this.attributes.get(key) ?? null;
},
append(...nodes) {
for (const child of nodes) {
child.parent = this;
this.children.push(child);
}
},
replaceChildren(...nodes) {
this.children = [];
this.append(...nodes);
},
closest(selector) {
let node = this;
while (node && !matches(node, selector)) node = node.parent;
return node || null;
},
querySelectorAll(selector) {
const found = [];
const walk = (node) => {
for (const child of node.children) {
if (matches(child, selector)) found.push(child);
walk(child);
}
};
walk(this);
return found;
},
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
},
contains(other) {
for (let node = other; node; node = node.parent)
if (node === this) return true;
return false;
},
focus() {
doc.activeElement = this;
},
blur() {
if (doc.activeElement === this) doc.activeElement = null;
},
};
return node;
};
return doc;
}
function harness(initial) {
const doc = fakeDocument();
const listEl = doc.createElement('ul');
const listeners = {};
const listen = (target, type, listener) => {
listeners[type] = listener;
};
let marks = initial.map((mark) => ({ hidden: false, ...mark }));
const changeListeners = new Set();
const changed = () => changeListeners.forEach((listener) => listener());
const calls = [];
const annotations = {
list: () => marks,
onChange: (listener) => {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
},
remove(id) {
calls.push(['remove', id]);
marks = marks.filter((mark) => mark.id !== id);
changed();
},
setHidden(id, hidden) {
calls.push(['hide', id, hidden]);
marks.find((mark) => mark.id === id).hidden = hidden;
changed();
},
frame(id) {
calls.push(['frame', id]);
},
restyle(id, change) {
calls.push(['restyle', id, change]);
Object.assign(
marks.find((mark) => mark.id === id),
change,
);
changed();
},
};
const list = createDrawMarkList({ listEl, annotations, listen });
const rows = () => listEl.children;
const control = (row, name) => row.querySelector(`[data-control="${name}"]`);
const click = (target) => listeners.click({ target });
return { list, listEl, listeners, rows, control, click, calls, changed };
}
const LINE = {
id: 'a1',
type: 'route',
manual: true,
name: null,
label: '1.2 km',
color: 'primary',
};
const PIN = {
id: 'a2',
type: 'pin',
manual: true,
name: 'Camp',
label: 'Camp',
color: 'amber',
};
const VOICE = {
id: 'a3',
type: 'area',
manual: false,
label: 'Texas',
color: 'primary',
};
test('lists only hand-drawn marks, named or by shape, with Import-style actions', () => {
const { rows, control, listEl } = harness([LINE, PIN, VOICE]);
assert.deepEqual(
rows().map((row) => row.dataset.markId),
['a1', 'a2'],
);
assert.ok(listEl.classList.contains('visible'));
const [line, pin] = rows();
assert.equal(control(line, 'name').value, '');
assert.equal(control(line, 'name').placeholder, 'Line · 1.2 km');
assert.equal(control(pin, 'name').value, 'Camp');
assert.equal(control(pin, 'swatch').style.background, '#ffb547');
assert.equal(control(pin, 'toggle').getAttribute('aria-pressed'), 'true');
assert.equal(control(pin, 'fly').getAttribute('aria-label'), 'Fly to Camp');
assert.equal(
control(pin, 'remove').getAttribute('aria-label'),
'Remove Camp',
);
});
test('show/hide, fly to and remove act on that one mark', () => {
const { rows, control, click, calls } = harness([LINE, PIN]);
click(control(rows()[1], 'toggle'));
assert.deepEqual(calls.at(-1), ['hide', 'a2', true]);
assert.equal(
control(rows()[1], 'toggle').getAttribute('aria-pressed'),
'false',
);
assert.ok(rows()[1].classList.contains('hidden-mark'));
click(control(rows()[0], 'fly'));
assert.deepEqual(calls.at(-1), ['frame', 'a1']);
click(control(rows()[0], 'remove'));
assert.deepEqual(calls.at(-1), ['remove', 'a1']);
assert.deepEqual(
rows().map((row) => row.dataset.markId),
['a2'],
);
});
test('the swatch opens a colour picker in the row; a colour restyles the mark', () => {
const { rows, control, click, calls } = harness([LINE]);
click(control(rows()[0], 'swatch'));
assert.equal(
control(rows()[0], 'swatch').getAttribute('aria-expanded'),
'true',
);
const red = control(rows()[0], 'color-red');
assert.ok(red, 'the picker offers every colour');
click(red);
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { color: 'red' }]);
assert.equal(
control(rows()[0], 'color-red'),
null,
'choosing closes the picker',
);
assert.equal(control(rows()[0], 'swatch').style.background, '#ff6b6b');
});
test('a name is committed on Enter or blur, and Escape undoes the edit without leaving Draw', () => {
const { rows, control, listeners, calls } = harness([LINE, PIN]);
const name = control(rows()[0], 'name');
name.value = ' Ridge walk ';
listeners.keydown({ key: 'Enter', target: name, preventDefault() {} });
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { name: 'Ridge walk' }]);
const pinName = control(rows()[1], 'name');
pinName.value = 'Base';
let stopped = false;
listeners.keydown({
key: 'Escape',
target: pinName,
preventDefault() {},
stopPropagation() {
stopped = true;
},
});
assert.equal(pinName.value, 'Camp', 'Escape restores the name');
assert.ok(stopped, 'Escape stays in the field');
const count = calls.length;
listeners.focusout({ target: control(rows()[1], 'name') });
assert.equal(calls.length, count, 'an unchanged name costs nothing');
control(rows()[1], 'name').value = 'Base';
listeners.focusout({ target: control(rows()[1], 'name') });
assert.deepEqual(calls.at(-1), ['restyle', 'a2', { name: 'Base' }]);
});
test('a re-render keeps focus on the same control, and destroy stops listening', () => {
const { rows, control, list, listEl, changed } = harness([LINE]);
control(rows()[0], 'fly').focus();
changed();
assert.equal(listEl.ownerDocument.activeElement, control(rows()[0], 'fly'));
list.destroy();
assert.equal(rows().length, 0);
assert.ok(!listEl.classList.contains('visible'));
changed();
assert.equal(rows().length, 0, 'a destroyed list does not re-render');
});
test('an unnamed shape is called by its kind', () => {
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
assert.equal(
drawMarkFallbackName({ type: 'route', label: '300 m' }),
'Line · 300 m',
);
assert.equal(
drawMarkFallbackName({
type: 'route',
name: 'Walk',
label: 'Walk — 300 m',
}),
'Line',
);
});
+35 -26
View File
@@ -3,7 +3,8 @@
*
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* or press Enter to finish, then name and colour it in its row. Each finished
* shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
@@ -50,19 +51,18 @@ import {
removeLastVertex,
} from './drawMode.js';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
createDrawMarkList,
} from './drawMarkList.js';
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
const PREVIEW = DRAW_COLOR_CSS;
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
@@ -74,8 +74,8 @@ export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select');
const colorRow = document.getElementById('draw-colors');
const markListEl = document.getElementById('draw-mark-list');
const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
@@ -238,14 +238,14 @@ export function initDrawTool({ viewer, annotations }) {
if (reason !== 'too-few') setHint(drawHint(session));
return null;
}
const spec = finishSpec(session, { label: labelInput?.value || '', color });
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
if (labelInput) labelInput.value = '';
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
@@ -285,7 +285,7 @@ export function initDrawTool({ viewer, annotations }) {
// ---- keys: only while drawing, never while typing in another field ----
const typingElsewhere = (event) => {
const t = event.target;
if (!t || t === labelInput) return false;
if (!t) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
@@ -293,12 +293,12 @@ export function initDrawTool({ viewer, annotations }) {
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
* not finish the shape — so the finish key is only taken from the canvas or
* the page body.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) return true;
if (!t) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
@@ -328,7 +328,7 @@ export function initDrawTool({ viewer, annotations }) {
else setActive(false);
return;
}
if (event.key === 'Backspace' && event.target !== labelInput) {
if (event.key === 'Backspace') {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
@@ -448,18 +448,21 @@ export function initDrawTool({ viewer, annotations }) {
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
const colorControl = createSegmentedControl({
buttons: [...(colorRow?.querySelectorAll('.draw-color[data-color]') || [])],
valueOf: (button) => button.dataset.color,
listen,
onChange: (value) => {
color = DRAW_COLORS.includes(value) ? value : 'primary';
colorControl.update(color);
syncPreview();
},
});
listen(clearButton, 'click', clearAll);
listen(labelInput, 'keydown', (event) => {
if (event.key === 'Enter' && session?.vertices?.length) {
event.preventDefault();
void finish();
}
});
setHint(drawHint(null));
const markList = markListEl
? createDrawMarkList({ listEl: markListEl, annotations, listen })
: null;
const api = {
get active() {
@@ -488,6 +491,11 @@ export function initDrawTool({ viewer, annotations }) {
finish,
cancel,
clearAll,
/** The hand-drawn marks the list shows. */
marks: () => markList?.marks() || [],
setColor(next) {
colorRow?.querySelector(`.draw-color[data-color="${next}"]`)?.click();
},
shapes: DRAW_SHAPES,
/** What this tool currently holds — for teardown and leak assertions. */
diagnostics() {
@@ -527,6 +535,7 @@ export function initDrawTool({ viewer, annotations }) {
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
markList?.destroy();
releaseSceneHandler();
releasePointer(lease);
lease = null;
+19 -6
View File
@@ -69,8 +69,8 @@ test('the Draw control markup carries the ids the tool binds to', () => {
'draw-toggle',
'draw-mode-row',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-colors',
'draw-mark-list',
'draw-clear',
'draw-hint',
];
@@ -81,13 +81,25 @@ test('the Draw control markup carries the ids the tool binds to', () => {
`#${id} missing from index.html`,
);
}
for (const shape of ['area', 'line', 'pin']) {
// Shapes are icons, named for screen readers and tooltips.
for (const [shape, name] of [
['area', 'Area'],
['line', 'Line'],
['pin', 'Pin'],
]) {
assert.match(
html,
new RegExp(`data-shape="${shape}"`),
new RegExp(
`data-shape="${shape}"[^>]*aria-label="${name}"[^>]*title="${name}"`,
),
`shape button ${shape} missing`,
);
}
for (const color of ['primary', 'amber', 'cyan', 'green', 'red'])
assert.match(
html,
new RegExp(`class="draw-color[^"]*" type="button" data-color="${color}"`),
);
for (const id of ids) {
assert.match(
tool(),
@@ -108,14 +120,15 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
);
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
/draw-hint|draw-color/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-color',
'.draw-mark-name',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
+1 -2
View File
@@ -116,8 +116,7 @@ function fakeDom() {
modeRow.children.push(button);
}
make('draw-label-row');
make('draw-label-input', 'INPUT');
make('draw-color-select', 'SELECT');
make('draw-colors');
make('draw-clear', 'BUTTON');
make('draw-hint', 'SPAN');
+5 -4
View File
@@ -902,14 +902,15 @@
"Board cleared.": "Tablero borrado.",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dibuja sobre el mundo: haz clic en los vértices, doble clic o Enter para terminar, Esc para cancelar",
"Draw": "Dibujo",
"Shape": "Forma",
"Shape to draw": "Forma a dibujar",
"Area": "Área",
"Line": "Línea",
"Pin": "Marcador",
"Label (optional)": "Etiqueta (opcional)",
"Label for the drawn shape": "Etiqueta de la forma dibujada",
"Colour of the drawn shape": "Color de la forma dibujada",
"Colour of {name}": "Color de {name}",
"Change the colour of {name}": "Cambiar el color de {name}",
"Name of {name}": "Nombre de {name}",
"Colour of the next shape": "Color de la próxima forma",
"Drawn shapes": "Formas dibujadas",
"Primary": "Principal",
"Amber": "Ámbar",
"Cyan": "Cian",
+5 -4
View File
@@ -910,14 +910,15 @@
"Board cleared.": "Доска очищена.",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Рисовать на карте мира — щёлкайте по вершинам, двойной щелчок или Enter — завершить, Esc — отменить",
"Draw": "Рисование",
"Shape": "Фигура",
"Shape to draw": "Фигура для рисования",
"Area": "Зона",
"Line": "Линия",
"Pin": "Метка",
"Label (optional)": "Подпись (необязательно)",
"Label for the drawn shape": "Подпись нарисованной фигуры",
"Colour of the drawn shape": "Цвет нарисованной фигуры",
"Colour of {name}": "Цвет: {name}",
"Change the colour of {name}": "Изменить цвет: {name}",
"Name of {name}": "Название: {name}",
"Colour of the next shape": "Цвет следующей фигуры",
"Drawn shapes": "Нарисованные фигуры",
"Primary": "Основной",
"Amber": "Янтарный",
"Cyan": "Голубой",
+5 -4
View File
@@ -2657,14 +2657,15 @@
"Scope edge feather": "视域边缘羽化",
"Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以视域半径的百分比表示",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击放置顶点,双击或按 Enter 完成,按 Esc 取消",
"Shape": "形状",
"Shape to draw": "要绘制的形状",
"Area": "面",
"Line": "线",
"Pin": "图钉",
"Label (optional)": "标签(可选)",
"Label for the drawn shape": "绘制形状的标签",
"Colour of the drawn shape": "绘制形状的颜色",
"Colour of {name}": "{name} 的颜色",
"Change the colour of {name}": "更改 {name} 的颜色",
"Name of {name}": "{name} 的名称",
"Colour of the next shape": "下一个形状的颜色",
"Drawn shapes": "已绘制的形状",
"Primary": "主题色",
"Amber": "琥珀色",
"Cyan": "青色",
+5 -4
View File
@@ -2645,14 +2645,15 @@
"Scope edge feather": "視域邊緣羽化",
"Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化,以視域半徑的百分比表示",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上繪製——單擊放置頂點,雙擊或按 Enter 完成,按 Esc 取消",
"Shape": "形狀",
"Shape to draw": "要繪製的形狀",
"Area": "面",
"Line": "線",
"Pin": "圖釘",
"Label (optional)": "標籤(可選)",
"Label for the drawn shape": "繪製形狀的標籤",
"Colour of the drawn shape": "繪製形狀的顏色",
"Colour of {name}": "{name} 的顏色",
"Change the colour of {name}": "更改 {name} 的顏色",
"Name of {name}": "{name} 的名稱",
"Colour of the next shape": "下一個形狀的顏色",
"Drawn shapes": "已繪製的形狀",
"Primary": "主題色",
"Amber": "琥珀色",
"Cyan": "青色",
+102 -23
View File
@@ -750,38 +750,117 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
min-width: 0;
}
/* UTILITIES ▸ Draw: shape segment, label field, colour, and the one-line hint */
/* UTILITIES ▸ Draw: shape icons and colour swatches for the next shape, the
hint with Clear, then one row per drawn shape (named and recoloured there) */
#draw-mode-row {
min-width: 184px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
column-gap: 10px;
}
.pp-text-input {
flex: 1 1 110px;
min-width: 0;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
#draw-mode-row .draw-shape-seg {
flex: 0 0 auto;
}
#draw-mode-row .draw-shape-seg .gev-segment {
min-width: 30px;
padding: 3px 6px;
}
.draw-shape-seg .material-symbols-outlined {
display: block;
font-size: 16px;
line-height: 1;
}
.draw-colors {
display: flex;
align-items: center;
gap: 6px;
}
#utilities-panel .draw-color {
flex: 0 0 auto;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid rgba(0, 0, 0, 0.45);
border-radius: 50%;
cursor: pointer;
}
#utilities-panel .draw-color[data-color='primary'] {
background: #8be9ff;
}
#utilities-panel .draw-color[data-color='amber'] {
background: #ffb547;
}
#utilities-panel .draw-color[data-color='cyan'] {
background: #39d0ff;
}
#utilities-panel .draw-color[data-color='green'] {
background: #5dff9f;
}
#utilities-panel .draw-color[data-color='red'] {
background: #ff6b6b;
}
/* The swatch keeps its own colour in every theme; only a ring marks it. */
#utilities-panel .draw-color:is(.active, :hover, :focus-visible) {
border-color: rgba(0, 0, 0, 0.45);
box-shadow: none;
}
#utilities-panel .draw-color.active,
#utilities-panel .draw-color:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 2px;
}
#draw-label-row {
justify-content: space-between;
gap: 8px;
}
#draw-label-row .draw-hint {
flex: 1 1 auto;
}
.draw-mark-row {
flex-wrap: wrap;
}
.draw-mark-swatch {
flex: 0 0 10px;
width: 10px;
height: 10px;
padding: 0;
border: 0;
border-radius: 3px;
cursor: pointer;
}
.draw-mark-swatch:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* The name reads as text until it is clicked into, and gives way so the
row's buttons stay on its line. */
.draw-mark-row .draw-mark-name {
flex: 1 1 0;
width: 0;
height: 22px;
padding: 0 4px;
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
outline: none;
}
.pp-text-input:focus {
.draw-mark-name::placeholder {
color: var(--text-primary);
opacity: 0.85;
}
.draw-mark-name:hover {
border-color: rgba(255, 255, 255, 0.14);
}
.draw-mark-name:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.06);
}
.draw-color-select {
flex: 0 0 74px;
.draw-mark-row.hidden-mark .draw-mark-name,
.draw-mark-row.hidden-mark .draw-mark-swatch {
opacity: 0.45;
}
/* Sized to its longest colour name so a translated one is never clipped. */
html:not([lang='en']) .draw-color-select {
flex: 0 0 auto;
min-width: 74px;
.draw-mark-colors {
flex-basis: 100%;
padding: 2px 0 4px 16px;
}
.draw-clear-btn {
flex: 0 0 auto;
@@ -105,8 +105,6 @@ test('Display and parameter rows keep their English layout', () => {
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
const [row] = unguardedRules(source, '.param-slider-row');
assert.equal(declaration(row, 'display'), 'flex');
const [color] = unguardedRules(source, '.draw-color-select');
assert.equal(declaration(color, 'flex'), '0 0 74px');
});
test('weather setting rows keep the 56px English label column', () => {
+1 -6
View File
@@ -1495,8 +1495,7 @@ body.recording-mode #right-context-rail > [data-panel-id] {
}
/* Draw's options sit flat under its name, like a layer row's controls, not
as the card that hangs under a DISPLAY button. DISPLAY's shared label
column means nothing here, so the Shape label takes its own width. */
as the card that hangs under a DISPLAY button. */
#utilities-panel .utilities-tools .pp-slider-row {
padding: 4px 0;
background: none;
@@ -1505,7 +1504,3 @@ body.recording-mode #right-context-rail > [data-panel-id] {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
#utilities-panel .utilities-tools .pp-slider-mini-label {
min-width: 0;
}
+13 -14
View File
@@ -159,25 +159,24 @@
<span class="pp-label" data-i18n>Draw</span>
</button>
<div class="pp-slider-row" id="draw-mode-row">
<span class="pp-slider-mini-label" data-i18n>Shape</span>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
<div class="gev-segmented pp-mode-seg draw-shape-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" aria-label="Area" title="Area" data-i18n-aria-label data-i18n-title><span class="material-symbols-outlined" aria-hidden="true">pentagon</span></button>
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" aria-label="Line" title="Line" data-i18n-aria-label data-i18n-title><span class="material-symbols-outlined" aria-hidden="true">timeline</span></button>
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" aria-label="Pin" title="Pin" data-i18n-aria-label data-i18n-title><span class="material-symbols-outlined" aria-hidden="true">location_on</span></button>
</div>
<div class="draw-colors" id="draw-colors" role="radiogroup" aria-label="Colour of the next shape" data-i18n-aria-label>
<button class="draw-color active" type="button" data-color="primary" role="radio" aria-checked="true" aria-label="Primary" title="Primary" data-i18n-aria-label data-i18n-title></button>
<button class="draw-color" type="button" data-color="amber" role="radio" aria-checked="false" aria-label="Amber" title="Amber" data-i18n-aria-label data-i18n-title></button>
<button class="draw-color" type="button" data-color="cyan" role="radio" aria-checked="false" aria-label="Cyan" title="Cyan" data-i18n-aria-label data-i18n-title></button>
<button class="draw-color" type="button" data-color="green" role="radio" aria-checked="false" aria-label="Green" title="Green" data-i18n-aria-label data-i18n-title></button>
<button class="draw-color" type="button" data-color="red" role="radio" aria-checked="false" aria-label="Red" title="Red" data-i18n-aria-label data-i18n-title></button>
</div>
</div>
<div class="pp-slider-row" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option>
<option value="green" data-i18n>Green</option>
<option value="red" data-i18n>Red</option>
</select>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
</div>
<ul class="geo-import-list draw-mark-list" id="draw-mark-list" aria-label="Drawn shapes" data-i18n-aria-label></ul>
</div>
<div class="pp-toggle-group" id="geo-import-group">
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>