mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into prereq-10-osm-overlays
This commit is contained in:
77 files changed
+2443
-346
No files matched your search
@@ -34,7 +34,8 @@ If it's bigger, link to it instead.
|
||||
|
||||
<!--
|
||||
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
|
||||
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
|
||||
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Audio,
|
||||
and Utilities (its rows render in the separate UTILITIES panel).
|
||||
If none fits, propose a new group and say why.
|
||||
-->
|
||||
|
||||
|
||||
+3
-2
@@ -247,8 +247,9 @@ template has a section for them.
|
||||
instead of attaching it.
|
||||
3. **The proposed category.** Say which layer-panel group the layer belongs in.
|
||||
The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today:
|
||||
Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer
|
||||
that isn't listed there falls under "Other layers". If none of the groups
|
||||
Movement, Cameras, Infrastructure, Events, Weather and Audio, plus Utilities,
|
||||
whose rows render in the separate UTILITIES panel). A layer that isn't
|
||||
listed there falls under "Other layers". If none of the groups
|
||||
fits, propose a new one and explain why.
|
||||
4. **The data source and licence.** Name the provider and endpoint, link its
|
||||
licence or terms, give the attribution shown in the app, and say whether it
|
||||
|
||||
@@ -393,7 +393,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** — DISPLAY ▸ **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.
|
||||
|
||||

