mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Draw rows take an application's own actions
createApplicationTools({ drawMarkActions }) adds an application's buttons to
the rows of drawn areas and lines. An action gets the shape's id, kind, name,
row title and [lon, lat] geometry, and what it returns or resolves to is a
note under the row. The standalone shell passes none, so the open-source
build is unchanged.
This commit is contained in:
1 parent
922712121c
commit
27847bd063
9 files changed
+643
-16
No files matched your search
@@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) {
|
||||
: shape;
|
||||
}
|
||||
|
||||
/** The kind of shape a row action is offered for: `area` or `line`. */
|
||||
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
|
||||
|
||||
const lonLat = (point) =>
|
||||
Array.isArray(point)
|
||||
? [Number(point[0]), Number(point[1])]
|
||||
: [Number(point?.lon), Number(point?.lat)];
|
||||
const finitePairs = (points) =>
|
||||
(Array.isArray(points) ? points : [])
|
||||
.map(lonLat)
|
||||
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
|
||||
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
|
||||
const wrapLon = (lon) =>
|
||||
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
/** Whether consecutive positions step across the antimeridian once wrapped. */
|
||||
const crossesAntimeridian = (points) =>
|
||||
points.some(
|
||||
(point, index) =>
|
||||
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
|
||||
);
|
||||
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
|
||||
const inRange = (points) => {
|
||||
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
|
||||
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
|
||||
return crossesAntimeridian(wrapped) ? null : wrapped;
|
||||
};
|
||||
const closed = (ring) => {
|
||||
if (ring.length < 3) return ring;
|
||||
const [first, last] = [ring[0], ring[ring.length - 1]];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
};
|
||||
|
||||
/**
|
||||
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
|
||||
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
|
||||
* handle has several parts), a line a `LineString`. A pin, a shape too small
|
||||
* to be one, or one that crosses the antimeridian has none, so it is offered
|
||||
* no action rather than a shape that wraps the wrong way round the world.
|
||||
*/
|
||||
export function drawMarkGeometry(mark) {
|
||||
if (mark?.type === 'route') {
|
||||
const path = inRange(finitePairs(mark.path));
|
||||
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
|
||||
}
|
||||
if (mark?.type !== 'area') return null;
|
||||
const ringOf = (points) => {
|
||||
const ring = inRange(closed(finitePairs(points)));
|
||||
return ring?.length >= 4 ? ring : null;
|
||||
};
|
||||
if (Array.isArray(mark.polygons) && mark.polygons.length) {
|
||||
const polygons = [];
|
||||
for (const rings of mark.polygons) {
|
||||
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
|
||||
// A part that cannot be given whole is no part: never a hole dropped.
|
||||
if (!parts.length || parts.some((ring) => !ring)) return null;
|
||||
polygons.push(parts);
|
||||
}
|
||||
return polygons.length > 1
|
||||
? { type: 'MultiPolygon', coordinates: polygons }
|
||||
: { type: 'Polygon', coordinates: polygons[0] };
|
||||
}
|
||||
const ring = ringOf(mark.ring);
|
||||
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions an application adds to Draw's rows, checked once: each has an
|
||||
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
|
||||
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
|
||||
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
|
||||
* as the row shows it). Malformed or repeated ids are dropped.
|
||||
*/
|
||||
export function drawMarkActions(actions = []) {
|
||||
const kept = new Map();
|
||||
for (const action of Array.isArray(actions) ? actions : []) {
|
||||
const wellFormed =
|
||||
typeof action?.id === 'string' &&
|
||||
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
|
||||
typeof action.icon === 'string' &&
|
||||
typeof action.label === 'function' &&
|
||||
typeof action.run === 'function' &&
|
||||
Array.isArray(action.shapes);
|
||||
if (!wellFormed || kept.has(action.id)) continue;
|
||||
kept.set(
|
||||
action.id,
|
||||
Object.freeze({
|
||||
id: action.id,
|
||||
icon: action.icon,
|
||||
label: action.label,
|
||||
shapes: Object.freeze(
|
||||
action.shapes.filter((shape) =>
|
||||
Object.values(ACTION_SHAPES).includes(shape),
|
||||
),
|
||||
),
|
||||
accepts:
|
||||
typeof action.accepts === 'function' ? action.accepts : () => true,
|
||||
run: action.run,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze([...kept.values()]);
|
||||
}
|
||||
|
||||
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
|
||||
export function drawMarkActionsFor(mark, actions = []) {
|
||||
const shape = ACTION_SHAPES[mark?.type];
|
||||
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
|
||||
const geometry = drawMarkGeometry(mark);
|
||||
if (!geometry) return { shape, geometry, actions: [] };
|
||||
return {
|
||||
shape,
|
||||
geometry,
|
||||
actions: actions.filter((action) => {
|
||||
if (!action.shapes.includes(shape)) return false;
|
||||
try {
|
||||
return action.accepts(geometry, shape) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** An action's words for a row; an application's label that throws shows its id. */
|
||||
function actionLabel(action, title) {
|
||||
try {
|
||||
const text = action.label(title);
|
||||
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
|
||||
} catch {
|
||||
return action.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for the hand-drawn marks on the board, in board order. Text is
|
||||
* worded here, in the current language; the view only lays it out.
|
||||
* @param {object[]} marks The engine's `list()`.
|
||||
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
|
||||
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
|
||||
*/
|
||||
export function presentDrawMarks(marks = []) {
|
||||
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
const offered = drawMarkActionsFor(mark, actions).actions;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
busy: notes.get(mark.id)?.busy === true,
|
||||
note: offered.length ? notes.get(mark.id)?.text || '' : '',
|
||||
actions: offered.map((action) => ({
|
||||
id: action.id,
|
||||
icon: action.icon,
|
||||
label: actionLabel(action, title),
|
||||
})),
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
|
||||
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkActions,
|
||||
drawMarkActionsFor,
|
||||
drawMarkFallbackName,
|
||||
drawMarkGeometry,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
@@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () =>
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
|
||||
test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => {
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
}),
|
||||
{
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
}).coordinates[0].at(-1),
|
||||
[0, 0],
|
||||
'an open ring is closed',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
{ lon: 1, lat: 2, height: 9 },
|
||||
{ lon: 3, lat: 4, height: 0 },
|
||||
],
|
||||
}),
|
||||
{
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }),
|
||||
null,
|
||||
);
|
||||
const square = (x) => [
|
||||
[x, 0],
|
||||
[x + 1, 0],
|
||||
[x + 1, 1],
|
||||
[x, 0],
|
||||
];
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: square(0),
|
||||
polygons: [[square(0)], [square(5)]],
|
||||
}),
|
||||
{ type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] },
|
||||
'an area handle with several parts keeps them all',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
polygons: [
|
||||
[
|
||||
square(0),
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.3, 0.2],
|
||||
],
|
||||
],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
'a hole that cannot be given whole is never dropped silently',
|
||||
);
|
||||
});
|
||||
|
||||
test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => {
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[370, 1],
|
||||
[371, 1],
|
||||
],
|
||||
}).coordinates,
|
||||
[
|
||||
[10, 1],
|
||||
[11, 1],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[180, 0],
|
||||
[179, 0],
|
||||
[179, 1],
|
||||
],
|
||||
}).coordinates[0][0],
|
||||
[180, 0],
|
||||
'the antimeridian itself stays put',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[179.9, 0],
|
||||
[-179.9, 0],
|
||||
[-179.9, 1],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[179.5, 0],
|
||||
[180.5, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[0, 91],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('row actions are checked once: malformed ones and repeated ids are dropped', () => {
|
||||
const run = () => {};
|
||||
const label = () => 'x';
|
||||
const actions = drawMarkActions([
|
||||
{ id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] },
|
||||
{ id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] },
|
||||
{
|
||||
id: 'tag',
|
||||
icon: 'sell',
|
||||
label,
|
||||
run,
|
||||
shapes: ['area', 'line', 'pin'],
|
||||
},
|
||||
{ id: 'tag', icon: 'other', label, run, shapes: ['area'] },
|
||||
null,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })),
|
||||
[{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }],
|
||||
);
|
||||
assert.deepEqual(drawMarkActions('nope'), []);
|
||||
const area = {
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
};
|
||||
const offer = drawMarkActionsFor(area, actions);
|
||||
assert.equal(offer.shape, 'area');
|
||||
assert.deepEqual(
|
||||
offer.actions.map((a) => a.id),
|
||||
['tag'],
|
||||
);
|
||||
const refusing = drawMarkActions([
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'a',
|
||||
label,
|
||||
run,
|
||||
shapes: ['area'],
|
||||
accepts: () => {
|
||||
throw new Error('no');
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
drawMarkActionsFor(area, refusing).actions,
|
||||
[],
|
||||
'an accepts that throws offers nothing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0]
|
||||
.actions,
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -50,9 +50,28 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkActions,
|
||||
drawMarkActionsFor,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
||||
/** The longest note a row action leaves under its row, in characters. */
|
||||
const NOTE_MAX = 200;
|
||||
/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */
|
||||
function noteText(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= NOTE_MAX) return trimmed;
|
||||
const cut = trimmed.slice(0, NOTE_MAX - 1);
|
||||
const space = cut.lastIndexOf(' ');
|
||||
return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** The id this tool claims the pointer under. */
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
@@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS;
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* `markActions` are the application's own buttons on an area's or a line's
|
||||
* row (drawMarks.js `drawMarkActions`); a press runs the action with the
|
||||
* shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`).
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({
|
||||
@@ -76,7 +98,12 @@ export function initDrawTool({
|
||||
navigation = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
markActions = [],
|
||||
}) {
|
||||
const rowActions = drawMarkActions(markActions);
|
||||
// What a row action said last on each drawn shape: `{ run, busy, text }`.
|
||||
const notes = new Map();
|
||||
let noteRun = 0;
|
||||
// A page without the Draw controls (the Display controls view omitted) has
|
||||
// no draw tool.
|
||||
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
|
||||
@@ -115,14 +142,19 @@ export function initDrawTool({
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () =>
|
||||
const present = () => {
|
||||
const marks = annotations.list?.() || [];
|
||||
// A note goes with its shape.
|
||||
for (const id of notes.keys())
|
||||
if (!marks.some((mark) => mark.id === id)) notes.delete(id);
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
marks: presentDrawMarks(marks, rowActions, notes),
|
||||
});
|
||||
};
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
@@ -450,6 +482,56 @@ export function initDrawTool({
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application's row action `actionId` on drawn shape `id`, if the
|
||||
* shape is still on the board and still offered it. Returns whether it ran.
|
||||
* What the action returns, or resolves to, is the row's note (a measurement,
|
||||
* "saved"): the row is busy until it settles, a newer press or the shape's
|
||||
* removal makes a late answer moot, and a failure clears the note, so an
|
||||
* action words its own refusals and resolves with them.
|
||||
*/
|
||||
function runMarkAction(id, actionId) {
|
||||
const mark = (annotations.list?.() || []).find(
|
||||
(entry) => entry.id === id && entry.manual === true,
|
||||
);
|
||||
const offer = drawMarkActionsFor(mark, rowActions);
|
||||
const action = offer.actions.find((entry) => entry.id === actionId);
|
||||
if (!action || notes.get(mark.id)?.busy) return false;
|
||||
const run = ++noteRun;
|
||||
const settle = (text) => {
|
||||
if (destroyed || notes.get(mark.id)?.run !== run) return;
|
||||
notes.set(mark.id, {
|
||||
run,
|
||||
busy: false,
|
||||
text: noteText(text),
|
||||
});
|
||||
present();
|
||||
};
|
||||
const fail = (error) => {
|
||||
console.warn(`[draw] ${actionId} failed`, error);
|
||||
settle('');
|
||||
};
|
||||
notes.set(mark.id, { run, busy: true, text: '' });
|
||||
let answer;
|
||||
try {
|
||||
answer = action.run({
|
||||
id: mark.id,
|
||||
shape: offer.shape,
|
||||
name: mark.name || '',
|
||||
title: mark.name || drawMarkFallbackName(mark),
|
||||
geometry: offer.geometry,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
return false;
|
||||
}
|
||||
if (typeof answer?.then === 'function') {
|
||||
present();
|
||||
Promise.resolve(answer).then(settle, fail);
|
||||
} else settle(answer);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
@@ -478,6 +560,7 @@ export function initDrawTool({
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
runDrawMarkAction: runMarkAction,
|
||||
});
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
@@ -500,13 +583,15 @@ export function initDrawTool({
|
||||
setActive,
|
||||
setShape: chooseShape,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
marks: () =>
|
||||
presentDrawMarks(annotations.list?.() || [], rowActions, notes),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
runMarkAction,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
|
||||
@@ -223,7 +223,7 @@ function fakeAnnotations() {
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness({ navigation = null } = {}) {
|
||||
function harness({ navigation = null, markActions = [] } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) {
|
||||
viewer,
|
||||
annotations,
|
||||
navigation,
|
||||
markActions,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
@@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
|
||||
}
|
||||
});
|
||||
|
||||
test('an application\'s row actions show on the shapes they are for and run with the shape', async () => {
|
||||
const ran = [];
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{
|
||||
id: 'tag',
|
||||
icon: 'sell',
|
||||
label: (name) => `Tag ${name}`,
|
||||
shapes: ['area', 'line'],
|
||||
run: (shape) => ran.push(['tag', shape]),
|
||||
},
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'straighten',
|
||||
label: (name) => `Measure ${name}`,
|
||||
shapes: ['area'],
|
||||
accepts: (geometry) => geometry.type === 'Polygon',
|
||||
run: (shape) => ran.push(['measure', shape]),
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] },
|
||||
{ id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] },
|
||||
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
|
||||
[
|
||||
['r1', [
|
||||
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
|
||||
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
|
||||
]],
|
||||
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
|
||||
['p1', []],
|
||||
],
|
||||
);
|
||||
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
|
||||
assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action');
|
||||
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
|
||||
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||
assert.deepEqual(ran, [
|
||||
['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }],
|
||||
['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }],
|
||||
]);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an action that throws is reported and leaves the row alone', async () => {
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
|
||||
],
|
||||
});
|
||||
const warn = console.warn;
|
||||
const warned = [];
|
||||
console.warn = (...args) => warned.push(args[0]);
|
||||
try {
|
||||
h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
|
||||
h.annotations.changed();
|
||||
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
|
||||
assert.deepEqual(warned, ['[draw] boom failed']);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
|
||||
const pending = [];
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'straighten',
|
||||
label: (name) => `Measure ${name}`,
|
||||
shapes: ['area'],
|
||||
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
|
||||
},
|
||||
{ id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' },
|
||||
{ id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` },
|
||||
],
|
||||
});
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const board = h.annotations;
|
||||
const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
|
||||
board.marks.push(area('r1'), area('r2'));
|
||||
board.changed();
|
||||
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
|
||||
h.act('runDrawMarkAction', 'r1', 'long');
|
||||
assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word');
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [true, '']);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press');
|
||||
pending[0].resolve('412 km², 38 km of edge');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
|
||||
h.act('runDrawMarkAction', 'r1', 'measure');
|
||||
pending[1].reject(new Error('refused'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
|
||||
h.act('runDrawMarkAction', 'r2', 'measure');
|
||||
board.marks.splice(1, 1);
|
||||
board.changed();
|
||||
pending[2].resolve('late');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(row('r2'), undefined);
|
||||
board.marks.push(area('r2'));
|
||||
board.changed();
|
||||
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,7 @@ export function createApplicationTools({
|
||||
startChrome,
|
||||
onSceneDirector,
|
||||
sceneDataPacks,
|
||||
drawMarkActions = [],
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
@@ -80,12 +81,15 @@ export function createApplicationTools({
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button. A row's fly-to
|
||||
// goes through the shell's navigation policy, as Import's does.
|
||||
// `drawMarkActions` are an application's own buttons on a drawn area's or
|
||||
// line's row (annotations/drawMarks.js); the standalone shell adds none.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
markActions: drawMarkActions,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
|
||||
@@ -987,6 +987,26 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
.draw-mark-row.hidden-mark .draw-mark-swatch {
|
||||
opacity: 0.45;
|
||||
}
|
||||
/* What an application's row action said (a measurement, a confirmation), under
|
||||
the row; empty, it takes no room. */
|
||||
.draw-mark-note {
|
||||
order: 2;
|
||||
flex-basis: 100%;
|
||||
padding: 0 0 2px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.draw-mark-note:empty {
|
||||
display: none;
|
||||
}
|
||||
/* Busy, but focusable: disabling the pressed button would drop focus. */
|
||||
.draw-mark-action[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
.draw-mark-colors {
|
||||
/* Right after its swatch for keyboard and screen readers, shown last. */
|
||||
order: 1;
|
||||
|
||||
@@ -7,7 +7,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { presentDrawMarks } from '../../../annotations/drawMarks.js';
|
||||
import { drawMarkActions, presentDrawMarks } from '../../../annotations/drawMarks.js';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
import DrawMarkList from './DrawMarkList.vue';
|
||||
|
||||
@@ -34,8 +34,8 @@ function mount(t) {
|
||||
return { store, calls };
|
||||
}
|
||||
|
||||
async function show(store, marks) {
|
||||
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks) });
|
||||
async function show(store, marks, actions = []) {
|
||||
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks, drawMarkActions(actions)) });
|
||||
await nextTick();
|
||||
}
|
||||
const row = (id) => document.querySelector(`#draw-mark-list [data-mark-id="${id}"]`);
|
||||
@@ -63,6 +63,32 @@ test('lists only hand-drawn marks, named or by kind, with Import-style actions',
|
||||
assert.equal(control('a2', 'remove').getAttribute('aria-label'), 'Remove Camp');
|
||||
});
|
||||
|
||||
test('an application\'s row actions sit before fly to, labelled, and act on their mark', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
const tag = { id: 'tag', icon: 'sell', label: (name) => `Tag ${name}`, shapes: ['line'], run: () => {} };
|
||||
await show(store, [{ ...LINE, path: [[0, 0], [1, 0]] }, PIN], [tag]);
|
||||
const button = control('a1', 'action-tag');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Tag Line · 950 m');
|
||||
assert.equal(button.textContent, 'sell');
|
||||
assert.equal(button.nextElementSibling, control('a1', 'fly'));
|
||||
assert.equal(control('a2', 'action-tag'), null, 'a pin is not offered a line action');
|
||||
button.click();
|
||||
assert.deepEqual(calls, [['runDrawMarkAction', 'a1', 'tag']]);
|
||||
const note = row('a1').querySelector('.draw-mark-note');
|
||||
assert.equal(note.getAttribute('role'), 'status');
|
||||
assert.equal(note.textContent, '');
|
||||
assert.equal(row('a2').querySelector('.draw-mark-note'), null, 'a row without actions has no note');
|
||||
const line = { ...LINE, path: [[0, 0], [1, 0]] };
|
||||
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) });
|
||||
await nextTick();
|
||||
assert.equal(control('a1', 'action-tag').getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(row('a1').querySelector('.draw-mark-note').textContent, '…');
|
||||
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: false, text: 'Tagged' }]])) });
|
||||
await nextTick();
|
||||
assert.equal(control('a1', 'action-tag').getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(row('a1').querySelector('.draw-mark-note').textContent, 'Tagged');
|
||||
});
|
||||
|
||||
test('show/hide, fly to and remove act on that one mark', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
|
||||
@@ -10,10 +10,11 @@ import { useDisplayStore } from '../../stores/display.js';
|
||||
|
||||
// UTILITIES ▸ Draw's shapes, one row each like Import's files: the colour
|
||||
// swatch (it opens the five colours in the row), the name typed in place,
|
||||
// and show/hide, fly to and remove. The draw tool publishes the rows and
|
||||
// answers the controls. Rows are keyed, so a board change elsewhere updates a
|
||||
// row in place and never takes the button under a click or the field being
|
||||
// typed in. The list's template is one line: templates keep whitespace.
|
||||
// and show/hide, the application's own row actions, fly to and remove. The
|
||||
// draw tool publishes the rows and answers the controls. Rows are keyed, so a
|
||||
// board change elsewhere updates a row in place and never takes the button
|
||||
// under a click or the field being typed in. The list's template is one
|
||||
// line: templates keep whitespace.
|
||||
const display = useDisplayStore();
|
||||
const { draw } = storeToRefs(display);
|
||||
const rows = computed(() => draw.value?.marks ?? []);
|
||||
@@ -98,5 +99,5 @@ function leave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="geo-import-list draw-mark-list" :class="{ visible: listed }" id="draw-mark-list" v-focus-safe-children="leave" :aria-label="t('Drawn shapes')"><li v-for="row in rows" :key="row.id" class="geo-import-row draw-mark-row" :class="{ 'hidden-mark': row.hidden }" :data-mark-id="row.id" @keydown="onRowKey(row, $event)"><button type="button" class="geo-import-swatch draw-mark-swatch" data-control="swatch" :style="{ background: DRAW_COLOR_CSS[row.color] }" :title="row.labels.swatch" :aria-label="row.labels.swatch" :aria-expanded="String(picking === row.id)" :aria-controls="`draw-mark-colors-${row.id}`" @click="picking = picking === row.id ? null : row.id"></button><DrawSwatches v-if="picking === row.id" :id="`draw-mark-colors-${row.id}`" class="draw-mark-colors" data-control="picker" autofocus :label="row.labels.colors" :model-value="row.color" @update:model-value="recolor(row, $event)" @choose="closePicker(row.id)" /><input type="text" class="geo-import-name draw-mark-name" data-control="name" maxlength="120" autocomplete="off" spellcheck="false" v-field-value="row.name" :placeholder="row.placeholder" :aria-label="row.labels.name" @keydown="onNameKey(row, $event)" @focusout="commit(row, $event.currentTarget)"><button type="button" class="geo-import-action" data-control="toggle" :title="row.labels.toggle" :aria-label="row.labels.toggle" @click="display.act('toggleDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ row.hidden ? 'visibility_off' : 'visibility' }}</span></button><button type="button" class="geo-import-action" data-control="fly" :title="row.labels.fly" :aria-label="row.labels.fly" @click="display.act('flyToDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">my_location</span></button><button type="button" class="geo-import-action" data-control="remove" :title="row.labels.remove" :aria-label="row.labels.remove" @click="display.act('removeDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">close</span></button></li></ul>
|
||||
<ul class="geo-import-list draw-mark-list" :class="{ visible: listed }" id="draw-mark-list" v-focus-safe-children="leave" :aria-label="t('Drawn shapes')"><li v-for="row in rows" :key="row.id" class="geo-import-row draw-mark-row" :class="{ 'hidden-mark': row.hidden }" :data-mark-id="row.id" @keydown="onRowKey(row, $event)"><button type="button" class="geo-import-swatch draw-mark-swatch" data-control="swatch" :style="{ background: DRAW_COLOR_CSS[row.color] }" :title="row.labels.swatch" :aria-label="row.labels.swatch" :aria-expanded="String(picking === row.id)" :aria-controls="`draw-mark-colors-${row.id}`" @click="picking = picking === row.id ? null : row.id"></button><DrawSwatches v-if="picking === row.id" :id="`draw-mark-colors-${row.id}`" class="draw-mark-colors" data-control="picker" autofocus :label="row.labels.colors" :model-value="row.color" @update:model-value="recolor(row, $event)" @choose="closePicker(row.id)" /><input type="text" class="geo-import-name draw-mark-name" data-control="name" maxlength="120" autocomplete="off" spellcheck="false" v-field-value="row.name" :placeholder="row.placeholder" :aria-label="row.labels.name" @keydown="onNameKey(row, $event)" @focusout="commit(row, $event.currentTarget)"><button type="button" class="geo-import-action" data-control="toggle" :title="row.labels.toggle" :aria-label="row.labels.toggle" @click="display.act('toggleDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ row.hidden ? 'visibility_off' : 'visibility' }}</span></button><button v-for="action in row.actions" :key="action.id" type="button" class="geo-import-action draw-mark-action" :data-control="`action-${action.id}`" :data-action="action.id" :title="action.label" :aria-label="action.label" :aria-disabled="String(row.busy)" @click="display.act('runDrawMarkAction', row.id, action.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ action.icon }}</span></button><button type="button" class="geo-import-action" data-control="fly" :title="row.labels.fly" :aria-label="row.labels.fly" @click="display.act('flyToDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">my_location</span></button><button type="button" class="geo-import-action" data-control="remove" :title="row.labels.remove" :aria-label="row.labels.remove" @click="display.act('removeDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">close</span></button><span v-if="row.actions.length" class="draw-mark-note" role="status" :aria-busy="String(row.busy)">{{ row.busy ? '…' : row.note }}</span></li></ul>
|
||||
</template>
|
||||
Reference in new issue
Block a user