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
+1061 -214

No files matched your search

+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',
);
});
+36 -27
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';
syncPreview();
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>