|
||||
|
||||
@@ -451,7 +451,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
|
||||
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
||||
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Utilities.
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
|
||||
|
||||
**The basemap ladder — what each tier buys you:**
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- Gather Directions, Draw and Import in a new UTILITIES panel in the left
|
||||
column, between Data Layers and Scenes. Draw and Import leave DISPLAY, and
|
||||
Radio stays in Data Layers under "Audio". Each drawn shape now gets a row
|
||||
under Draw, like an imported file: name it, recolour it, hide it, fly to it
|
||||
or remove it there. Area, Line and Pin are icon buttons, and the colour for
|
||||
the next shape is a row of swatches (asturksever, #1469).
|
||||
+29
-8
@@ -131,6 +131,26 @@ provider registration rejects compositions that cannot fit rather than truncatin
|
||||
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||
field. Existing whole-link size limits still apply.
|
||||
|
||||
## Utilities panel — October 29, 2026
|
||||
|
||||
UTILITIES is its own left-side panel, between Data Layers and Scenes, with
|
||||
its own rail item (`handyman`). It is a `RailPanel` (`#utilities-panel`) in
|
||||
`src/ui/views/LayerPanelsView.vue` and holds the Directions layer row, which
|
||||
`LayerPanel` renders into `#utilities-toggles` instead of the Data Layers
|
||||
list, then Draw (`DrawControls.vue`) and Import (`GeoImport.vue`), which left
|
||||
DISPLAY with their ids unchanged. Without `#utilities-toggles` (a host that
|
||||
omits the panel) Directions stays in Data Layers under its Utilities heading.
|
||||
Radio, formerly the other row under that heading, stays in Data Layers under
|
||||
"Audio". Like the other side panels it follows the one-open-panel rule,
|
||||
remembers its state, and is not part of share links. It hides in clean view
|
||||
and recording. In Cockpit it stays with Directions alone, as Data Layers does;
|
||||
Draw and Import hide there, as DISPLAY always did, and opening Utilities folds
|
||||
the Cockpit context rail away as Data Layers does (and Contact expanding
|
||||
closes it). Voice `show_data_layers_menu` for Directions opens Utilities, the
|
||||
panel that holds its row, and `set_panel_open` reaches it as
|
||||
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
|
||||
it sits collapsed in the Layers sheet under Data Layers.
|
||||
|
||||
## God's Eye View in conversations — October 2, 2026
|
||||
|
||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||
@@ -296,7 +316,7 @@ settings from active map/contact effects. Sonar settings are session-only and
|
||||
return to their defaults on reload. Sliders accept integer values matching the
|
||||
action's ranges.
|
||||
|
||||
Wind appears in the Weather group before Utilities. The surface-weather prototype
|
||||
Wind appears in the Weather group before Audio. The surface-weather prototype
|
||||
uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid.
|
||||
It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning.
|
||||
Weather rows contain only the toggle and source/meta line. Configuration lives
|
||||
@@ -5537,9 +5557,10 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
### Manual Whiteboard Drawing (September 2026)
|
||||
|
||||
- DISPLAY ▸ **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.
|
||||
- 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 is in the DISPLAY-rail view (`src/ui/views/DisplayControlsView.vue`), rendered into its `index.html` host before the page is wired. 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()`.
|
||||
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
||||
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
|
||||
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
|
||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
|
||||
@@ -5550,7 +5571,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`.
|
||||
|
||||
@@ -5578,7 +5599,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
### Import Your Own Files (October 2026)
|
||||
|
||||
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
|
||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
||||
@@ -6541,8 +6562,8 @@ when their footprints touch or contain one another, never across a seam toleranc
|
||||
|
||||
### Shared native dropdowns and numeric fields
|
||||
|
||||
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
|
||||
draw colour retain compact density. Radio filter, tuner gain and FM frequency
|
||||
- Scene and CCTV selects share the default `gev-field` surface. HUD layout
|
||||
retains compact density; Draw's colour is a row of swatches, not a select. Radio filter, tuner gain and FM frequency
|
||||
use the same base while retaining their feature-specific skins and layout.
|
||||
- The CCTV inline number editor is rendered by its pose chip component with
|
||||
its existing precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
||||
|
||||
+1
-1
@@ -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,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,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,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/annotations/drawMarks.test.mjs",
|
||||
"src/annotations/drawMode.js",
|
||||
"src/annotations/drawMode.test.mjs",
|
||||
"src/annotations/drawTool.js",
|
||||
|
||||
@@ -88,6 +88,10 @@
|
||||
"src/ui/rail/RailChips.vue",
|
||||
"src/ui/views/panels/DisplayPanel.vue",
|
||||
"src/ui/views/panels/GeoImport.vue",
|
||||
"src/ui/views/panels/DrawControls.vue",
|
||||
"src/ui/views/panels/DrawMarkList.vue",
|
||||
"src/ui/views/panels/DrawSwatches.vue",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/ui/stores/display.js",
|
||||
"src/ui/localSdrPresentation.js",
|
||||
"src/ui/unitLabels.js",
|
||||
@@ -2829,6 +2833,7 @@
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/annotations/drawMode.js",
|
||||
"src/annotations/drawTool.js",
|
||||
"src/data/geoFileImport.js",
|
||||
|
||||
+12
-10
@@ -107,7 +107,7 @@ const layerState = () =>
|
||||
const manager = gev.dataManager;
|
||||
const module = manager.layers.get('directions').module;
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const controls = module.getRowControls();
|
||||
const viewer = gev.viewer;
|
||||
@@ -152,7 +152,7 @@ const layerState = () =>
|
||||
const clickChip = async (chipId) => {
|
||||
const clicked = await page.evaluate((id) => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
|
||||
if (!button || button.disabled) return false;
|
||||
@@ -232,24 +232,26 @@ try {
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
|
||||
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
|
||||
const opened = await page.evaluate(() => {
|
||||
const panel = document.getElementById('data-panel');
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (!panel) return false;
|
||||
if (panel.classList.contains('collapsed')) {
|
||||
panel
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
}
|
||||
return !document
|
||||
.getElementById('data-panel')
|
||||
.getElementById('utilities-panel')
|
||||
.classList.contains('collapsed');
|
||||
});
|
||||
await sleep(700);
|
||||
check('the DATA LAYERS panel opens from its own header control', opened);
|
||||
check('the UTILITIES panel opens from its own header control', opened);
|
||||
const toggled = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
if (!button) return false;
|
||||
@@ -397,7 +399,7 @@ try {
|
||||
);
|
||||
const focused = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const items = row.querySelectorAll('.data-row-list-item');
|
||||
items[2]?.focus();
|
||||
@@ -776,7 +778,7 @@ try {
|
||||
}
|
||||
}
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
return {
|
||||
entities: left.length,
|
||||
|
||||
+198
-25
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
|
||||
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
@@ -58,6 +58,47 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
/** A clip of the globe canvas, clear of the panels on every side. */
|
||||
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
|
||||
|
||||
// Clear of the open UTILITIES panel (x 100–380), whose own amber swatch
|
||||
// dims when its row's shape is hidden.
|
||||
const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 };
|
||||
/** The canvas clip as a PNG, for comparing two moments of the same view. */
|
||||
const canvasShot = () =>
|
||||
page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' });
|
||||
|
||||
/**
|
||||
* Amber pixels in one shot that are not amber in another of the same view:
|
||||
* a mark's own pixels, apart from imagery and panels that both shots share.
|
||||
*/
|
||||
function amberOnlyIn(shotA, shotB) {
|
||||
return page.evaluate(
|
||||
async (a, b) => {
|
||||
const pixels = async (base64) => {
|
||||
const bitmap = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
||||
};
|
||||
const amber = (d, i) =>
|
||||
d[i] > 150 &&
|
||||
d[i + 1] > 90 &&
|
||||
d[i + 1] < 220 &&
|
||||
d[i + 2] < 130 &&
|
||||
d[i] - d[i + 2] > 70 &&
|
||||
d[i] - d[i + 1] > 20;
|
||||
const [da, db] = [await pixels(a), await pixels(b)];
|
||||
let only = 0;
|
||||
for (let i = 0; i < da.length; i += 4)
|
||||
if (amber(da, i) && !amber(db, i)) only += 1;
|
||||
return only;
|
||||
},
|
||||
shotA,
|
||||
shotB,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many amber pixels the clip contains. The drawn area's stroke and fill are
|
||||
* the only amber in this scene, so the count going UP after a shape is finished
|
||||
@@ -196,12 +237,14 @@ try {
|
||||
);
|
||||
check('visible tile content settles before drawing', settled);
|
||||
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
|
||||
// UTILITIES starts collapsed on a first run: open it the way a user does.
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
@@ -210,7 +253,7 @@ try {
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check(
|
||||
'the Draw control is on the DISPLAY rail and starts off',
|
||||
'the Draw control is in the UTILITIES panel and starts off',
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
return (
|
||||
@@ -298,8 +341,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',
|
||||
@@ -313,7 +354,8 @@ try {
|
||||
|
||||
// ── a line along a road, with Backspace ─────────────────────────────────
|
||||
await page.click('.pp-mode-btn[data-shape="line"]');
|
||||
await clickWorld(360, 300);
|
||||
// Clear of the open UTILITIES panel, which sits beside the left rail.
|
||||
await clickWorld(420, 300);
|
||||
await clickWorld(470, 380);
|
||||
await clickWorld(600, 470);
|
||||
const beforeBackspace = (await drawState()).vertices;
|
||||
@@ -326,16 +368,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) &&
|
||||
@@ -347,10 +382,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);
|
||||
@@ -373,14 +406,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(
|
||||
@@ -389,6 +442,118 @@ 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);
|
||||
// Wait for a mark to reach a state rather than for a fixed time.
|
||||
const markReaches = (id, want) =>
|
||||
page
|
||||
.waitForFunction(
|
||||
(id, want) => {
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
return (
|
||||
mark &&
|
||||
Object.entries(want).every(([key, value]) => mark[key] === value)
|
||||
);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
id,
|
||||
want,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
await page.click(rowControl(areaId, 'swatch'));
|
||||
await page.click(rowControl(areaId, 'color-green'));
|
||||
await markReaches(areaId, { color: 'green' });
|
||||
check(
|
||||
'a colour picked in the row recolours that shape',
|
||||
(await markState(areaId))?.color === 'green',
|
||||
);
|
||||
await page.click(rowControl(areaId, 'toggle'));
|
||||
await markReaches(areaId, { hidden: true, alpha: 0 });
|
||||
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'));
|
||||
check(
|
||||
'showing it again brings it straight back',
|
||||
await markReaches(areaId, { hidden: false, alpha: 1 }),
|
||||
);
|
||||
// Fly-to frames the line by its whole path: once the flight settles, the
|
||||
// middle of the line sits near the middle of the view.
|
||||
await page.click(rowControl(ids[1], 'fly'));
|
||||
await page
|
||||
.waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
await wait(300);
|
||||
const framed = await page.evaluate((id) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
// No Cesium global in the page: borrow Cartesian3 from a live object.
|
||||
const Cartesian3 = viewer.camera.positionWC.constructor;
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
const lons = mark.path.map((p) => p.lon);
|
||||
const lats = mark.path.map((p) => p.lat);
|
||||
const middle = Cartesian3.fromDegrees(
|
||||
(Math.min(...lons) + Math.max(...lons)) / 2,
|
||||
(Math.min(...lats) + Math.max(...lats)) / 2,
|
||||
);
|
||||
const at = viewer.scene.cartesianToCanvasCoordinates(middle);
|
||||
const canvas = viewer.scene.canvas;
|
||||
return (
|
||||
at && {
|
||||
dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth,
|
||||
dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight,
|
||||
}
|
||||
);
|
||||
}, ids[1]);
|
||||
check(
|
||||
'fly to frames that shape: its middle is near the middle of the view',
|
||||
Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2,
|
||||
JSON.stringify(framed),
|
||||
);
|
||||
|
||||
// ── a hidden Line leaves the globe: its flowing stroke keeps its own
|
||||
// colour, so the check is the pixels, not the mark's state ─────────────
|
||||
const lineId = ids[1];
|
||||
await page.click(rowControl(lineId, 'swatch'));
|
||||
await page.click(rowControl(lineId, 'color-amber'));
|
||||
await markReaches(lineId, { color: 'amber', alpha: 1 });
|
||||
await wait(800);
|
||||
const shown = await canvasShot();
|
||||
await page.click(rowControl(lineId, 'toggle'));
|
||||
await markReaches(lineId, { hidden: true, alpha: 0 });
|
||||
await wait(800);
|
||||
const hiddenShot = await canvasShot();
|
||||
// Amber the shown line had and the hidden one lost; amber that appears
|
||||
// only once it is hidden is noise, and must stay small beside it.
|
||||
const gone = await amberOnlyIn(shown, hiddenShot);
|
||||
const noise = await amberOnlyIn(hiddenShot, shown);
|
||||
check(
|
||||
'a hidden Line is no longer painted',
|
||||
gone > 40 && noise < gone * 0.2,
|
||||
`${gone} amber pixels went with the line, ${noise} appeared`,
|
||||
);
|
||||
await page.click(rowControl(lineId, 'toggle'));
|
||||
await wait(1200);
|
||||
const back = await amberOnlyIn(await canvasShot(), hiddenShot);
|
||||
check(
|
||||
'and paints again when shown',
|
||||
back > gone * 0.5,
|
||||
`${back} of ${gone} line pixels back`,
|
||||
);
|
||||
await shot('03-pins-and-shapes');
|
||||
|
||||
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
|
||||
@@ -419,6 +584,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();
|
||||
@@ -480,10 +651,12 @@ try {
|
||||
|
||||
// ── Clear wipes the board ───────────────────────────────────────────────
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -282,13 +281,6 @@ try {
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
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;
|
||||
@@ -296,12 +288,7 @@ try {
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
];
|
||||
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
@@ -432,19 +419,11 @@ try {
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
|
||||
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
|
||||
* input and a real drop, parsed by Cesium in the page.
|
||||
*
|
||||
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
|
||||
@@ -269,10 +269,12 @@ try {
|
||||
}, VIEW);
|
||||
await wait(4000);
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
@@ -283,7 +285,7 @@ try {
|
||||
);
|
||||
const before = await state();
|
||||
check(
|
||||
'Import is on the DISPLAY rail with an empty, hidden list',
|
||||
'Import is in the UTILITIES panel with an empty, hidden list',
|
||||
before.rows === 0 &&
|
||||
!(await page.evaluate(() =>
|
||||
document
|
||||
|
||||
@@ -126,6 +126,14 @@ try {
|
||||
'ordinary layer rows return after reassembly',
|
||||
container.querySelectorAll('.data-toggle-row').length > 10,
|
||||
]);
|
||||
result.push([
|
||||
'Directions returns to the Utilities list after reassembly',
|
||||
Boolean(
|
||||
document.querySelector(
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
) && !container.querySelector('[data-layer-id="directions"]'),
|
||||
),
|
||||
]);
|
||||
return result;
|
||||
});
|
||||
for (const [name, passed] of results) check(name, passed);
|
||||
|
||||
@@ -35,15 +35,21 @@ try {
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
}),
|
||||
);
|
||||
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'utilities-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
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"]'),
|
||||
]);
|
||||
@@ -69,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(),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import {
|
||||
isRateLimitedOutcome,
|
||||
@@ -145,6 +146,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) {
|
||||
@@ -190,8 +202,11 @@ export function createAnnotationEngine({
|
||||
// World annotations carry persistent per-frame scene animation (pulsing
|
||||
// live colors, camera-scaled rings, route-flow uniforms in the renderer),
|
||||
// so the scene must render continuously while any mark exists. (perf wave 2)
|
||||
// Only a mark someone can see needs every frame drawn: a board of marks
|
||||
// hidden from Draw's list lets the scene idle.
|
||||
function syncAnnotationHold() {
|
||||
if (annotations.size > 0) holdContinuousRender('annotations');
|
||||
const visible = [...annotations.values()].some((anno) => !anno.hidden);
|
||||
if (visible) holdContinuousRender('annotations');
|
||||
else releaseContinuousRender('annotations');
|
||||
}
|
||||
|
||||
@@ -220,6 +235,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) {
|
||||
@@ -230,6 +246,7 @@ export function createAnnotationEngine({
|
||||
annotations.delete(anno.id);
|
||||
renderer.remove(anno);
|
||||
changed = true;
|
||||
expired = true;
|
||||
continue;
|
||||
}
|
||||
if (pendingAnimation(anno, now)) pending = true;
|
||||
@@ -237,7 +254,11 @@ export function createAnnotationEngine({
|
||||
if (changed) {
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
// The frame that takes a just-hidden mark off: with every mark hidden
|
||||
// the hold is gone, and nothing else would draw it.
|
||||
governorRequestRender('annotations');
|
||||
}
|
||||
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
|
||||
@@ -467,6 +488,7 @@ export function createAnnotationEngine({
|
||||
if (ids.length) {
|
||||
renderer.sync(annotations);
|
||||
ensureTicking();
|
||||
notifyChange();
|
||||
}
|
||||
if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor);
|
||||
else if (ids.length) ensureMarksVisible(ids);
|
||||
@@ -796,6 +818,11 @@ export function createAnnotationEngine({
|
||||
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
|
||||
function findDuplicate(anno) {
|
||||
if (!anno.anchor) return null;
|
||||
// A shape drawn by hand is always new: each click is meant, and every
|
||||
// shape is unnamed until its row names it, so two nearby pins would
|
||||
// otherwise read as one and the second would replace or vanish into the
|
||||
// first.
|
||||
if (anno.manual) return null;
|
||||
const near = (a, b) =>
|
||||
a &&
|
||||
b &&
|
||||
@@ -814,6 +841,10 @@ export function createAnnotationEngine({
|
||||
};
|
||||
for (const ex of annotations.values()) {
|
||||
if (ex.type !== anno.type || !ex.anchor) continue;
|
||||
// A shape drawn by hand and a voice mark are never the same mark: a
|
||||
// match would let a voice TTL expire the drawn one, or replace it. A
|
||||
// hidden mark is not refreshed either; a new shape there is new.
|
||||
if (Boolean(ex.manual) !== Boolean(anno.manual) || ex.hidden) continue;
|
||||
// Identity is GEOMETRY, not label: "Marina" and "Marina District" resolve to the SAME
|
||||
// polygon/anchor and must collapse to one mark (the caller replaces label/color in place
|
||||
// so the latest caption wins). Resolution is deterministic, so the same place re-resolves
|
||||
@@ -886,6 +917,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') {
|
||||
@@ -971,6 +1007,97 @@ 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);
|
||||
// Take it off (or put it back) now, not when its alpha next changes: a
|
||||
// mark hidden before its first frame has no alpha change to wait for.
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
governorRequestRender('annotations');
|
||||
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);
|
||||
renderer.sync(annotations);
|
||||
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]);
|
||||
// Unwrap around the first vertex, as ringRange does, so a line across
|
||||
// the antimeridian is centred on it, not on the far side of the globe.
|
||||
const ref = ring[0][0];
|
||||
const lons = ring.map(
|
||||
([lon]) => lon - 360 * Math.round((lon - ref) / 360),
|
||||
);
|
||||
const lats = ring.map(([, lat]) => lat);
|
||||
const midLon = (Math.min(...lons) + Math.max(...lons)) / 2;
|
||||
frameAnnotation({
|
||||
...anno,
|
||||
to: null,
|
||||
ring,
|
||||
polygons: null,
|
||||
anchor: {
|
||||
lon: ((((midLon + 180) % 360) + 360) % 360) - 180,
|
||||
lat: (Math.min(...lats) + Math.max(...lats)) / 2,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
} else frameAnnotation(anno);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Begin a graceful fade-out of everything, then remove. */
|
||||
@@ -1101,6 +1228,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;
|
||||
@@ -1110,6 +1247,7 @@ export function createAnnotationEngine({
|
||||
if (tickHandle != null) cancelAnimationFrame(tickHandle);
|
||||
tickHandle = null;
|
||||
outlineListeners.clear();
|
||||
changeListeners.clear();
|
||||
releaseContinuousRender('annotations');
|
||||
renderer.destroy();
|
||||
}
|
||||
@@ -1365,6 +1503,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);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createAnnotationEngine,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { resolveAnnotationTarget } from './annotationResolver.js';
|
||||
import {
|
||||
getRenderGovernorDiagnostics,
|
||||
installRenderGovernor,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
@@ -731,6 +733,246 @@ test('only an explicit navigation request permits resolving distant annotation t
|
||||
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');
|
||||
});
|
||||
|
||||
// The list trusts these calls to change the globe, not just the list: each
|
||||
// is checked by its effect (renderer calls, alpha, camera, notifications).
|
||||
test('hand-drawn marks: remove, hide, clear, expiry and framing have their effect', async (t) => {
|
||||
const frames = [];
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
let now = 1_000_000;
|
||||
t.mock.method(performance, 'now', () => now);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
_resetRenderGovernorForTest();
|
||||
});
|
||||
const runFrames = () => {
|
||||
for (let i = 0; i < 5 && frames.length; i += 1) frames.shift()();
|
||||
};
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere) => flights.push(sphere),
|
||||
},
|
||||
},
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
engine.onChange(() => {
|
||||
changes += 1;
|
||||
});
|
||||
|
||||
await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
label: 'Camp',
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.3,
|
||||
longitude: -97.7,
|
||||
label: 'Base',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
now += 1000;
|
||||
runFrames();
|
||||
const [camp, base] = engine.list();
|
||||
assert.equal(camp.alpha, 1);
|
||||
|
||||
engine.setHidden(camp.id, true);
|
||||
runFrames();
|
||||
assert.equal(camp.alpha, 0, 'a hidden mark is drawn at alpha 0');
|
||||
assert.ok(engine.list().includes(camp), 'and stays on the board');
|
||||
engine.setHidden(camp.id, false);
|
||||
runFrames();
|
||||
assert.equal(camp.alpha, 1, 'showing it again is immediate, with no fade-in');
|
||||
|
||||
const removes = calls.remove;
|
||||
let seen = changes;
|
||||
engine.remove(base.id);
|
||||
assert.equal(calls.remove, removes + 1, 'remove takes it off the globe');
|
||||
assert.equal(changes, seen + 1, 'and tells the list');
|
||||
|
||||
seen = changes;
|
||||
engine.clear();
|
||||
assert.equal(changes, seen + 1, 'a clear tells the list');
|
||||
|
||||
// A voice mark with a TTL: its expiry must reach the list too.
|
||||
await engine.annotate(
|
||||
[{ type: 'pin', latitude: 1, longitude: 1, label: 'Brief' }],
|
||||
{
|
||||
persist: false,
|
||||
ttlMs: 2000,
|
||||
},
|
||||
);
|
||||
seen = changes;
|
||||
now += 60_000;
|
||||
runFrames();
|
||||
assert.equal(engine.list().length, 0, 'the mark expired');
|
||||
assert.equal(changes, seen + 1, 'expiry tells the list');
|
||||
|
||||
// A line across the antimeridian is framed there, not on the far side.
|
||||
await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
manual: true,
|
||||
path: [
|
||||
[179.9, -17],
|
||||
[-179.9, -17.1],
|
||||
],
|
||||
label: '',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
const [line] = engine.list();
|
||||
engine.frame(line.id);
|
||||
const centre = Cesium.Cartographic.fromCartesian(flights.at(-1).center);
|
||||
assert.ok(
|
||||
Math.abs(Math.abs(Cesium.Math.toDegrees(centre.longitude)) - 180) < 0.5,
|
||||
`framed at ${Cesium.Math.toDegrees(centre.longitude)}°, not near 180°`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hand-drawn marks: a voice mark or a hidden mark is not taken as their duplicate', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
label: 'Camp',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
const [drawn] = engine.list();
|
||||
// The agent marks the same spot with the same label, briefly.
|
||||
await engine.annotate(
|
||||
[{ type: 'pin', latitude: 30.27, longitude: -97.74, label: 'Camp' }],
|
||||
{
|
||||
persist: false,
|
||||
},
|
||||
);
|
||||
assert.equal(engine.list().length, 2, 'the voice mark is its own mark');
|
||||
assert.equal(drawn.ttlMs, null, 'the drawn pin keeps persisting');
|
||||
assert.equal(drawn.manual, true);
|
||||
|
||||
engine.setHidden(drawn.id, true);
|
||||
const again = await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
label: 'Camp',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
assert.equal(again.drawn, 1);
|
||||
const visible = engine.list().filter((mark) => mark.manual && !mark.hidden);
|
||||
assert.equal(
|
||||
visible.length,
|
||||
1,
|
||||
'a pin dropped on a hidden one is a new, visible pin',
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const labels = [];
|
||||
@@ -745,3 +987,108 @@ test('a drawn line that rounds up to the next unit is labelled in it, not as 100
|
||||
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('hand-drawn marks: two unnamed pins close together are two pins, whatever their colours', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const pin = (lon, color) => ({ type: 'pin', manual: true, latitude: 30.27, longitude: lon, label: '', color });
|
||||
await engine.annotate([pin(-97.74, 'primary')], { persist: true });
|
||||
await engine.annotate([pin(-97.7402, 'primary')], { persist: true });
|
||||
await engine.annotate([pin(-97.7404, 'red')], { persist: true });
|
||||
assert.deepEqual(
|
||||
engine.list().map(({ color }) => color),
|
||||
['primary', 'primary', 'red'],
|
||||
'no drawn pin is dropped or replaced by the next',
|
||||
);
|
||||
});
|
||||
|
||||
test('hand-drawn marks: hiding a mark before its first frame takes it off at once', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
let syncs = 0;
|
||||
const renderer = { add() {}, update() {}, remove() {}, sync() { syncs += 1; }, destroy() {} };
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
const before = syncs;
|
||||
engine.setHidden(mark.id, true);
|
||||
assert.ok(syncs > before, 'the renderer hears about it now');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'and the render hold goes with the last visible mark',
|
||||
);
|
||||
});
|
||||
|
||||
test('hand-drawn marks: with every mark hidden the scene idles, and showing one wakes it', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
let renders = 0;
|
||||
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([
|
||||
{ type: 'pin', manual: true, latitude: 1, longitude: 1 },
|
||||
{ type: 'pin', manual: true, latitude: 2, longitude: 2 },
|
||||
], { persist: true });
|
||||
const [first, second] = engine.list();
|
||||
const held = () => getRenderGovernorDiagnostics().holds.includes('annotations');
|
||||
assert.ok(held(), 'visible marks keep the scene drawing');
|
||||
engine.setHidden(first.id, true);
|
||||
assert.ok(held(), 'one visible mark is enough');
|
||||
const before = renders;
|
||||
engine.setHidden(second.id, true);
|
||||
assert.equal(held(), false, 'every mark hidden: the scene idles');
|
||||
assert.ok(renders > before, 'after one last frame that takes them off');
|
||||
engine.setHidden(second.id, false);
|
||||
assert.ok(held(), 'showing one wakes it again');
|
||||
});
|
||||
|
||||
test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off and says so', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer, failAddOnce } = throwingRendererHarness();
|
||||
let syncs = 0;
|
||||
renderer.sync = () => (syncs += 1);
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
let changes = 0;
|
||||
engine.onChange(() => (changes += 1));
|
||||
const before = syncs;
|
||||
failAddOnce();
|
||||
assert.throws(() => engine.restyle(mark.id, { color: 'red' }), /WebGL context lost/);
|
||||
assert.equal(engine.list().length, 0, 'no ghost row is left behind');
|
||||
assert.equal(changes, 1, 'the list hears about it');
|
||||
assert.ok(syncs > before, 'and the renderer is told');
|
||||
});
|
||||
|
||||
test('hand-drawn marks: a mark shown again right after it was drawn appears at once, not fading in', async (t) => {
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const queued = [];
|
||||
const raf = globalThis.requestAnimationFrame;
|
||||
const caf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = raf;
|
||||
globalThis.cancelAnimationFrame = caf;
|
||||
});
|
||||
const tick = () => queued.splice(0).forEach((callback) => callback());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
engine.setHidden(mark.id, true);
|
||||
tick();
|
||||
assert.equal(mark.alpha, 0);
|
||||
engine.setHidden(mark.id, false);
|
||||
tick();
|
||||
assert.equal(mark.alpha, 1, 'no fade-in after a hide');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
|
||||
* with its colour, its name, and show/hide, fly-to and remove, the way Import
|
||||
* lists its files. The board is the annotation engine's; 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',
|
||||
]);
|
||||
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
|
||||
export const DRAW_COLOR_CSS = Object.freeze({
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
});
|
||||
export const DRAW_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()`.
|
||||
*/
|
||||
export function presentDrawMarks(marks = []) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
colors: t('Colour of {name}', { name: title }),
|
||||
toggle: visible
|
||||
? t('Hide {name}', { name: title })
|
||||
: t('Show {name}', { name: title }),
|
||||
fly: t('Fly to {name}', { name: title }),
|
||||
remove: t('Remove {name}', { name: title }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows list only hand-drawn marks, in board order, with their words', () => {
|
||||
const rows = presentDrawMarks([
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'route',
|
||||
manual: true,
|
||||
name: null,
|
||||
label: '950 m',
|
||||
color: 'amber',
|
||||
},
|
||||
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
|
||||
{
|
||||
id: 'a3',
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
name: 'Camp',
|
||||
color: 'teal',
|
||||
hidden: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
rows.map(({ id, name, placeholder, color, hidden }) => ({
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
color,
|
||||
hidden,
|
||||
})),
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: '',
|
||||
placeholder: 'Line · 950 m',
|
||||
color: 'amber',
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
name: 'Camp',
|
||||
placeholder: 'Pin',
|
||||
color: 'primary',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
|
||||
assert.equal(rows[1].labels.toggle, 'Show Camp');
|
||||
assert.equal(rows[1].labels.fly, 'Fly to Camp');
|
||||
assert.equal(rows[1].labels.colors, 'Colour of Camp');
|
||||
});
|
||||
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
+69
-31
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Manual whiteboard drawing: the Cesium + DOM half.
|
||||
*
|
||||
* DISPLAY ▸ 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
|
||||
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin) and a colour, click the vertices on the real world,
|
||||
* double-click or press Enter to finish, then name, recolour, hide, fly to or
|
||||
* remove the shape from its row (drawMarks.js). 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.
|
||||
@@ -49,32 +50,30 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
||||
|
||||
import { onLocaleChange, 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;
|
||||
|
||||
/**
|
||||
* Run the Draw control. Returns a handle with a `destroy()` the application
|
||||
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
||||
* The tool hands what the controls show (`{ active, shape, color, label,
|
||||
* hint }`) to `publish`, which the view renders, and answers the controls
|
||||
* The tool hands what the controls show (`{ active, shape, color, hint,
|
||||
* marks }`) to `publish`, which the view renders, and answers the controls
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function, connect?: Function}} deps
|
||||
* 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
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
@@ -88,7 +87,6 @@ export function initDrawTool({
|
||||
let session = null;
|
||||
let shape = 'area';
|
||||
let color = 'primary';
|
||||
let label = '';
|
||||
let hint = '';
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
@@ -117,7 +115,14 @@ export function initDrawTool({
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () => publish({ active, shape, color, label, hint });
|
||||
const present = () =>
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
});
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
@@ -240,15 +245,14 @@ export function initDrawTool({
|
||||
if (reason !== 'too-few') setHint(() => drawHint(session));
|
||||
return null;
|
||||
}
|
||||
const spec = finishSpec(session, { label, 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();
|
||||
label = '';
|
||||
present();
|
||||
const attempt = generation;
|
||||
try {
|
||||
const result = await annotations.annotate([spec], {
|
||||
@@ -287,11 +291,9 @@ export function initDrawTool({
|
||||
};
|
||||
|
||||
// ---- keys: only while drawing, never while typing in another field ----
|
||||
// The label field is this tool's own: typing there still drives drawing.
|
||||
const isLabelField = (target) => target?.id === 'draw-label-input';
|
||||
const typingElsewhere = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || isLabelField(t)) return false;
|
||||
if (!t) return false;
|
||||
return (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
|
||||
);
|
||||
@@ -299,12 +301,12 @@ export function initDrawTool({
|
||||
/**
|
||||
* 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 || isLabelField(t)) return true;
|
||||
if (!t) return true;
|
||||
if (
|
||||
typeof t.closest === 'function' &&
|
||||
t.closest('button, a, select, [role="button"], [role="radio"]')
|
||||
@@ -316,6 +318,9 @@ export function initDrawTool({
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || typingElsewhere(event)) return;
|
||||
// Keys on the shape list's own controls are the list's (Esc closes a
|
||||
// colour picker there), not the drawing's.
|
||||
if (event.target?.closest?.('#draw-mark-list')) return;
|
||||
// Enter goes to finish() once a shape has been started; finish() owns the
|
||||
// decision and says WHY it refused. Swallowing the key on a degenerate
|
||||
// shape left the person pressing Enter at a silent panel. A focused button
|
||||
@@ -334,7 +339,7 @@ export function initDrawTool({
|
||||
else setActive(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' && !isLabelField(event.target)) {
|
||||
if (event.key === 'Backspace') {
|
||||
if (removeLastVertex(session)) {
|
||||
event.preventDefault();
|
||||
syncPreview();
|
||||
@@ -431,6 +436,20 @@ export function initDrawTool({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
|
||||
* and the current orbit, follow or track lets go first.
|
||||
*/
|
||||
function flyToMark(id) {
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('drawing', () =>
|
||||
annotations.frame?.(id),
|
||||
) !== false
|
||||
);
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
@@ -446,16 +465,26 @@ export function initDrawTool({
|
||||
toggleDraw: () => setActive(!active),
|
||||
setDrawShape: chooseShape,
|
||||
setDrawColor: (value) => {
|
||||
color = COLORS.includes(value) ? value : 'primary';
|
||||
color = DRAW_COLORS.includes(value) ? value : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
setDrawLabel: (text) => {
|
||||
label = String(text ?? '');
|
||||
present();
|
||||
},
|
||||
clearDraw: clearAll,
|
||||
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
|
||||
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
|
||||
toggleDrawMark: (id) => {
|
||||
const mark = annotations.list?.().find((entry) => entry.id === id);
|
||||
if (mark) annotations.setHidden?.(id, !mark.hidden);
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
});
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
const offBoard =
|
||||
annotations.onChange?.(() => {
|
||||
if (!destroyed) present();
|
||||
}) || (() => {});
|
||||
setHint(() => drawHint(null));
|
||||
|
||||
const api = {
|
||||
@@ -470,6 +499,14 @@ export function initDrawTool({
|
||||
},
|
||||
setActive,
|
||||
setShape: chooseShape,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
@@ -519,6 +556,7 @@ export function initDrawTool({
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
offLocale();
|
||||
offBoard();
|
||||
disconnect();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
|
||||
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
|
||||
const tools = read('src/app/tools.js');
|
||||
const engineAt = tools.indexOf('const annotations = initAnnotations({');
|
||||
const drawAt = tools.indexOf(
|
||||
'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
);
|
||||
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
||||
assert.ok(
|
||||
@@ -62,42 +62,62 @@ test('the engine resolves manual geometry before any name resolution', () => {
|
||||
});
|
||||
|
||||
test('the Draw control markup carries the ids the tool binds to', () => {
|
||||
// The DISPLAY rail is a Vue view now, rendered into index.html's view host —
|
||||
// the markup is not in index.html any more.
|
||||
const html = read('src/ui/views/panels/DisplayPanel.vue');
|
||||
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
|
||||
// drawn shapes' rows), rendered into the Utilities panel.
|
||||
const html = [
|
||||
read('src/ui/views/panels/DrawControls.vue'),
|
||||
read('src/ui/views/panels/DrawMarkList.vue'),
|
||||
].join('\n');
|
||||
const ids = [
|
||||
'draw-toggle',
|
||||
'draw-mode-row',
|
||||
'draw-colors',
|
||||
'draw-label-row',
|
||||
'draw-label-input',
|
||||
'draw-color-select',
|
||||
'draw-clear',
|
||||
'draw-hint',
|
||||
'draw-mark-list',
|
||||
];
|
||||
for (const id of ids) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"`),
|
||||
`#${id} missing from index.html`,
|
||||
`#${id} missing from the Draw views`,
|
||||
);
|
||||
}
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
for (const [shape, icon] of [
|
||||
['area', 'pentagon'],
|
||||
['line', 'timeline'],
|
||||
['pin', 'location_on'],
|
||||
]) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`'data-shape': '${shape}'`),
|
||||
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
|
||||
`shape button ${shape} missing`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
|
||||
'the Utilities panel renders Draw',
|
||||
);
|
||||
assert.ok(
|
||||
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
|
||||
'Draw is no longer in DISPLAY',
|
||||
);
|
||||
// The rows and the hint render what the tool publishes; the controls
|
||||
// call the actions the tool connects to the display store.
|
||||
for (const action of [
|
||||
"act('toggleDraw')",
|
||||
"act('setDrawShape', $event)",
|
||||
"act('setDrawLabel', $event.target.value)",
|
||||
"act('setDrawColor', $event)",
|
||||
"act('clearDraw')",
|
||||
"act('renameDrawMark', row.id, next)",
|
||||
"act('recolorDrawMark', row.id, color)",
|
||||
"act('toggleDrawMark', row.id)",
|
||||
"act('flyToDrawMark', row.id)",
|
||||
"act('removeDrawMark', row.id)",
|
||||
]) {
|
||||
assert.ok(html.includes(action), `DisplayPanel must call ${action}`);
|
||||
assert.ok(html.includes(action), `the Draw views must call ${action}`);
|
||||
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
|
||||
assert.match(
|
||||
tool(),
|
||||
@@ -119,14 +139,15 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
|
||||
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
|
||||
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',
|
||||
@@ -149,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
|
||||
`${file} still claims a GeoJSON export`,
|
||||
);
|
||||
}
|
||||
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
|
||||
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
|
||||
for (const [file, heading] of [
|
||||
['README.md', 'UTILITIES ▸ **Draw**'],
|
||||
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
|
||||
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
|
||||
]) {
|
||||
const source = read(file);
|
||||
const at = source.indexOf('DISPLAY ▸ **Draw**');
|
||||
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
|
||||
const start = source.indexOf(heading);
|
||||
assert.ok(start >= 0, `${file} should describe the Draw control`);
|
||||
const section = source.slice(start, start + 1200);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -96,11 +96,10 @@ function fakeDom() {
|
||||
byId.set(id, element);
|
||||
return element;
|
||||
};
|
||||
// The control whose presence means the page has Draw, a focused button
|
||||
// whose Enter is its own, and the tool's label field.
|
||||
// The control whose presence means the page has Draw, and a focused button
|
||||
// whose Enter is its own.
|
||||
make('draw-toggle', 'BUTTON');
|
||||
make('draw-clear', 'BUTTON');
|
||||
make('draw-label-input', 'INPUT');
|
||||
|
||||
const body = new FakeElement('BODY');
|
||||
const documentDouble = {
|
||||
@@ -176,9 +175,16 @@ function fakeViewer() {
|
||||
}
|
||||
|
||||
function fakeAnnotations() {
|
||||
const calls = { annotate: [], clear: 0 };
|
||||
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
|
||||
const marks = [];
|
||||
const listeners = new Set();
|
||||
const changed = () => listeners.forEach((listener) => listener());
|
||||
const find = (id) => marks.find((mark) => mark.id === id);
|
||||
return {
|
||||
calls,
|
||||
marks,
|
||||
listeners,
|
||||
changed,
|
||||
async annotate(specs, options) {
|
||||
calls.annotate.push({ specs, options });
|
||||
return { drawn: specs.length };
|
||||
@@ -186,11 +192,38 @@ function fakeAnnotations() {
|
||||
clear() {
|
||||
calls.clear += 1;
|
||||
},
|
||||
list: () => marks,
|
||||
onChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
restyle(id, change) {
|
||||
calls.restyle.push([id, change]);
|
||||
Object.assign(find(id), change);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
setHidden(id, hidden) {
|
||||
calls.setHidden.push([id, hidden]);
|
||||
find(id).hidden = hidden;
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
frame(id) {
|
||||
calls.frame.push(id);
|
||||
return true;
|
||||
},
|
||||
remove(id) {
|
||||
calls.remove.push(id);
|
||||
marks.splice(marks.indexOf(find(id)), 1);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness() {
|
||||
function harness({ navigation = null } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -205,6 +238,7 @@ function harness() {
|
||||
const tool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
@@ -426,6 +460,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.ok(h.tool.diagnostics().domListeners > 0);
|
||||
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
|
||||
|
||||
await h.tool.destroy();
|
||||
await Promise.resolve();
|
||||
@@ -443,6 +478,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
'the viewer got its selecting click back',
|
||||
);
|
||||
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
|
||||
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
|
||||
|
||||
// Idempotent: a second destroy is harmless.
|
||||
await h.tool.destroy();
|
||||
@@ -467,16 +503,15 @@ test('Clear wipes the board and the shape in progress', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the controls choose the shape, colour and label the next shape is drawn with', async () => {
|
||||
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.act('setDrawColor', 'amber');
|
||||
h.act('setDrawLabel', 'Route A');
|
||||
assert.deepEqual(
|
||||
{ ...h.shown.view, hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', label: 'Route A', hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
|
||||
'the view shows what the controls chose',
|
||||
);
|
||||
h.act('setDrawColor', 'purple');
|
||||
@@ -494,36 +529,83 @@ test('the controls choose the shape, colour and label the next shape is drawn wi
|
||||
const spec = h.annotations.calls.annotate[0].specs[0];
|
||||
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
|
||||
assert.equal(spec.color, 'cyan');
|
||||
assert.equal(spec.label, 'Route A');
|
||||
assert.equal(h.shown.view.label, '', 'a finished shape empties the label');
|
||||
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys typed in the label field: Enter finishes, Backspace edits, Escape cancels', async () => {
|
||||
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
|
||||
const navigations = [];
|
||||
const h = harness({
|
||||
navigation: {
|
||||
runImmediateNavigation: (noun, navigate) => {
|
||||
navigations.push(noun);
|
||||
return navigate();
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
|
||||
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
|
||||
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
|
||||
'only hand-drawn marks are listed, an unnamed line by its length',
|
||||
);
|
||||
h.act('renameDrawMark', 'a1', 'Ridge walk');
|
||||
h.act('recolorDrawMark', 'a1', 'green');
|
||||
h.act('toggleDrawMark', 'a1');
|
||||
h.act('flyToDrawMark', 'a1');
|
||||
h.act('removeDrawMark', 'a1');
|
||||
assert.deepEqual(board.calls.restyle, [
|
||||
['a1', { name: 'Ridge walk' }],
|
||||
['a1', { color: 'green' }],
|
||||
]);
|
||||
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
|
||||
assert.deepEqual(board.calls.frame, ['a1']);
|
||||
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
|
||||
assert.deepEqual(board.calls.remove, ['a1']);
|
||||
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const label = h.element('draw-label-input');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const listButton = new FakeElement('BUTTON');
|
||||
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
|
||||
const elsewhere = new FakeElement('INPUT', 'location-search');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: label });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Backspace edits the label');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
|
||||
assert.equal(h.tool.active, true, 'and keeps Draw on');
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: label });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape drops the open shape');
|
||||
assert.equal(h.tool.active, true, 'and leaves Draw on');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: label });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter in the label finishes');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
|
||||
@@ -483,7 +483,9 @@ export function createScreenAnnotationRenderer(
|
||||
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
if (rec.hidden) {
|
||||
// A mark hidden from Draw's list is not projected, so it neither shows
|
||||
// nor pushes a visible callout aside in the placement below.
|
||||
if (rec.hidden || anno.hidden) {
|
||||
group.display = 'none';
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -390,3 +390,49 @@ test("an earlier renderer leaves a later renderer's board alone", async (t) => {
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
|
||||
});
|
||||
|
||||
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
|
||||
// happy-dom measures every SVG box the same, so callout spreading cannot
|
||||
// settle here; a hidden mark simply has to stay out of drawing and
|
||||
// placement, which is what keeps it from pushing a visible callout aside.
|
||||
const { store, svg } = board(t);
|
||||
projectTo(t, { x: 200, y: 300 });
|
||||
const camera = cameraOver(0, 0);
|
||||
let project = null;
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{
|
||||
scene: sceneFor(camera, {
|
||||
postRender: {
|
||||
addEventListener: (listener) => (project = listener),
|
||||
removeEventListener() {},
|
||||
},
|
||||
}),
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
},
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const camp = {
|
||||
id: 'camp',
|
||||
type: 'pin',
|
||||
color: 'amber',
|
||||
label: 'Camp',
|
||||
alpha: 1,
|
||||
hidden: true,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
renderer.add(camp);
|
||||
project();
|
||||
await settle();
|
||||
const drawn = () =>
|
||||
[...svg().querySelectorAll('g.gev-anno')].some(
|
||||
(group) =>
|
||||
group.textContent.includes('Camp') && group.style.display !== 'none',
|
||||
);
|
||||
assert.equal(drawn(), false, 'the hidden mark is not drawn');
|
||||
camp.hidden = false;
|
||||
project();
|
||||
await settle();
|
||||
assert.equal(drawn(), true, 'shown again, it is drawn');
|
||||
});
|
||||
@@ -51,6 +51,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
// Every mark with entities on the globe, so sync() can take a hidden one out.
|
||||
const drawn = new Set();
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -89,6 +91,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// path cannot reach them and they stay on the globe forever.
|
||||
const entities = [];
|
||||
anno._entities = entities;
|
||||
drawn.add(anno);
|
||||
|
||||
if (
|
||||
anno.ring &&
|
||||
@@ -247,6 +250,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
() => anno.alpha ?? 1,
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
@@ -320,6 +324,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
function remove(anno) {
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
drawn.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -331,13 +336,23 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
anno._entities = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
|
||||
* out whole, so no material that keeps its own colour (the route's flow),
|
||||
* ground classification or pick can still show it.
|
||||
*/
|
||||
function sync() {
|
||||
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
|
||||
for (const anno of drawn) {
|
||||
const show = !anno.hidden;
|
||||
for (const entity of anno._entities || [])
|
||||
if (entity.show !== show) entity.show = show;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
drawn.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -407,8 +422,11 @@ export function ensureFlowFabricRegistered() {
|
||||
* returned a standalone Material Cesium never rendered, or treated `result` as a
|
||||
* Material — both left the real uniforms untouched, so nothing animated.)
|
||||
*/
|
||||
export function FlowMaterialProperty(colorCss) {
|
||||
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
|
||||
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
|
||||
// The mark's live alpha (fade in and out, hidden), applied every frame.
|
||||
this._alphaOf = alphaOf;
|
||||
this._live = this._color.clone();
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||
@@ -431,7 +449,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
|
||||
// `time` is read straight from the wall clock so the dashes flow every rendered
|
||||
// frame (the scene renders continuously; no requestRenderMode here).
|
||||
if (!Cesium.defined(result)) result = {};
|
||||
result.color = this._color;
|
||||
this._live.alpha = this._color.alpha * this._alphaOf();
|
||||
result.color = this._live;
|
||||
result.time = performance.now() / 1000; // the per-frame animated value
|
||||
result.repeat = 64.0;
|
||||
result.duty = 0.46;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// A mark hidden from Draw's list leaves the globe whole: every entity it put
|
||||
// there is taken out on sync and comes back when shown, and the line's
|
||||
// flowing stroke, which keeps its own colour, follows the mark's alpha.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import {
|
||||
FlowMaterialProperty,
|
||||
createWorldAnnotationRenderer,
|
||||
} from './worldAnnotationRenderer.js';
|
||||
|
||||
const noop = () => {};
|
||||
function viewer(t) {
|
||||
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
|
||||
const original = globalThis[name];
|
||||
globalThis[name] = class {};
|
||||
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
|
||||
}
|
||||
const before = globalThis.document;
|
||||
globalThis.document = { addEventListener: noop, removeEventListener: noop };
|
||||
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
|
||||
return {
|
||||
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
|
||||
entities: new C.EntityCollection(),
|
||||
dataSources: new C.DataSourceCollection(),
|
||||
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
|
||||
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
|
||||
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
|
||||
};
|
||||
}
|
||||
|
||||
const marks = () => [
|
||||
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
|
||||
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
|
||||
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
|
||||
];
|
||||
|
||||
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
|
||||
const renderer = createWorldAnnotationRenderer(viewer(t));
|
||||
const [route, area, pin] = marks();
|
||||
for (const anno of [route, area, pin]) renderer.add(anno);
|
||||
const shown = (anno) => anno._entities.map((entity) => entity.show);
|
||||
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
|
||||
|
||||
route.hidden = true;
|
||||
area.hidden = true;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === false), 'the line is off');
|
||||
assert.ok(shown(area).every((show) => show === false), 'the area is off');
|
||||
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
|
||||
|
||||
route.hidden = false;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === true), 'the line is back');
|
||||
renderer.remove(area);
|
||||
renderer.sync();
|
||||
assert.equal(area._entities, null, 'a removed mark is no longer synced');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
|
||||
let alpha = 1;
|
||||
const material = new FlowMaterialProperty('#ffb547', () => alpha);
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
|
||||
alpha = 0;
|
||||
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
|
||||
alpha = 0.5;
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
|
||||
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
|
||||
});
|
||||
+5
-3
@@ -62,17 +62,19 @@ export function createApplicationTools({
|
||||
const token = displayStore.connect(actions);
|
||||
return () => displayStore.detach(token);
|
||||
};
|
||||
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button.
|
||||
// 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.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// own data source. They live for the page session, so the application
|
||||
// lifetime removes them and the drop listeners on the viewer container.
|
||||
const geoFileImport = initGeoFileImport({
|
||||
|
||||
+6
-1
@@ -1746,8 +1746,13 @@ export function flyRoute(
|
||||
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
// A route hidden from Draw's list is not on screen, so it is not flown.
|
||||
const routes = (annoList || []).filter(
|
||||
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
|
||||
(a) =>
|
||||
a.type === 'route' &&
|
||||
!a.hidden &&
|
||||
Array.isArray(a.path) &&
|
||||
a.path.length >= 2,
|
||||
);
|
||||
if (!routes.length) {
|
||||
return {
|
||||
|
||||
@@ -633,17 +633,22 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);\s*this\.actions\.setPanelCollapsed\('utilities-panel', true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
'closing Data Layers must restore Contact only after an automatic collapse',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/const COCKPIT_LEFT_PANEL_IDS = new Set\(\['data-panel', 'utilities-panel'\]\);/,
|
||||
'Utilities folds Contact away in Cockpit as Data Layers does',
|
||||
);
|
||||
assert.match(
|
||||
setContextCollapsed.toString(),
|
||||
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
|
||||
@@ -678,6 +683,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
for (const panelId of [
|
||||
'data-panel',
|
||||
'cctv-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
@@ -1379,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => {
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/,
|
||||
'opening the second left panel keeps the fold the first recorded',
|
||||
);
|
||||
const { PanelChrome } = await import('./ui/panelChrome.js');
|
||||
const previous = globalThis.document;
|
||||
const panels = {};
|
||||
globalThis.document = { getElementById: (id) => panels[id] ?? null };
|
||||
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
|
||||
try {
|
||||
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
|
||||
panels['data-panel'] = panel('collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
|
||||
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
|
||||
panels['data-panel'] = panel();
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
|
||||
} finally {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
});
|
||||
@@ -3138,14 +3138,10 @@
|
||||
"Sector": "القطاع",
|
||||
"Sonar sector width": "عرض قطاع السونار",
|
||||
"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": "لون الشكل المرسوم",
|
||||
"Primary": "أساسي",
|
||||
"Amber": "كهرماني",
|
||||
"Cyan": "سماوي",
|
||||
|
||||
@@ -2864,14 +2864,10 @@
|
||||
"Sector": "Sektor",
|
||||
"Sonar sector width": "Breite des Sonarsektors",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Auf die Welt zeichnen – Eckpunkte anklicken, Doppelklick oder Enter zum Beenden, Esc zum Abbrechen",
|
||||
"Shape": "Form",
|
||||
"Shape to draw": "Zu zeichnende Form",
|
||||
"Area": "Fläche",
|
||||
"Line": "Linie",
|
||||
"Pin": "Pin",
|
||||
"Label (optional)": "Beschriftung (optional)",
|
||||
"Label for the drawn shape": "Beschriftung der gezeichneten Form",
|
||||
"Colour of the drawn shape": "Farbe der gezeichneten Form",
|
||||
"Primary": "Primär",
|
||||
"Amber": "Bernstein",
|
||||
"Cyan": "Cyan",
|
||||
|
||||
@@ -885,14 +885,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",
|
||||
@@ -1456,6 +1457,7 @@
|
||||
"NOIR": "NOIR",
|
||||
"Rendered globe frames per second · toggle with `": "Fotogramas por segundo renderizados del globo · alterna con `",
|
||||
"Utilities": "Utilidades",
|
||||
"Audio": "Audio",
|
||||
"Other layers": "Otras capas",
|
||||
"Movement": "Movimiento",
|
||||
"Infrastructure": "Infraestructura",
|
||||
|
||||
@@ -2883,14 +2883,10 @@
|
||||
"Scope edge feather": "Adoucissement du bord de la lunette",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "Adoucissement du bord de la lunette, en pourcentage du rayon de l'ouverture",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dessinez sur le monde — cliquez sur les sommets, double-clic ou Enter pour terminer, Esc pour annuler",
|
||||
"Shape": "Forme",
|
||||
"Shape to draw": "Forme à dessiner",
|
||||
"Area": "Zone",
|
||||
"Line": "Ligne",
|
||||
"Pin": "Épingle",
|
||||
"Label (optional)": "Étiquette (facultative)",
|
||||
"Label for the drawn shape": "Étiquette de la forme dessinée",
|
||||
"Colour of the drawn shape": "Couleur de la forme dessinée",
|
||||
"Primary": "Principale",
|
||||
"Amber": "Ambre",
|
||||
"Cyan": "Cyan",
|
||||
|
||||
@@ -2883,14 +2883,10 @@
|
||||
"Scope edge feather": "Sfumatura del bordo dello scope",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "Sfumatura del bordo dello scope, come percentuale del raggio del foro",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Disegna sul mondo: fai clic sui vertici, doppio clic o Enter per finire, Esc per annullare",
|
||||
"Shape": "Forma",
|
||||
"Shape to draw": "Forma da disegnare",
|
||||
"Area": "Area",
|
||||
"Line": "Linea",
|
||||
"Pin": "Segnaposto",
|
||||
"Label (optional)": "Etichetta (opzionale)",
|
||||
"Label for the drawn shape": "Etichetta della forma disegnata",
|
||||
"Colour of the drawn shape": "Colore della forma disegnata",
|
||||
"Primary": "Principale",
|
||||
"Amber": "Ambra",
|
||||
"Cyan": "Ciano",
|
||||
|
||||
@@ -2741,14 +2741,10 @@
|
||||
"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": "描画した図形の色",
|
||||
"Primary": "プライマリー",
|
||||
"Amber": "アンバー",
|
||||
"Cyan": "シアン",
|
||||
|
||||
@@ -2885,14 +2885,10 @@
|
||||
"Scope edge feather": "Suavização da borda da mira",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "Suavização da borda da mira, em porcentagem do raio do visor",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Desenhar sobre o mundo — clique nos vértices, clique duplo ou Enter para concluir, Esc para cancelar",
|
||||
"Shape": "Forma",
|
||||
"Shape to draw": "Forma a desenhar",
|
||||
"Area": "Área",
|
||||
"Line": "Linha",
|
||||
"Pin": "Marcador",
|
||||
"Label (optional)": "Rótulo (opcional)",
|
||||
"Label for the drawn shape": "Rótulo da forma desenhada",
|
||||
"Colour of the drawn shape": "Cor da forma desenhada",
|
||||
"Primary": "Principal",
|
||||
"Amber": "Âmbar",
|
||||
"Cyan": "Ciano",
|
||||
|
||||
@@ -894,14 +894,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": "Голубой",
|
||||
@@ -1479,6 +1480,7 @@
|
||||
"NOIR": "НУАР",
|
||||
"Rendered globe frames per second · toggle with `": "Кадров глобуса в секунду · переключение клавишей `",
|
||||
"Utilities": "Инструменты",
|
||||
"Audio": "Аудио",
|
||||
"Other layers": "Другие слои",
|
||||
"Movement": "Движение",
|
||||
"Infrastructure": "Инфраструктура",
|
||||
|
||||
@@ -2953,14 +2953,10 @@
|
||||
"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": "Колір намальованої фігури",
|
||||
"Primary": "Основний",
|
||||
"Amber": "Бурштиновий",
|
||||
"Cyan": "Блакитний",
|
||||
|
||||
@@ -1757,6 +1757,7 @@
|
||||
"Events": "事件",
|
||||
"Weather": "天气",
|
||||
"Utilities": "工具",
|
||||
"Audio": "音频",
|
||||
"Other layers": "其他图层",
|
||||
"{state} · {source}": "{state} · {source}",
|
||||
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对",
|
||||
@@ -2639,14 +2640,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": "青色",
|
||||
|
||||
@@ -1747,6 +1747,7 @@
|
||||
"Events": "事件",
|
||||
"Weather": "天氣",
|
||||
"Utilities": "工具",
|
||||
"Audio": "音訊",
|
||||
"Other layers": "其他圖層",
|
||||
"{state} · {source}": "{state} · {source}",
|
||||
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對",
|
||||
@@ -2627,14 +2628,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": "青色",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
@@ -210,6 +211,7 @@
|
||||
"AUTO",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
@@ -339,6 +341,7 @@
|
||||
"Altitude {altitude}",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"Aviation / Marine",
|
||||
"BRG {value}°",
|
||||
@@ -523,6 +526,7 @@
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Area",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
|
||||
@@ -25,6 +25,7 @@ const COCKPIT_REFUSALS = Object.freeze({
|
||||
fire: msg('Exit cockpit to fly to a fire'),
|
||||
route: msg('Exit cockpit to fly to a route'),
|
||||
file: msg('Exit cockpit to fly to a file'),
|
||||
drawing: msg('Exit cockpit to fly to a drawing'),
|
||||
scene: msg('Exit cockpit to fly to a scene'),
|
||||
// Radar, satellite and lightning fly to the layer's coverage area (their
|
||||
// «View coverage» button); the cyclone layer flies to the selected storm.
|
||||
|
||||
@@ -117,6 +117,7 @@ test('every noun the callers pass has its own Cockpit refusal', () => {
|
||||
'photo',
|
||||
'shared view',
|
||||
'file',
|
||||
'drawing',
|
||||
'scene',
|
||||
'route',
|
||||
'weather',
|
||||
|
||||
@@ -297,3 +297,25 @@ test('expanded right panels highlight the title divider without changing collaps
|
||||
/#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Utilities sits between Data Layers and Scenes and follows the left panels through every mode', () => {
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
const order = (id) => Number(new RegExp(`#left-panel-stack > #${id} \\{\\s*order: (\\d+);`).exec(css)?.[1]);
|
||||
assert.ok(order('data-panel') < order('utilities-panel'));
|
||||
assert.ok(order('utilities-panel') < order('scene-panel'));
|
||||
assert.match(css, /body\.ui-clean-view #utilities-panel,/);
|
||||
assert.match(css, /body\.recording-mode #utilities-panel,/);
|
||||
// Directions stays reachable in Cockpit; only Draw and Import step aside.
|
||||
assert.match(css, /body\.cockpit-mode #utilities-panel \.utilities-tools \{\s*display: none !important;/);
|
||||
assert.doesNotMatch(css, /body\.cockpit-mode #left-panel-stack > #utilities-panel[,\s{]/);
|
||||
assert.match(css, /body\.mobile-shell\[data-mobile-sheet='layers'\]\s+#left-panel-stack\s+> #utilities-panel,/);
|
||||
});
|
||||
|
||||
test('every left panel has its rail item, Utilities with its own icon', async () => {
|
||||
const { PANEL_RAIL_ITEMS } = await import('./ui/panelRailCatalog.js');
|
||||
const view = readFileSync(new URL('./ui/views/LayerPanelsView.vue', import.meta.url), 'utf8');
|
||||
const ids = [...view.matchAll(/<RailPanel id="([\w-]+)"/g)].map(([, id]) => id);
|
||||
assert.ok(ids.includes('utilities-panel'));
|
||||
for (const id of ids) assert.ok(PANEL_RAIL_ITEMS[id], `${id} has a rail item`);
|
||||
assert.equal(PANEL_RAIL_ITEMS['utilities-panel'].icon, 'handyman');
|
||||
});
|
||||
@@ -152,7 +152,7 @@ test('the edited existing tools changed exactly as intended', () => {
|
||||
const panel = byName.get('set_panel_open');
|
||||
assert.deepEqual(
|
||||
panel.parameters.properties.panelId.enum,
|
||||
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
|
||||
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'utilities-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
|
||||
);
|
||||
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
||||
|
||||
|
||||
@@ -1078,3 +1078,20 @@ test('a new viewer lifetime drops the previous view-target getter', () => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
second.restore();
|
||||
});
|
||||
|
||||
test('a route hidden from Draw is not flown; the newest visible one is', () => {
|
||||
const viewer = createTickableViewer();
|
||||
initCameraVerbs(viewer.viewer, () => null);
|
||||
const route = (label, hidden) => ({
|
||||
type: 'route',
|
||||
label,
|
||||
hidden,
|
||||
path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
});
|
||||
const onlyHidden = flyRoute([route('hidden walk', true)]);
|
||||
assert.equal(onlyHidden.ok, false, 'a board of hidden routes has none to fly');
|
||||
const started = flyRoute([route('shown walk', false), route('hidden walk', true)]);
|
||||
assert.equal(started.ok, true);
|
||||
assert.match(JSON.stringify(started), /shown walk/, 'the visible route is flown');
|
||||
interruptCameraMotion?.('test');
|
||||
});
|
||||
@@ -84,8 +84,9 @@ export function syncContextLayout() {
|
||||
return;
|
||||
}
|
||||
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
|
||||
// An open left panel Cockpit keeps (Data Layers or Utilities).
|
||||
const dataPanel = document.querySelector(
|
||||
'#left-panel-stack > #data-panel:not(.collapsed)',
|
||||
'#left-panel-stack > :is(#data-panel, #utilities-panel):not(.collapsed)',
|
||||
);
|
||||
let desiredTop = defaultTop;
|
||||
if (this.contextCollapsed && dataPanel) {
|
||||
|
||||
@@ -539,11 +539,11 @@ test('Cyber side panels share one width and one framed surface material', () =>
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
/:is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.utilities-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
@@ -854,7 +854,7 @@ test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
/body\.cockpit-mode\s+#left-panel-stack\s+> :is\(#data-panel, #utilities-panel\) \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import DisplayPanel from './views/panels/DisplayPanel.vue';
|
||||
import DrawControls from './views/panels/DrawControls.vue';
|
||||
import GeoImport from './views/panels/GeoImport.vue';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { initGeoFileImport } from './geoFileImport.js';
|
||||
@@ -15,11 +17,14 @@ import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
|
||||
const coreClasses = (el) =>
|
||||
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
|
||||
|
||||
function fixture(t) {
|
||||
// UTILITIES' Draw and Import, as the Utilities panel holds them.
|
||||
const UtilitiesTools = { render: () => [h(DrawControls), h(GeoImport)] };
|
||||
|
||||
function fixture(t, view = DisplayPanel) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(DisplayPanel).use(pinia);
|
||||
const app = createApp(view).use(pinia);
|
||||
app.mount(host);
|
||||
let mounted = true;
|
||||
const unmount = () => {
|
||||
@@ -354,17 +359,32 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
||||
assert.equal(all.getAttribute('aria-checked'), 'false');
|
||||
});
|
||||
|
||||
test('DISPLAY no longer holds Draw or Import', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
for (const id of ['draw-toggle', 'draw-mode-row', 'geo-import-button'])
|
||||
assert.equal($(id), null, `${id} moved to UTILITIES`);
|
||||
});
|
||||
|
||||
test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { store, $ } = fixture(t, UtilitiesTools);
|
||||
const toggle = $('draw-toggle');
|
||||
const segment = (shape) =>
|
||||
document.querySelector(`#draw-mode-row [data-shape="${shape}"]`);
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(segment('area').getAttribute('aria-checked'), 'true');
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
assert.equal(segment('area').getAttribute('aria-label'), 'Area');
|
||||
assert.equal(segment('area').title, 'Area');
|
||||
assert.equal(segment('area').textContent, 'pentagon');
|
||||
const swatch = (color) => document.querySelector(`#draw-colors [data-color="${color}"]`);
|
||||
assert.equal(swatch('primary').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber');
|
||||
assert.equal($('draw-hint').textContent, '');
|
||||
|
||||
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
|
||||
store.publishDraw({ active: true, shape: 'line', color: 'amber', hint: 'Click to add points.' });
|
||||
await nextTick();
|
||||
assert.equal(swatch('amber').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(swatch('primary').classList.contains('active'), false);
|
||||
assert.equal(coreClasses(toggle), 'pp-toggle-btn active');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal($('draw-mode-row').classList.contains('visible'), true);
|
||||
@@ -382,7 +402,7 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
});
|
||||
|
||||
test('the imported files render what the file import publishes and keep focus', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { store, $ } = fixture(t, UtilitiesTools);
|
||||
const list = $('geo-import-list');
|
||||
assert.equal(list.className, 'geo-import-list');
|
||||
assert.equal(list.children.length, 0);
|
||||
|
||||
@@ -10,13 +10,15 @@ import { useDisplayStore } from './stores/display.js';
|
||||
import { DisplayBindings } from './displayBindings.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import DisplayControlsView from './views/DisplayControlsView.vue';
|
||||
import DrawControls from './views/panels/DrawControls.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const app = createApp({
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView)],
|
||||
// Draw sits in UTILITIES now; it answers through the same store.
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView), h(DrawControls)],
|
||||
}).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
@@ -102,13 +104,11 @@ test('the Draw controls call the draw tool and show what it publishes', async (t
|
||||
const { calls } = recorder(store);
|
||||
$('draw-toggle').click();
|
||||
document.querySelector('[data-shape="pin"]').click();
|
||||
input($('draw-label-input'), 'Camp');
|
||||
input($('draw-color-select'), 'red', 'change');
|
||||
document.querySelector('#draw-colors [data-color="red"]').click();
|
||||
$('draw-clear').click();
|
||||
assert.deepEqual(calls, [
|
||||
['toggleDraw'],
|
||||
['setDrawShape', 'pin'],
|
||||
['setDrawLabel', 'Camp'],
|
||||
['setDrawColor', 'red'],
|
||||
['clearDraw'],
|
||||
]);
|
||||
@@ -116,15 +116,16 @@ test('the Draw controls call the draw tool and show what it publishes', async (t
|
||||
active: true,
|
||||
shape: 'line',
|
||||
color: 'green',
|
||||
label: '',
|
||||
hint: 'Click to add points.',
|
||||
marks: [],
|
||||
});
|
||||
await nextTick();
|
||||
const line = document.querySelector('[data-shape="line"]');
|
||||
assert.equal(line.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(line.classList.contains('active'));
|
||||
assert.equal($('draw-color-select').value, 'green');
|
||||
assert.equal($('draw-label-input').value, '', 'a finished shape empties it');
|
||||
const green = document.querySelector('#draw-colors [data-color="green"]');
|
||||
assert.equal(green.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(green.classList.contains('active'));
|
||||
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||
assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
@@ -43,8 +43,8 @@ export function initGeoFileImport({
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
// A page without the Import control (the Display controls view omitted)
|
||||
// has no file import.
|
||||
// A page without the Import control (the Utilities panel omitted, in
|
||||
// LayerPanelsView) has no file import.
|
||||
if (!viewer || !doc?.getElementById('geo-import-button')) return null;
|
||||
|
||||
const imports = [];
|
||||
|
||||
@@ -7,7 +7,8 @@ import { computed } from 'vue';
|
||||
// stop that arrows move) or `radio` (arrows plus Home and
|
||||
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
|
||||
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
|
||||
// selected option's `activeClass`. Not mirrored yet: an option's icon.
|
||||
// selected option's `activeClass`. An option with an `icon` shows the glyph,
|
||||
// and its label becomes the button's accessible name and tooltip.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
|
||||
@@ -117,10 +118,17 @@ function onKey(event) {
|
||||
},
|
||||
]"
|
||||
:aria-checked="String(option.value === props.modelValue)"
|
||||
:aria-label="option.icon ? option.label : undefined"
|
||||
:title="option.icon ? option.label : undefined"
|
||||
:disabled="optionOff(option)"
|
||||
:tabindex="tabindex(index)"
|
||||
@click="choose(index)"
|
||||
>{{ option.label }}</button
|
||||
><span
|
||||
v-if="option.icon"
|
||||
class="ui-segmented__icon material-symbols-outlined"
|
||||
aria-hidden="true"
|
||||
>{{ option.icon }}</span
|
||||
><template v-else>{{ option.label }}</template></button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
+61
-21
@@ -90,13 +90,20 @@ const PANEL_GROUPS = [
|
||||
'weather-cyclones',
|
||||
],
|
||||
},
|
||||
// Directions renders in the Utilities panel, beside Draw and Import, and
|
||||
// needs no heading there.
|
||||
{
|
||||
label: msg('Utilities'),
|
||||
ids: ['directions', 'radio'],
|
||||
ids: ['directions'],
|
||||
containerId: 'utilities-toggles',
|
||||
},
|
||||
{
|
||||
label: msg('Audio'),
|
||||
ids: ['radio'],
|
||||
},
|
||||
];
|
||||
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) =>
|
||||
ids.map((id) => ({ id, label })),
|
||||
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids, containerId }) =>
|
||||
ids.map((id) => ({ id, label, containerId })),
|
||||
);
|
||||
const PANEL_POSITIONS = new Map(
|
||||
PANEL_ORDER.map(({ id }, index) => [id, index]),
|
||||
@@ -266,6 +273,8 @@ export class LayerPanel {
|
||||
this._generation = 0;
|
||||
this._removers = [];
|
||||
this._destroyed = false;
|
||||
/** @type {Map<string, HTMLElement>} lists of their own, by element id */
|
||||
this._groupContainers = new Map();
|
||||
this._cancelRowControlsRefresh = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._recentImageryPanel = null;
|
||||
@@ -278,9 +287,21 @@ export class LayerPanel {
|
||||
this._releaseBindings();
|
||||
if (this._toggleContainer && this._toggleContainer !== container)
|
||||
renderVue(null, this._toggleContainer);
|
||||
for (const element of this._groupContainers.values())
|
||||
renderVue(null, element);
|
||||
// The rows replace whatever the container held before.
|
||||
if (container && !container._vnode) container.replaceChildren();
|
||||
this._toggleContainer = container;
|
||||
// A group with a list of its own (Directions in Utilities) renders there;
|
||||
// without that list it stays in Data Layers under its heading.
|
||||
this._groupContainers = new Map();
|
||||
for (const { containerId } of PANEL_GROUPS) {
|
||||
const element =
|
||||
containerId && container?.ownerDocument?.getElementById?.(containerId);
|
||||
if (!element) continue;
|
||||
if (!element._vnode) element.replaceChildren();
|
||||
this._groupContainers.set(containerId, element);
|
||||
}
|
||||
this._weatherPanel?.destroy();
|
||||
this._weatherPanel = createWeatherPanel({
|
||||
clock: this.weatherClock,
|
||||
@@ -327,6 +348,9 @@ export class LayerPanel {
|
||||
this._recentImageryPanel = null;
|
||||
this._recentImageryFactory = null;
|
||||
if (this._toggleContainer) renderVue(null, this._toggleContainer);
|
||||
for (const element of this._groupContainers.values())
|
||||
renderVue(null, element);
|
||||
this._groupContainers = new Map();
|
||||
this._toggleContainer = null;
|
||||
}
|
||||
_renderToggles() {
|
||||
@@ -350,9 +374,15 @@ export class LayerPanel {
|
||||
const group =
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
|
||||
msg('Other layers');
|
||||
if (group && group !== previousGroup)
|
||||
const containerId =
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.containerId;
|
||||
const container = this._groupContainers.has(containerId)
|
||||
? containerId
|
||||
: null;
|
||||
// A group in a list of its own needs no heading there.
|
||||
if (!container && group && group !== previousGroup)
|
||||
this._rows.push({ key: `group:${group}`, heading: group });
|
||||
previousGroup = group;
|
||||
previousGroup = container ? '' : group;
|
||||
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
|
||||
const controls = this.hasRowControls(layer.id);
|
||||
if (controls) {
|
||||
@@ -367,6 +397,7 @@ export class LayerPanel {
|
||||
this._rows.push({
|
||||
key: layer.id,
|
||||
id: layer.id,
|
||||
container,
|
||||
count: !readout,
|
||||
controls: controls && !readout,
|
||||
info: false,
|
||||
@@ -387,23 +418,32 @@ export class LayerPanel {
|
||||
_paint() {
|
||||
if (this._destroyed || !this._toggleContainer) return;
|
||||
const layers = new Map(this.getAll().map((layer) => [layer.id, layer]));
|
||||
const items = this._rows.map((row) => {
|
||||
if (row.heading) return { key: row.key, heading: t(row.heading) };
|
||||
const layer = layers.get(row.id);
|
||||
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
|
||||
return this._rowViews.get(row.id);
|
||||
});
|
||||
// Each list renders its own rows: Data Layers, and any group's own list.
|
||||
const lists = new Map([[null, []]]);
|
||||
for (const id of this._groupContainers.keys()) lists.set(id, []);
|
||||
for (const row of this._rows) {
|
||||
let item;
|
||||
if (row.heading) item = { key: row.key, heading: t(row.heading) };
|
||||
else {
|
||||
const layer = layers.get(row.id);
|
||||
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
|
||||
item = this._rowViews.get(row.id);
|
||||
}
|
||||
if (item) lists.get(row.container ?? null).push(item);
|
||||
}
|
||||
const generation = this._generation;
|
||||
renderVue(
|
||||
h(LayerRows, {
|
||||
items: items.filter(Boolean),
|
||||
onToggle: (id) => this._toggle(id, generation),
|
||||
onChip: (id, chipId) => this._selectChip(id, chipId, generation),
|
||||
onListItem: (id, itemId) =>
|
||||
this._selectListItem(id, itemId, generation),
|
||||
}),
|
||||
this._toggleContainer,
|
||||
);
|
||||
for (const [id, items] of lists)
|
||||
renderVue(
|
||||
h(LayerRows, {
|
||||
items,
|
||||
onToggle: (layerId) => this._toggle(layerId, generation),
|
||||
onChip: (layerId, chipId) =>
|
||||
this._selectChip(layerId, chipId, generation),
|
||||
onListItem: (layerId, itemId) =>
|
||||
this._selectListItem(layerId, itemId, generation),
|
||||
}),
|
||||
id === null ? this._toggleContainer : this._groupContainers.get(id),
|
||||
);
|
||||
}
|
||||
|
||||
_presentRow(row, layer) {
|
||||
|
||||
@@ -340,3 +340,94 @@ test('the Weather readout receives enabled air-quality alongside weather layers'
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// Directions renders in the Utilities panel's list; Data Layers keeps the rest.
|
||||
async function utilitiesFixture({ withUtilities = true } = {}) {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
container.id = 'data-toggles';
|
||||
const utilities = document.createElement('div');
|
||||
utilities.id = 'utilities-toggles';
|
||||
if (withUtilities) document.body.append(utilities);
|
||||
const layers = ['radio', 'directions', 'flights'].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
icon: '*',
|
||||
source: 'S',
|
||||
enabled: false,
|
||||
showInTogglePanel: true,
|
||||
stats: {},
|
||||
}));
|
||||
const panel = new LayerPanel({
|
||||
getLayers: () => layers,
|
||||
isEnabled: (id) => layers.find((layer) => layer.id === id).enabled,
|
||||
setEnabled() {},
|
||||
setLayerParams() {},
|
||||
getRowControls: () => null,
|
||||
hasRowControls: () => false,
|
||||
subscribeRowControls: () => () => {},
|
||||
});
|
||||
panel.mount(container);
|
||||
const headings = (root) =>
|
||||
[...root.querySelectorAll('.data-layer-group-heading')].map((node) =>
|
||||
node.textContent.trim(),
|
||||
);
|
||||
const rowIds = (root) =>
|
||||
[...root.querySelectorAll('[data-layer-id]')].map(
|
||||
(node) => node.dataset.layerId,
|
||||
);
|
||||
const restore = () => {
|
||||
panel.destroy();
|
||||
document.body.replaceChildren();
|
||||
};
|
||||
return { container, utilities, panel, layers, headings, rowIds, restore };
|
||||
}
|
||||
|
||||
test('Directions renders in the Utilities list without a heading, and Radio under Audio', async () => {
|
||||
const { container, utilities, panel, headings, rowIds, restore } =
|
||||
await utilitiesFixture();
|
||||
try {
|
||||
assert.deepEqual(rowIds(utilities), ['directions']);
|
||||
assert.deepEqual(headings(utilities), []);
|
||||
assert.deepEqual(rowIds(container), ['flights', 'radio']);
|
||||
assert.deepEqual(headings(container), ['Movement', 'Audio']);
|
||||
panel.mount(container);
|
||||
assert.deepEqual(
|
||||
rowIds(utilities),
|
||||
['directions'],
|
||||
'a remount replaces it',
|
||||
);
|
||||
panel.destroy();
|
||||
assert.deepEqual(rowIds(utilities), [], 'destroy releases both lists');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a refresh updates the Directions row in the Utilities list', async () => {
|
||||
const { utilities, panel, layers, restore } = await utilitiesFixture();
|
||||
try {
|
||||
const toggle = () =>
|
||||
utilities.querySelector('[data-layer-id="directions"] .data-toggle-btn');
|
||||
assert.ok(!toggle().classList.contains('active'));
|
||||
layers.find(({ id }) => id === 'directions').enabled = true;
|
||||
panel._refreshTogglePanel();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.ok(toggle().classList.contains('active'));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('without the Utilities list, Directions stays in Data Layers under Utilities', async () => {
|
||||
const { container, headings, rowIds, restore } = await utilitiesFixture({
|
||||
withUtilities: false,
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(rowIds(container), ['flights', 'directions', 'radio']);
|
||||
assert.deepEqual(headings(container), ['Movement', 'Utilities', 'Audio']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
+32
-5
@@ -25,6 +25,11 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||
{ id: 'pp-toggles' },
|
||||
{ id: 'param-slider-panel' },
|
||||
]);
|
||||
/**
|
||||
* The left panels Cockpit keeps (Data Layers, and Utilities for Directions):
|
||||
* opening one there folds the Cockpit context rail away until it closes.
|
||||
*/
|
||||
const COCKPIT_LEFT_PANEL_IDS = new Set(['data-panel', 'utilities-panel']);
|
||||
/** The command dock trays with a pin button. */
|
||||
const PINNABLE_PANEL_IDS = new Set(
|
||||
SHARE_PANEL_STATE_SPECS.filter((spec) => spec.pinnable).map(
|
||||
@@ -39,6 +44,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||
'recent-imagery-panel',
|
||||
'street-level-panel',
|
||||
'scene-panel',
|
||||
'utilities-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
'radio-panel',
|
||||
@@ -46,6 +52,23 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||
|
||||
/** Own panel disclosure, docking, persistence and Cockpit rail restoration. */
|
||||
export class PanelChrome {
|
||||
/**
|
||||
* Whether another left panel Cockpit keeps is open, or only folded aside
|
||||
* for this one and coming back when it closes.
|
||||
* @param {string} panelId The panel closing.
|
||||
*/
|
||||
_otherCockpitLeftPanelOpen(panelId) {
|
||||
return [...COCKPIT_LEFT_PANEL_IDS].some((id) => {
|
||||
if (id === panelId) return false;
|
||||
const panel = document.getElementById(id);
|
||||
return (
|
||||
!!panel &&
|
||||
(!panel.classList.contains('collapsed') ||
|
||||
panel.classList.contains('layout-auto-collapsed'))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
constructor({
|
||||
elements,
|
||||
operations,
|
||||
@@ -496,10 +519,13 @@ export class PanelChrome {
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
panelId === 'data-panel'
|
||||
COCKPIT_LEFT_PANEL_IDS.has(panelId)
|
||||
) {
|
||||
this._cockpitContextCollapsedForDataPanel =
|
||||
!this.cockpitView.contextCollapsed;
|
||||
// Record the fold once: opening the other left panel after the first
|
||||
// must not forget that Contact was folded for them.
|
||||
if (!this._cockpitContextCollapsedForDataPanel)
|
||||
this._cockpitContextCollapsedForDataPanel =
|
||||
!this.cockpitView.contextCollapsed;
|
||||
if (this._cockpitContextCollapsedForDataPanel) {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
@@ -527,8 +553,9 @@ export class PanelChrome {
|
||||
if (
|
||||
nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
panelId === 'data-panel' &&
|
||||
this._cockpitContextCollapsedForDataPanel
|
||||
COCKPIT_LEFT_PANEL_IDS.has(panelId) &&
|
||||
this._cockpitContextCollapsedForDataPanel &&
|
||||
!this._otherCockpitLeftPanelOpen(panelId)
|
||||
) {
|
||||
this._cockpitContextCollapsedForDataPanel = false;
|
||||
this.cockpitView.setContextCollapsed(false);
|
||||
|
||||
@@ -17,6 +17,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
|
||||
label: msg('DATA LAYERS'),
|
||||
shownBy: 'active',
|
||||
},
|
||||
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
|
||||
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
|
||||
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') },
|
||||
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
|
||||
|
||||
@@ -682,10 +682,18 @@ function flatRules(block) {
|
||||
|
||||
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) {
|
||||
for (const panel of [
|
||||
'data-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'cctv-panel',
|
||||
]) {
|
||||
// The glow rule may be shared: `#data-panel .panel-glow, #utilities-panel .panel-glow {`.
|
||||
assert.match(
|
||||
css,
|
||||
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
|
||||
new RegExp(
|
||||
`#${panel} \\.panel-glow(?:,[^{]*)? \\{[^}]*\\binset: -\\d+px;`,
|
||||
),
|
||||
`${panel} glow no longer overhangs its panel; revisit this pin`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,9 @@ export function bindRadioControls() {
|
||||
window,
|
||||
'gev:cockpit-context-expanded',
|
||||
() => {
|
||||
// Contact takes the room the open left panel had.
|
||||
this.actions.setPanelCollapsed('data-panel', true);
|
||||
this.actions.setPanelCollapsed('utilities-panel', true);
|
||||
},
|
||||
tunerListenerOptions,
|
||||
);
|
||||
|
||||
@@ -141,9 +141,19 @@ test('a collapsed launcher drops its divider so English titles fit whole', () =>
|
||||
});
|
||||
|
||||
test('the default theme geometry change leaves cyber gutters alone', () => {
|
||||
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
|
||||
for (const surface of [
|
||||
'.data-panel-inner',
|
||||
'.utilities-panel-inner',
|
||||
'.global-context-panel-inner',
|
||||
]) {
|
||||
// A surface may share its rule with another (`.data-panel-inner,
|
||||
// .utilities-panel-inner {`).
|
||||
const base = rules.find(
|
||||
({ selector, body }) => selector === surface && /padding:/.test(body),
|
||||
({ selector, body }) =>
|
||||
selector
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.includes(surface) && /padding:/.test(body),
|
||||
);
|
||||
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
|
||||
}
|
||||
|
||||
@@ -1996,6 +1996,11 @@ body.cockpit-mode #left-panel-stack {
|
||||
body.cockpit-mode #left-panel-stack > #scene-panel {
|
||||
display: none !important;
|
||||
}
|
||||
/* Directions stays reachable in Cockpit, as in Data Layers; Draw and Import
|
||||
were never there (DISPLAY sits in the hidden right rail). */
|
||||
body.cockpit-mode #utilities-panel .utilities-tools {
|
||||
display: none !important;
|
||||
}
|
||||
body.cockpit-mode #right-context-rail {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
+111
-24
@@ -220,6 +220,7 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
@@ -236,6 +237,7 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
@@ -892,38 +894,104 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* DISPLAY ▸ 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;
|
||||
}
|
||||
/* 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 {
|
||||
/* Right after its swatch for keyboard and screen readers, shown last. */
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
padding: 2px 0 4px 16px;
|
||||
}
|
||||
.draw-clear-btn {
|
||||
flex: 0 0 auto;
|
||||
@@ -943,7 +1011,7 @@ body.gev-drawing #cesiumContainer canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
|
||||
/* UTILITIES ▸ Import: one row per imported file, and the drop-target outline */
|
||||
.geo-import-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
@@ -1102,6 +1170,7 @@ body.ui-clean-view #data-panel,
|
||||
body.ui-clean-view #cctv-panel,
|
||||
body.ui-clean-view #radio-panel,
|
||||
body.ui-clean-view #global-context-panel,
|
||||
body.ui-clean-view #utilities-panel,
|
||||
body.ui-clean-view #scene-panel {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
@@ -1269,3 +1338,21 @@ html:not([lang='en']) .param-slider-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Touch: Draw's swatches keep their small dots but answer a 24px target. */
|
||||
@media (pointer: coarse) {
|
||||
#utilities-panel .draw-color,
|
||||
#utilities-panel .draw-mark-swatch {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#utilities-panel .draw-color::before,
|
||||
#utilities-panel .draw-mark-swatch::before {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
+25
-9
@@ -201,6 +201,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner,
|
||||
#pp-toggles:not(.collapsed),
|
||||
@@ -222,6 +223,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -304,6 +306,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -320,6 +323,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -344,6 +348,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -517,6 +522,14 @@
|
||||
color: #f7f8f7;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#utilities-panel
|
||||
.utilities-tools
|
||||
.pp-toggle-btn:is([aria-pressed='true'], .active) {
|
||||
background: rgba(64, 45, 46, 0.94);
|
||||
box-shadow: 3px 0 0 var(--cyber-red) inset;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .pp-toggle-btn.active .pp-label {
|
||||
color: #f5f7f6;
|
||||
}
|
||||
@@ -1680,7 +1693,7 @@
|
||||
:root[data-ui-theme='cyber']
|
||||
body:not(.cockpit-mode)
|
||||
#left-panel-stack:has(> [data-panel-id]:not(.collapsed))
|
||||
> :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) {
|
||||
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(.collapsed) {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
@@ -1702,21 +1715,21 @@
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #scene-panel) {
|
||||
> :is(#data-panel, #utilities-panel, #scene-panel) {
|
||||
--panel-expanded-width: var(--cyber-panel-width);
|
||||
width: var(--panel-expanded-width);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #scene-panel).collapsed {
|
||||
> :is(#data-panel, #utilities-panel, #scene-panel).collapsed {
|
||||
width: var(--cyber-panel-collapsed-width);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #scene-panel).collapsed
|
||||
:is(.data-panel-inner, .scene-panel-inner) {
|
||||
> :is(#data-panel, #utilities-panel, #scene-panel).collapsed
|
||||
:is(.data-panel-inner, .utilities-panel-inner, .scene-panel-inner) {
|
||||
box-sizing: border-box;
|
||||
height: 50px;
|
||||
min-height: 50px;
|
||||
@@ -1743,7 +1756,7 @@
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
body:not(.cockpit-mode)
|
||||
:is(#data-panel, #scene-panel)
|
||||
:is(#data-panel, #utilities-panel, #scene-panel)
|
||||
.panel-collapse-btn {
|
||||
width: var(--cyber-panel-control-size);
|
||||
min-width: var(--cyber-panel-control-size);
|
||||
@@ -2758,7 +2771,10 @@
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack > #data-panel {
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #utilities-panel) {
|
||||
--panel-expanded-width: var(--cyber-panel-width);
|
||||
width: var(--cyber-panel-width);
|
||||
max-height: min(64vh, 610px);
|
||||
@@ -2767,7 +2783,7 @@
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
#left-panel-stack
|
||||
> #data-panel.collapsed {
|
||||
> :is(#data-panel, #utilities-panel).collapsed {
|
||||
width: var(--cyber-panel-collapsed-width);
|
||||
}
|
||||
|
||||
@@ -2800,7 +2816,7 @@
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
#left-panel-stack
|
||||
> #data-panel:not(.collapsed) {
|
||||
> :is(#data-panel, #utilities-panel):not(.collapsed) {
|
||||
flex: 0 0 var(--cyber-cockpit-panel-height, 40vh);
|
||||
height: var(--cyber-cockpit-panel-height, 40vh);
|
||||
max-height: var(--cyber-cockpit-panel-height, 40vh);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#data-panel .panel-glow {
|
||||
#data-panel .panel-glow,
|
||||
#utilities-panel .panel-glow {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(
|
||||
@@ -53,7 +54,8 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.data-panel-inner {
|
||||
.data-panel-inner,
|
||||
.utilities-panel-inner {
|
||||
position: relative;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -1258,3 +1260,96 @@ body.recording-mode #right-context-rail > [data-panel-id] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Utilities Panel ─────────────────────────── */
|
||||
/* Directions (from the layer list), Draw and Import. Draw and Import read as
|
||||
rows like Directions above them (.data-toggle-row): icon and name, their
|
||||
options indented under the name. */
|
||||
#utilities-panel {
|
||||
--panel-expanded-width: 280px;
|
||||
width: var(--panel-expanded-width);
|
||||
}
|
||||
|
||||
/* The layer list takes its natural height; the panel scrolls as a whole. */
|
||||
.utilities-list {
|
||||
flex: 0 0 auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.utilities-tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.utilities-tools .pp-toggle-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#utilities-panel .utilities-tools .pp-toggle-btn {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 6px;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
#utilities-panel .utilities-tools .pp-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
#utilities-panel .utilities-tools .pp-icon {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#utilities-panel .utilities-tools .pp-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Draw marks its on state with an accent edge, like an active DISPLAY
|
||||
button; Cyber swaps in its red edge (cyber.css). */
|
||||
#utilities-panel
|
||||
.utilities-tools
|
||||
.pp-toggle-btn:is([aria-pressed='true'], .active) {
|
||||
background: var(--accent-dim);
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
#utilities-panel
|
||||
.utilities-tools
|
||||
:is(.pp-slider-row, .geo-import-list, .geo-import-hint) {
|
||||
margin-left: 34px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* Draw's options sit flat under its name, like a layer row's controls, not
|
||||
as the card that hangs under a DISPLAY button. */
|
||||
#utilities-panel .utilities-tools .pp-slider-row {
|
||||
padding: 4px 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
@@ -166,6 +166,9 @@ body.mobile-shell #right-context-rail > #recent-imagery-panel {
|
||||
/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD
|
||||
hiding collapsed siblings) never take a panel out of an open sheet. */
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel,
|
||||
body.mobile-shell[data-mobile-sheet='layers']
|
||||
#left-panel-stack
|
||||
> #utilities-panel,
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
|
||||
@@ -15,6 +15,7 @@ body.recording-mode #cctv-panel,
|
||||
body.recording-mode #radio-panel,
|
||||
body.recording-mode #global-context-panel,
|
||||
body.recording-mode #gev-voice-control,
|
||||
body.recording-mode #utilities-panel,
|
||||
body.recording-mode #scene-panel {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
|
||||
@@ -220,11 +220,13 @@
|
||||
#left-panel-stack > #cctv-panel,
|
||||
#left-panel-stack > #street-level-panel,
|
||||
#left-panel-stack > #global-context-panel,
|
||||
#left-panel-stack > #utilities-panel,
|
||||
#left-panel-stack > #scene-panel,
|
||||
#left-panel-stack > #data-panel.collapsed,
|
||||
#left-panel-stack > #cctv-panel.collapsed,
|
||||
#left-panel-stack > #street-level-panel.collapsed,
|
||||
#left-panel-stack > #global-context-panel.collapsed,
|
||||
#left-panel-stack > #utilities-panel.collapsed,
|
||||
#left-panel-stack > #scene-panel.collapsed {
|
||||
position: relative;
|
||||
top: auto;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#left-panel-stack > #data-panel,
|
||||
#left-panel-stack > #cctv-panel,
|
||||
#left-panel-stack > #global-context-panel,
|
||||
#left-panel-stack > #utilities-panel,
|
||||
#left-panel-stack > #scene-panel {
|
||||
position: relative;
|
||||
top: auto;
|
||||
@@ -37,6 +38,10 @@
|
||||
order: 2;
|
||||
}
|
||||
|
||||
#left-panel-stack > #utilities-panel {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
#left-panel-stack > #scene-panel {
|
||||
order: 4;
|
||||
}
|
||||
@@ -52,6 +57,7 @@
|
||||
|
||||
#left-panel-stack > #data-panel:not(.collapsed),
|
||||
#left-panel-stack > #cctv-panel:not(.collapsed),
|
||||
#left-panel-stack > #utilities-panel:not(.collapsed),
|
||||
#left-panel-stack > #scene-panel:not(.collapsed) {
|
||||
box-sizing: border-box;
|
||||
flex: 0 1 var(--left-panel-allocated-height, auto);
|
||||
@@ -64,7 +70,9 @@
|
||||
* the canonical control order and center their combined stack at 50vh.
|
||||
*/
|
||||
#left-panel-stack.layout-tail
|
||||
> :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) {
|
||||
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(
|
||||
.collapsed
|
||||
) {
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 var(--left-panel-allocated-height);
|
||||
height: var(--left-panel-allocated-height);
|
||||
@@ -73,6 +81,7 @@
|
||||
|
||||
#left-panel-stack > #data-panel:not(.collapsed) .data-panel-inner,
|
||||
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
|
||||
#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner,
|
||||
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
@@ -83,6 +92,7 @@
|
||||
}
|
||||
|
||||
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
|
||||
#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner,
|
||||
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
@@ -449,6 +459,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#scene-panel.collapsed .scene-shot-list,
|
||||
#scene-panel.collapsed .scene-progress,
|
||||
#scene-panel.collapsed #scene-status,
|
||||
#utilities-panel.collapsed .utilities-list,
|
||||
#utilities-panel.collapsed .utilities-tools,
|
||||
#param-slider-panel.collapsed #param-sliders,
|
||||
#location-bar.collapsed .poi-row-container,
|
||||
#location-bar.collapsed .location-bar-divider,
|
||||
@@ -459,6 +471,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
|
||||
#data-panel.collapsed,
|
||||
#cctv-panel.collapsed,
|
||||
#utilities-panel.collapsed,
|
||||
#scene-panel.collapsed {
|
||||
width: var(--left-collapsed-width);
|
||||
}
|
||||
@@ -472,6 +485,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#cctv-panel.collapsed .cctv-panel-inner,
|
||||
#radio-panel.collapsed .radio-panel-inner,
|
||||
#scene-panel.collapsed .scene-panel-inner,
|
||||
#utilities-panel.collapsed .utilities-panel-inner,
|
||||
#param-slider-panel.collapsed,
|
||||
#location-bar.collapsed .location-inner {
|
||||
padding-bottom: 10px;
|
||||
@@ -483,6 +497,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#radio-panel.collapsed .panel-header,
|
||||
#global-context-panel.collapsed .panel-header,
|
||||
#scene-panel.collapsed .panel-header,
|
||||
#utilities-panel.collapsed .panel-header,
|
||||
#param-slider-panel.collapsed .param-panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import SceneOptions from './SceneOptions.vue';
|
||||
import SceneShotList from './SceneShotList.vue';
|
||||
import CctvPanel from './panels/CctvPanel.vue';
|
||||
import StreetLevelPanel from './panels/StreetLevelPanel.vue';
|
||||
import DrawControls from './panels/DrawControls.vue';
|
||||
import GeoImport from './panels/GeoImport.vue';
|
||||
import UiSelect from '../kit/UiSelect.js';
|
||||
|
||||
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents
|
||||
@@ -43,6 +45,12 @@ async function importChosen(event) {
|
||||
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
|
||||
</RailPanel>
|
||||
|
||||
<!-- Utilities: Directions (LayerPanel renders its row here), Draw, Import -->
|
||||
<RailPanel id="utilities-panel" start-collapsed :title="t('UTILITIES')" :hint="t('Directions, drawing and your own files')" scroll>
|
||||
<div id="utilities-toggles" class="data-toggle-list utilities-list"></div>
|
||||
<div class="utilities-tools"><DrawControls /><GeoImport /></div>
|
||||
</RailPanel>
|
||||
|
||||
<!-- CCTV Coverage Panel -->
|
||||
<CctvPanel />
|
||||
|
||||
|
||||
@@ -5,22 +5,20 @@ import { t } from '../../../i18n/index.js';
|
||||
import { vPanelState } from '../../rail/panelState.js';
|
||||
import { vClassToggle } from '../../rail/classToggle.js';
|
||||
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||
import { vRangeValue } from '../../rail/rangeValue.js';
|
||||
import UiSegmented from '../../kit/UiSegmented.vue';
|
||||
import UiSlider from '../../kit/UiSlider.js';
|
||||
import GeoImport from './GeoImport.vue';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
import UiButton from '../../kit/UiButton.vue';
|
||||
import UiSelect from '../../kit/UiSelect.js';
|
||||
import { StyleParameterRows } from '../../styleParameters.js';
|
||||
|
||||
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
|
||||
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
|
||||
// the draw tool its controls, and each publishes them; until they do, the
|
||||
// panel shows its initial markup. GeoImport renders the file import.
|
||||
// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group,
|
||||
// and each publishes them; until they do, the panel shows its initial markup.
|
||||
// Draw and Import live in UTILITIES (LayerPanelsView).
|
||||
const display = useDisplayStore();
|
||||
const { view, models3d, draw, parameters, cockpitPortal } = storeToRefs(display);
|
||||
// A control's change goes to its owner (DisplayBindings or the draw tool).
|
||||
const { view, models3d, parameters, cockpitPortal } = storeToRefs(display);
|
||||
// A control's change goes to its owner (DisplayBindings).
|
||||
const act = (name, ...args) => display.act(name, ...args);
|
||||
const pct = (value) => `${value}%`;
|
||||
const hud = computed(() => view.value?.hud ?? null);
|
||||
@@ -43,12 +41,6 @@ const modelsOptions = computed(() => [
|
||||
{ value: 'proximity', label: t('Proximity'), attrs: { id: 'models3d-mode-proximity', 'data-mode': 'proximity' } },
|
||||
{ value: 'all', label: t('All'), attrs: { id: 'models3d-mode-all', 'data-mode': 'all' } },
|
||||
]);
|
||||
const drawShape = computed(() => draw.value?.shape ?? 'area');
|
||||
const drawShapeOptions = computed(() => [
|
||||
{ value: 'area', label: t('Area'), attrs: { 'data-shape': 'area' } },
|
||||
{ value: 'line', label: t('Line'), attrs: { 'data-shape': 'line' } },
|
||||
{ value: 'pin', label: t('Pin'), attrs: { 'data-shape': 'pin' } },
|
||||
]);
|
||||
const allocationOptions = computed(() => [
|
||||
{ value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } },
|
||||
{ value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } },
|
||||
@@ -177,29 +169,6 @@ const allocationOptions = computed(() => [
|
||||
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" :title="t('Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel')">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||
<span class="pp-label">{{ t('Draw') }}</span>
|
||||
</UiButton>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
|
||||
<span class="pp-slider-mini-label">{{ t('Shape') }}</span>
|
||||
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="drawShapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
|
||||
</div>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
|
||||
<input type="text" class="pp-text-input" id="draw-label-input" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" maxlength="120" :placeholder="t('Label (optional)')" :aria-label="t('Label for the drawn shape')" autocomplete="off" spellcheck="false">
|
||||
<UiSelect class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" :aria-label="t('Colour of the drawn shape')">
|
||||
<option value="primary">{{ t('Primary') }}</option>
|
||||
<option value="amber">{{ t('Amber') }}</option>
|
||||
<option value="cyan">{{ t('Cyan') }}</option>
|
||||
<option value="green">{{ t('Green') }}</option>
|
||||
<option value="red">{{ t('Red') }}</option>
|
||||
</UiSelect>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" :title="t('Remove every mark from the board')">{{ t('Clear') }}</button>
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<GeoImport />
|
||||
<div class="pp-toggle-group">
|
||||
<UiButton class="pp-toggle-btn" :pressed="celestial?.active ?? false" pressed-class="active" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import UiButton from '../../kit/UiButton.vue';
|
||||
import UiSegmented from '../../kit/UiSegmented.vue';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
import DrawMarkList from './DrawMarkList.vue';
|
||||
import DrawSwatches from './DrawSwatches.vue';
|
||||
|
||||
// UTILITIES ▸ Draw: the toggle, the shape and the colour for the next shape,
|
||||
// the hint with Clear, and the drawn shapes' rows. The draw tool publishes
|
||||
// what they show and answers them through the display store.
|
||||
const display = useDisplayStore();
|
||||
const { draw } = storeToRefs(display);
|
||||
const act = (name, ...args) => display.act(name, ...args);
|
||||
const drawShape = computed(() => draw.value?.shape ?? 'area');
|
||||
|
||||
// Icons, named for screen readers and tooltips.
|
||||
const shapeOptions = computed(() => [
|
||||
{ value: 'area', label: t('Area'), icon: 'pentagon', attrs: { 'data-shape': 'area' } },
|
||||
{ value: 'line', label: t('Line'), icon: 'timeline', attrs: { 'data-shape': 'line' } },
|
||||
{ value: 'pin', label: t('Pin'), icon: 'location_on', attrs: { 'data-shape': 'pin' } },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pp-toggle-group" id="draw-group">
|
||||
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" :title="t('Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel')">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||
<span class="pp-label">{{ t('Draw') }}</span>
|
||||
</UiButton>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
|
||||
<UiSegmented class="pp-mode-seg draw-shape-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="shapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
|
||||
<DrawSwatches id="draw-colors" :label="t('Colour of the next shape')" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" />
|
||||
</div>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" :title="t('Remove every mark from the board')">{{ t('Clear') }}</button>
|
||||
</div>
|
||||
<DrawMarkList />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
// src/ui/views/panels/DrawMarkList.test.mjs — UTILITIES ▸ Draw's shapes:
|
||||
// one row each, rendered from what the draw tool publishes, with the row
|
||||
// controls answered through the display store. Rows are updated in place, so
|
||||
// a board change never takes a half-typed name or the button under a click.
|
||||
import '../../../testSupport/registerDom.mjs';
|
||||
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 { useDisplayStore } from '../../stores/display.js';
|
||||
import DrawMarkList from './DrawMarkList.vue';
|
||||
|
||||
const LINE = { id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'primary', hidden: false };
|
||||
const PIN = { id: 'a2', type: 'pin', manual: true, name: 'Camp', label: 'Camp', color: 'amber', hidden: false };
|
||||
const VOICE = { id: 'a3', type: 'area', manual: false, label: 'Texas', color: 'primary', hidden: false };
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const toggle = document.body.appendChild(document.createElement('button'));
|
||||
toggle.id = 'draw-toggle';
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const calls = [];
|
||||
store.connect(
|
||||
new Proxy(
|
||||
{},
|
||||
{ get: (_, name) => (...args) => calls.push([name, ...args]) },
|
||||
),
|
||||
);
|
||||
const app = createApp(DrawMarkList).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
return { store, calls };
|
||||
}
|
||||
|
||||
async function show(store, marks) {
|
||||
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks) });
|
||||
await nextTick();
|
||||
}
|
||||
const row = (id) => document.querySelector(`#draw-mark-list [data-mark-id="${id}"]`);
|
||||
const control = (id, name) => row(id)?.querySelector(`[data-control="${name}"]`);
|
||||
const key = (el, keyName) => {
|
||||
const event = new KeyboardEvent('keydown', { key: keyName, bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
test('lists only hand-drawn marks, named or by kind, with Import-style actions', async (t) => {
|
||||
const { store } = mount(t);
|
||||
const list = document.getElementById('draw-mark-list');
|
||||
assert.equal(list.classList.contains('visible'), false);
|
||||
await show(store, [LINE, PIN, VOICE]);
|
||||
assert.equal(list.classList.contains('visible'), true);
|
||||
assert.deepEqual([...list.children].map((node) => node.dataset.markId), ['a1', 'a2']);
|
||||
assert.equal(control('a1', 'name').value, '');
|
||||
assert.equal(control('a1', 'name').placeholder, 'Line · 950 m');
|
||||
assert.equal(control('a2', 'name').value, 'Camp');
|
||||
assert.match(control('a2', 'swatch').style.background, /#ffb547|rgb\(255, 181, 71\)/);
|
||||
assert.equal(control('a2', 'toggle').getAttribute('aria-label'), 'Hide Camp');
|
||||
assert.equal(control('a2', 'toggle').hasAttribute('aria-pressed'), false, 'the label says the state');
|
||||
assert.equal(control('a2', 'fly').getAttribute('aria-label'), 'Fly to Camp');
|
||||
assert.equal(control('a2', 'remove').getAttribute('aria-label'), 'Remove Camp');
|
||||
});
|
||||
|
||||
test('show/hide, fly to and remove act on that one mark', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
control('a2', 'toggle').click();
|
||||
control('a1', 'fly').click();
|
||||
control('a1', 'remove').click();
|
||||
assert.deepEqual(calls, [
|
||||
['toggleDrawMark', 'a2'],
|
||||
['flyToDrawMark', 'a1'],
|
||||
['removeDrawMark', 'a1'],
|
||||
]);
|
||||
await show(store, [{ ...PIN, hidden: true }]);
|
||||
assert.equal(control('a2', 'toggle').getAttribute('aria-label'), 'Show Camp');
|
||||
assert.ok(row('a2').classList.contains('hidden-mark'));
|
||||
});
|
||||
|
||||
test('the swatch opens the colours in the row; a colour recolours and closes it', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [LINE]);
|
||||
control('a1', 'swatch').click();
|
||||
await nextTick();
|
||||
assert.equal(control('a1', 'swatch').getAttribute('aria-expanded'), 'true');
|
||||
control('a1', 'color-red').click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.deepEqual(calls, [['recolorDrawMark', 'a1', 'red']]);
|
||||
assert.equal(control('a1', 'picker'), null, 'choosing closes the picker');
|
||||
assert.equal(document.activeElement, control('a1', 'swatch'), 'and focus goes back to the swatch');
|
||||
});
|
||||
|
||||
test('a name is saved on Enter or leaving the field; an unchanged one costs nothing', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
const name = control('a1', 'name');
|
||||
name.focus();
|
||||
name.value = ' Ridge walk ';
|
||||
key(name, 'Enter');
|
||||
assert.deepEqual(calls, [['renameDrawMark', 'a1', 'Ridge walk']]);
|
||||
assert.equal(document.activeElement, name, 'Enter keeps focus in the field');
|
||||
name.blur();
|
||||
assert.equal(calls.length, 1, 'the blur after Enter saves nothing more');
|
||||
const pinName = control('a2', 'name');
|
||||
pinName.focus();
|
||||
pinName.blur();
|
||||
assert.equal(calls.length, 1, 'leaving an unchanged name saves nothing');
|
||||
pinName.focus();
|
||||
pinName.value = 'Base';
|
||||
pinName.blur();
|
||||
assert.deepEqual(calls.at(-1), ['renameDrawMark', 'a2', 'Base']);
|
||||
});
|
||||
|
||||
test('Escape drops the draft and stays in the list, even with the row\'s colours open', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [PIN]);
|
||||
control('a2', 'swatch').click();
|
||||
await nextTick();
|
||||
const name = control('a2', 'name');
|
||||
name.focus();
|
||||
name.value = 'Base';
|
||||
const event = key(name, 'Escape');
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(name.value, 'Camp', 'the saved name comes back');
|
||||
assert.equal(document.activeElement, name, 'focus stays in the field');
|
||||
await nextTick();
|
||||
assert.deepEqual(calls, [], 'the draft is not saved');
|
||||
// Escape in the open colours closes them and gives focus to the swatch.
|
||||
control('a2', 'color-green').focus();
|
||||
key(control('a2', 'color-green'), 'Escape');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(control('a2', 'picker'), null);
|
||||
assert.equal(document.activeElement, control('a2', 'swatch'));
|
||||
});
|
||||
|
||||
test('a board change keeps the row, the name being typed and the focus', async (t) => {
|
||||
const { store } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
const before = row('a1');
|
||||
const name = control('a1', 'name');
|
||||
name.focus();
|
||||
name.value = 'Half typ';
|
||||
await show(store, [{ ...LINE, color: 'green' }, PIN, VOICE]);
|
||||
assert.equal(row('a1'), before, 'the row is kept');
|
||||
assert.equal(control('a1', 'name').value, 'Half typ', 'the draft survives');
|
||||
assert.equal(document.activeElement, name);
|
||||
});
|
||||
|
||||
test('when the focused row goes, focus moves to the next row, else the Draw button', async (t) => {
|
||||
const { store } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
control('a1', 'remove').focus();
|
||||
await show(store, [PIN]);
|
||||
assert.equal(document.activeElement, control('a2', 'remove'));
|
||||
await show(store, []);
|
||||
assert.equal(document.activeElement, document.getElementById('draw-toggle'));
|
||||
});
|
||||
|
||||
test('the colours open next to the swatch with the current one focused, and the arrows try others', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [PIN]);
|
||||
control('a2', 'swatch').click();
|
||||
await nextTick();
|
||||
const picker = control('a2', 'picker');
|
||||
assert.equal(control('a2', 'swatch').getAttribute('aria-controls'), picker.id);
|
||||
assert.equal(picker.previousElementSibling, control('a2', 'swatch'), 'right after its swatch');
|
||||
assert.equal(document.activeElement, control('a2', 'color-amber'), 'the current colour has focus');
|
||||
assert.deepEqual(
|
||||
[...picker.querySelectorAll('[role="radio"]')].map((node) => node.getAttribute('tabindex')),
|
||||
['-1', '0', '-1', '-1', '-1'],
|
||||
'one tab stop, on the checked colour',
|
||||
);
|
||||
key(control('a2', 'color-amber'), 'ArrowRight');
|
||||
key(control('a2', 'color-amber'), 'End');
|
||||
assert.deepEqual(calls, [
|
||||
['recolorDrawMark', 'a2', 'cyan'],
|
||||
['recolorDrawMark', 'a2', 'red'],
|
||||
]);
|
||||
await nextTick();
|
||||
assert.ok(control('a2', 'picker'), 'the arrows keep the colours open');
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { DRAW_COLOR_CSS } from '../../../annotations/drawMarks.js';
|
||||
import { vFieldValue } from '../../rail/fieldValue.js';
|
||||
import { vFocusSafeChildren } from '../../rail/focusSafeChildren.js';
|
||||
import DrawSwatches from './DrawSwatches.vue';
|
||||
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.
|
||||
const display = useDisplayStore();
|
||||
const { draw } = storeToRefs(display);
|
||||
const rows = computed(() => draw.value?.marks ?? []);
|
||||
// No `>` in the template: the catalog reads its tags with a regex.
|
||||
const listed = computed(() => rows.value.length > 0);
|
||||
const picking = ref(null);
|
||||
// The name field (v-field-value) shows the saved name except while it is
|
||||
// being typed in: that text is the draft until Enter or leaving saves it.
|
||||
|
||||
const rowOf = (id) => rows.value.find((row) => row.id === id);
|
||||
// The name last sent for each row, so Enter and the blur after it save once
|
||||
// even before the tool publishes the new name.
|
||||
const sent = new Map();
|
||||
|
||||
/** Save a typed name; an unchanged one costs nothing. */
|
||||
function commit(row, input) {
|
||||
const next = input.value.trim();
|
||||
input.value = next;
|
||||
const saved = rowOf(row.id)?.name ?? row.name;
|
||||
if (next === saved || next === sent.get(row.id)) return;
|
||||
sent.set(row.id, next);
|
||||
display.act('renameDrawMark', row.id, next);
|
||||
}
|
||||
|
||||
function onNameKey(row, event) {
|
||||
const input = event.currentTarget;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
// Saved, and focus stays here: blurring would drop it to the page.
|
||||
commit(row, input);
|
||||
input.select();
|
||||
} else if (event.key === 'Escape') {
|
||||
// Escape drops the draft before anything else, an open colour picker
|
||||
// included, and stays in the list: it must not leave Draw.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
input.value = rowOf(row.id)?.name ?? row.name;
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
|
||||
async function closePicker(id) {
|
||||
picking.value = null;
|
||||
await nextTick();
|
||||
document
|
||||
.querySelector(`#draw-mark-list [data-mark-id="${id}"] .draw-mark-swatch`)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
function onRowKey(row, event) {
|
||||
// Escape in an open picker closes it and gives focus back to its swatch.
|
||||
if (event.key !== 'Escape' || picking.value !== row.id) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void closePicker(row.id);
|
||||
}
|
||||
|
||||
/** A colour from the row's picker: the arrows try it, a click also closes. */
|
||||
function recolor(row, color) {
|
||||
if (color !== row.color) display.act('recolorDrawMark', row.id, color);
|
||||
}
|
||||
|
||||
// Before the row holding keyboard focus goes, focus moves to the next row's
|
||||
// same control, else the previous row's, else the Draw button.
|
||||
function leave() {
|
||||
const active = document.activeElement;
|
||||
const row = active?.closest('[data-mark-id]');
|
||||
const control = active?.dataset.control;
|
||||
const others = [];
|
||||
for (let next = row?.nextElementSibling; next; next = next.nextElementSibling)
|
||||
others.push(next);
|
||||
for (let prev = row?.previousElementSibling; prev; prev = prev.previousElementSibling)
|
||||
others.push(prev);
|
||||
for (const other of others) {
|
||||
const target =
|
||||
other.querySelector(`[data-control="${control}"]`) ||
|
||||
other.querySelector('button');
|
||||
if (target) return target.focus();
|
||||
}
|
||||
document.getElementById('draw-toggle')?.focus();
|
||||
}
|
||||
</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>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
DRAW_COLOR_NAMES,
|
||||
} from '../../../annotations/drawMarks.js';
|
||||
|
||||
// Draw's five colours as one radio group: the next shape's colour and a
|
||||
// drawn shape's picker. Only the checked colour is a tab stop; the arrows,
|
||||
// Home and End move to and choose another, as a radio group's do. Each swatch
|
||||
// shows its colour from DRAW_COLOR_CSS, the same value the shape is drawn in.
|
||||
// The template is one line: templates keep whitespace.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: 'primary' },
|
||||
/** The group's accessible name, already translated. */
|
||||
label: { type: String, required: true },
|
||||
/** Focus the checked colour once shown (a picker that just opened). */
|
||||
autofocus: { type: Boolean, default: false },
|
||||
});
|
||||
// `choose` is a click: a picker closes on it, and stays open for the arrows.
|
||||
const emit = defineEmits(['update:modelValue', 'choose']);
|
||||
const buttons = [];
|
||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
|
||||
function onKey(event) {
|
||||
const from = Math.max(0, DRAW_COLORS.indexOf(props.modelValue));
|
||||
let to;
|
||||
if (STEPS[event.key])
|
||||
to = (from + STEPS[event.key] + DRAW_COLORS.length) % DRAW_COLORS.length;
|
||||
else if (event.key === 'Home') to = 0;
|
||||
else if (event.key === 'End') to = DRAW_COLORS.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
buttons[to]?.focus();
|
||||
if (to !== from) emit('update:modelValue', DRAW_COLORS[to]);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus)
|
||||
buttons[Math.max(0, DRAW_COLORS.indexOf(props.modelValue))]?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="draw-colors" role="radiogroup" :aria-label="props.label" @keydown="onKey"><button v-for="(color, index) in DRAW_COLORS" :key="color" :ref="(el) => (buttons[index] = el)" type="button" class="draw-color" :class="{ active: color === props.modelValue }" role="radio" :data-color="color" :data-control="`color-${color}`" :aria-checked="String(color === props.modelValue)" :tabindex="color === props.modelValue ? 0 : -1" :style="{ background: DRAW_COLOR_CSS[color] }" :title="t(DRAW_COLOR_NAMES[color])" :aria-label="t(DRAW_COLOR_NAMES[color])" @click="emit('update:modelValue', color), emit('choose', color)"></button></div>
|
||||
</template>
|
||||
@@ -174,6 +174,7 @@ const schemas = [
|
||||
'cctv-panel',
|
||||
'radio-panel',
|
||||
'scene-panel',
|
||||
'utilities-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
'weather-panel',
|
||||
|
||||
@@ -43,6 +43,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// Globe rotation adds one motion; visual keyhole fit and explicit
|
||||
// reset guidance/defaults update only move_camera and mode descriptions.
|
||||
// S2 GhostMaps extends the generated layer enums.
|
||||
// The Utilities panel (Directions, Draw, Import) adds one panel target.
|
||||
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
|
||||
// is consolidated without changing either feature's arguments.
|
||||
// Snow leaves the set_visual_style enum.
|
||||
@@ -142,6 +143,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
'voice-settings',
|
||||
'voice-preview',
|
||||
'provider-settings',
|
||||
'utilities-panel',
|
||||
].includes(id),
|
||||
);
|
||||
for (const tool of legacy) {
|
||||
|
||||
+21
-8
@@ -99,6 +99,12 @@ const PANEL_ALIASES = new Map([
|
||||
['global context', 'global-context-panel'],
|
||||
['right context', 'global-context-panel'],
|
||||
['context right panel', 'global-context-panel'],
|
||||
['utilities', 'utilities-panel'],
|
||||
['utilities panel', 'utilities-panel'],
|
||||
['draw', 'utilities-panel'],
|
||||
['drawing', 'utilities-panel'],
|
||||
['import', 'utilities-panel'],
|
||||
['import files', 'utilities-panel'],
|
||||
['authoring', 'scene-panel'],
|
||||
['scenes', 'scene-panel'],
|
||||
['scene', 'scene-panel'],
|
||||
@@ -122,6 +128,7 @@ const PANEL_IDS = new Set([
|
||||
'cctv-panel',
|
||||
'radio-panel',
|
||||
'global-context-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'weather-panel',
|
||||
@@ -1053,15 +1060,18 @@ export function createGevActionRunner({
|
||||
|
||||
if (name === 'show_data_layers_menu') {
|
||||
const layerId = normalizeLayerId(args.layerId || args.layer);
|
||||
setPanelOpen(styleManager, 'data-panel', true);
|
||||
const focusedLayer =
|
||||
const row =
|
||||
layerId && dataManager.layers.has(layerId)
|
||||
? focusDataLayerRow(layerId)
|
||||
? findDataLayerRow(layerId)
|
||||
: null;
|
||||
// Open the panel that holds the row: Directions lives in Utilities.
|
||||
const panelId = row?.closest('[data-panel-id]')?.id || 'data-panel';
|
||||
setPanelOpen(styleManager, panelId, true);
|
||||
const focusedLayer = row ? focusDataLayerRow(row, layerId) : null;
|
||||
return {
|
||||
ok: true,
|
||||
action: 'show_data_layers_menu',
|
||||
panelId: 'data-panel',
|
||||
panelId,
|
||||
focusedLayer,
|
||||
layers: dataManager
|
||||
.getAll()
|
||||
@@ -3197,11 +3207,14 @@ function normalizeCockpitAction(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusDataLayerRow(layerId) {
|
||||
const row = document.querySelector(
|
||||
`#data-toggles [data-layer-id="${CSS.escape(layerId)}"]`,
|
||||
function findDataLayerRow(layerId) {
|
||||
const selector = `[data-layer-id="${CSS.escape(layerId)}"]`;
|
||||
return document.querySelector(
|
||||
`#data-toggles ${selector}, #utilities-toggles ${selector}`,
|
||||
);
|
||||
if (!row) return null;
|
||||
}
|
||||
|
||||
function focusDataLayerRow(row, layerId) {
|
||||
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
row.classList.remove('gev-voice-focus');
|
||||
void row.offsetWidth;
|
||||
|
||||
@@ -743,6 +743,61 @@ test('Data Layers voice inventory hides the Context coordinator while current-vi
|
||||
assert.deepEqual(current.cockpit, { active: false, entryAllowed: true });
|
||||
});
|
||||
|
||||
test('showing a layer opens the panel that holds its row: Directions lives in Utilities', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const previous = { document: globalThis.document, CSS: globalThis.CSS };
|
||||
const focused = [];
|
||||
const rowIn = (panelId) => ({
|
||||
dataset: { layerName: panelId === 'utilities-panel' ? 'Directions' : 'Live Flights' },
|
||||
classList: { add: (name) => focused.push(name), remove() {} },
|
||||
scrollIntoView() {},
|
||||
closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null),
|
||||
});
|
||||
const rows = { directions: rowIn('utilities-panel'), flights: rowIn('data-panel') };
|
||||
// Each row is found only inside the list that really holds it.
|
||||
const lists = { directions: '#utilities-toggles', flights: '#data-toggles' };
|
||||
globalThis.CSS = { escape: (value) => value };
|
||||
globalThis.document = {
|
||||
querySelector: (selector) => {
|
||||
for (const part of selector.split(',')) {
|
||||
const [, list, id] = /^\s*(#[\w-]+) \[data-layer-id="([^"]+)"\]\s*$/.exec(part) || [];
|
||||
if (id && lists[id] === list) return rows[id];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
try {
|
||||
const opened = [];
|
||||
const layers = [
|
||||
{ id: 'flights', name: 'Live Flights', enabled: false, showInTogglePanel: true, stats: {} },
|
||||
{ id: 'directions', name: 'Directions', enabled: false, showInTogglePanel: true, stats: {} },
|
||||
];
|
||||
const dataManager = {
|
||||
layers: new Map(layers.map((layer) => [layer.id, { module: {} }])),
|
||||
getAll: () => layers,
|
||||
};
|
||||
const styleManager = {
|
||||
setPanelCollapsed: (panelId, collapsed) => opened.push([panelId, collapsed]),
|
||||
};
|
||||
const viewer = {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
};
|
||||
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
|
||||
const directions = await runner('show_data_layers_menu', { layerId: 'directions' });
|
||||
assert.equal(directions.panelId, 'utilities-panel');
|
||||
assert.deepEqual(directions.focusedLayer, { id: 'directions', name: 'Directions' });
|
||||
const flights = await runner('show_data_layers_menu', { layerId: 'flights' });
|
||||
assert.equal(flights.panelId, 'data-panel');
|
||||
assert.deepEqual(opened, [['utilities-panel', false], ['data-panel', false]]);
|
||||
assert.deepEqual(focused, ['gev-voice-focus', 'gev-voice-focus']);
|
||||
} finally {
|
||||
globalThis.document = previous.document;
|
||||
globalThis.CSS = previous.CSS;
|
||||
}
|
||||
});
|
||||
|
||||
test('generic layer visibility forwards cancellation and reports semantic failure', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const viewer = {
|
||||
@@ -3954,3 +4009,26 @@ test('set_panel_open accepts the Authoring name for the scene panel', async () =
|
||||
}
|
||||
assert.deepEqual(calls.at(-1), ['scene-panel', false, { explicit: true }]);
|
||||
});
|
||||
|
||||
test('set_panel_open reaches the Utilities panel by its name, Draw or Import', async () => {
|
||||
const calls = [];
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000),
|
||||
},
|
||||
},
|
||||
styleManager: {
|
||||
setPanelCollapsed: (...args) => calls.push(args),
|
||||
},
|
||||
dataManager: { layers: new Map(), isEnabled: () => false, getAll: () => [] },
|
||||
});
|
||||
for (const panel of ['utilities-panel', 'utilities', 'drawing', 'import']) {
|
||||
const result = await runner('set_panel_open', { panelId: panel, open: true });
|
||||
assert.equal(result.panelId, 'utilities-panel', panel);
|
||||
}
|
||||
assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]);
|
||||
});
|
||||
Reference in new issue
Block a user