Merge pull request #1113 from asturksever/pr/street-level-show-action

feat(street-level): "show me X on Street View" — assistant action, view field and share link
This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-11 19:13:30 +04:00
commit 98b642869e
84 files changed
+7364 -133

No files matched your search

+1 -1
View File
@@ -384,7 +384,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients.
Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
Thirty-five tools (thirty-four unless an Overpass server is set), four jobs — the commands below come straight from the product's voice test suite and tool playbook:
**🎥 Direct it** — drone-operator camera verbs:
+1
View File
@@ -132,6 +132,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; freshness is judged only by the result's `issued_at`, so a result older than five minutes only arms, whether the panel was mounted again (reopening a conversation) or got it later on the same mount, while one re-sent or remounted within five minutes of issue opens the photo again; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it, or when the screen centre cannot be picked at all (no ground picking, or a globe with no size yet); with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
## Scope & expectations
+18
View File
@@ -0,0 +1,18 @@
- "Show me X on Street View" (or on Mapillary, KartaView or Panoramax): a
`show_street_level` voice and typed action flies to the place, switches to
Google 3D when the source needs it, lights that source in Street Level,
turns the layer on and opens the nearest photo, saying which step failed.
With no place named it looks under the screen centre, as OPEN does. Views
and share links carry the same request (`street_level`, `sl=`). A plain
`sl=google` link never loads a billed panorama by itself: it arms Street
View and leaves the load to OPEN GOOGLE STREET VIEW; embedded views open it,
and so does the God's Eye View panel for a result issued in the last five
minutes; a link whose source has no working key says so. A request stops
when the user navigates, or enters cockpit, clean view, recording or scene
playback, before its photo loads (collapsing the panel does not stop it: the
photo reveals it); a new photo replaces the open one in place, so EXPAND
stays. When nothing is found and a 360°, flat or SINCE filter is on, the
answer names the filter. Failure cards are in the interface language. An
unavailable map stack now reports the
controller's own reason instead of always asking for a Cesium ion token
(Said Turksever, @asturksever, #1113).
+107 -2
View File
@@ -1137,7 +1137,7 @@ Voice controls bind to a protocol-independent session factory. The default WebRT
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 35 tools using separate description-only metadata; a session is offered 34 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
@@ -5257,6 +5257,111 @@ core's lookups alone. The MCP globe panel allows the meta-catalogue and the
three biggest instances; a picture on another instance shows only in the
app.
**"Show me X on Street View."** `show_street_level` (voice and typed;
`src/voice/gevActions.js`) is one call whose stages fail separately
(`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`):
- **Key first.** A keyless source explains itself and changes nothing, as a
chip does.
- **Place.** A name, coordinates, a pointed spot or a numbered item: it flies
there at street framing and waits for arrival. With no place it looks at the
ground under the screen centre, as OPEN does (`aim: 'screen-centre'`,
`armed: false`: sky refuses, and a link's armed point is not used and is kept
until a photo opens). Coordinates off the globe are refused.
- **Map and source.** It switches to the map the source needs (Google Street
View: Google 3D), lights the source through `setLayerParams`, one at a time
and kept like a chip click (origin `voice`; a link runs the action with
origin `programmatic`, so it never writes the recipient's saved layer state),
turns the layer on and calls `openNearest`. `provider` is `google` for
"Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit
source.
- **Hidden panel.** Cockpit, clean view, recording or scene playback end it at
`view`, before the flight and after every await; embed mode's own clean view
is exempt, since the photo is lifted over the globe.
- **Superseded.** After every await (flight, map switch, layer on, lookup) a
newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where
its own flight lands) or a press or wheel on the globe stops it
(`superseded: true`). The same checks run before a found photo loads
(`proceed`), so nothing billed loads for a request that was given up. A
lookup given up leaves the panel as it was when the request kept the photo's
source; one that had switched the source off closes that source's photo with
it. A photo that opened after either is closed again.
- **Replacing a photo.** An open photo stays up while the new request looks
(`openNearest` `replace: true`; the viewer is replaced, not closed, so EXPAND
stays, also across sources through `holdViewer`, which holds only the action's
own switch-off of the photo's source: a source switched by hand meanwhile
still stops the load) and closes if nothing opens, so a place with no imagery
shows its own error; a link that only arms closes it at once. A replacement
from another source drops the old photo's details, and the request's error or
armed OPEN outlives the old viewer. A replacement stops a playing Mapillary
sequence.
- **Outcome.** `noImagery` marks an empty result, `imageryError` a source or
viewer failure and `aim: 'sky'` a sky centre (from `street.nearestOutcome`);
an empty result names a narrowing 360°/flat or SINCE filter the source
honours, as `filter`. Every photo the action opens reveals the panel, also
one the user had collapsed.
- **Search.** A source without a coverage layer searches up to 250 m
(`NEAREST_RADIUS_MAX_M`; Google honours it); clicks keep 50 m. Mapillary
asks for 100 photos within 50 m, its API's most (the API returns them
unordered and cuts at the limit), and gives up after 12 s, body included
(`NEAREST_TIMEOUT_MS`).
- **Phone.** The panel sits in the Cameras tab sheet: a photo that opens, or a
link that arms, opens the sheet and scrolls the panel into view
(`revealPanel`, which does nothing above 720px).
The layer also exposes `requiredMapStack(id)` and `providerStatus(id)` for
callers that decide before the panel lists a source, and `holdViewer()`. A
caller's abort is honoured through the viewer's mount and
load, and only the lookup that still owns the panel changes its spinner or
error.
Views carry the request as `street_level` (`src/view/index.js`): a source and
a point, which defaults to where the camera looks (below 20 km, at a pitch
steeper than -5° and within 10 km ahead; else the camera's own point); half a
point is not mixed with the camera's: both coordinates come from that default. A `google` source brings `map: photoreal` and refuses
another map; `street_level` does not combine with `follow`.
`show_in_gods_eye_view`, and the embedded app for a posted view with no
point, measure the view's altitude from the terrain under the camera
(`groundedLookAt` with the terrain heights service) before taking the look-at
point, and keep the view's own estimate without a terrain answer (the embedded
app waits 2 s for one). Links carry
`sl=<source>,<lat>,<lon>`; the globe panel applies views through the action,
and a plain link runs it once restored (one shot: the rewritten hash drops
`sl`). A link that follows an entity ignores `sl`.
- **Billing gate.** A plain link never loads a photo from a source that bills
every load (`billedPerImage`; Google): the action runs with
`autoOpen: false` and arms the layer (`armNearest`: the link's point and
radius, for the next OPEN press only, and only while the ground under the
screen centre is within 1 km of it (`ARMED_REACH_M`) or cannot be picked; a
sky centre refuses; any other lookup, open, close, disable, the source
switched off or its map leaving drops it), opens the panel and shows a toast
pointing at OPEN GOOGLE STREET VIEW. A link whose source has no working
key here changes nothing and says so in a toast (not in a frame).
- **Who may open.** Posted views, and the panel page's own link (inline embed)
for a tool result issued in the last 5 minutes, open the photo:
`show_in_gods_eye_view` stamps a street-level result `issued_at`, every
`gev:view` the panel posts carries `fresh`, and the inline app applies a
view without it with `autoOpen: false` (a framing page's posted views still
open). `?embed=1` on a published link only arms, without the panel nudge or
toast.
- **Who owns the camera.** A link's photo step is skipped when a newer link
superseded its restore, when its restore flight never landed
(`share.camera !== 'applied'`: the visitor dragged or searched), or after
any navigation since the generation that flight owned (`navigationToken` on
the settled result), also while its layers still load. A link pasted into
the open tab runs its step after its restore settles
(`gev:share-navigation-restore-settled`).
- **Embed.** The rail is hidden, so an open photo moves to an overlay on the
globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in
full screen, Esc leaves full screen first). EXPAND stays under embed mode's
clean view; cockpit, recording and scene playback hide the overlay. Loading
errors render inside the overlay, and inside the viewer while EXPAND is on.
A posted view whose photo step fails before the imagery stage closes the
earlier photo before its camera flies; one the user overtook (a click on the
map, a newer request) leaves their photo and camera alone; one that found a
photo it could not load keeps that error.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
@@ -6312,7 +6417,7 @@ and unreachable upstream (502/504) separately from road geometry.
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
- **Thirty-five tools** (thirty-four in a session without `OVERPASS_UPSTREAMS`, which adds `osm_query`; argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `resolve_area`, `find_imagery`, `osm_query`, `next_iss_pass`, and `next_satellite_pass`.
> **Reading `npm test` totals:** the count depends on the Node major. The two
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
+10
View File
@@ -68,6 +68,16 @@ cannot name still makes a place view. Opening a vessel thing view with a
world time waits for the vessel layer's snapshot at that time and selects the
vessel once it has it.
## Street-level photos (`sl`)
Views built by `show_in_gods_eye_view` or `src/view/index.js` can add
`sl=<source>,<lat>,<lon>` to the long form. Once the link has restored, the
app opens the nearest photo there, or, for a source that bills every load,
arms the panel so OPEN loads it (see `docs/TOOLS.md`). The app's own links do
not carry it: the address bar's long form drops `sl` when the camera next
moves, and the short form has no field for it. A reload or the app's Copy
Link keeps the lit source, the layer and the map, but not the photo request.
## Payload
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
+23 -5
View File
@@ -102,11 +102,29 @@ action draws (pins, highlights, areas, arrows, routes and labels at a place
name or coordinates). Links carry them in the `an` parameter, and the app
draws them once the link has been restored.
A view can open street-level photos too: `street_level` names a source
(`google` for Street View, `mapillary`, `kartaview`, `panoramax`, or none for
the lit one) and a point, by default the spot the camera looks at. Street View
shows only on the Google 3D map, so a `google` source sets `map` to
`photoreal` and refuses another; a view cannot both follow an entity and open
photos. Links carry it as `sl=<source>,<lat>,<lon>`: once restored, the app
runs `show_street_level`, which flies there and opens the nearest photo. A
plain link with a `google` source only arms the panel for OPEN GOOGLE STREET
VIEW; a view posted into the embedded app opens it, and so does the panel
page's own opening link, but only for a tool result issued in the last five
minutes (an older one, such as a host remounting the panel, only arms);
`?embed=1` on a published link does not. Mapillary,
KartaView and Panoramax, which do not bill per image, open at once. A link
that follows an entity ignores `sl`.
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
tilted camera over an area looks at its center from behind; camera fields
given with an area override its framing. `resolveViewArguments` turns them
into a view.
`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and
`street_level`. A tilted camera over an area looks at its center from behind;
camera fields given with an area override its framing. A passed-through
view's `street_level` point moves with a new `area` or camera position (a
turned camera keeps it). `resolveViewArguments` turns them into a view. The
app's own links (the address bar, Copy Link, `/share/<code>`) do not carry
`sl`; see `docs/SHARE-LINK-FORMAT.md`.
### The God's Eye View panel
@@ -299,5 +317,5 @@ and the development server's internal routes. See SECURITY.md.
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks, something to follow, or street-level photos (Street View or Mapillary) at a place |
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
@@ -181,6 +181,18 @@ export function answerMapillaryRequest({ method, url, images }) {
: [];
return { ...json(200, { data }), known: true };
}
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return {
...json(400, { error: 'unsupported images query' }),
+1
View File
@@ -2192,6 +2192,7 @@
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/marker.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
+244
View File
@@ -46,6 +46,15 @@ const VIEWPORT = { width: 1440, height: 900 };
/** Over downtown Sacramento, above the fixture photo line, looking down. */
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
const DAY_MS = 86_400_000;
/** A Sacramento street with Street View, for the sl=google link step. */
const STREET_VIEW_STREET = Object.freeze({ lon: -121.49372, lat: 38.57986 });
/**
* What a Street View lookup and load cost: the Maps JS API's
* SingleImageSearch (the nearest lookup), GetMetadata and the panorama tiles.
* Script loads are not counted.
*/
const STREET_VIEW_REQUEST =
/MapsJsInternalService\/(SingleImageSearch|GetMetadata)|GeoPhotoService|streetviewpixels-pa\.googleapis\.com/;
/**
* Coverage for any street-zoom tile: a grid of 360°/flat, recent/old
@@ -234,6 +243,28 @@ async function boot(page, url) {
});
}
/**
* Open a share link as a fresh document. Changing only the hash of a live
* page and reloading races the app's share-link writer, which can put its own
* state back in the hash before the reload reads it. The page's fixtures (a
* CDP session on the page) carry over.
*/
async function bootLink(page, link) {
await page.goto('about:blank');
await boot(page, link);
}
/** The camera's position in degrees and metres. */
const cameraAt = (page) =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionCartographic;
return {
lon: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
height: c.height,
};
});
/** Run `test(uiState, arg)` in the page until it is true. */
const uiUntil = (page, test, arg = null, timeout = 30_000) =>
page.waitForFunction(
@@ -780,6 +811,219 @@ async function main() {
await uiUntil(page, litAlone, 'mapillary');
await park(page);
const base = url.split('#')[0];
await fixtureStep(
'a link to street-level photos (sl=) opens the photo at its point once the link restores',
async () => {
const photo = fixture.images[0];
// The camera sits ~330 m north of the photo: the lookup must use
// the link's point, not the view centre.
const cameraLat = Number((photo.lat + 0.003).toFixed(6));
await bootLink(
page,
`${base}#v=2&lat=${cameraLat}&lon=${photo.lon}&alt=600&heading=0&pitch=-60&roll=0&sl=mapillary,${photo.lat},${photo.lon}`,
);
await uiUntil(
page,
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
null,
90_000,
);
const { street } = await getUI(page);
assert.equal(street.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, 'mapillary');
assert.equal(
street.imageId,
photo.id,
`the photo at the link's point, not the one nearest the camera: ${street.imageId}`,
);
assert.equal(
await page.evaluate(() => location.hash.includes('sl=')),
false,
'the rewritten link no longer carries the one-shot request',
);
},
);
await fixtureStep(
'the show_street_level action opens the nearest photo at a point without a pointer',
async () => {
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
const photo = fixture.images[0];
const result = await page.evaluate(
(at) =>
window.__godsEyeView.voiceCommands.runner('show_street_level', {
latitude: at.lat,
longitude: at.lon,
provider: 'mapillary',
fly: false,
}),
{ lat: photo.lat, lon: photo.lon },
);
assert.equal(
result.ok,
true,
`action failed: ${result.stage} ${result.error}`,
);
assert.equal(result.provider, 'mapillary');
assert.ok(
fixture.images.some((image) => image.id === result.imageId),
`a fixture photo opened: ${result.imageId}`,
);
assert.match(result.say || '', /^Mapillary at /);
},
);
await step(
'an sl=google link never loads a photo by itself: keyless it stops at the key, keyed it arms and waits for OPEN',
async ({ skipped }) => {
const point = STREET_VIEW_STREET;
// ~330 m north of the street: a flight would move the camera, and a
// press that ignored the armed point would open something else.
const cameraLat = Number((point.lat + 0.003).toFixed(6));
const googleRequests = [];
const onRequest = (request) => {
if (STREET_VIEW_REQUEST.test(request.url()))
googleRequests.push(request.url());
};
page.on('request', onRequest);
try {
await bootLink(
page,
`${base}#v=2&lat=${cameraLat}&lon=${point.lon}&alt=600&heading=0&pitch=-60&roll=0&map=osm&sl=google,${point.lat},${point.lon}`,
);
await page.waitForFunction(
(want) => {
const c =
window.__godsEyeView.viewer.camera.positionCartographic;
return Math.abs((c.latitude * 180) / Math.PI - want) < 0.0005;
},
{ timeout: 60_000 },
cameraLat,
);
const googleKey = await page.evaluate(() =>
Boolean(window.__GOOGLE_MAPS_API_KEY__),
);
if (!googleKey) {
// Without a key the action stops at the key, before any flight,
// map switch or source change, and the direct call says why.
const direct = await page.evaluate(
(at) =>
window.__godsEyeView.voiceCommands.runner(
'show_street_level',
{
latitude: at.lat,
longitude: at.lon,
provider: 'google',
fly: false,
autoOpen: false,
},
),
point,
);
assert.equal(direct.ok, false);
assert.equal(
direct.stage,
'key',
`${direct.stage} ${direct.error}`,
);
const after = await page.evaluate(() => {
const m =
window.__godsEyeView.dataManager.layers.get(
'street-level',
).module;
const u = m.getUIState();
return {
stack: window.__godsEyeView.mapStackController.getActiveId(),
google: m.getParams().google === true,
armed: u.street.armed,
open: u.street.open,
};
});
assert.deepEqual(
after,
{ stack: 'osm', google: false, armed: null, open: false },
'a keyless Street View link changes nothing',
);
const still = await cameraAt(page);
assert.ok(
Math.abs(still.lat - cameraLat) < 0.0005,
`the camera did not fly: ${still.lat}`,
);
assert.deepEqual(googleRequests, [], 'no Street View request');
return;
}
const photoreal = await page.evaluate(() =>
window.__godsEyeView.mapStackController.isStackAvailable(
'photoreal',
),
);
if (!photoreal) return skipped('no Google 3D');
await uiUntil(
page,
(u) =>
u.street.armed !== null &&
u.providers.some((p) => p.id === 'google' && p.on),
null,
90_000,
);
const beforePress = googleRequests.length;
const waiting = await getUI(page);
assert.equal(
waiting.street.open,
false,
'nothing open without a click',
);
assert.equal(waiting.street.imageId, null);
assert.deepEqual(
waiting.street.armed.point,
{ lat: point.lat, lon: point.lon },
'armed at the link point',
);
assert.equal(
beforePress,
0,
'no Street View request before the press',
);
await expandPanel(page);
await press(page, '#sl-nearest-btn');
await uiUntil(
page,
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
null,
90_000,
);
const { street } = await getUI(page);
assert.equal(street.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, 'google');
assert.ok(
googleRequests.length >= 1,
'the press made the Street View request',
);
const metres = Math.hypot(
(street.position.lat - point.lat) * 111_320,
(street.position.lon - point.lon) *
111_320 *
Math.cos((point.lat * Math.PI) / 180),
);
assert.ok(
metres <= 250,
`within 250 m of the link point: ${Math.round(metres)} m`,
);
// Back to Mapillary: the keyless page restores the saved layer
// and expects its KEY REQUIRED.
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
await uiUntil(page, litAlone, 'mapillary');
} finally {
page.off('request', onRequest);
}
},
);
await fixtureStep(
// The fixture bundle carries a token, so this covers a keyless
// server behind a keyed bundle; the keyless default is checked above.
+1
View File
@@ -904,6 +904,7 @@ const SLOW_TOOLS = new Set([
'annotate_map',
'fly_to_location',
'select_nearest_aircraft',
'show_street_level',
'analyst_query',
'fly_route',
'frame_overhead',
+8
View File
@@ -35,6 +35,14 @@ export const CORE_PHRASES = Object.freeze([
{ phrase: 'Show me the whole earth', expect: 'zoom_to_globe' },
{ phrase: 'Frame the aircraft near us from overhead', expect: 'frame_overhead', typedArgs: [where('frame_overhead', (a) => a.target === 'flights')] },
// — street-level photos: one composite action, never fly + basemap —
{ phrase: 'Show me the Eiffel Tower on Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && /eiffel/i.test(a.query || ''))] },
{ phrase: 'Street view of 10th Street in Sacramento', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google')] },
{ phrase: 'Show me Times Square on Mapillary', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'mapillary')] },
{ phrase: 'Switch to Google Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && !a.query)] },
{ phrase: 'Show me the Eiffel Tower', expect: 'fly_to_location' },
{ phrase: 'Turn on street level', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'street-level' && a.enabled === true)] },
// — the satellites trap: data layer, never basemap —
{ phrase: 'Show me the satellites', expect: { oneOf: ['set_layer_visibility', 'frame_overhead'] } },
{ phrase: 'Turn off the satellites', expect: 'set_layer_visibility', args: { layerId: 'satellites' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'satellites' && a.enabled === false)] },
+2 -1
View File
@@ -38,7 +38,7 @@ function realtimeDirectives(
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
'Do not mention disabled layers or stale selections.',
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, show_street_level, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.',
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers, approximate outlines and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
@@ -65,6 +65,7 @@ function realtimeDirectives(
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
'STREET LEVEL: "X on Street View/Mapillary/KartaView/Panoramax", "street view of X" → show_street_level once, not fly_to_location; no place = view centre. Street View is never a basemap.',
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
@@ -65,6 +65,28 @@ export const ACTION_DESCRIPTIONS = {
},
},
},
show_street_level: {
description:
'Fly to a place and open its nearest street-level photo; Street View switches to Google 3D (billed). No query: view centre.',
$position: 1,
parameters: {
properties: {
query: {
description: 'Place or address; "pointer" = under the cursor.',
$position: 1,
},
provider: {
description:
'google = Street View; others by name; omit: the lit source.',
$position: 1,
},
referent: {
description: 'Numbered item n from the last result list.',
$position: 1,
},
},
},
},
adjust_camera_zoom: {
description:
'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
+272 -24
View File
@@ -12,13 +12,25 @@
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
import {
announceNavigationAuthority,
watchCameraOwner,
} from '../navigationPolicy.js';
import { t, translateKnown } from '../i18n/index.js';
import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js';
import {
annotationsFromParams,
createView,
followFromParams,
groundedLookAt,
streetLevelFromParams,
} from '../view/index.js';
const EMBED_VIEW_MESSAGE = 'gev:view';
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
const EMBED_READY_MESSAGE = 'gev:ready';
const STREET_LEVEL_LAYER = 'street-level';
const FOLLOW_LAYERS = {
aircraft: 'flights',
military_aircraft: 'military',
@@ -27,6 +39,8 @@ const FOLLOW_LAYERS = {
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/** How long a posted view waits for the terrain under its camera. */
const TERRAIN_WAIT_MS = 2000;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
@@ -45,6 +59,14 @@ export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
/**
* Whether the panel page started the app for a tool result issued just now,
* not one its host handed back on mounting the panel again.
*/
function isFreshPanelResult() {
return isEmbeddedInline() && globalThis.GEV_EMBED_FRESH === true;
}
/**
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
* may report it hidden while it is on screen (see keepPanelRendering), so an
@@ -165,6 +187,40 @@ function flyTo(viewer, camera) {
});
}
/**
* The `show_street_level` arguments for a view's street-level photos. A plain
* link (`autoOpen: false`) only arms a source that bills every load; a view
* posted into the embedded app is the client's own request and opens.
*/
function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
return {
latitude: streetLevel.lat,
longitude: streetLevel.lon,
...(streetLevel.provider ? { provider: streetLevel.provider } : {}),
fly: true,
...(autoOpen ? {} : { autoOpen: false }),
};
}
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
/** Dispatched on the window when a link opened after start-up has restored. */
const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled';
/** The armed link's toast, naming the OPEN button as the panel shows it. */
function armedToast() {
return t('Street View is ready: press {open} to load it', {
open: t('OPEN {labels}', { labels: translateKnown(GOOGLE_LABEL) }),
});
}
/** Why a link's photo stays shut: its source has no working key here. */
function keyToast(result) {
return t(
"{source} cannot open this link's street-level photo without a working key",
{ source: translateKnown(result.providerName || 'Street Level') },
);
}
/**
* Make the app show a view: style and map, exactly the view's layers,
* annotations, the camera, and the followed entity. Each step runs through the
@@ -173,13 +229,20 @@ function flyTo(viewer, camera) {
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
{
viewer,
dataManager,
run,
signal,
autoOpen = true,
retryMs = FOLLOW_RETRY_MS,
},
) {
const steps = [];
const act = async (name, args) => {
const act = async (name, args, options) => {
let result = null;
try {
result = await run(name, args);
result = await run(name, args, options);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
@@ -198,6 +261,8 @@ export async function applyView(
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
}
for (const layerId of view.layers) {
// The street-level action lights its source first, then the layer.
if (view.street_level && layerId === STREET_LEVEL_LAYER) continue;
if (!dataManager.isEnabled(layerId))
await act('set_layer_visibility', { layerId, enabled: true });
}
@@ -207,6 +272,29 @@ export async function applyView(
await act('clear_annotations', {});
if (view.annotations.length)
await act('annotate_map', { annotations: [...view.annotations] });
if (view.street_level) {
// The photos frame the camera; when the action fails before flying, the
// view's own camera still applies.
// A posted view replays someone else's view, as a link does: the source
// it lights is not saved as the visitor's own choice.
const result = await act(
'show_street_level',
streetLevelArgs(view.street_level, { autoOpen }),
{ origin: 'programmatic' },
);
if (result?.ok !== true) {
// Overtaken (a click on the map, a newer request): hands off.
if (result?.cancelled || result?.superseded) return steps;
// An earlier view's photo must not stay over a place this view left.
// From the imagery stage on the action has settled the panel itself
// (a photo it could not load keeps its error).
if (result?.stage !== 'imagery')
dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.();
if (!result?.location)
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
}
return steps;
}
if (!view.follow) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
return steps;
@@ -250,18 +338,115 @@ export function installViews({
dataManager,
run,
signal,
terrain = null,
location = globalThis.location,
windowRef = globalThis.window,
}) {
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
const linked = annotationsFromParams(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
const hashParams = new URLSearchParams(
String(location?.hash || '').replace(/^#/, ''),
);
if (linked.length)
void ready.then(() => {
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
// The link's own steps run first, one at a time, and posted views queue
// behind them; one failing step does not take the next with it. A passive
// link never flies over someone who took the camera: its steps are skipped
// when a newer link superseded it, when its flight never landed (the
// recipient dragged or searched), or after any navigation since the
// generation its restore flight owned.
const cameraLanded = (outcome) => outcome?.share?.camera === 'applied';
const restoreGeneration = (outcome) =>
Number.isInteger(outcome?.navigationToken)
? outcome.navigationToken
: shell._navigationGeneration;
let generationAtRestore = null;
let startSuperseded = false;
let queue = ready.then((outcome) => {
generationAtRestore = restoreGeneration(outcome);
startSuperseded =
outcome?.status === 'superseded' ||
(Boolean(outcome?.share) && !cameraLanded(outcome));
});
const navigatedSince = (generation) =>
Number.isFinite(generation) && shell._navigationGeneration !== generation;
const linkStep = (work) =>
queue.then(async () => {
if (signal?.aborted) return;
try {
await work();
} catch {
/* reported by the action itself */
}
});
if (!isEmbedded(location)) return () => {};
const linked = annotationsFromParams(hashParams);
if (linked.length)
queue = linkStep(() => run('annotate_map', { annotations: linked }));
// A link's street-level photos open once it has restored. A billed source
// only arms, except for the panel page's own link to a result issued just
// now. A link that follows an entity keeps the camera on it.
const linkedStreetLevel = (params) =>
followFromParams(params) ? null : streetLevelFromParams(params);
const openLinkedStreetLevel = async (
streetLevel,
{ generation, autoOpen = false },
) => {
if (navigatedSince(generation)) return;
// The link replays the sender's view: nothing it switches becomes the
// recipient's own preference.
const result = await run(
'show_street_level',
streetLevelArgs(streetLevel, { autoOpen }),
{ origin: 'programmatic' },
);
// A source without a working key changes nothing: say why, or the link
// looks broken. In a frame the host reads the result instead.
if (result?.stage === 'key' && !isEmbedded(location)) {
shell._showToast?.(keyToast(result));
return;
}
// OPEN must be in view, but embed mode hides the rail that holds it: in
// a frame a posted view is the way to open a photo.
if (result?.armed === true && !isEmbedded(location)) {
// Opened for this link only: the recipient's saved layout stays theirs.
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
explicit: true,
persist: false,
});
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
shell._showToast?.(armedToast());
}
};
const startStreetLevel = linkedStreetLevel(hashParams);
if (startStreetLevel)
queue = linkStep(() =>
startSuperseded
? undefined
: openLinkedStreetLevel(startStreetLevel, {
generation: generationAtRestore,
autoOpen: isFreshPanelResult(),
}),
);
// A link pasted into the open tab: its photos follow once its restore
// settles, under the same rules.
const onNavigatedRestore = (event) => {
if (event?.detail?.status !== 'settled' || !cameraLanded(event.detail))
return;
const streetLevel = linkedStreetLevel(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (!streetLevel) return;
const generation = restoreGeneration(event.detail);
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
};
windowRef?.addEventListener?.(
SHARE_NAVIGATION_RESTORED_EVENT,
onNavigatedRestore,
);
const stopNavigatedRestores = () =>
windowRef?.removeEventListener?.(
SHARE_NAVIGATION_RESTORED_EVENT,
onNavigatedRestore,
);
if (!isEmbedded(location)) return stopNavigatedRestores;
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
@@ -278,7 +463,6 @@ export function installViews({
peer.postMessage(message, origin);
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
const onMessage = (event) => {
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
@@ -302,19 +486,82 @@ export function installViews({
);
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
// A panel's host can hand back old results on a remount: only one issued
// just now may open a billed photo. A framing page's view is its own.
const autoOpen = !isEmbeddedInline() || event.data.fresh === true;
// Photos asked for without a point open where the camera looks, measured
// from the ground under it, as show_in_gods_eye_view places them.
const asked = event.data.view?.street_level;
const unplaced =
asked &&
typeof asked === 'object' &&
asked.lat == null &&
asked.lon == null;
queue = queue
.then(async () => {
if (signal?.aborted) return;
if (unplaced && view.street_level) {
// A terrain service that does not answer soon leaves the view's
// own estimate.
const wait = AbortSignal.timeout(TERRAIN_WAIT_MS);
const owner = watchCameraOwner(viewer, shell);
const point = await groundedLookAt(
view.camera,
terrain,
signal ? AbortSignal.any([signal, wait]) : wait,
)
.catch(() => null)
.finally(() => owner.stop());
if (signal?.aborted) return;
// Whoever takes the camera while the terrain answers (a drag, a
// search, the assistant) keeps it: the view stands down.
if (owner.taken()) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
steps: [{ step: 'street_level', ok: false, superseded: true }],
},
replyOrigin,
);
return;
}
if (point)
view = createView({
...event.data.view,
street_level: { ...asked, ...point },
});
}
const steps = await applyView(view, {
viewer,
dataManager,
run,
signal,
autoOpen,
});
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
})
// A view that fails is answered, and the views behind it still apply.
.catch((error) =>
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error?.message || String(error),
},
replyOrigin,
),
);
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
@@ -322,6 +569,7 @@ export function installViews({
});
return () => {
stopRendering();
stopNavigatedRestores();
windowRef.removeEventListener('message', onMessage);
};
}
+901 -3
View File
@@ -8,7 +8,7 @@ import {
panelAwareVisibility,
} from './embed.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { createView } from '../view/index.js';
import { createView, viewToParams } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
const options = [];
const run = async (name, args, runOptions) => {
calls.push([name, args]);
options.push([name, runOptions]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
return { calls, run, options };
};
test('embed mode is opt-in by query parameter', () => {
@@ -83,6 +85,19 @@ test('a view is applied through the app actions in order', async () => {
assert.ok(steps.some((step) => step.step === 'camera'));
});
test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => {
const { options, run } = recorder();
await applyView(
createView({
camera: { lat: 48.86, lon: 2.29 },
street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 },
}),
{ viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run },
);
const [, runOptions] = options.find(([name]) => name === 'show_street_level');
assert.equal(runOptions?.origin, 'programmatic');
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
@@ -102,6 +117,65 @@ test('a followed entity is retried until its layer has it', async () => {
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
});
test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => {
const parent = { postMessage() {} };
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} };
const { calls, run } = recorder();
const asked = [];
// Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m.
const terrain = {
getHeights: async (points) => {
asked.push(points);
return points.map(() => ({ ellipsoid: 1590 }));
},
};
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
terrain,
location: { search: '?embed=1', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
const event = new Event('message');
Object.assign(event, {
source: parent,
origin: 'https://host.example',
data: {
type: 'gev:view',
id: 1,
view: {
camera: {
lat: 39.74,
lon: -104.99,
altitude_m: 1909,
pitch_deg: -30,
heading_deg: 0,
},
street_level: { provider: 'mapillary' },
},
},
});
windowRef.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 10));
remove();
const [, args] = calls.find(([name]) => name === 'show_street_level');
// 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the
// ellipsoid altitude would give.
assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]);
assert.ok(
args.latitude > 39.744 && args.latitude < 39.746,
String(args.latitude),
);
assert.equal(args.longitude, -104.99);
});
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const targets = [];
@@ -186,6 +260,59 @@ test('an embedded page takes views only from its parent and answers it', async (
remove();
});
test('an inline panel opens a billed photo only for a view whose result is fresh', async () => {
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = () => {};
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
const { calls, run } = recorder();
globalThis.GEV_EMBED_INLINE = true;
try {
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: '' },
windowRef,
});
const view = {
camera: { lat: 48.8584, lon: 2.2945 },
map: 'photoreal',
street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 },
};
for (const [id, fresh] of [
[1, true],
[2, false],
[3, undefined],
]) {
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id, view, fresh },
});
windowRef.dispatchEvent(event);
}
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(
calls
.filter(([name]) => name === 'show_street_level')
.map(([, args]) => args.autoOpen ?? true),
[true, false, false],
);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
@@ -393,3 +520,774 @@ test('a followed aircraft owns the camera, and cockpit view is entered after it'
/no cockpit view/,
);
});
test('a view with street-level photos lets the action fly and open them', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const view = createView({
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 },
street_level: { provider: 'google' },
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set()),
run,
});
assert.deepEqual(
calls.map(([name]) => name),
[
'set_map_stack',
'stop_tracking',
'clear_annotations',
'show_street_level',
],
);
assert.deepEqual(calls.at(-1)[1], {
latitude: 38.5799,
longitude: -121.4937,
provider: 'google',
fly: true,
});
assert.equal(viewer.flights.length, 0, 'the action frames the camera');
assert.ok(steps.every((step) => step.ok));
assert.equal(steps.at(-1).step, 'show_street_level');
});
test('a link to street-level photos opens them after it restores', async () => {
const { calls, run } = recorder();
const toasts = [];
const panels = [];
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_showToast: (text) => toasts.push(text),
setPanelCollapsed: (...args) => panels.push(args),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
[
'show_street_level',
{
latitude: 38.58,
longitude: -121.49,
provider: 'mapillary',
fly: true,
autoOpen: false,
},
],
]);
assert.deepEqual(
[toasts, panels],
[[], []],
'an opened photo needs no toast or panel nudge',
);
// A failure (no key) is not an armed state either: it says why instead.
const failed = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
error: 'needs its key',
}),
});
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_showToast: (text) => toasts.push(text),
setPanelCollapsed: (...args) => panels.push(args),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: failed.run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
[toasts, panels],
[
[
"Street Level cannot open this link's street-level photo without a working key",
],
[],
],
'it says why, and nothing to press opens',
);
});
test('a link that follows an entity keeps the camera: its photos are not opened', async () => {
const { calls, run } = recorder();
const params = viewToParams(
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'satellite', id: '25544' },
}),
);
params.set('sl', 'mapillary,1.00000,2.00000');
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: `#${params}` },
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, []);
});
test('a failing link step does not take the next one with it', async () => {
const calls = [];
const run = async (name, args) => {
calls.push(name);
if (name === 'annotate_map') throw new Error('marks failed');
return { ok: true, armed: false, ...args };
};
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['annotate_map', 'show_street_level']);
});
test('a link whose source has no working key says why instead of doing nothing; a frame stays quiet', async () => {
for (const embed of [false, true]) {
const toasts = [];
const { calls, run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
provider: 'google',
providerName: 'Google Street View',
error: 'Google Street View needs its key',
}),
});
const windowRef = new EventTarget();
if (embed) {
windowRef.document = { body: { classList: { add() {} } } };
windowRef.parent = { postMessage() {} };
}
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
setCleanView() {},
_showToast: (text) => toasts.push(text),
setPanelCollapsed() {
throw new Error('nothing to arm');
},
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: embed ? '?embed=1' : '',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls[0][0], 'show_street_level');
assert.deepEqual(
toasts,
embed
? []
: [
"Google Street View cannot open this link's street-level photo without a working key",
],
);
}
});
test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => {
const toasts = [];
const panels = [];
const revealed = [];
const { calls, run } = recorder({
show_street_level: () => ({ ok: true, armed: true }),
});
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_showToast: (text) => toasts.push(text),
setPanelCollapsed: (id, collapsed, options) =>
panels.push([id, collapsed, options]),
revealPanel: (id) => revealed.push(id),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls[0][1].autoOpen, false);
assert.deepEqual(toasts, [
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it',
]);
assert.deepEqual(
panels,
[['street-level-panel', false, { explicit: true, persist: false }]],
"the panel opens so OPEN is in view, without saving the layout as the recipient's",
);
assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too');
// Adding ?embed=1 to a published link does not lift the gate: the page is
// still a plain top-level load.
const framedToasts = [];
const framedPanels = [];
const framed = recorder({
show_street_level: () => ({ ok: true, armed: true }),
});
const windowRef = new EventTarget();
windowRef.document = { body: { classList: { add() {} } } };
windowRef.parent = { postMessage() {} };
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
setCleanView() {},
_showToast: (text) => framedToasts.push(text),
setPanelCollapsed: (...args) => framedPanels.push(args),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: framed.run,
location: {
search: '?embed=1',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(framed.calls[0][1].autoOpen, false);
// In a frame the rail that holds OPEN is hidden: no nudge, no toast.
assert.deepEqual([framedToasts, framedPanels], [[], []]);
// Only the panel page's own link for a result issued just now is the
// client's request: a host that mounts the panel again hands back an old
// result, and that one only arms, like any link.
globalThis.GEV_EMBED_INLINE = true;
try {
for (const fresh of [true, false]) {
globalThis.GEV_EMBED_FRESH = fresh;
const inline = recorder({ show_street_level: () => ({ ok: true }) });
const inlineWindow = new EventTarget();
inlineWindow.document = { body: { classList: { add() {} } } };
inlineWindow.postMessage = () => {};
// The inline panel keeps rendering from a timer; give it inert clocks.
Object.assign(inlineWindow, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setTimeout: () => 1,
clearTimeout() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = installViews({
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: inline.run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef: inlineWindow,
});
await new Promise((resolve) => setTimeout(resolve, 0));
if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false);
else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms');
stop();
}
} finally {
delete globalThis.GEV_EMBED_INLINE;
delete globalThis.GEV_EMBED_FRESH;
}
// applyView (a posted view) opens too.
const posted = recorder();
await applyView(
createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'google' },
}),
{
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: posted.run,
},
);
assert.equal('autoOpen' in posted.calls.at(-1)[1], false);
});
test('a view whose photos fail before any flight still gets its camera', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
error: 'needs its key',
}),
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'google' },
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set()),
run,
});
assert.equal(viewer.flights.length, 1, 'the view flies itself');
assert.deepEqual(
steps.map((step) => [step.step, step.ok]),
[
['set_map_stack', true],
['stop_tracking', true],
['clear_annotations', true],
['show_street_level', false],
['camera', true],
],
);
assert.ok(calls.some(([name]) => name === 'show_street_level'));
// A flight that happened (the action reports where) is not repeated.
const flown = fakeViewer();
const { run: flownRun } = recorder({
show_street_level: () => ({
ok: false,
stage: 'imagery',
location: 'Somewhere',
error: 'no imagery',
}),
});
await applyView(view, {
viewer: flown,
dataManager: fakeLayers(new Set()),
run: flownRun,
});
assert.equal(flown.flights.length, 0);
});
test('a link runs its marks and photos in order, and posted views wait behind them', async () => {
const log = [];
let releaseStreetLevel = null;
let releaseMarks = null;
const run = async (name, args) => {
log.push(`start:${name}`);
if (name === 'annotate_map')
await new Promise((resolve) => {
releaseMarks = resolve;
});
if (name === 'show_street_level')
await new Promise((resolve) => {
releaseStreetLevel = resolve;
});
log.push(`end:${name}`);
return {
ok: true,
...(name === 'show_street_level' ? { location: 'x' } : {}),
};
};
const windowRef = new EventTarget();
windowRef.document = { body: { classList: { add() {} } } };
// A framing page: answers go to it, not to the app's own window.
windowRef.parent = {
postMessage: (message) => log.push(`post:${message.type}`),
};
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '?embed=1',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
// A view arrives while the link's photos are still opening.
windowRef.dispatchEvent(
Object.assign(new Event('message'), {
source: windowRef.parent,
origin: 'null',
data: {
type: 'gev:view',
id: 'v1',
view: { camera: { lat: 3, lon: 4 } },
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const actions = () => log.filter((entry) => !entry.startsWith('post:'));
assert.deepEqual(
actions(),
['start:annotate_map'],
'the photos wait for the marks',
);
releaseMarks();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(actions().slice(0, 3), [
'start:annotate_map',
'end:annotate_map',
'start:show_street_level',
]);
assert.ok(
!log.includes('start:stop_tracking'),
'the posted view waits for the link',
);
releaseStreetLevel();
for (
let tick = 0;
tick < 20 && !log.includes('post:gev:view-applied');
tick++
)
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'),
true,
);
assert.ok(log.includes('post:gev:view-applied'), log.join(' '));
});
test('an aborted install never runs the link photos', async () => {
const { calls, run } = recorder();
const controller = new AbortController();
controller.abort();
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
signal: controller.signal,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, []);
});
test('a passive link stands down when someone navigated while it waited behind its marks', async () => {
const calls = [];
let releaseMarks = null;
const shell = {
initialRestorePromise: Promise.resolve(),
_navigationGeneration: 7,
};
const run = async (name) => {
calls.push(name);
if (name === 'annotate_map')
await new Promise((resolve) => {
releaseMarks = resolve;
});
return { ok: true };
};
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['annotate_map']);
shell._navigationGeneration = 8; // the recipient searched, or the assistant flew
releaseMarks();
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
calls,
['annotate_map'],
'the link did not fly over the newer navigation',
);
// Unchanged navigation: the photos open as before.
const quiet = [];
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_navigationGeneration: 3,
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: async (name) => {
quiet.push(name);
return { ok: true };
},
location: {
search: '',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
});
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
const { calls, run, options } = recorder();
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, 1);
assert.deepEqual(options, [
['show_street_level', { origin: 'programmatic' }],
]);
});
test('a start link whose restore a newer link superseded opens nothing', async () => {
const { calls, run } = recorder();
installViews({
shell: {
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
_navigationGeneration: 4,
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
});
test('a start link whose restore flight the recipient interrupted leaves the camera with them', async () => {
for (const [camera, expected] of [
['cancelled', 0],
['skipped', 0],
['applied', 1],
]) {
const { calls, run } = recorder();
installViews({
shell: {
initialRestorePromise: Promise.resolve({
status: 'settled',
share: { camera },
}),
_navigationGeneration: 4,
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, expected, camera);
}
});
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
const { calls, run, options } = recorder();
const windowRef = new EventTarget();
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
const shell = {
initialRestorePromise: Promise.resolve(),
_navigationGeneration: 1,
};
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location,
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'the start link has no photos');
const settled = (status, camera = 'applied') =>
windowRef.dispatchEvent(
Object.assign(new Event('gev:share-navigation-restore-settled'), {
detail: { status, share: { camera } },
}),
);
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
shell._navigationGeneration = 2; // the restore's own flight
settled('failed');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'a failed restore opens nothing');
// The recipient dragged or searched during the restore flight: the camera
// is theirs, and the link does not pull it back.
shell._navigationGeneration = 3;
for (const camera of ['cancelled', 'superseded']) {
settled('settled', camera);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
calls,
[],
`an interrupted restore (${camera}) opens nothing`,
);
}
settled('settled');
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
[
'show_street_level',
{
latitude: 48.8874,
longitude: 2.3495,
provider: 'mapillary',
fly: true,
autoOpen: false,
},
],
]);
assert.deepEqual(options.at(-1), [
'show_street_level',
{ origin: 'programmatic' },
]);
remove();
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
settled('settled');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, 1, 'removed with the install');
});
test('a view whose photo step the user overtook leaves their photo and camera alone', async () => {
// Photo B, opened by a click while the posted view looked up its photo.
const street = { open: false, imageId: null };
const dataManager = {
...fakeLayers(new Set()),
layers: new Map([
[
'street-level',
{
module: {
closeViewer: () =>
Object.assign(street, { open: false, imageId: null }),
},
},
],
]),
};
const { run } = recorder({
show_street_level: () => {
Object.assign(street, { open: true, imageId: 'b' });
return {
ok: false,
stage: 'imagery',
cancelled: true,
superseded: true,
error: 'The camera moved on before the photo opened',
};
},
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'mapillary' },
});
const viewer = fakeViewer();
const steps = await applyView(view, { viewer, dataManager, run });
assert.deepEqual(street, { open: true, imageId: 'b' }, 'photo B stays');
assert.equal(viewer.flights.length, 0, 'the camera stays with the user');
assert.equal(steps.at(-1).ok, false, 'reported as not applied');
});
test('a view whose photo was found but could not load keeps its error in view', async () => {
const closed = [];
const dataManager = {
...fakeLayers(new Set()),
layers: new Map([
['street-level', { module: { closeViewer: () => closed.push(true) } }],
]),
};
const { run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'imagery',
imageryError: true,
location: '1.00000, 2.00000',
error: 'Image could not be opened',
}),
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'mapillary' },
});
await applyView(view, { viewer: fakeViewer(), dataManager, run });
assert.equal(closed.length, 0, 'the viewer keeps the new photo’s error');
});
test('a view whose photos fail closes the photo an earlier view opened', async () => {
const closed = [];
const dataManager = {
...fakeLayers(new Set()),
layers: new Map([
['street-level', { module: { closeViewer: () => closed.push(true) } }],
]),
};
const { run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
error: 'needs its key',
}),
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'google' },
});
const viewer = fakeViewer();
await applyView(view, { viewer, dataManager, run });
assert.equal(closed.length, 1, 'closed before the view flies away');
assert.equal(viewer.flights.length, 1);
const opened = recorder({ show_street_level: () => ({ ok: true }) });
await applyView(view, { viewer, dataManager, run: opened.run });
assert.equal(closed.length, 1, 'an opened photo stays');
});
+118
View File
@@ -0,0 +1,118 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { installViews } from './embed.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { createAnnotationEngine } from '../annotations/annotationEngine.js';
import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs';
// A camera taken while a posted view waits on terrain stays with whoever took
// it: the stale view stands down whole, so it neither stops a newer follow nor
// frames its own marks. Offline: real installViews, action runner and
// annotation engine; the viewer, renderer, flights data and terrain are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); };
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
async function scenario(t, effect) {
document.body.className = '';
const entered = deferred();
const terrain = deferred();
const viewer = fakeCesiumViewer();
viewer.clock = { onTick: new Cesium.Event() };
viewer.canvas = viewer.scene.canvas;
// A real perspective frustum looking down at 0,0; the old view's mark
// at 48N,2E is outside it. No override of the engine's visibility logic.
Object.assign(viewer.camera, {
position: Cesium.Cartesian3.fromDegrees(0, 0, 1000),
direction: new Cesium.Cartesian3(-1, 0, 0),
up: new Cesium.Cartesian3(0, 0, 1),
heading: 0,
frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }),
});
const entity = { id: 'fixture-aircraft' };
const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 };
let stopCalls = 0;
const flights = {
findByQuery: () => aircraft,
trackById: () => { viewer.trackedEntity = entity; return true; },
getTrackedInfo: () => viewer.trackedEntity ? aircraft : null,
stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; },
};
const dataManager = {
layers: new Map([['flights', { module: flights }]]),
getAll: () => [{ id: 'flights', enabled: true }],
isEnabled: id => id === 'flights',
setLayerParams: () => true,
};
const shell = {
_navigationGeneration: 1,
initialRestorePromise: Promise.resolve(),
setCleanView() {},
runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); },
};
const annotations = createAnnotationEngine({ viewer,
renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} },
});
const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations });
const calls = [];
const run = async (name, args, options) => {
calls.push(name);
return runner(name, args, options);
};
const replies = [];
const peer = { postMessage: message => replies.push(message) };
const windowRef = new EventTarget();
windowRef.parent = peer;
windowRef.document = document;
const remove = installViews({ shell, viewer, dataManager, run, windowRef,
location: { search: '?embed=1', hash: '' },
terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } },
});
t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; });
await tick();
const event = new Event('message');
Object.assign(event, { source: peer, origin: 'https://host.example', data: {
type: 'gev:view', id: 1, view: {
camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 },
layers: ['flights'],
street_level: { provider: 'mapillary' },
annotations: effect === 'annotations'
? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [],
},
} });
windowRef.dispatchEvent(event);
await entered.promise;
if (effect === 'follow') {
const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' });
assert.equal(follow.ok, true, 'new follow starts through the real runner');
assert.equal(viewer.trackedEntity, entity);
} else {
viewer.canvas.dispatchEvent(new Event('pointerdown'));
}
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick();
const reply = replies.find(reply => reply.id === 1);
t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity,
annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply }));
assert.ok(reply, 'host receives completion');
assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied');
assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler');
if (effect === 'follow') {
assert.equal(stopCalls, 0, 'stale view must not stop the newer follow');
assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected');
} else {
assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera');
}
}
for (const effect of ['follow', 'annotations']) {
test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect));
}
+23 -7
View File
@@ -462,8 +462,11 @@ export function panelRuntime(config) {
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
async function startApp(url, view, fresh = false) {
window.GEV_EMBED_INLINE = true;
// Whether this result was just issued, not handed back by a host that
// mounted the panel again: only then may its link open a billed photo.
window.GEV_EMBED_FRESH = fresh;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
@@ -551,13 +554,21 @@ export function panelRuntime(config) {
);
}
// Tool results: the first view starts the app, later ones move it.
// Tool results: the first view starts the app, later ones move it. A
// result issued longer ago than this was handed back on a remount.
const FRESH_RESULT_MS = 5 * 60 * 1000;
let started = false;
let ready = false;
let queued = null;
let queuedFresh = false;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
// Every view carries whether its result was issued just now: only those may
// open a billed photo, whichever result on this mount they came in.
const postView = (view, fresh) =>
window.postMessage(
{ type: 'gev:view', id: nextId++, view, fresh: fresh === true },
'*',
);
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
@@ -565,13 +576,18 @@ export function panelRuntime(config) {
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
const issuedAt = Number(data.issued_at);
const fresh =
Number.isFinite(issuedAt) &&
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
if (ready) return postView(view, fresh);
queued = view;
queuedFresh = fresh;
if (started) return;
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
startApp(url, view, fresh).catch((error) =>
// i18n-ignore: host-panel script, no app catalogs
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
@@ -592,7 +608,7 @@ export function panelRuntime(config) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
if (queued) postView(queued, queuedFresh);
queued = null;
}
return;
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { panelRuntime } from './globePanelRuntime.js';
const element = () => ({
isConnected: true,
hidden: true,
textContent: '',
addEventListener() {},
remove() {
this.isConnected = false;
},
});
test('every view the panel posts says whether its result was issued just now', async () => {
const savedWindow = globalThis.window;
const savedDocument = globalThis.document;
const views = [];
const parent = { postMessage() {} };
const windowRef = new EventTarget();
Object.assign(windowRef, {
parent,
postMessage: (message) => {
if (message?.type === 'gev:view') views.push(message);
},
});
const elements = { status: element(), open: element(), expand: element() };
globalThis.window = windowRef;
globalThis.document = {
getElementById: (id) => elements[id] ?? null,
addEventListener() {},
baseURI: 'https://host.example/panel',
body: { clientWidth: 600 },
};
const deliver = (source, data) => {
const event = new Event('message');
Object.assign(event, { source, data });
windowRef.dispatchEvent(event);
};
const result = (issuedAt) => ({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: {
structuredContent: {
url: 'http://localhost:4173/#sl=google,48.85840,2.29450',
issued_at: issuedAt,
view: { camera: { lat: 48.8584, lon: 2.2945 } },
},
},
});
try {
panelRuntime({
appBaseUrl: 'http://localhost:4173/',
panelBase: '/panel/',
protocolVersion: '2025-06-18',
panelHeight: 480,
});
deliver(windowRef, { type: 'gev:ready' });
deliver(parent, result(Date.now()));
// A host replaying an old result on the same mount.
deliver(parent, result(Date.now() - 60 * 60 * 1000));
deliver(parent, result(undefined));
} finally {
globalThis.window = savedWindow;
globalThis.document = savedDocument;
}
assert.deepEqual(
views.map((message) => message.fresh),
[true, false, false],
);
});
+3 -1
View File
@@ -246,8 +246,10 @@ export function createApplicationTools({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
run: (name, args, options = {}) =>
voiceCommands.runner(name, args, { ...options, signal }),
signal,
terrain: operations.requests.terrain,
}),
);
// An application's own tool takes the pointer as Draw does, and its own
+2
View File
@@ -982,6 +982,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
// show_street_level (street-level photos) is a later, separate tool.
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
(tool) =>
![
@@ -989,6 +990,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'resolve_area',
'find_imagery',
'osm_query',
'show_street_level',
].includes(tool.name),
);
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
+28 -1
View File
@@ -1889,6 +1889,7 @@
"KartaView did not filter this area in time — move the map to ask again": "لم يُصفِّ KartaView هذه المنطقة في الوقت المحدد — حرّك الخريطة لإعادة الطلب",
"None of KartaView’s photos here pass the imagery filter": "لا تجتاز أي من صور KartaView هنا مرشح الصور",
"KartaView did not answer in time — try again": "لم يستجب KartaView في الوقت المحدد — أعد المحاولة",
"Mapillary did not answer in time — try again": "لم يستجب Mapillary في الوقت المحدد — أعد المحاولة",
"KartaView could not be reached — check the network": "تعذّر الوصول إلى KartaView — تحقّق من الشبكة",
"KartaView sent an answer that could not be read": "أرسل KartaView استجابة تعذّرت قراءتها",
"KartaView is rate-limiting requests": "يقيّد KartaView معدل الطلبات",
@@ -3734,6 +3735,17 @@
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.",
"Press OPEN to load the photo": "اضغط «فتح» لتحميل الصورة",
"Captured {month}": "التُقطت في {month}",
"this spot": "هذا الموضع",
"the view centre": "مركز العرض",
"{source} at {place}": "{source} في {place}",
"Street level at {place}": "مستوى الشارع في {place}",
"Switching map": "جارٍ تبديل الخريطة",
"Opening imagery": "جارٍ فتح الصور",
"Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله",
"{source} cannot open this link's street-level photo without a working key": "لا يمكن لـ {source} فتح صورة مستوى الشارع في هذا الرابط بدون مفتاح صالح",
"Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا.",
"STATIONS": "المحطات",
"Crewed stations and the craft that visit them": "المحطات المأهولة والمركبات التي تزورها",
"IMAGING": "التصوير",
@@ -3788,5 +3800,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}"
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}",
"Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°",
"Street Level is unavailable": "مستوى الشارع غير متاح",
"No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع",
"Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع",
"Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور",
"Aim the view at a street": "وجّه العرض نحو شارع",
"{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا",
"{source} needs its key": "يحتاج {source} إلى مفتاحه",
"{source} needs the {map} map, which is not available": "يحتاج {source} إلى خريطة {map}، وهي غير متاحة",
"{source} could not be switched on": "تعذّر تفعيل {source}",
"{source} is not available on this map": "{source} غير متاح على هذه الخريطة",
"{source} imagery could not be opened": "تعذّر فتح صور {source}",
"{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه",
"No {source} imagery near {place}": "لا توجد صور {source} قرب {place}",
"{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})"
}
+28 -1
View File
@@ -1791,6 +1791,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView hat dieses Gebiet nicht rechtzeitig gefiltert – Karte verschieben, um erneut anzufragen",
"None of KartaView’s photos here pass the imagery filter": "Keines der KartaView-Fotos hier passt zum Bildfilter",
"KartaView did not answer in time — try again": "KartaView hat nicht rechtzeitig geantwortet – erneut versuchen",
"Mapillary did not answer in time — try again": "Mapillary hat nicht rechtzeitig geantwortet – erneut versuchen",
"KartaView could not be reached — check the network": "KartaView ist nicht erreichbar – Netzwerk prüfen",
"KartaView sent an answer that could not be read": "KartaView hat eine unlesbare Antwort gesendet",
"KartaView is rate-limiting requests": "KartaView begrenzt die Anfragen",
@@ -3410,6 +3411,17 @@
"Refresh map still": "Kartenstandbild aktualisieren",
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
"Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden",
"Captured {month}": "Aufgenommen im {month}",
"this spot": "diese Stelle",
"the view centre": "die Bildmitte",
"{source} at {place}": "{source} bei {place}",
"Street level at {place}": "Straßenebene bei {place}",
"Switching map": "Karte wird gewechselt",
"Opening imagery": "Bilder werden geöffnet",
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
"{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen",
"Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.",
"STATIONS": "STATIONEN",
"Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen",
"IMAGING": "BILDGEBUNG",
@@ -3464,5 +3476,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "Schiffs-UUID: {id}"
"Vessel UUID: {id}": "Schiffs-UUID: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen",
"Street Level is unavailable": "Straßenebene ist nicht verfügbar",
"No street-level imagery source is configured": "Keine Quelle für Bilder auf Straßenebene konfiguriert",
"Street Level could not be enabled": "Straßenebene konnte nicht eingeschaltet werden",
"Street Level changed while it looked for imagery": "Straßenebene hat sich während der Bildersuche geändert",
"Aim the view at a street": "Die Ansicht auf eine Straße richten",
"{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene",
"{source} needs its key": "{source} braucht seinen Schlüssel",
"{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist",
"{source} could not be switched on": "{source} konnte nicht eingeschaltet werden",
"{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar",
"{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden",
"{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann",
"No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}",
"{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})"
}
+28 -1
View File
@@ -2411,6 +2411,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo",
"None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes",
"KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo",
"Mapillary did not answer in time — try again": "Mapillary no respondió a tiempo — inténtalo de nuevo",
"KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red",
"KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer",
"KartaView is rate-limiting requests": "KartaView está limitando las peticiones",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Actualizar imagen fija del mapa",
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
"Press OPEN to load the photo": "Pulse ABRIR para cargar la foto",
"Captured {month}": "Capturada en {month}",
"this spot": "este punto",
"the view centre": "el centro de la vista",
"{source} at {place}": "{source} en {place}",
"Street level at {place}": "A pie de calle en {place}",
"Switching map": "Cambiando de mapa",
"Opening imagery": "Abriendo imágenes",
"Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo",
"{source} cannot open this link's street-level photo without a working key": "{source} no puede abrir la foto a pie de calle de este enlace sin una clave válida",
"Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero.",
"STATIONS": "ESTACIONES",
"Crewed stations and the craft that visit them": "Estaciones tripuladas y las naves que las visitan",
"IMAGING": "IMÁGENES",
@@ -3545,5 +3557,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID del buque: {id}"
"Vessel UUID: {id}": "UUID del buque: {id}",
"Latitude must be within ±90° and longitude within ±180°": "La latitud debe estar dentro de ±90° y la longitud dentro de ±180°",
"Street Level is unavailable": "A pie de calle no está disponible",
"No street-level imagery source is configured": "No hay ninguna fuente de imágenes a pie de calle configurada",
"Street Level could not be enabled": "No se pudo activar A pie de calle",
"Street Level changed while it looked for imagery": "A pie de calle cambió mientras buscaba imágenes",
"Aim the view at a street": "Apunta la vista a una calle",
"{source} is not a street-level imagery source here": "{source} no es una fuente de imágenes a pie de calle aquí",
"{source} needs its key": "{source} necesita su clave",
"{source} needs the {map} map, which is not available": "{source} requiere el mapa {map}, que no está disponible",
"{source} could not be switched on": "No se pudo activar {source}",
"{source} is not available on this map": "{source} no está disponible en este mapa",
"{source} imagery could not be opened": "No se pudieron abrir las imágenes de {source}",
"{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes",
"No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}",
"{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})"
}
+28 -1
View File
@@ -1185,6 +1185,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView n'a pas filtré cette zone à temps — déplacez la carte pour relancer la demande",
"None of KartaView’s photos here pass the imagery filter": "Aucune photo KartaView de cet endroit ne passe le filtre d'images",
"KartaView did not answer in time — try again": "KartaView n'a pas répondu à temps — réessayez",
"Mapillary did not answer in time — try again": "Mapillary n'a pas répondu à temps — réessayez",
"KartaView could not be reached — check the network": "KartaView est injoignable — vérifiez le réseau",
"KartaView sent an answer that could not be read": "KartaView a envoyé une réponse illisible",
"KartaView is rate-limiting requests": "KartaView limite les requêtes",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Actualiser l'image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
"Press OPEN to load the photo": "Appuyez sur OUVRIR pour charger la photo",
"Captured {month}": "Prise en {month}",
"this spot": "cet endroit",
"the view centre": "le centre de la vue",
"{source} at {place}": "{source} à {place}",
"Street level at {place}": "Niveau rue à {place}",
"Switching map": "Changement de carte",
"Opening imagery": "Ouverture des images",
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
"{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide",
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.",
"STATIONS": "STATIONS",
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
"IMAGING": "IMAGERIE",
@@ -3545,5 +3557,20 @@
"ICAO: {id}": "ICAO : {id}",
"NORAD: {id}": "NORAD : {id}",
"MMSI: {id}": "MMSI : {id}",
"Vessel UUID: {id}": "UUID du navire : {id}"
"Vessel UUID: {id}": "UUID du navire : {id}",
"Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°",
"Street Level is unavailable": "Niveau rue n'est pas disponible",
"No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée",
"Street Level could not be enabled": "Impossible d'activer Niveau rue",
"Street Level changed while it looked for imagery": "Niveau rue a changé pendant la recherche d'images",
"Aim the view at a street": "Orientez la vue vers une rue",
"{source} is not a street-level imagery source here": "{source} n'est pas une source d'images au niveau de la rue ici",
"{source} needs its key": "{source} a besoin de sa clé",
"{source} needs the {map} map, which is not available": "{source} nécessite la carte {map}, qui n'est pas disponible",
"{source} could not be switched on": "Impossible d'activer {source}",
"{source} is not available on this map": "{source} n'est pas disponible sur cette carte",
"{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}",
"{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images",
"No {source} imagery near {place}": "Aucune image {source} près de {place}",
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})"
}
+28 -1
View File
@@ -1051,6 +1051,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView לא סינן את האזור בזמן — הזז את המפה כדי לשאול שוב",
"None of KartaView’s photos here pass the imagery filter": "אף תמונת KartaView כאן לא עוברת את מסנן הצילומים",
"KartaView did not answer in time — try again": "KartaView לא ענה בזמן — נסה שוב",
"Mapillary did not answer in time — try again": "Mapillary לא ענה בזמן — נסה שוב",
"KartaView could not be reached — check the network": "KartaView לא נגיש — בדוק את הרשת",
"KartaView sent an answer that could not be read": "KartaView שלח תשובה שלא ניתן לקרוא",
"KartaView is rate-limiting requests": "KartaView מגביל את קצב הבקשות",
@@ -3292,6 +3293,17 @@
"Refresh map still": "רענון תמונת המפה",
"The map still uses a separate request and may show a different moment.": "תמונת המפה נשלפת בבקשה נפרדת ועשויה להראות רגע שונה.",
"Video plays from Québec 511, which sees your IP address.": "הווידאו זורם מ-Québec 511, שרואה את כתובת ה-IP שלך.",
"Press OPEN to load the photo": "לחץ על פתח כדי לטעון את התמונה",
"Captured {month}": "צולם ב-{month}",
"this spot": "המקום הזה",
"the view centre": "מרכז התצוגה",
"{source} at {place}": "{source} ב-{place}",
"Street level at {place}": "רמת רחוב ב-{place}",
"Switching map": "מחליף מפה",
"Opening imagery": "פותח צילומים",
"Street View is ready: press {open} to load it": "Street View מוכן: לחץ על {open} כדי לטעון אותו",
"{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת רמת הרחוב של הקישור הזה בלי מפתח תקף",
"Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.",
"Ship bridge view": "תצוגת גשר האונייה",
"Satellite orbital view": "התצוגה המסלולית של הלוויין",
"Exit bridge view": "יציאה מתצוגת הגשר",
@@ -3545,5 +3557,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID של כלי השיט: {id}"
"Vessel UUID: {id}": "UUID של כלי השיט: {id}",
"Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°",
"Street Level is unavailable": "Street Level אינו זמין",
"No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב",
"Street Level could not be enabled": "לא ניתן היה להפעיל את Street Level",
"Street Level changed while it looked for imagery": "Street Level השתנה בזמן החיפוש אחר צילומים",
"Aim the view at a street": "כוון את התצוגה אל רחוב",
"{source} is not a street-level imagery source here": "{source} אינו מקור לצילומי רחוב כאן",
"{source} needs its key": "{source} דורש את המפתח שלו",
"{source} needs the {map} map, which is not available": "{source} דורש את מפת {map}, שאינה זמינה",
"{source} could not be switched on": "לא ניתן היה להפעיל את {source}",
"{source} is not available on this map": "{source} אינו זמין במפה הזו",
"{source} imagery could not be opened": "לא ניתן היה לפתוח את הצילומים של {source}",
"{place} has no position to look for imagery at": "ל-{place} אין מיקום לחיפוש צילומים",
"No {source} imagery near {place}": "אין צילומים של {source} ליד {place}",
"{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})"
}
+28 -1
View File
@@ -1185,6 +1185,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView non ha filtrato quest'area in tempo: sposta la mappa per riprovare",
"None of KartaView’s photos here pass the imagery filter": "Nessuna delle foto KartaView qui supera il filtro delle immagini",
"KartaView did not answer in time — try again": "KartaView non ha risposto in tempo: riprova",
"Mapillary did not answer in time — try again": "Mapillary non ha risposto in tempo: riprova",
"KartaView could not be reached — check the network": "KartaView non è raggiungibile: controlla la rete",
"KartaView sent an answer that could not be read": "KartaView ha inviato una risposta illeggibile",
"KartaView is rate-limiting requests": "KartaView sta limitando le richieste",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
"Press OPEN to load the photo": "Premi APRI per caricare la foto",
"Captured {month}": "Scattata a {month}",
"this spot": "questo punto",
"the view centre": "il centro della vista",
"{source} at {place}": "{source} a {place}",
"Street level at {place}": "Vista stradale a {place}",
"Switching map": "Cambio mappa",
"Opening imagery": "Apertura delle immagini",
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
"{source} cannot open this link's street-level photo without a working key": "{source} non può aprire la foto a livello stradale di questo link senza una chiave valida",
"Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista.",
"STATIONS": "STAZIONI",
"Crewed stations and the craft that visit them": "Stazioni con equipaggio e i veicoli che le visitano",
"IMAGING": "IMMAGINI",
@@ -3545,5 +3557,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID della nave: {id}"
"Vessel UUID: {id}": "UUID della nave: {id}",
"Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°",
"Street Level is unavailable": "Vista stradale non è disponibile",
"No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata",
"Street Level could not be enabled": "Impossibile attivare Vista stradale",
"Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini",
"Aim the view at a street": "Punta la vista su una strada",
"{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui",
"{source} needs its key": "{source} richiede la sua chiave",
"{source} needs the {map} map, which is not available": "{source} richiede la mappa {map}, che non è disponibile",
"{source} could not be switched on": "Impossibile attivare {source}",
"{source} is not available on this map": "{source} non è disponibile su questa mappa",
"{source} imagery could not be opened": "Impossibile aprire le immagini di {source}",
"{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini",
"No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}",
"{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})"
}
+28 -1
View File
@@ -1147,6 +1147,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaViewがこの範囲を時間内に絞り込めませんでした。地図を動かして再度リクエストしてください",
"None of KartaView’s photos here pass the imagery filter": "この場所のKartaViewの写真はいずれも画像フィルターを通過しません",
"KartaView did not answer in time — try again": "KartaViewから時間内に応答がありませんでした。もう一度お試しください",
"Mapillary did not answer in time — try again": "Mapillaryから時間内に応答がありませんでした。もう一度お試しください",
"KartaView could not be reached — check the network": "KartaViewに接続できません。ネットワークを確認してください",
"KartaView sent an answer that could not be read": "KartaViewから読み取れない応答が返されました",
"KartaView is rate-limiting requests": "KartaViewがリクエストをレート制限中です",
@@ -3329,6 +3330,17 @@
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
"Press OPEN to load the photo": "「開く」を押して写真を読み込む",
"Captured {month}": "{month}撮影",
"this spot": "この地点",
"the view centre": "表示中心",
"{source} at {place}": "{place}の{source}",
"Street level at {place}": "{place}のストリートレベル",
"Switching map": "地図を切り替え中",
"Opening imagery": "画像を開いています",
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
"{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません",
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。",
"STATIONS": "ステーション",
"Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機",
"IMAGING": "地球観測",
@@ -3383,5 +3395,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "船舶UUID: {id}"
"Vessel UUID: {id}": "船舶UUID: {id}",
"Latitude must be within ±90° and longitude within ±180°": "緯度は±90°、経度は±180°の範囲内で指定してください",
"Street Level is unavailable": "ストリートレベルは利用できません",
"No street-level imagery source is configured": "ストリートレベル画像のソースが設定されていません",
"Street Level could not be enabled": "ストリートレベルをオンにできませんでした",
"Street Level changed while it looked for imagery": "画像の検索中にストリートレベルが変更されました",
"Aim the view at a street": "表示を通りに向けてください",
"{source} is not a street-level imagery source here": "{source}はここではストリートレベル画像のソースではありません",
"{source} needs its key": "{source}にはキーが必要です",
"{source} needs the {map} map, which is not available": "{source}には{map}マップが必要ですが、利用できません",
"{source} could not be switched on": "{source}をオンにできませんでした",
"{source} is not available on this map": "{source}はこのマップでは利用できません",
"{source} imagery could not be opened": "{source}の画像を開けませんでした",
"{place} has no position to look for imagery at": "{place}には画像を探す位置がありません",
"No {source} imagery near {place}": "{place}の近くに{source}の画像はありません",
"{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})"
}
+28 -1
View File
@@ -1183,6 +1183,7 @@
"KartaView did not filter this area in time — move the map to ask again": "O KartaView não filtrou esta área a tempo — mova o mapa para pedir de novo",
"None of KartaView’s photos here pass the imagery filter": "Nenhuma foto do KartaView aqui passa no filtro de imagens",
"KartaView did not answer in time — try again": "O KartaView não respondeu a tempo — tente novamente",
"Mapillary did not answer in time — try again": "O Mapillary não respondeu a tempo — tente novamente",
"KartaView could not be reached — check the network": "Não foi possível acessar o KartaView — verifique a rede",
"KartaView sent an answer that could not be read": "O KartaView enviou uma resposta que não pôde ser lida",
"KartaView is rate-limiting requests": "O KartaView está limitando as requisições",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Atualizar imagem fixa no mapa",
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.",
"Press OPEN to load the photo": "Clique em ABRIR para carregar a foto",
"Captured {month}": "Capturada em {month}",
"this spot": "este ponto",
"the view centre": "o centro da vista",
"{source} at {place}": "{source} em {place}",
"Street level at {place}": "Nível da rua em {place}",
"Switching map": "Mudando de mapa",
"Opening imagery": "Abrindo imagens",
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
"{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida",
"Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.",
"STATIONS": "ESTAÇÕES",
"Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam",
"IMAGING": "IMAGENS",
@@ -3545,5 +3557,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID da embarcação: {id}"
"Vessel UUID: {id}": "UUID da embarcação: {id}",
"Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°",
"Street Level is unavailable": "Nível da rua não está disponível",
"No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada",
"Street Level could not be enabled": "Não foi possível ativar Nível da rua",
"Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens",
"Aim the view at a street": "Aponte a vista para uma rua",
"{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui",
"{source} needs its key": "{source} precisa da sua chave",
"{source} needs the {map} map, which is not available": "{source} requer o mapa {map}, que não está disponível",
"{source} could not be switched on": "Não foi possível ativar {source}",
"{source} is not available on this map": "{source} não está disponível neste mapa",
"{source} imagery could not be opened": "Não foi possível abrir as imagens de {source}",
"{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens",
"No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}",
"{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})"
}
+28 -1
View File
@@ -2452,6 +2452,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова",
"None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков",
"KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова",
"Mapillary did not answer in time — try again": "Mapillary не ответил вовремя — попробуйте снова",
"KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть",
"KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать",
"KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов",
@@ -3572,6 +3573,17 @@
"Refresh map still": "Обновить кадр на карте",
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.",
"Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок",
"Captured {month}": "Снято: {month}",
"this spot": "эта точка",
"the view centre": "центр вида",
"{source} at {place}": "{source}: {place}",
"Street level at {place}": "Уличные снимки: {place}",
"Switching map": "Смена карты",
"Opening imagery": "Открытие снимков",
"Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить",
"{source} cannot open this link's street-level photo without a working key": "{source} не может открыть уличное фото по этой ссылке без рабочего ключа",
"Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него.",
"STATIONS": "СТАНЦИИ",
"Crewed stations and the craft that visit them": "Пилотируемые станции и прилетающие к ним корабли",
"IMAGING": "СЪЁМКА",
@@ -3626,5 +3638,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
"Vessel UUID: {id}": "UUID судна: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°",
"Street Level is unavailable": "Уличные снимки недоступны",
"No street-level imagery source is configured": "Источник уличных снимков не настроен",
"Street Level could not be enabled": "Не удалось включить уличные снимки",
"Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска",
"Aim the view at a street": "Наведите вид на улицу",
"{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков",
"{source} needs its key": "{source}: нужен ключ",
"{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна",
"{source} could not be switched on": "Не удалось включить {source}",
"{source} is not available on this map": "{source}: недоступно на этой карте",
"{source} imagery could not be opened": "Не удалось открыть снимки {source}",
"{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков",
"No {source} imagery near {place}": "Нет снимков {source} рядом с {place}",
"{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})"
}
+27
View File
@@ -1713,6 +1713,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView hann inte filtrera området – flytta kartan för att fråga igen",
"None of KartaView’s photos here pass the imagery filter": "Inga av KartaViews foton här klarar bildfiltret",
"KartaView did not answer in time — try again": "KartaView svarade inte i tid – försök igen",
"Mapillary did not answer in time — try again": "Mapillary svarade inte i tid – försök igen",
"KartaView could not be reached — check the network": "KartaView kunde inte nås – kontrollera nätverket",
"KartaView sent an answer that could not be read": "KartaView skickade ett svar som inte kunde läsas",
"KartaView is rate-limiting requests": "KartaView begränsar antalet förfrågningar",
@@ -3327,6 +3328,17 @@
"Refresh map still": "Uppdatera stillbilden på kartan",
"The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.",
"Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.",
"Press OPEN to load the photo": "Tryck på ÖPPNA för att läsa in bilden",
"Captured {month}": "Tagen {month}",
"this spot": "den här platsen",
"the view centre": "vyns mitt",
"{source} at {place}": "{source} vid {place}",
"Street level at {place}": "Gatunivå vid {place}",
"Switching map": "Byter karta",
"Opening imagery": "Öppnar bilder",
"Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att läsa in den",
"{source} cannot open this link's street-level photo without a working key": "{source} kan inte öppna länkens bild på gatunivå utan en fungerande nyckel",
"Street-level photos are hidden in the current view. Leave it first.": "Bilder på gatunivå är dolda i den aktuella vyn. Lämna den först.",
"STATIONS": "STATIONER",
"Crewed stations and the craft that visit them": "Bemannade rymdstationer och farkosterna som besöker dem",
"IMAGING": "AVBILDNING",
@@ -3457,6 +3469,21 @@
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "Fartygs-UUID: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Latituden måste ligga inom ±90° och longituden inom ±180°",
"Street Level is unavailable": "”Gatunivå” är inte tillgängligt",
"No street-level imagery source is configured": "Ingen källa för bilder på gatunivå är konfigurerad",
"Street Level could not be enabled": "”Gatunivå” kunde inte aktiveras",
"Street Level changed while it looked for imagery": "”Gatunivå” ändrades medan bilder söktes",
"Aim the view at a street": "Rikta vyn mot en gata",
"{source} is not a street-level imagery source here": "{source} är inte en källa för bilder på gatunivå här",
"{source} needs its key": "{source} behöver sin nyckel",
"{source} needs the {map} map, which is not available": "{source} behöver kartan {map}, som inte är tillgänglig",
"{source} could not be switched on": "{source} kunde inte slås på",
"{source} is not available on this map": "{source} är inte tillgänglig på den här kartan",
"{source} imagery could not be opened": "Bilder från {source} kunde inte öppnas",
"{place} has no position to look for imagery at": "{place} har ingen position att söka efter bilder vid",
"No {source} imagery near {place}": "Inga bilder från {source} nära {place}",
"{reason} (filters on: {filters})": "{reason} (aktiva filter: {filters})",
"1 h/s": "1 h/s",
"1 d/s": "1 d/s",
"1 wk/s": "1 v/s",
+28 -1
View File
@@ -1203,6 +1203,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView не встиг відфільтрувати цю область — зсуньте карту, щоб запитати знову",
"None of KartaView’s photos here pass the imagery filter": "Жодне фото KartaView тут не проходить фільтр знімків",
"KartaView did not answer in time — try again": "KartaView не відповів вчасно — спробуйте знову",
"Mapillary did not answer in time — try again": "Mapillary не відповів вчасно — спробуйте знову",
"KartaView could not be reached — check the network": "Не вдалося звʼязатися з KartaView — перевірте мережу",
"KartaView sent an answer that could not be read": "KartaView надіслав відповідь, яку не вдалося прочитати",
"KartaView is rate-limiting requests": "KartaView обмежує частоту запитів",
@@ -3572,6 +3573,17 @@
"Refresh map still": "Оновити кадр на карті",
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.",
"Press OPEN to load the photo": "Натисніть «ВІДКРИТИ», щоб завантажити фото",
"Captured {month}": "Знято: {month}",
"this spot": "це місце",
"the view centre": "центр карти",
"{source} at {place}": "{source}: {place}",
"Street level at {place}": "Вуличні знімки: {place}",
"Switching map": "Перемикання карти",
"Opening imagery": "Відкриття знімків",
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
"{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа",
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.",
"STATIONS": "СТАНЦІЇ",
"Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають",
"IMAGING": "ЗЙОМКА",
@@ -3626,5 +3638,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
"Vessel UUID: {id}": "UUID судна: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°",
"Street Level is unavailable": "Вуличні знімки недоступні",
"No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано",
"Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки",
"Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку",
"Aim the view at a street": "Наведіть вигляд на вулицю",
"{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків",
"{source} needs its key": "{source}: потрібен ключ",
"{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна",
"{source} could not be switched on": "Не вдалося увімкнути {source}",
"{source} is not available on this map": "{source}: недоступно на цій карті",
"{source} imagery could not be opened": "Не вдалося відкрити знімки {source}",
"{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків",
"No {source} imagery near {place}": "Немає знімків {source} поблизу {place}",
"{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})"
}
+28 -1
View File
@@ -1124,6 +1124,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及时筛选此区域——移动地图以重新请求",
"None of KartaView’s photos here pass the imagery filter": "此处没有 KartaView 照片通过影像筛选",
"KartaView did not answer in time — try again": "KartaView 未及时响应——请重试",
"Mapillary did not answer in time — try again": "Mapillary 未及时响应——请重试",
"KartaView could not be reached — check the network": "无法连接 KartaView——请检查网络",
"KartaView sent an answer that could not be read": "KartaView 返回的响应无法读取",
"KartaView is rate-limiting requests": "KartaView 已被限流",
@@ -3329,6 +3330,17 @@
"Refresh map still": "刷新地图静止画面",
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。",
"Press OPEN to load the photo": "点击“打开”加载照片",
"Captured {month}": "拍摄于 {month}",
"this spot": "此处",
"the view centre": "视图中心",
"{source} at {place}": "{place} 的 {source}",
"Street level at {place}": "{place} 的街景",
"Switching map": "正在切换地图",
"Opening imagery": "正在打开影像",
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
"{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片",
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。",
"STATIONS": "空间站",
"Crewed stations and the craft that visit them": "载人空间站及来访飞行器",
"IMAGING": "成像",
@@ -3383,5 +3395,20 @@
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
"Vessel UUID: {id}": "船舶 UUID:{id}",
"Latitude must be within ±90° and longitude within ±180°": "纬度须在 ±90° 以内,经度须在 ±180° 以内",
"Street Level is unavailable": "街景不可用",
"No street-level imagery source is configured": "未配置街景影像来源",
"Street Level could not be enabled": "无法开启街景",
"Street Level changed while it looked for imagery": "查找影像时街景已更改",
"Aim the view at a street": "请将视野对准街道",
"{source} is not a street-level imagery source here": "{source} 在此处不是街景影像来源",
"{source} needs its key": "{source} 需要其密钥",
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地图,但该地图不可用",
"{source} could not be switched on": "无法开启 {source}",
"{source} is not available on this map": "{source} 在此地图上不可用",
"{source} imagery could not be opened": "无法打开 {source} 影像",
"{place} has no position to look for imagery at": "{place} 没有可查找影像的位置",
"No {source} imagery near {place}": "{place} 附近没有 {source} 影像",
"{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})"
}
+28 -1
View File
@@ -1119,6 +1119,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求",
"None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選",
"KartaView did not answer in time — try again": "KartaView 未及時響應——請重試",
"Mapillary did not answer in time — try again": "Mapillary 未及時響應——請重試",
"KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡",
"KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取",
"KartaView is rate-limiting requests": "KartaView 已被限流",
@@ -3329,6 +3330,17 @@
"Refresh map still": "重新整理地圖靜態畫面",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
"Press OPEN to load the photo": "點擊“打開”加載照片",
"Captured {month}": "拍攝於 {month}",
"this spot": "此處",
"the view centre": "視圖中心",
"{source} at {place}": "{place} 的 {source}",
"Street level at {place}": "{place} 的街景",
"Switching map": "正在切換地圖",
"Opening imagery": "正在打開影像",
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載",
"{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片",
"Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。",
"STATIONS": "太空站",
"Crewed stations and the craft that visit them": "載人太空站及來訪飛行器",
"IMAGING": "成像",
@@ -3383,5 +3395,20 @@
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
"Vessel UUID: {id}": "船舶 UUID:{id}",
"Latitude must be within ±90° and longitude within ±180°": "緯度須在 ±90° 以內,經度須在 ±180° 以內",
"Street Level is unavailable": "街景不可用",
"No street-level imagery source is configured": "未配置街景影像來源",
"Street Level could not be enabled": "無法開啟街景",
"Street Level changed while it looked for imagery": "查找影像時街景已更改",
"Aim the view at a street": "請將視野對準街道",
"{source} is not a street-level imagery source here": "{source} 在此處不是街景影像來源",
"{source} needs its key": "{source} 需要其密鑰",
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地圖,但該地圖不可用",
"{source} could not be switched on": "無法開啟 {source}",
"{source} is not available on this map": "{source} 在此地圖上不可用",
"{source} imagery could not be opened": "無法打開 {source} 影像",
"{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置",
"No {source} imagery near {place}": "{place} 附近沒有 {source} 影像",
"{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})"
}
+195 -18
View File
@@ -15,13 +15,14 @@ import {
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { groundUnderCamera, viewCentre } from './view.js';
import { groundUnderCamera, metresBetween, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
import {
AIM_AT_GROUND_MESSAGE,
keyStatusLabel,
NEAREST_RADIUS_M,
NEAREST_RADIUS_MAX_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
@@ -33,6 +34,11 @@ const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
const GROUND_RETRY_LIMIT = 5;
const GROUND_RETRY_MS = 1000;
/**
* An armed point stays the OPEN button's target while the view centre is
* this close to it (m); a user who moved on opens what the view shows now.
*/
const ARMED_REACH_M = 1000;
/**
* The Street Level layer over imagery providers (contract in registry.js).
@@ -170,6 +176,12 @@ export function createStreetLevelLayer({
const owner = state.providers.get(state.street.providerId);
if (state.street.open && owner && !providerAvailable(owner))
layer.closeViewer();
// An armed lookup for a source whose map left dies too.
const armedFor = state.street.armed?.providerIds?.map((id) =>
state.providers.get(id),
);
if (armedFor?.some((entry) => entry && !providerAvailable(entry)))
state.street.armed = null;
for (const entry of state.providers.values()) {
if (!entry.def.requiresMapStack) continue;
if (state.enabled && entry.on && providerAvailable(entry))
@@ -254,8 +266,11 @@ export function createStreetLevelLayer({
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
}
/** Sources whose open photo a replacing request keeps up while it switches them off. */
const heldViewers = new Map();
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
if (!heldViewers.has(entry.def.id)) parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
}
@@ -268,6 +283,13 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
/** Close the image and deselect its sequence on the map. */
function closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
}
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
function screenCentreGround() {
const canvas = state.viewer?.scene?.canvas;
@@ -288,8 +310,14 @@ export function createStreetLevelLayer({
}
/** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) {
function openImage(
providerId,
imageId,
{ frame = true, signal = null } = {},
) {
if (signal?.aborted) return Promise.resolve(false);
abortNearest();
state.street.armed = null;
const entry = state.providers.get(providerId);
if (entry && !providerAvailable(entry)) {
state.street.loading = false;
@@ -297,7 +325,7 @@ export function createStreetLevelLayer({
notify();
return Promise.resolve(false);
}
return parts.viewerHost.open(providerId, imageId, { frame });
return parts.viewerHost.open(providerId, imageId, { frame, signal });
}
function unavailableReason(entry) {
@@ -343,6 +371,9 @@ export function createStreetLevelLayer({
function switchProvider(entry, on) {
if (entry.on === on) return;
entry.on = on;
// A lookup armed for this source dies with it.
if (!on && state.street.armed?.providerIds?.includes(entry.def.id))
state.street.armed = null;
if (!state.enabled) return;
if (on) activate(entry);
else deactivate(entry);
@@ -479,6 +510,33 @@ export function createStreetLevelLayer({
},
/** Registered providers, in chip order. */
providerIds: defs.map((def) => def.id),
/** The map stack a provider needs (`requiresMapStack`), or null for any. */
requiredMapStack(providerId) {
const def = defs.find((entry) => entry.id === providerId);
return def?.requiresMapStack || null;
},
/**
* A provider's name and key state on any map (the panel lists a provider
* only on the map it needs). `configured` is null until its status call
* answers; `keyRequired` covers a missing or refused key.
*/
providerStatus(providerId) {
const entry = state.providers.get(providerId);
if (!entry) return null;
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
name: entry.def.name,
on: entry.on,
configured: entry.status ? entry.status.configured === true : null,
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
keyRejected: stats.keyRejected === true,
groundClick: entry.def.groundClick === true,
billed: entry.def.billedPerImage === true,
available: providerAvailable(entry),
error: stats.error || null,
};
},
getSourceAvailability() {
const available = defs.length > 0;
return {
@@ -636,19 +694,66 @@ export function createStreetLevelLayer({
* Open the nearest image of the active providers (or `providerIds`) near
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
* under the screen centre (sky: nothing). Providers without
* `nearestImage` are skipped.
* `nearestImage` are skipped. Options:
* - `radiusM` widens the search where a provider can (a geocoded landmark
* is rarely within 50 m of a street);
* - `signal` retires the lookup: nothing opens after it fires;
* - `armed: false` (the assistant's own request) ignores a link's armed
* point and keeps it until a photo opens;
* - `replace: true` keeps an open photo (and EXPAND) up while it looks and
* closes it if nothing opens;
* - `proceed()` is asked before a found photo loads or an empty lookup
* reports; false retires the request and leaves the panel as it was.
* Why nothing opened is in `street.nearestOutcome`: 'empty', 'sky' or
* null (an error).
*/
async openNearest(
point,
{ providerIds = null, frame = true, aim = 'view' } = {},
{
providerIds = null,
frame = true,
aim = 'view',
radiusM = null,
signal: callerSignal = null,
armed: useArmed = true,
replace = false,
proceed = null,
} = {},
) {
if (callerSignal?.aborted) return false;
state.street.nearestOutcome = null;
// An OPEN press after a link armed a lookup opens what the link asked;
// any other lookup is a new request and drops the armed one.
const armed = useArmed ? state.street.armed : null;
if (useArmed) state.street.armed = null;
const closeReplaced = () => {
if (replace && state.street.open) closeViewer();
};
if (!point && aim === 'screen-centre' && armed) {
// OPEN promises the view centre: the link's spot only while the view
// still shows it, or when the view cannot be picked.
const centre = armed.point ? screenCentreGround() : null;
if (!armed.point) radiusM ??= armed.radiusM;
else if (
centre === undefined ||
(centre && metresBetween(centre, armed.point) <= ARMED_REACH_M)
) {
point = armed.point;
radiusM ??= armed.radiusM;
}
}
const radius = Number.isFinite(radiusM)
? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM))
: NEAREST_RADIUS_M;
let view = point;
if (!view && aim === 'screen-centre') {
view = screenCentreGround();
if (view === null) {
abortNearest();
closeReplaced();
state.street.loading = false;
state.street.error = AIM_AT_GROUND_MESSAGE;
state.street.nearestOutcome = 'sky';
notify();
return false;
}
@@ -660,6 +765,19 @@ export function createStreetLevelLayer({
const lookup = new AbortController();
nearestLookup = lookup;
const { signal } = lookup;
const onCallerAbort = () => lookup.abort();
callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
const retired = () => {
callerSignal?.removeEventListener('abort', onCallerAbort);
// A newer lookup owns the panel, its spinner and its error.
if (nearestLookup !== lookup) return false;
nearestLookup = null;
if (state.street.loading) {
state.street.loading = false;
notify();
}
return false;
};
state.street.loading = true;
state.street.error = null;
notify();
@@ -674,49 +792,108 @@ export function createStreetLevelLayer({
try {
imageId = await entry.instance.nearestImage(
{ lat: view.lat, lon: view.lon },
{ signal },
radius === NEAREST_RADIUS_M
? { signal }
: { signal, radiusM: radius },
);
} catch (error) {
if (callerSignal?.aborted) return retired();
// Overtaken: whatever replaced it owns the panel, errors included.
if (signal.aborted) return false;
if (signal.aborted) return retired();
// One provider failing (no key, offline) must not hide the others.
lastError = error;
continue;
}
if (signal.aborted) return false;
if (callerSignal?.aborted) return retired();
if (signal.aborted) return retired();
if (!state.enabled) break;
// Switched off, or its map left, while it looked: try the next.
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
if (proceed && proceed() !== true) return retired();
nearestLookup = null;
return openImage(entry.def.id, imageId, { frame });
callerSignal?.removeEventListener('abort', onCallerAbort);
// A sequence playing in the replaced photo would step past this one.
if (replace && state.street.open) parts.viewerHost.stopPlayback();
// The caller's signal still counts while the viewer mounts and loads.
return openImage(entry.def.id, imageId, {
frame,
signal: callerSignal,
});
}
if (nearestLookup === lookup) nearestLookup = null;
callerSignal?.removeEventListener('abort', onCallerAbort);
// Replaced while it looked: the panel is the newer lookup's.
if (nearestLookup !== lookup) return false;
// Given up by its caller: the panel stays as it was.
if (proceed && proceed() !== true) return retired();
nearestLookup = null;
closeReplaced();
// Switched off while it looked: nothing to open, nothing to report.
const stillOn = entries.some(
(entry) => entry.on && providerAvailable(entry),
);
// A provider's own refusal is English data that the panel translates;
// the core's refusal is built here, for the panel only, in the language.
if (state.enabled && stillOn && !lastError)
state.street.nearestOutcome = 'empty';
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
(point
? t('No street-level imagery within {radius} m of that point', {
radius: NEAREST_RADIUS_M,
radius,
})
: t(
'No street-level imagery within {radius} m of the view centre',
{ radius: NEAREST_RADIUS_M },
{
radius,
},
));
state.street.loading = false;
notify();
return false;
},
/** Close the image and deselect its sequence on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
closeViewer,
/**
* Keep the open photo up while a request that replaces it switches its
* source off, so EXPAND stays; only that source's own switch-off is held,
* a source switched by hand meanwhile still stops a load. Release frees
* the old viewer.
*/
holdViewer() {
const id = state.street.open ? state.street.providerId : null;
if (!id) return () => {};
heldViewers.set(id, (heldViewers.get(id) || 0) + 1);
let held = true;
return () => {
if (!held) return;
held = false;
const left = heldViewers.get(id) - 1;
if (left > 0) return void heldViewers.set(id, left);
heldViewers.delete(id);
if (state.providers.get(id)?.on !== false) return;
// Its photo still up (the request gave up) closes with its source;
// otherwise the request replaced or closed it, and the panel keeps
// what that request left (an error, an armed OPEN).
if (state.street.open && state.street.providerId === id)
parts.viewerHost.unmount(id);
else parts.viewerHost.release(id);
};
},
/**
* Arm a nearest lookup for the next OPEN press without running it (a link
* to a source that bills every load). Any other lookup, open or close, the
* source switched off or its map leaving clears it.
*/
armNearest(point, { providerIds = null, radiusM = null } = {}) {
state.street.armed = {
point:
Number.isFinite(point?.lat) && Number.isFinite(point?.lon)
? { lat: point.lat, lon: point.lon }
: null,
providerIds: Array.isArray(providerIds) ? [...providerIds] : null,
radiusM: Number.isFinite(radiusM) ? radiusM : null,
};
notify();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(),
+950 -2
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getEventListeners } from 'node:events';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
@@ -12,6 +13,9 @@ import {
fakeStreetLevelProvider as fakeProvider,
} from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js';
// Loaded up front: it brings Vue, which reads `document` once when it loads,
// and several tests here stand a bare fake document in for the page.
import { createGevActionRunner } from '../../voice/gevActions.js';
/**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
@@ -362,7 +366,11 @@ function fakeViewer() {
}
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
async function enabledLayer(t, providers = [fakeProvider()]) {
async function enabledLayer(
t,
providers = [fakeProvider()],
{ services, setup } = {},
) {
const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas.
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
@@ -370,7 +378,11 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
setup?.(viewer);
const layer = createStreetLevelLayer({
providers,
...(services ? { services } : {}),
});
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
@@ -944,6 +956,54 @@ test('the button’s “centre of the view” is the ground under the middle of
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
});
test('OPEN after a link armed opens at its point only while the view centre is still near it', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point, options) => {
lookups.push({ point, radiusM: options?.radiusM ?? null });
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
// About 120 m from the view centre: the link's spot is still in view.
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
await layer.openNearest(undefined, { aim: 'screen-centre' });
assert.deepEqual(lookups.at(-1), {
point: { lat: 38.5808, lon: -121.494 },
radiusM: 250,
});
// The user flew to Paris after the link armed in Sacramento: OPEN looks
// under the view centre, and the link's radius goes with its point.
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
await layer.openNearest(undefined, { aim: 'screen-centre' });
const press = lookups.at(-1);
assert.ok(Math.abs(press.point.lat - 38.5799) < 1e-9, 'the view centre');
assert.ok(Math.abs(press.point.lon - -121.4937) < 1e-9);
assert.equal(press.radiusM, null, 'the default radius');
assert.equal(layer.getUIState().street.armed, null, 'dropped');
});
test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
@@ -1118,6 +1178,732 @@ test('switching source by params clears a failed lookup’s error; reapplying th
assert.equal(second.getUIState().street.error, kept);
});
test('openNearest searches wider on request, never past the cap, and says how far it looked', async (t) => {
const { layer, fake } = await withStreetView(t, { nearest: () => null });
layer.setProviderEnabled('google', true);
await settle();
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, radiusM: 250 },
);
assert.equal(fake.lookups.at(-1).radius, 250);
assert.equal(
layer.getUIState().street.error,
'No street-level imagery within 250 m of that point',
);
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, radiusM: 5000 },
);
assert.equal(fake.lookups.at(-1).radius, 250, 'capped');
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(fake.lookups.at(-1).radius, 50, 'a click keeps the default');
});
test('a caller’s signal retires a nearest lookup: nothing opens after it fires', async (t) => {
let answer = null;
const { layer, fake } = await withStreetView(t, {
nearest: () => 'pano-a',
});
const lib = fake.library;
const service = lib.StreetViewService.prototype.getPanorama;
lib.StreetViewService.prototype.getPanorama = function (request) {
return new Promise((resolve, reject) => {
answer = () => service.call(this, request).then(resolve, reject);
});
};
layer.setProviderEnabled('google', true);
await settle();
const controller = new AbortController();
const pending = layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, signal: controller.signal },
);
await settle();
controller.abort();
answer?.();
assert.equal(await pending, false);
const street = layer.getUIState().street;
assert.equal(street.open, false, 'the retired lookup opened nothing');
assert.equal(street.loading, false, 'no stuck spinner');
assert.equal(street.error, null, 'and reports no missing imagery');
const refused = await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, signal: controller.signal },
);
assert.equal(refused, false, 'an already-aborted signal asks nothing');
});
test('providerStatus reports name, key state and billing on any map', async (t) => {
const { layer } = await withStreetView(t, { mapStack: 'osm' });
const google = layer.providerStatus('google');
assert.equal(google.name, 'Google Street View');
assert.equal(google.billed, true, 'every Street View load is billed');
assert.equal(google.available, false, 'not on this map');
assert.equal(google.keyRequired, false);
assert.equal(layer.providerStatus('mapillary').billed, false);
assert.equal(layer.providerStatus('nope'), null);
assert.equal(layer.requiredMapStack('google'), 'photoreal');
assert.equal(layer.requiredMapStack('mapillary'), null);
});
test('an armed lookup asks nothing until the OPEN press, then opens at its point and radius once', async (t) => {
const { layer, fake, mapillaryLookups } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
const before = fake.lookups.length;
layer.armNearest(
{ lat: 38.57986, lon: -121.49372 },
{ providerIds: ['google'], radiusM: 250 },
);
await settle();
await new Promise((resolve) => setTimeout(resolve, 20));
// The click gate: arming is free. No provider was asked, nothing opened.
assert.equal(
fake.lookups.length,
before,
'no Google lookup before the press',
);
assert.deepEqual(mapillaryLookups, [], 'no Mapillary lookup either');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.loading, false);
assert.deepEqual(layer.getUIState().street.armed, {
point: { lat: 38.57986, lon: -121.49372 },
providerIds: ['google'],
radiusM: 250,
});
// The OPEN button: no point, aimed at the screen centre.
const opened = await layer.openNearest(undefined, {
providerIds: ['google'],
frame: false,
aim: 'screen-centre',
});
assert.equal(opened, true);
assert.equal(
fake.lookups.length,
before + 1,
'exactly one lookup for the press',
);
const request = fake.lookups.at(-1);
assert.deepEqual(
request.location,
{ lat: 38.57986, lng: -121.49372 },
'the armed point, not the screen centre',
);
assert.equal(request.radius, 250);
assert.equal(layer.getUIState().street.armed, null, 'used once');
layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] });
layer.closeViewer();
assert.equal(layer.getUIState().street.armed, null, 'closing clears it');
layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] });
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.deepEqual(
fake.lookups.at(-1).location,
{ lat: 38.58, lng: -121.49 },
'an explicit point wins',
);
assert.equal(layer.getUIState().street.armed, null);
// Only the OPEN press consumes the armed point: "street view here" after a
// pan is a new request at the view centre, and it drops the armed one.
layer.closeViewer();
layer.armNearest(
{ lat: 10, lon: 20 },
{ providerIds: ['google'], radiusM: 250 },
);
await layer.openNearest(null, { providerIds: ['google'], frame: false });
const centre = fake.lookups.at(-1);
assert.notDeepEqual(
centre.location,
{ lat: 10, lng: 20 },
'the view centre, not the armed point',
);
assert.equal(centre.radius, 50, 'and the default radius');
assert.equal(layer.getUIState().street.armed, null, 'dropped');
// Opening any image, or switching the layer off, clears it too.
layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] });
await layer.openImage('google', 'pano-a');
assert.equal(layer.getUIState().street.armed, null, 'cleared by an open');
layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] });
layer.disable();
assert.equal(layer.getUIState().street.armed, null, 'cleared by disable');
});
test('a caller’s signal: an aborted one asks nothing, and a provider that ignores the abort still opens nothing', async (t) => {
const { layer, fake } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
const before = fake.lookups.length;
const done = new AbortController();
done.abort();
assert.equal(
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, signal: done.signal },
),
false,
);
assert.equal(
fake.lookups.length,
before,
'an already-aborted signal asks nothing',
);
});
test('a provider that ignores the abort and answers late still opens nothing', async (t) => {
let seen = null;
let release = null;
const slow = fakeProvider({
nearestImage: async (_point, { signal } = {}) => {
seen = signal;
await new Promise((resolve) => {
release = resolve;
});
return 'late-image';
},
});
const { layer: ignoring } = await enabledLayer(t, [slow]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
ignoring.attachViewerHost(fakeHost());
const controller = new AbortController();
const pending = ignoring.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: controller.signal },
);
await settle();
controller.abort();
assert.equal(seen?.aborted, true, 'the provider sees the abort');
release();
assert.equal(await pending, false);
const street = ignoring.getUIState().street;
assert.equal(street.open, false, 'the late answer opened nothing');
assert.equal(street.loading, false, 'no stuck spinner');
assert.equal(street.error, null);
});
test('providerStatus reports a keyless source on any map', async (t) => {
const keyless = await withStreetView(t, {
mapStack: 'osm',
mapillaryKeyless: true,
});
await settle();
const mapillary = keyless.layer.providerStatus('mapillary');
assert.equal(mapillary.configured, false);
assert.equal(mapillary.keyRequired, true);
assert.equal(mapillary.keyRejected, false);
});
test('providerStatus reports the key state as unknown until the status call answers', async (t) => {
// A freshly loaded sl= link meets its source in this state: unknown is not
// "needs its key", or the link would fail on a cold load.
const pending = fakeProvider();
pending.requiresKeyId = 'mapillary';
const create = pending.create;
pending.create = (context) => ({
...create(context),
status: () => new Promise(() => {}),
});
const { layer } = await enabledLayer(t, [pending]);
const status = layer.providerStatus(pending.id);
assert.equal(status.configured, null);
assert.equal(status.keyRequired, false);
});
test('providerStatus reports a refused key on any map', async (t) => {
const refused = await withStreetView(t, { mapStack: 'osm' });
refused.loader.refuseKey();
await settle();
const google = refused.layer.providerStatus('google');
assert.equal(
google.keyRequired,
true,
'a refused key gates like a missing one',
);
assert.equal(google.keyRejected, true);
assert.equal(google.available, false, 'still not on this map');
});
test('an armed lookup dies with its source: switched off, or its map gone', async (t) => {
const { layer, stack } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
layer.armNearest(
{ lat: 1, lon: 2 },
{ providerIds: ['google'], radiusM: 250 },
);
layer.setProviderEnabled('google', false);
assert.equal(
layer.getUIState().street.armed,
null,
'switching the source off clears it',
);
layer.setProviderEnabled('google', true);
layer.armNearest(
{ lat: 1, lon: 2 },
{ providerIds: ['google'], radiusM: 250 },
);
// Lighting Mapillary instead (one source at a time) turns Google off too.
layer.setProviderEnabled('mapillary', true);
assert.equal(
layer.getUIState().street.armed,
null,
'another source lit clears it',
);
layer.setProviderEnabled('google', true);
layer.armNearest(
{ lat: 1, lon: 2 },
{ providerIds: ['google'], radiusM: 250 },
);
// A map-stack notify that keeps Google 3D (a silent re-settle) keeps it.
stack.switchTo('photoreal');
assert.notEqual(
layer.getUIState().street.armed,
null,
'a notify on the same map keeps it',
);
stack.switchTo('osm');
assert.equal(
layer.getUIState().street.armed,
null,
'leaving Google 3D clears it',
);
// Arming notifies, so a panel can show the state.
let notified = 0;
const unsubscribe = layer.subscribe(() => notified++);
stack.switchTo('photoreal');
await settle();
const before = notified;
layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] });
await settle();
assert.ok(notified > before, 'subscribers hear about the armed lookup');
unsubscribe();
});
test('a lookup armed with no point opens under the middle of the screen on the press', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point, options) => {
lookups.push({ ...point, radiusM: options?.radiusM ?? null });
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
// Registered before the layer exists, so a throwing create() still
// restores the document; destroy() runs first, while the stub is in place.
let layer = null;
t.after(() => {
layer?.destroy();
globalThis.document = saved;
});
layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
layer.armNearest(null, { providerIds: [provider.id], radiusM: 250 });
assert.equal(
layer.getUIState().street.armed.point,
null,
'no point stored, not 0,0',
);
await layer.openNearest(undefined, { aim: 'screen-centre' });
assert.equal(lookups.length, 1);
assert.ok(
Math.abs(lookups[0].lat - 38.5799) < 1e-9,
'the screen centre, not Null Island',
);
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
assert.equal(lookups[0].radiusM, 250, 'with the armed radius');
});
test('the action over the real layer: a billed link arms without a lookup, and one press makes exactly one at the point', async (t) => {
const { layer, fake, viewer } = await withStreetView(t);
// The runner wires pointer tracking to the clock and camera.
viewer.clock ??= { onTick: { addEventListener: () => () => {} } };
viewer.camera.moveEnd ??= { addEventListener() {} };
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => layer.getParams(),
setLayerParams: (_id, params) => {
layer.setParams(params);
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }],
};
const styleManager = {
mapStackController: { getActiveId: () => 'photoreal' },
setMapStack: async () => ({ ok: true, activeStack: 'photoreal' }),
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
const before = fake.lookups.length;
const armed = await runner('show_street_level', {
latitude: 38.57986,
longitude: -121.49372,
provider: 'google',
fly: false,
autoOpen: false,
});
assert.equal(armed.ok, true, armed.error);
assert.equal(armed.armed, true);
assert.equal(armed.providerName, 'Google Street View');
await settle();
assert.equal(fake.lookups.length, before, 'arming made no Google request');
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['google'],
);
assert.equal(layer.getUIState().street.open, false);
// The OPEN button.
assert.equal(
await layer.openNearest(undefined, {
providerIds: ['google'],
frame: false,
aim: 'screen-centre',
}),
true,
);
assert.equal(fake.lookups.length, before + 1, 'one request for the press');
assert.deepEqual(fake.lookups.at(-1).location, {
lat: 38.57986,
lng: -121.49372,
});
assert.equal(fake.lookups.at(-1).radius, 250);
assert.equal(layer.getUIState().street.imageId, 'pano-a');
// The same call without the gate opens at once, with one request.
layer.closeViewer();
const opened = await runner('show_street_level', {
latitude: 38.57986,
longitude: -121.49372,
provider: 'google',
fly: false,
});
assert.equal(opened.ok, true, opened.error);
assert.equal(opened.imageId, 'pano-a');
assert.equal(fake.lookups.length, before + 2);
});
test('openNearest keeps honouring the caller after the lookup: an abort while the viewer loads opens nothing', async (t) => {
let releaseMount = null;
const provider = fakeProvider({ nearestImage: async () => 'img-1' });
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
const mount = instance.viewer.mount;
instance.viewer.mount = async (...args) => {
await new Promise((resolve) => {
releaseMount = resolve;
});
return mount(...args);
};
return instance;
};
const { layer } = await enabledLayer(t, [provider]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
const controller = new AbortController();
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: controller.signal },
);
for (let i = 0; i < 20 && !releaseMount; i++) await settle();
assert.ok(releaseMount, 'the lookup answered and the viewer is mounting');
controller.abort();
releaseMount();
assert.equal(await pending, false);
assert.deepEqual(provider.calls.open, [], 'the photo was never opened');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.loading, false);
});
for (const rejectsOnAbort of [true, false]) {
test(`cancelling a lookup over an open photo clears its spinner (provider rejects: ${rejectsOnAbort})`, async (t) => {
const answer = deferred();
const provider = fakeProvider({
nearestImage: (_point, { signal }) => {
if (rejectsOnAbort)
signal.addEventListener('abort', () => answer.reject(signal.reason), {
once: true,
});
return answer.promise;
},
});
const { layer } = await enabledLayer(t, [provider]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
assert.equal(
await layer.openImage('mapillary', 'existing', { frame: false }),
true,
);
const controller = new AbortController();
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: controller.signal },
);
assert.equal(layer.getUIState().street.loading, true);
controller.abort();
if (!rejectsOnAbort) answer.resolve('late-image');
assert.equal(await pending, false);
const street = layer.getUIState().street;
assert.equal(
street.loading,
false,
'the cancelled lookup owns and clears the spinner',
);
assert.equal(street.open, true, 'the existing photo remains open');
assert.equal(street.error, null);
assert.deepEqual(
provider.calls.open,
['existing'],
'no replacement photo opens',
);
});
}
for (const rejectsOnAbort of [true, false]) {
test(`retired lookups release the caller listener (provider rejects: ${rejectsOnAbort})`, async (t) => {
let answer;
const provider = fakeProvider({
nearestImage: (_point, { signal }) => {
answer = deferred();
if (rejectsOnAbort)
signal.addEventListener('abort', () => answer.reject(signal.reason), {
once: true,
});
return answer.promise;
},
});
const { layer } = await enabledLayer(t, [provider]);
const lifetime = new AbortController();
for (let i = 0; i < 3; i++) {
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: lifetime.signal },
);
assert.equal(getEventListeners(lifetime.signal, 'abort').length, 1);
layer.closeViewer();
if (!rejectsOnAbort) answer.resolve('late-image');
assert.equal(await pending, false);
assert.equal(
getEventListeners(lifetime.signal, 'abort').length,
0,
'closing the lookup releases its listener on the application lifetime',
);
}
assert.equal(lifetime.signal.aborted, false);
});
}
test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => {
const answers = [];
const provider = fakeProvider({
nearestImage: (_point, { signal } = {}) =>
new Promise((resolve, reject) => {
answers.push(resolve);
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
}),
});
const { layer } = await enabledLayer(t, [provider]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
const older = new AbortController();
const first = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: older.signal },
);
await settle();
// A newer request replaces it, and its caller withdraws the old one too.
const second = layer.openNearest({ lat: 3, lon: 4 }, { frame: false });
older.abort();
assert.equal(await first, false);
assert.equal(
layer.getUIState().street.loading,
true,
'the newer lookup is still loading',
);
answers.at(-1)(null);
assert.equal(await second, false);
assert.equal(layer.getUIState().street.loading, false);
assert.match(
layer.getUIState().street.error,
/No street-level imagery/,
'the newer lookup reports its own result',
);
});
test('show_street_level opens a KartaView photo, and a plain link opens it at once since it is not billed', async (t) => {
const mapillary = fakeProvider();
let nearest = 'kv-1';
const kartaview = fakeProvider({
id: 'kartaview',
pickPrefix: 'kv:',
nearestImage: async () => nearest,
});
kartaview.name = 'KartaView';
kartaview.label = 'KARTAVIEW';
kartaview.defaultOn = false;
// As the real provider: 360° / flat and SINCE (the setFilter log stays in
// the fake's closure).
kartaview.filters = Object.freeze(['pano', 'since']);
const { layer, viewer } = await enabledLayer(t, [mapillary, kartaview]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
viewer.clock ??= { onTick: { addEventListener: () => () => {} } };
viewer.camera.moveEnd ??= { addEventListener() {} };
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => layer.getParams(),
setLayerParams: (_id, params) => {
layer.setParams(params);
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }],
};
const styleManager = {
mapStackController: { getActiveId: () => 'osm' },
setMapStack: async () => ({ ok: false, error: 'no map switch expected' }),
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
// The way a plain sl=kartaview link calls it: autoOpen false.
const result = await runner('show_street_level', {
latitude: 52.52,
longitude: 13.405,
provider: 'kartaview',
fly: false,
autoOpen: false,
});
assert.equal(result.ok, true, result.error);
assert.equal(result.armed, undefined, 'not armed: KartaView bills nothing');
assert.equal(result.provider, 'kartaview');
assert.equal(result.providerName, 'KartaView');
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['kartaview'],
);
assert.deepEqual(kartaview.calls.open, ['kv-1']);
assert.equal(layer.providerStatus('kartaview').billed, false);
assert.equal(layer.requiredMapStack('kartaview'), null);
// With 360° only on, an empty search names the filter, not just "no imagery".
layer.closeViewer?.();
layer.setParams({ pano: 'pano' });
nearest = null;
const empty = await runner('show_street_level', {
latitude: 52.52,
longitude: 13.405,
provider: 'kartaview',
fly: false,
});
assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']);
assert.equal(empty.filter?.pano, 'pano');
assert.match(empty.error, /with the 360° filter on$/);
layer.setParams({ pano: 'all' });
const plain = await runner('show_street_level', {
latitude: 52.52,
longitude: 13.405,
provider: 'kartaview',
fly: false,
});
assert.equal(plain.ok, false);
assert.equal(plain.filter, undefined, 'no filter, no hint');
assert.doesNotMatch(plain.error, /filter/);
});
test('show_street_level switches from KartaView to Panoramax and opens its photo at once, unbilled', async (t) => {
const mapillary = fakeProvider();
const kartaview = fakeProvider({ id: 'kartaview', pickPrefix: 'kv:' });
kartaview.name = 'KartaView';
kartaview.defaultOn = false;
const panoramax = fakeProvider({
id: 'panoramax',
pickPrefix: 'px:',
nearestImage: async () => 'px-1',
});
panoramax.name = 'Panoramax';
panoramax.label = 'PANORAMAX';
panoramax.defaultOn = false;
const { layer, viewer } = await enabledLayer(t, [
mapillary,
kartaview,
panoramax,
]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
viewer.clock ??= { onTick: { addEventListener: () => () => {} } };
viewer.camera.moveEnd ??= { addEventListener() {} };
layer.setParams({ mapillary: false, kartaview: true, panoramax: false });
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => layer.getParams(),
setLayerParams: (_id, params) => {
layer.setParams(params);
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }],
};
const styleManager = {
mapStackController: { getActiveId: () => 'osm' },
setMapStack: async () => ({ ok: false, error: 'no map switch expected' }),
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
// The way a plain sl=panoramax link calls it: autoOpen false.
const result = await runner('show_street_level', {
latitude: 48.8874,
longitude: 2.3495,
provider: 'panoramax',
fly: false,
autoOpen: false,
});
assert.equal(result.ok, true, result.error);
assert.equal(result.armed, undefined, 'not armed: Panoramax bills nothing');
assert.equal(result.providerName, 'Panoramax');
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['panoramax'],
'KartaView switched off',
);
assert.deepEqual(panoramax.calls.open, ['px-1']);
assert.deepEqual(kartaview.calls.open, [], 'nothing asked of KartaView');
assert.equal(layer.providerStatus('panoramax').billed, false);
assert.equal(layer.requiredMapStack('panoramax'), null);
});
test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => {
const { presentStreetLevelPanel } =
await import('../../ui/streetLevelPresentation.js');
@@ -1128,6 +1914,88 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
assert.match(view.meta, /Google Street View needs the Google 3D map/);
});
test('OPEN aimed at the sky after a link armed opens nothing: never the far link point', async (t) => {
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const { layer } = await enabledLayer(t, [provider], {
services: { scenePick },
setup: (viewer) => {
// Every pick misses the ground: the screen centre is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.globe = { pick: () => undefined };
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
},
});
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
assert.equal(
await layer.openNearest(undefined, { aim: 'screen-centre' }),
false,
);
assert.deepEqual(lookups, [], 'no paid Paris lookup from another city’s sky');
const street = layer.getUIState().street;
assert.equal(street.nearestOutcome, 'sky');
assert.match(street.error, /Aim the middle of the view/);
});
test('a request of its own (armed: false) neither uses nor drops the link’s armed point', async (t) => {
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point, options) => {
lookups.push({ point, radiusM: options?.radiusM ?? null });
return null;
},
});
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
const { layer } = await enabledLayer(t, [provider], {
services: { scenePick },
setup: (viewer) => {
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) =>
at.x === 500 && at.y === 300 ? centre : undefined,
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
},
});
// About 120 m away: an OPEN press would take the link's spot.
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
await layer.openNearest(undefined, { aim: 'screen-centre', armed: false });
const asked = lookups.at(-1);
assert.ok(Math.abs(asked.point.lat - 38.5799) < 1e-9, 'the screen centre');
assert.equal(asked.radiusM, null, 'its own radius');
assert.equal(layer.getUIState().street.nearestOutcome, 'empty');
assert.deepEqual(
layer.getUIState().street.armed?.point,
{ lat: 38.5808, lon: -121.494 },
'still armed for the OPEN press',
);
});
test('a failing source is not reported as missing imagery', async (t) => {
const provider = fakeProvider({
nearestImage: async () => {
throw new Error('Mapillary graph HTTP 500');
},
});
const { layer } = await enabledLayer(t, [provider]);
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false);
const street = layer.getUIState().street;
assert.equal(street.nearestOutcome, null);
assert.equal(street.error, 'Mapillary graph HTTP 500');
});
/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */
function keylessLayer(t, mapillary = { hasToken: () => false }) {
const layer = createStreetLevelLayer({
@@ -1183,3 +2051,83 @@ test('filter-only updates keep the lit source, even a chosen keyless Mapillary',
);
}
});
test('a replacing lookup keeps the viewer open while it looks, so EXPAND stays with the new photo', async (t) => {
const answers = ['img-1', 'img-2'];
const provider = posingProvider({
nearestImage: async () => answers.shift(),
});
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), true);
const seen = [];
const unsubscribe = layer.subscribe(() =>
seen.push(layer.getUIState().street.open),
);
t.after(unsubscribe);
assert.equal(
await layer.openNearest({ lat: 3, lon: 4 }, { replace: true }),
true,
);
assert.ok(seen.length > 0);
assert.ok(!seen.includes(false), 'never published closed on the way');
assert.deepEqual(provider.calls.open, ['img-1', 'img-2']);
});
test('a replacing lookup that finds nothing closes the old photo and keeps its own message', async (t) => {
const answers = ['img-1', null];
const provider = posingProvider({
nearestImage: async () => answers.shift(),
});
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
await layer.openNearest({ lat: 1, lon: 2 });
assert.equal(
await layer.openNearest({ lat: 3, lon: 4 }, { replace: true }),
false,
);
const street = layer.getUIState().street;
assert.equal(street.open, false, 'no photo of another place');
assert.equal(street.nearestOutcome, 'empty');
assert.match(street.error, /No street-level imagery within 50 m/);
});
test('a found photo the viewer cannot load keeps its error on the open viewer', async (t) => {
const provider = posingProvider({ nearestImage: async () => 'img-1' });
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
instance.viewer.open = async () => {
throw new Error('Image could not be opened');
};
return instance;
};
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
assert.equal(
await layer.openNearest({ lat: 1, lon: 2 }, { replace: true }),
false,
);
const street = layer.getUIState().street;
assert.equal(street.open, true);
assert.equal(street.error, 'Image could not be opened');
});
test('proceed() is asked after the lookup: false loads nothing and clears the spinner', async (t) => {
const provider = posingProvider({ nearestImage: async () => 'img-1' });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
let asked = 0;
const opened = await layer.openNearest(
{ lat: 1, lon: 2 },
{ proceed: () => (asked++, false) },
);
assert.equal(opened, false);
assert.equal(asked, 1);
assert.deepEqual(provider.calls.open, [], 'the viewer never loads it');
assert.equal(layer.getUIState().street.loading, false);
assert.equal(
await layer.openNearest({ lat: 1, lon: 2 }, { proceed: () => true }),
true,
);
});
+8
View File
@@ -40,6 +40,14 @@ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres. */
export const NEAREST_RADIUS_M = 50;
/**
* Widest nearest-image radius a caller may ask for, in metres. A geocoded
* landmark sits at its centroid, often further than 50 m from the nearest
* street; providers that can search wider (Google) honour it, the rest keep
* their own cap.
*/
export const NEAREST_RADIUS_MAX_M = 250;
/** The Google 3D map stack: Street View may be shown only on it. */
export const GOOGLE_3D_MAP_STACK_ID = 'photoreal';
@@ -12,6 +12,7 @@ import {
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_RADIUS_MAX_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
@@ -67,6 +68,7 @@ export function createGoogleProvider({
requiresMapStack: GOOGLE_3D_MAP_STACK_ID,
// Every panorama is billed to the Maps key.
defaultOn: false,
billedPerImage: true,
// All 360°, and no coverage for a date filter to thin.
filters: [],
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
@@ -136,8 +138,12 @@ export function createGoogleProvider({
* Nearest Google-collected outdoor panorama, or null. The imagery
* filters do not apply (see `filters`).
*/
async nearestImage({ lat, lon }, { signal } = {}) {
async nearestImage({ lat, lon }, { signal, radiusM } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
// Wider than the shared default only on request, never past the cap.
const radius = Number.isFinite(radiusM)
? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM))
: NEAREST_RADIUS_M;
signal?.throwIfAborted();
// Set once the caller has its answer (aborted, timed out, refused):
// a lookup still waiting on Google's script then asks nothing.
@@ -150,7 +156,7 @@ export function createGoogleProvider({
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
radius,
// Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, OUTDOOR],
preference: lib.StreetViewPreference.NEAREST,
@@ -1,7 +1,7 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { msg } from '../../../../i18n/index.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
export { NEAREST_RADIUS_M, NEAREST_RADIUS_MAX_M } from '../../policy.js';
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = msg('Google Street View');
@@ -291,3 +291,13 @@ test('the panorama a lookup found is dated without a second lookup', async () =>
'no getPanorama({pano}) for a panorama the lookup already described',
);
});
test('a wider lookup radius is honoured up to the cap', async () => {
const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 250 });
assert.equal(fake.lookups.at(-1).radius, 250);
await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 9999 });
assert.equal(fake.lookups.at(-1).radius, 250);
await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: NaN });
assert.equal(fake.lookups.at(-1).radius, 50);
});
@@ -5,6 +5,8 @@ import {
} from '../../lineCoverage.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { coverageZoomForHeight } from '../../tileMath.js';
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import { decodeCoverageTile } from './decode.js';
import { createMapillaryViewer } from './viewer.js';
import {
@@ -195,6 +197,35 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
error: state.sequence.error,
}),
/**
* The nearest image that passes the imagery filter, or null: what
* show_street_level and sl= links open (clicks open a line, then a
* cone). The core aborts `signal` once a newer request overtakes it.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const images = await source.nearestImages({ lat, lon }, { signal });
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const filter = context.getFilter();
const hit = [...images]
.sort((a, b) => metres(a) - metres(b))
.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
filter,
),
);
return hit ? String(hit.id) : null;
},
viewer:
photoViewer ||
createMapillaryViewer({ source, render: context.services?.render }),
@@ -16,6 +16,7 @@ export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
'getSequenceImages',
'nearestImages',
]);
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
@@ -72,3 +73,18 @@ export function mapillaryImageUrl(imageId) {
/** Fields requested for a sequence's images. */
export const SEQUENCE_IMAGE_FIELDS =
'id,captured_at,compass_angle,geometry,is_pano';
/**
* Images asked for in a nearest-image search: the API cuts at the limit
* before any order, so this is its most; the distance sort and the imagery
* filter choose among the photos this one request returns.
*/
export const NEAREST_LIMIT = 100;
/**
* A nearest-image search not answered in full (body included) by then is
* given up: a link or posted view, and every view queued behind it, waits.
*/
export const NEAREST_TIMEOUT_MS = 12_000;
/** Fields a nearest-image search needs: where each is, and the filter's. */
export const NEAREST_IMAGE_FIELDS =
'id,captured_at,geometry,computed_geometry,is_pano';
@@ -170,3 +170,41 @@ test('a status the server never answers falls back to the bundled token once the
assert.deepEqual(await status, { configured: false });
assert.equal(instance.coverageStats().keyRequired, true);
});
/** A graph image record at a point. */
const imageAt = (id, lon, lat, extra = {}) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
...extra,
});
test('nearestImage opens the closest image that passes the filter, not the first the API returned', async () => {
const requests = [];
const answer = [
imageAt('far', 2.0004, 1), // ~45 m east
imageAt('near-pano', 2.00002, 1, { is_pano: true }), // ~2 m, a panorama
imageAt('near', 2.00005, 1), // ~5 m east
imageAt('mid', 2, 1.0002), // ~22 m north
];
const source = fakeMapillarySource({
nearestImages: async (query, options) => {
requests.push({ query, options });
return answer;
},
});
const { signal } = new AbortController();
const flat = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'flat', sinceMs: null }),
);
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near');
assert.equal(requests[0].options.signal, signal, 'the core can abort it');
assert.deepEqual(requests[0].query, { lat: 1, lon: 2 });
const any = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano');
const tooOld = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'all', sinceMs: 100 }),
);
assert.equal(await tooOld.nearestImage({ lat: 1, lon: 2 }), null);
});
@@ -0,0 +1,56 @@
import '../../../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createStreetLevelLayer } from '../../index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from '../../../../testSupport/streetLevelFakes.mjs';
import { createMapillaryViewer } from './viewer.js';
import { showStreetLevel } from '../../../../voice/gevActions.js';
// Library boundary fixture: moveTo changes the image without stopping PLAY,
// matching installed MapillaryJS Viewer.moveTo / Navigator.moveTo$.
const fakeLibrary = `export class Viewer {
constructor() { globalThis.__replacePlayViewer = this; this.playing = false; this.id = null; this.stops = 0; }
on() {} remove() {} resize() {} setRenderMode() {}
getComponent() { return { play: () => {this.playing = true;}, stop: () => {this.playing = false; this.stops++;} }; }
async moveTo(id) { this.id = id; return { id, sequenceId: 'fixture-sequence', cameraType: 'perspective' }; }
async getPosition() { return { lng: 2, lat: 48 }; }
async getPointOfView() { return { bearing: 0 }; }
}
export class GraphDataProvider {}
export const RenderMode = { Fill: 1, Letterbox: 0 };`;
const hooks = registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mapillary-js') return { url: 'data:text/javascript,' + encodeURIComponent(fakeLibrary), shortCircuit: true };
if (specifier.endsWith('mapillary.css')) return { url: 'data:text/javascript,export default {}', shortCircuit: true };
return next(specifier, context);
},
});
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
test('a photo the action asks for stops sequence PLAY', async t => {
const provider = fakeStreetLevelProvider({ nearestImage: async () => 'B' });
const create = provider.create;
provider.create = context => ({ ...create(context), viewer: createMapillaryViewer({ source: { token: '' } }) });
const viewer = fakeCesiumViewer(); viewer.canvas = viewer.scene.canvas;
const layer = createStreetLevelLayer({ providers: [provider] });
layer.init(viewer); layer.enable(viewer);
layer.attachViewerHost(document.body.appendChild(document.createElement('div')));
t.after(() => { layer.destroy(); hooks.deregister(); delete globalThis.__replacePlayViewer; document.body.replaceChildren(); });
await Promise.resolve();
await layer.openImage('mapillary', 'A', { frame: false });
const libraryViewer = globalThis.__replacePlayViewer;
libraryViewer.getComponent('sequence').play();
assert.equal(libraryViewer.playing, true);
const manager = {
layers: new Map([['street-level', { module: layer }]]), isEnabled: () => true,
getLayerParams: () => layer.getParams(), setLayerParams: (_id, params) => layer.setParams(params),
};
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, {
viewer, dataManager: manager, styleManager: { _navigationGeneration: 1 },
runGevAction: async () => ({ ok: true, latitude: 48, longitude: 2, label: 'fixture destination' }),
});
assert.equal(result.ok, true);
assert.equal(libraryViewer.id, 'B');
assert.equal(layer.getUIState().street.open, true);
assert.equal(libraryViewer.playing, false, 'requested image must stay selected rather than continue sequence PLAY');
});
@@ -1,9 +1,17 @@
import { msg } from '../../../../i18n/marker.js';
import { NEAREST_RADIUS_M } from '../../policy.js';
import {
MAPILLARY_GRAPH_HOST,
NEAREST_IMAGE_FIELDS,
NEAREST_LIMIT,
NEAREST_TIMEOUT_MS,
SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
/** English data: the Street Level panel translates it. */
const NO_ANSWER = msg('Mapillary did not answer in time — try again');
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
export class MapillarySourceError extends Error {
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
@@ -66,7 +74,47 @@ export function createMapillarySource({
...endpoints,
};
async function graph(path, params = {}, { signal } = {}) {
async function graph(path, params = {}, { signal, timeoutMs = 0 } = {}) {
if (!timeoutMs) return graphOnce(path, params, { signal });
// The whole answer, body included, within `timeoutMs`; the caller's
// abort still wins.
if (signal?.aborted) throw signal.reason;
const controller = new AbortController();
const onAbort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(
() =>
controller.abort(
new MapillarySourceError(NO_ANSWER, {
status: 504,
}),
),
timeoutMs,
);
try {
// A body that never ends is given up too, whatever the fetch does.
const gaveUp = new Promise((_, reject) =>
controller.signal.addEventListener(
'abort',
() => reject(controller.signal.reason),
{ once: true },
),
);
return await Promise.race([
graphOnce(path, params, { signal: controller.signal }),
gaveUp,
]);
} catch (error) {
if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason;
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
async function graphOnce(path, params, { signal }) {
if (graphBase) {
const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
@@ -147,5 +195,21 @@ export function createMapillarySource({
);
return Array.isArray(payload?.data) ? payload.data : [];
},
/** Images within `NEAREST_RADIUS_M` of a point, in no particular order. */
async nearestImages({ lat, lon }, { signal } = {}) {
const payload = await graph(
'images',
{
lat: String(lat),
lng: String(lon),
radius: String(NEAREST_RADIUS_M),
limit: String(NEAREST_LIMIT),
fields: NEAREST_IMAGE_FIELDS,
},
{ signal, timeoutMs: NEAREST_TIMEOUT_MS },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
};
}
@@ -126,6 +126,25 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
);
});
test('nearestImages asks the graph API around a point, within 50 m, for at most 100 images', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const images = await source.nearestImages({ lat: 38.5, lon: -121.5 });
assert.equal(images.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.pathname, '/images');
assert.equal(url.searchParams.get('lat'), '38.5');
assert.equal(url.searchParams.get('lng'), '-121.5');
assert.equal(url.searchParams.get('radius'), '50');
// The API cuts at the limit before any order: a small one would sort and
// filter a handful of arbitrary photos (a 360° filter could find none).
assert.equal(url.searchParams.get('limit'), '100', "the API's most");
assert.match(url.searchParams.get('fields'), /computed_geometry/);
assert.match(url.searchParams.get('fields'), /is_pano/);
});
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
@@ -157,3 +176,63 @@ test('Graph endpoint errors keep their status', async () => {
(error) => error.status === 503 && error.keyRequired === true,
);
});
test('a nearest-image search that never answers is given up, body included', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
// Headers arrive; the body never ends, and the fetch ignores its signal.
const { fetchImpl } = fakeFetch(() => ({
ok: true,
status: 200,
json: () => new Promise(() => {}),
}));
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const pending = source.nearestImages({ lat: 1, lon: 2 });
const outcome = pending.then(
() => 'answered',
(error) => error,
);
await Promise.resolve();
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
const error = await outcome;
assert.ok(error instanceof MapillarySourceError, String(error));
assert.equal(error.status, 504);
assert.equal(error.keyRequired, false, 'not a key problem');
// English data, shown in the interface language like the other sources'.
const { setLocale } = await import('../../../../i18n/index.js');
const { translateStreetLevelError } =
await import('../../../../ui/streetLevelPresentation.js');
assert.equal(
translateStreetLevelError(error.message),
'Mapillary did not answer in time — try again',
);
for (const [locale, expected] of [
['de', /nicht rechtzeitig/],
['ja', /時間内に応答/],
]) {
setLocale(locale);
try {
assert.match(translateStreetLevelError(error.message), expected, locale);
} finally {
setLocale('en');
}
}
});
test('the caller’s abort still ends a nearest-image search before its deadline', async () => {
const { fetchImpl } = fakeFetch(
(_url, init) =>
new Promise((_, reject) =>
init.signal.addEventListener('abort', () => reject(init.signal.reason)),
),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const controller = new AbortController();
const pending = source.nearestImages(
{ lat: 1, lon: 2 },
{ signal: controller.signal },
);
const reason = new Error('the turn moved on');
controller.abort(reason);
await assert.rejects(pending, (error) => error === reason);
});
@@ -174,6 +174,15 @@ export function createMapillaryViewer({ source, render } = {}) {
current = null;
}
/** A playing sequence keeps stepping through (and downloading) images. */
function stopPlayback() {
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
}
return {
async mount(host) {
if (!host) throw new Error('Street Level viewer has no host element');
@@ -195,15 +204,12 @@ export function createMapillaryViewer({ source, render } = {}) {
close() {
pendingOpen = null;
current = null;
// A playing sequence would keep stepping through (and downloading)
// images in the hidden viewer.
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
// Hidden, the viewer must not keep playing.
stopPlayback();
},
stopPlayback,
unmount() {
pendingOpen = null;
// A build still waiting for the library gives up when it arrives.
+2 -1
View File
@@ -20,6 +20,7 @@ export function filtersOf(provider) {
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack.
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
* @property {boolean} [billedPerImage] Every image load costs the key's owner: a link never opens one without a click.
* @property {() => boolean} [keyReady] False when its key is missing now; the
* first keyless provider is lit instead of a default that cannot draw.
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
@@ -50,7 +51,7 @@ export function filtersOf(provider) {
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError).
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal, radiusM?: number}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). `radiusM` asks for a wider search than NEAREST_RADIUS_M; a provider that cannot search wider ignores it.
* @property {ViewerAdapter} viewer
*
* @typedef {object} ViewerAdapter
+4
View File
@@ -19,6 +19,8 @@ export function freshStreet() {
capturedAt: null,
/** 'day', or 'month' for providers that date images by month (Google). */
capturedAtPrecision: 'day',
/** Why the last nearest lookup opened nothing: 'empty', 'sky' or null. */
nearestOutcome: null,
/** A place name for the caption (Google's address), or null. */
title: null,
/** Whether the open viewer has FIT/FILL render modes. */
@@ -29,6 +31,8 @@ export function freshStreet() {
externalUrl: null,
loading: false,
error: null,
// A lookup a link prepared but left to a click: {point, providerIds, radiusM}.
armed: null,
};
}
+55 -5
View File
@@ -73,6 +73,19 @@ export function createViewerHost({ state, parts }) {
active.unsubscribe();
active.adapter.unmount();
active = null;
// The photo being replaced belonged to that source: none of its details
// (id, place, author, link) describe the one loading now.
const { open, loading, error, providerId, providerName, providerLabel } =
state.street;
Object.assign(state.street, freshStreet(), {
open,
loading,
error,
providerId,
providerName,
providerLabel,
});
parts.marker.clear();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
@@ -99,12 +112,17 @@ export function createViewerHost({ state, parts }) {
}
/**
* Open an image: true once its first pose is in, false if it failed or was
* overtaken. `frame: false` leaves the camera where it is.
* Open an image: true once its first pose is in, false if it failed, was
* overtaken, or `signal` fired first. An abort while the viewer mounts or
* loads closes the photo: nothing opens for a request that was withdrawn.
*/
async function open(providerId, imageId, { frame = true } = {}) {
async function open(
providerId,
imageId,
{ frame = true, signal = null } = {},
) {
const entry = state.providers.get(providerId);
if (!entry || !imageId) return false;
if (!entry || !imageId || signal?.aborted) return false;
if (!state.street.host) {
// A nearest-image lookup may have set it; nothing is loading now.
state.street.loading = false;
@@ -115,6 +133,10 @@ export function createViewerHost({ state, parts }) {
}
const seq = ++openSeq;
const current = () => seq === openSeq;
const onAbort = () => {
if (current()) close();
};
signal?.addEventListener('abort', onAbort, { once: true });
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket = frame ? parts.framing.begin() : null;
Object.assign(state.street, {
@@ -138,6 +160,7 @@ export function createViewerHost({ state, parts }) {
if (current())
state.street.error = error?.message || msg('Image could not be opened');
} finally {
signal?.removeEventListener('abort', onAbort);
if (current()) state.street.loading = false;
notify();
}
@@ -165,6 +188,11 @@ export function createViewerHost({ state, parts }) {
active?.adapter.resize();
}
/** Stop a sequence playing in the open photo (adapters that play). */
function stopPlayback() {
active?.adapter.stopPlayback?.();
}
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
active?.adapter.setRenderMode?.(state.street.renderMode);
@@ -187,5 +215,27 @@ export function createViewerHost({ state, parts }) {
active = null;
}
return { attach, open, close, resize, setRenderMode, unmount };
/**
* Destroy `providerId`'s viewer and leave the panel's state alone: what a
* request leaves behind once it replaced or closed that source's photo.
*/
function release(providerId) {
if (active?.id !== providerId && mounting?.id !== providerId) return;
const pending = mounting;
mounting = null;
active?.unsubscribe();
(active?.adapter ?? pending?.adapter)?.unmount();
active = null;
}
return {
attach,
open,
close,
resize,
setRenderMode,
stopPlayback,
unmount,
release,
};
}
@@ -557,6 +557,51 @@ test('an adapter that can frame but says this image has no frame offers no FIT /
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
});
test('a caller that withdraws while the viewer is still mounting gets no photo', async () => {
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host } = harness(adapter);
const controller = new AbortController();
const pending = host.open('mapillary', 'a', { signal: controller.signal });
assert.equal(state.street.open, true, 'opening');
controller.abort(); // the library is still loading
gate.resolve();
assert.equal(await pending, false);
assert.deepEqual(adapter.calls.open, [], 'the image was never opened');
assert.equal(state.street.open, false, 'the photo is closed');
assert.equal(state.street.loading, false, 'no stuck spinner');
assert.equal(state.street.imageId, null);
});
test('a caller that withdraws while the photo is loading gets it closed', async () => {
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host } = harness(adapter);
const controller = new AbortController();
const pending = host.open('mapillary', 'a', { signal: controller.signal });
await settle();
assert.deepEqual(adapter.calls.open, ['a'], 'loading');
controller.abort();
openGate.resolve();
assert.equal(await pending, false);
assert.equal(state.street.open, false, 'the withdrawn photo is closed');
assert.equal(state.street.imageId, null);
// An already-withdrawn request asks nothing at all.
const done = new AbortController();
done.abort();
assert.equal(
await host.open('mapillary', 'b', { signal: done.signal }),
false,
);
assert.deepEqual(adapter.calls.open, ['a']);
// A signal that never fires changes nothing.
assert.equal(
await host.open('mapillary', 'c', { signal: new AbortController().signal }),
true,
);
assert.equal(state.street.imageId, 'c');
});
test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
+6
View File
@@ -380,6 +380,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
});
return {
label,
latitude: lat,
longitude: lng,
navigationMode: 'natural-region-swath',
rangeM: swath.rangeM,
};
@@ -415,6 +417,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
if (flight) {
return {
label,
latitude: lat,
longitude: lng,
navigationMode,
rangeM: null,
};
@@ -468,6 +472,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
});
return {
label,
latitude: lat,
longitude: lng,
navigationMode: requestedRange
? 'explicit-range'
: options.forceClose
+32
View File
@@ -778,3 +778,35 @@ test('a precise search without an outline frames against the resolved ground, no
const eye = target.height + flight.offset.range * Math.sin(-flight.offset.pitch);
assert.ok(eye > 171 + 100, `eye ${eye} must clear the 171 m surface`);
});
test('searchAndFlyTo reports the place it flew to, whichever framing it chose', async () => {
// A city frames its viewport.
const city = await runSearch(stubViewer(), {});
assert.equal(city.latitude, 30.2672);
assert.equal(city.longitude, -97.7431);
// A precise place flies to a landmark.
const place = await runSearch(stubViewer(), {}, {
query: 'Texas State Capitol',
result: {
formatted_address: '1100 Congress Ave, Austin, TX',
types: ['establishment', 'point_of_interest'],
geometry: { location: { lat: 30.2747, lng: -97.7403 } },
},
});
assert.equal(place.latitude, 30.2747);
assert.equal(place.longitude, -97.7403);
// A natural region frames a swath.
const region = await runSearch(stubViewer(), {}, {
query: 'Rocky Mountains',
result: {
formatted_address: 'Rocky Mountains',
types: ['natural_feature', 'establishment'],
geometry: {
location: { lat: 43.7, lng: -110.7 },
viewport: { southwest: { lat: 35, lng: -118 }, northeast: { lat: 52, lng: -104 } },
},
},
});
assert.equal(region.latitude, 43.7);
assert.equal(region.longitude, -110.7);
});
+34
View File
@@ -181,3 +181,37 @@ export function reassertNavigationHandoff({
release?.();
return true;
}
/** Gestures on the globe that take the camera from a pending request. */
const CAMERA_GESTURES = ['pointerdown', 'wheel'];
/**
* Whether the camera is still a request's: a navigation the shell stamps (a
* search, the assistant, a link) or a press or wheel on the globe hands it to
* someone else. `claim()` takes the navigation stamped so far as the
* request's (its own flight); a gesture is never forgiven. `stop()` removes
* the gesture listeners.
*/
export function watchCameraOwner(viewer, navigation) {
let generation = navigation?._navigationGeneration;
const canvas = viewer?.canvas ?? viewer?.scene?.canvas ?? null;
let taken = false;
const onGesture = () => {
taken = true;
};
for (const type of CAMERA_GESTURES)
canvas?.addEventListener?.(type, onGesture, { passive: true });
return {
claim: () => {
generation = navigation?._navigationGeneration;
},
taken: () =>
taken ||
(Number.isFinite(generation) &&
navigation._navigationGeneration !== generation),
stop: () => {
for (const type of CAMERA_GESTURES)
canvas?.removeEventListener?.(type, onGesture);
},
};
}
+6 -3
View File
@@ -25,14 +25,15 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
function realtimeTools() { return GEV_REALTIME_TOOLS; }
test('Realtime schema exposes the authoritative 34-tool inventory', () => {
test('Realtime schema exposes the authoritative 35-tool inventory', () => {
const tools = realtimeTools();
assert.equal(tools.length, 34);
assert.equal(tools.length, 35);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, 34, 'tool names are unique');
assert.equal(new Set(names).size, 35, 'tool names are unique');
assert.ok(names.includes('resolve_area'));
assert.ok(names.includes('find_imagery'));
assert.ok(names.includes('osm_query'));
assert.ok(names.includes('show_street_level'));
assert.ok(names.includes('set_context_mode'));
assert.ok(names.includes('control_cockpit'));
assert.ok(names.includes('set_voice_inactivity_timeout'));
@@ -206,6 +207,8 @@ test('no unchanged Realtime tool definition drifts silently', () => {
'resolve_area',
'find_imagery',
'osm_query',
// Street-level photos: a new composite tool, not an edit to a shipped one.
'show_street_level',
]);
const unchanged = realtimeTools()
.filter((tool) => !TOUCHED.has(tool.name))
+186
View File
@@ -0,0 +1,186 @@
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStreetLevelLayer } from './layers/streetLevel/index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs';
import { showStreetLevel } from './voice/gevActions.js';
import { installViews } from './app/embed.js';
import { ShareRestoration } from './ui/shareRestoration.js';
import { StreetLevelControls } from './ui/streetLevelControls.js';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import StreetLevelPanel from './ui/views/panels/StreetLevelPanel.vue';
import { useStreetLevelStore } from './ui/stores/streetLevel.js';
// Street-level request lifecycle across the action, the layer, links and the
// panel. Offline: providers and camera flights are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
async function harness(t, { nearestImage = async () => 'A', failImage = null, extraProviders = [] } = {}) {
document.body.className = '';
document.body.replaceChildren();
const provider = fakeStreetLevelProvider({ nearestImage });
const originalCreate = provider.create;
const opened = [];
provider.create = context => {
const instance = originalCreate(context);
let pose;
instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; };
instance.viewer.open = async id => {
opened.push(id);
if (id === failImage) throw new Error('Fixture image load failed');
pose?.({ imageId: id });
};
return instance;
};
const viewer = fakeCesiumViewer();
viewer.canvas = viewer.scene.canvas;
viewer.canvas.setPointerCapture = () => {};
const layer = createStreetLevelLayer({ providers: [provider, ...extraProviders] });
layer.init(viewer);
layer.enable(viewer);
const host = document.body.appendChild(document.createElement('div'));
layer.attachViewerHost(host);
t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); });
await settle();
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
getAll: () => [{ id: 'street-level', enabled: true }],
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true }),
setLayerParams: (_id, params) => layer.setParams(params),
};
const styleManager = { _navigationGeneration: 1 };
const flights = [];
const context = {
viewer, dataManager, styleManager,
runOptions: { signal: new AbortController().signal },
runGevAction: async (name, args) => {
assert.equal(name, 'fly_to_location');
flights.push(args);
return { ok: true, latitude: 48, longitude: 2, label: 'fixture point' };
},
};
const run = (name, args) =>
name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true });
return { layer, viewer, dataManager, context, run, opened, flights };
}
test('a landed link does not reclaim a camera the user moved while its layers load', async t => {
const h = await harness(t);
const location = { search: '', hash: '' };
const shell = h.context.styleManager;
shell.initialRestorePromise = Promise.resolve();
const remove = installViews({ ...h, shell, location, windowRef: window });
t.after(remove);
await settle();
location.hash = '#sl=mapillary,48,2';
const layers = deferred();
const landed = deferred();
const restoration = Object.assign(Object.create(ShareRestoration.prototype), {
_shareRestoreGeneration: 1, _disposed: false,
shareLinkManager: {
applyState: async () => { landed.resolve(); return { camera: 'applied' }; },
completeInitialRestore() {},
},
_sharedVessel: () => null,
});
const restoring = restoration._runShareRestore({}, {
applyCamera: true, navigationToken: 1, layersPromise: layers.promise,
settle: detail => window.dispatchEvent(new CustomEvent('gev:share-navigation-restore-settled', { detail })),
});
await landed.promise;
shell._navigationGeneration++;
layers.resolve([]);
await restoring;
await settle(); await settle();
assert.equal(h.flights.length, 0, 'an old landed link must not fly after later navigation');
});
test('a replacement from another source keeps EXPAND', async t => {
const h = await harness(t, {
extraProviders: [fakeStreetLevelProvider({ id: 'panoramax', pickPrefix: 'pm:', nearestImage: async () => 'B' })],
});
const root = document.body.appendChild(document.createElement('div'));
root.innerHTML = '<button id="sl-status"></button><div id="sl-viewer-wrap"><div id="sl-viewer"></div></div><button id="sl-viewer-expand"></button>';
const wrap = root.querySelector('#sl-viewer-wrap');
let fullscreen = null;
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
const oldExit = document.exitFullscreen;
// As a browser does: the change is announced after the switch.
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
document.exitFullscreen = async () => { fullscreen = null; announce(); };
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
const store = { attach: () => 1, detach() {}, publish() {}, setExpandAvailable() {}, setExpanded() {}, setEmbedOverlay() {} };
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
const off = h.layer.subscribe(state => controls.render(state));
t.after(() => { off(); controls.destroy(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
assert.equal(await h.layer.openImage('mapillary', 'previous', { frame: false }), true);
await controls._toggleExpanded();
assert.equal(controls.isViewerExpanded(), true);
const result = await showStreetLevel({ provider: 'panoramax', latitude: 48, longitude: 2 }, h.context);
assert.equal(result.ok, true);
assert.equal(h.layer.getUIState().street.providerId, 'panoramax');
assert.equal(controls.isViewerExpanded(), true, 'replacement must preserve the expanded viewing surface');
});
for (const outcome of ['empty', 'error', 'image']) {
test(`a replacement retired by a navigation keeps the open photo (${outcome})`, async t => {
const lookup = deferred();
const entered = deferred();
const h = await harness(t, { nearestImage: async () => {
entered.resolve(); await lookup.promise;
if (outcome === 'error') throw new Error('Fixture lookup failure');
return outcome === 'image' ? 'B' : null;
} });
await h.layer.openImage('mapillary', 'A', { frame: false });
const action = showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
await entered.promise;
h.context.styleManager._navigationGeneration++;
lookup.resolve();
const result = await action;
assert.ok(result.cancelled || result.superseded);
assert.deepEqual(h.opened, ['A']);
assert.equal(h.layer.getUIState().street.open, true, 'retired lookup must preserve A');
assert.equal(h.layer.getUIState().street.error, null, 'retired lookup must not publish an error');
});
}
test('a failed replacement shows its error inside the expanded viewer', async t => {
const h = await harness(t, { nearestImage: async () => 'B', failImage: 'B' });
const root = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(StreetLevelPanel); app.use(pinia); app.mount(root);
const store = useStreetLevelStore(pinia);
const wrap = root.querySelector('#sl-viewer-wrap');
let fullscreen = null;
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
const oldExit = document.exitFullscreen;
// As a browser does: the change is announced after the switch.
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
document.exitFullscreen = async () => { fullscreen = null; announce(); };
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
const off = h.layer.subscribe(state => controls.render(state));
t.after(() => { off(); controls.destroy(); app.unmount(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
await h.layer.openImage('mapillary', 'A', { frame: false });
await nextTick();
await controls._toggleExpanded();
await settle();
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
await nextTick();
assert.equal(result.ok, false);
assert.equal(controls.isViewerExpanded(), true);
assert.equal(document.fullscreenElement, wrap);
assert.equal(h.layer.getUIState().street.imageId, 'A');
assert.match(h.layer.getUIState().street.error, /Fixture image load failed/);
assert.equal(wrap.contains(root.querySelector('#sl-error')), false);
const error = wrap.querySelector('#sl-viewer-error');
assert.match(error.textContent, /Fixture image load failed/);
assert.equal(error.hidden, false, 'error must be visible inside fullscreen');
});
+23
View File
@@ -115,3 +115,26 @@ test('OPEN STREET VIEW keeps its width beside a long coverage line', () => {
);
assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/);
});
test('embed mode shows an open photo over the globe, filling the overlay', () => {
const overlay = css.match(
/body\.ui-embed \.sl-viewer-wrap-embed \{([^}]*)\}/,
);
assert.ok(overlay, 'embed styles the viewer it moves over the globe');
for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000'])
assert.ok(overlay[1].includes(rule), rule);
// Cockpit, recording and scene playback own the screen: the photo hides.
for (const mode of ['cockpit-mode', 'recording-mode', 'scene-playback-mode'])
assert.match(
css,
new RegExp(
`body\\.ui-embed\\.${mode} \\.sl-viewer-wrap-embed[^{]*\\{[^}]*display: none`,
),
mode,
);
// The photo takes the overlay's height, as it does full screen.
assert.match(
css,
/body\.ui-embed \.sl-viewer-wrap-embed \.sl-viewer \{[^}]*flex: 1 1 auto/,
);
});
+281
View File
@@ -0,0 +1,281 @@
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStreetLevelLayer } from './layers/streetLevel/index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs';
import { showStreetLevel } from './voice/gevActions.js';
import { installViews } from './app/embed.js';
import { groundedLookAt } from './view/index.js';
import { composeCatalog, coreTools } from './tools/index.js';
// Replacing a street-level photo, across sources and against the user's own
// moves; posted views waiting on terrain, and their look-at near a pole.
// Offline: providers, lookups, terrain and flights are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
function provider(id, { nearest = async () => 'B', load = async () => {}, billed = false } = {}) {
const def = fakeStreetLevelProvider({ id, pickPrefix: id + ':', nearestImage: nearest });
def.name = id;
def.label = id.toUpperCase();
def.billedPerImage = billed;
const create = def.create;
def.create = context => {
const instance = create(context);
let pose;
instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; };
instance.viewer.open = async imageId => {
def.calls.open.push(imageId);
await load(imageId);
pose?.({ imageId, position: { lat: 48, lon: 2 }, creator: 'fixture photographer',
externalUrl: 'https://www.mapillary.com/app/?pKey=' + imageId });
};
return instance;
};
return def;
}
async function harness(t, defs) {
document.body.className = '';
document.body.replaceChildren();
const viewer = fakeCesiumViewer();
viewer.canvas = viewer.scene.canvas;
viewer.canvas.setPointerCapture = () => {};
const layer = createStreetLevelLayer({ providers: defs });
layer.init(viewer);
layer.enable(viewer);
layer.attachViewerHost(document.body.appendChild(document.createElement('div')));
t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); });
await settle();
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
getAll: () => [{ id: 'street-level', enabled: true }],
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true }),
setLayerParams: (_id, params) => layer.setParams(params),
};
const shell = { _navigationGeneration: 1, initialRestorePromise: Promise.resolve(), setCleanView() {} };
const flights = [];
const context = { viewer, dataManager, styleManager: shell,
runOptions: { signal: new AbortController().signal },
runGevAction: async (name, args) => {
assert.equal(name, 'fly_to_location');
flights.push(args);
return { ok: true, latitude: args.latitude, longitude: args.longitude, label: 'fixture point' };
},
};
const run = (name, args) => name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true });
return { layer, viewer, dataManager, shell, context, run, flights };
}
async function embed(t, h, terrain) {
const replies = [];
const peer = { postMessage: message => replies.push(message) };
const windowRef = new EventTarget();
windowRef.parent = peer;
windowRef.document = document;
const remove = installViews({ ...h, terrain, windowRef, location: { search: '?embed=1', hash: '' } });
t.after(remove);
await settle();
const post = (id, view) => {
const event = new Event('message');
Object.assign(event, { source: peer, origin: 'https://host.example', data: { type: 'gev:view', id, view } });
windowRef.dispatchEvent(event);
};
return { post, replies };
}
const args = provider => ({ provider, latitude: 48, longitude: 2, fly: false });
const view = pitch => ({ camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: pitch }, street_level: { provider: 'mapillary' } });
for (const outcome of ['empty', 'error']) {
test('a replacement from another source that opens nothing keeps its explanation (' + outcome + ')', async t => {
const a = provider('mapillary');
const b = provider('panoramax', { nearest: async () => {
if (outcome === 'error') throw new Error('Fixture lookup failure');
return null;
} });
const h = await harness(t, [a, b]);
assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true);
const result = await showStreetLevel(args('panoramax'), h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, error: street.error, nearestOutcome: street.nearestOutcome }));
assert.equal(result.ok, false);
assert.equal(street.open, false);
assert.ok(street.error, 'replacement explanation must survive release');
if (outcome === 'empty') assert.equal(street.nearestOutcome, 'empty');
});
}
test('an arming link from another source keeps its armed point', async t => {
const h = await harness(t, [provider('mapillary'), provider('google', { billed: true })]);
assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true);
const result = await showStreetLevel({ ...args('google'), autoOpen: false }, h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, armed: street.armed }));
assert.equal(result.armed, true);
assert.deepEqual(street.armed?.point, { lat: 48, lon: 2 });
});
test('a same-source lookup that finds nothing keeps its explanation', async t => {
const h = await harness(t, [provider('mapillary', { nearest: async () => null })]);
await h.layer.openImage('mapillary', 'A', { frame: false });
const result = await showStreetLevel(args('mapillary'), h.context);
assert.equal(result.noImagery, true);
assert.ok(h.layer.getUIState().street.error);
assert.equal(h.layer.getUIState().street.nearestOutcome, 'empty');
});
for (const takeover of ['navigation', 'gesture', 'none']) {
test('a posted view waiting on terrain yields to the user (' + takeover + ')', async t => {
const a = provider('mapillary');
const h = await harness(t, [a]);
const entered = deferred();
const terrain = deferred();
const e = await embed(t, h, { getHeights: async () => { entered.resolve(); return terrain.promise; } });
e.post(1, view(-45));
await entered.promise;
if (takeover === 'navigation') h.shell._navigationGeneration++;
if (takeover === 'gesture') h.viewer.canvas.dispatchEvent(new Event('pointerdown'));
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 8 && !e.replies.some(r => r.id === 1); n++) await settle();
const reply = e.replies.find(r => r.id === 1);
t.diagnostic(JSON.stringify({ takeover, flights: h.flights, opened: a.calls.open, reply }));
assert.ok(reply, 'host receives completion');
assert.equal(h.flights.length, takeover === 'none' ? 1 : 0, 'superseded view must not reclaim the camera');
assert.equal(a.calls.open.length, takeover === 'none' ? 1 : 0);
assert.equal(reply.ok, takeover === 'none');
});
}
test('a posted view looking at the horizon does not wait on terrain or hold the queue', async t => {
const h = await harness(t, [provider('mapillary')]);
const terrain = deferred();
let terrainCalls = 0;
const e = await embed(t, h, { getHeights: () => { terrainCalls++; return terrain.promise; } });
e.post(1, view(0));
e.post(2, { ...view(-90), street_level: { provider: 'mapillary', lat: 49, lon: 3 } });
for (let n = 0; n < 8; n++) await settle();
const before = e.replies.filter(r => r.id).map(r => r.id);
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 8 && !e.replies.some(r => r.id === 2); n++) await settle();
const after = e.replies.filter(r => r.id).map(r => r.id);
t.diagnostic(JSON.stringify({ terrainCalls, before, after, flights: h.flights }));
assert.deepEqual(after, [1, 2], 'both complete after irrelevant terrain is released');
assert.deepEqual(before, [1, 2], 'both should complete without any terrain response');
assert.equal(terrainCalls, 0);
});
for (const initial of [true, false]) {
test('a source switched by hand during the load stops the request (photo open before: ' + initial + ')', async t => {
const entered = deferred();
const load = deferred();
const a = provider('mapillary', { load: async id => { if (id === 'B') { entered.resolve(); await load.promise; } } });
const h = await harness(t, [a, provider('panoramax')]);
if (initial) await h.layer.openImage('mapillary', 'A', { frame: false });
const request = showStreetLevel(args('mapillary'), h.context);
await entered.promise;
h.layer.setProviderEnabled('panoramax', true);
load.resolve();
const result = await request;
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ initial, result, open: street.open, sourceOn: h.layer.providerStatus('mapillary').on }));
assert.equal(h.layer.providerStatus('mapillary').on, false);
assert.equal(street.open, false);
assert.equal(result.ok, false, 'request for the disabled source must not report success');
assert.equal(result.cancelled, true);
});
}
test('a failed replacement from another source does not keep the old photo\'s details', async t => {
const h = await harness(t, [provider('mapillary'), provider('panoramax', { load: async () => { throw new Error('Fixture image load failure'); } })]);
await h.layer.openImage('mapillary', 'A', { frame: false });
const before = h.layer.getUIState().street;
assert.equal(before.imageId, 'A');
assert.ok(before.externalUrl);
const result = await showStreetLevel(args('panoramax'), h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, street }));
assert.equal(result.ok, false);
assert.equal(street.providerId, 'panoramax');
assert.match(street.error, /Fixture image load failure/);
assert.deepEqual({ imageId: street.imageId, position: street.position, creator: street.creator, externalUrl: street.externalUrl },
{ imageId: null, position: null, creator: null, externalUrl: null }, 'destroyed provider photo must not be attributed to replacement');
});
const rejections = [];
process.on('unhandledRejection', reason => { rejections.push(String(reason?.message || reason)); });
// 111 m from the North Pole, looking east and down: 1 km ahead is more than a
// full turn of that parallel.
const north = { camera: { lat: 89.999, lon: 170, altitude_m: 1000, pitch_deg: -45, heading_deg: 90 }, street_level: { provider: 'mapillary' } };
const placed = { camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 }, street_level: { provider: 'mapillary', lat: 48, lon: 2 } };
test('a grounded look-at near a pole stays on the globe', async () => {
const point = await groundedLookAt(north.camera, { getHeights: async () => [{ ellipsoid: 0 }] });
assert.ok(Math.abs(point.lat) <= 90 && Math.abs(point.lon) <= 180, JSON.stringify(point));
});
test('show_in_gods_eye_view frames the South Pole with a tilted camera', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' }, terrain: { getHeights: async () => [{ ellipsoid: 2835 }] } },
});
const result = await catalog.call('show_in_gods_eye_view', {
camera: { lat: -90, lon: 0, altitude_m: 3500, pitch_deg: -30, heading_deg: 45 },
street_level: { provider: 'mapillary' },
});
const street = result.data?.view?.street_level;
assert.ok(street && Math.abs(street.lat) <= 90 && Math.abs(street.lon) <= 180, JSON.stringify(result));
});
test('a posted polar view is answered and the next view still applies', async t => {
rejections.length = 0;
const h = await harness(t, [provider('mapillary')]);
const e = await embed(t, h, { getHeights: async () => [{ ellipsoid: 0 }] });
e.post(1, north);
e.post(2, placed);
for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle();
t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections }));
assert.ok(e.replies.some(r => r.id === 1), 'the polar view gets an answer');
assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies');
assert.deepEqual(rejections, [], 'no rejection escapes the views queue');
});
test('a stalled terrain answer does not hold posted views', { timeout: 15000 }, async t => {
const h = await harness(t, [provider('mapillary')]);
let asked = 0;
// Hangs like a stalled upstream; gives up only when its signal aborts.
const terrain = {
getHeights: (_points, { signal } = {}) => {
asked++;
return new Promise((_, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true }));
},
};
const e = await embed(t, h, terrain);
e.post(1, view(-45));
e.post(2, placed);
const started = Date.now();
while (Date.now() - started < 5000 && !e.replies.some(r => r.id === 2)) await new Promise(resolve => setTimeout(resolve, 100));
t.diagnostic(JSON.stringify({ asked, waitedMs: Date.now() - started, replies: e.replies.filter(r => r.id).map(r => r.id) }));
assert.equal(asked, 1);
assert.ok(e.replies.some(r => r.id === 1), 'the view falls back to its own estimate within 5 s');
assert.ok(e.replies.some(r => r.id === 2), 'the next view is not held behind it');
});
test('a posted view that throws is answered and does not hold the views behind it', async t => {
rejections.length = 0;
const h = await harness(t, [provider('mapillary')]);
const getAll = h.dataManager.getAll;
let calls = 0;
h.dataManager.getAll = () => {
if (calls++ === 0) throw new Error('Fixture layer list failure');
return getAll();
};
const e = await embed(t, h, null);
e.post(1, placed);
e.post(2, placed);
for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle();
t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections }));
assert.deepEqual(e.replies.find(r => r.id === 1), { type: 'gev:view-applied', id: 1, ok: false, error: 'Fixture layer list failure' });
assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies');
assert.deepEqual(rejections, []);
});
+1
View File
@@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) {
return new Uint8Array(0);
},
getSequenceImages: async () => [],
nearestImages: async () => [],
...overrides,
};
}
+47 -3
View File
@@ -1,6 +1,11 @@
/** Links that open God's Eye View at a view. */
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
import {
VIEW_PROPERTIES,
createView,
groundedLookAt,
viewUrl,
} from '../../view/index.js';
import { defineTool, ToolError } from '../catalog.js';
import { GLOBE_PANEL_URI } from '../globePanel.js';
import { AREA_SCHEMA, resolveArea } from '../area.js';
@@ -47,10 +52,31 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
? { ...args.camera }
: { ...base.camera, ...args.camera };
let label = null;
// Street-level photos open at the area's centre unless a point is given;
// a new area or camera position moves a passed-through view's photos with
// it.
const moved =
Boolean(args.area) ||
args.camera?.lat !== undefined ||
args.camera?.lon !== undefined;
let streetLevel =
args.street_level && typeof args.street_level === 'object'
? { ...args.street_level }
: base.street_level && typeof base.street_level === 'object'
? moved
? { provider: base.street_level.provider }
: { ...base.street_level }
: null;
if (args.area) {
const area = await resolveArea(args.area, { services, signal });
label = area.label;
const framed = cameraForArea(area);
if (
streetLevel &&
streetLevel.lat === undefined &&
streetLevel.lon === undefined
)
streetLevel = { ...streetLevel, lat: framed.lat, lon: framed.lon };
// A tilted view of an area looks at its center from behind, unless the
// caller placed the camera itself.
camera =
@@ -75,6 +101,15 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
camera = { altitude_m: FOLLOW_ALTITUDE_M, ...camera, ...position };
label = `${follow.kind === 'military_aircraft' ? 'military aircraft' : 'aircraft'} ${follow.id}`;
}
// Photos for a camera with no point open where it looks, from its ground.
if (
streetLevel &&
streetLevel.lat === undefined &&
streetLevel.lon === undefined
) {
const point = await groundedLookAt(camera, services?.terrain, signal);
if (point) streetLevel = { ...streetLevel, ...point };
}
let view;
try {
view = createView({
@@ -84,6 +119,7 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
map: args.map ?? base.map ?? null,
follow,
annotations: args.annotations ?? base.annotations ?? [],
street_level: streetLevel,
});
} catch (error) {
// A view passed through can hold anything; report it as bad arguments.
@@ -113,7 +149,9 @@ export const showInGodsEyeView = defineTool({
'it. Pass the view another answer returned, or describe one: an area ' +
'framed from above or a camera position, with chosen data layers, visual ' +
'style and map, optionally following an aircraft (or riding in its ' +
'cockpit view) or a satellite, and with marks drawn on the map.',
'cockpit view) or a satellite, with marks drawn on the map, or opening ' +
'street-level photos (Google Street View, Mapillary, KartaView or ' +
'Panoramax) at a place.',
inputSchema: {
type: 'object',
properties: VIEW_ARGUMENTS,
@@ -131,7 +169,13 @@ export const showInGodsEyeView = defineTool({
const url = viewUrl(base, view);
return {
summary: `Open ${label} in God's Eye View: ${url}`,
data: { url, view },
// A panel opens a billed photo only for a result it receives fresh;
// hosts hand a panel its result again whenever they mount it.
data: {
url,
view,
...(view.street_level ? { issued_at: Date.now() } : {}),
},
};
},
});
+186
View File
@@ -113,3 +113,189 @@ test('links carry a camera, style, map and an entity to follow', async () => {
/Give a view, an area, or a camera with lat and lon/,
);
});
test('links can open street-level photos at the area, on the map the source needs', async () => {
const { viewFromParams } = await import('../../view/index.js');
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const result = await catalog.call('show_in_gods_eye_view', {
area: { lat: 38.5799, lon: -121.4937, radius_km: 1 },
street_level: { provider: 'google' },
});
const { view } = result.data;
assert.equal(view.map, 'photoreal');
assert.deepEqual(view.layers, ['street-level']);
assert.deepEqual(view.street_level, {
provider: 'google',
lat: 38.5799,
lon: -121.4937,
});
const params = new URLSearchParams(new URL(result.data.url).hash.slice(1));
assert.equal(params.get('sl'), 'google,38.57990,-121.49370');
assert.equal(params.get('map'), 'photoreal');
assert.deepEqual(viewFromParams(params), view);
// The panel opens a billed photo only for a result it gets fresh.
assert.ok(Math.abs(Date.now() - result.data.issued_at) < 60_000);
const point = await catalog.call('show_in_gods_eye_view', {
view,
street_level: { provider: 'mapillary', lat: 38.58, lon: -121.49 },
});
assert.deepEqual(point.data.view.street_level, {
provider: 'mapillary',
lat: 38.58,
lon: -121.49,
});
await assert.rejects(
catalog.call('show_in_gods_eye_view', {
area: { lat: 0, lon: 0, radius_km: 1 },
map: 'osm',
street_level: { provider: 'google' },
}),
/photoreal/,
);
});
test('a new area or camera position moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const first = await catalog.call('show_in_gods_eye_view', {
area: { lat: 38.5799, lon: -121.4937, radius_km: 1 },
street_level: { provider: 'google' },
});
const second = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
area: { lat: 42.3314, lon: -83.0458, radius_km: 1 },
});
assert.deepEqual(second.data.view.street_level, {
provider: 'google',
lat: 42.3314,
lon: -83.0458,
});
// A new camera position moves them too; turning the camera keeps them.
const moved = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
camera: { lat: 40.758, lon: -73.9855, altitude_m: 800, pitch_deg: -90 },
});
assert.deepEqual(moved.data.view.street_level, {
provider: 'google',
lat: 40.758,
lon: -73.9855,
});
const turned = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
camera: { heading_deg: 90 },
});
assert.deepEqual(turned.data.view.street_level, first.data.view.street_level);
const tilted = await catalog.call('show_in_gods_eye_view', {
area: { lat: 48.8584, lon: 2.2945, radius_km: 1 },
camera: { pitch_deg: -30 },
street_level: { provider: 'mapillary' },
});
assert.notEqual(
tilted.data.view.camera.lat,
48.8584,
'the camera sits behind the target',
);
assert.deepEqual(tilted.data.view.street_level, {
provider: 'mapillary',
lat: 48.8584,
lon: 2.2945,
});
// A wide area frames the camera tens of km up, past where a camera's
// look-at point is trusted: the photos still open at the area's centre.
const wide = await catalog.call('show_in_gods_eye_view', {
area: { lat: 48.8584, lon: 2.2945, radius_km: 15 },
camera: { pitch_deg: -30 },
street_level: { provider: 'mapillary' },
});
assert.ok(wide.data.view.camera.altitude_m > 20_000);
assert.deepEqual(wide.data.view.street_level, {
provider: 'mapillary',
lat: 48.8584,
lon: 2.2945,
});
await assert.rejects(
catalog.call('show_in_gods_eye_view', {
area: { lat: 0, lon: 0, radius_km: 1 },
follow: { kind: 'aircraft', id: 'AE1234' },
street_level: {},
}),
/cannot both follow/,
);
});
test('a camera with no photo point opens photos where it looks, measured from its ground', async () => {
const { lookAtPoint } = await import('../../view/index.js');
const asked = [];
const catalog = composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/' },
terrain: {
getHeights: async (points) => {
asked.push(points);
return [{ ellipsoid: 1590 }];
},
},
},
});
// Denver: 1,909 m above the ellipsoid is about 320 m above its streets.
const camera = {
lat: 39.7392,
lon: -104.9903,
altitude_m: 1909,
pitch_deg: -30,
heading_deg: 0,
};
const result = await catalog.call('show_in_gods_eye_view', {
camera,
street_level: { provider: 'mapillary' },
});
assert.deepEqual(asked, [[{ lat: 39.7392, lon: -104.9903 }]]);
const grounded = lookAtPoint({ ...camera, altitude_m: 319 });
const { street_level: street } = result.data.view;
assert.ok(Math.abs(street.lat - grounded.lat) < 1e-5, 'about 550 m ahead');
assert.ok(
street.lat - camera.lat < 0.006,
'not the 3.3 km a sea-level guess gives',
);
// No terrain answer: the view's own estimate, as before.
const offline = composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/' },
terrain: {
getHeights: async () => {
throw new Error('offline');
},
},
},
});
const fallback = await offline.call('show_in_gods_eye_view', {
camera,
street_level: { provider: 'mapillary' },
});
const guess = lookAtPoint(camera);
assert.ok(Math.abs(fallback.data.view.street_level.lat - guess.lat) < 1e-5);
});
test('a view passed through alone keeps its street-level photos', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const first = await catalog.call('show_in_gods_eye_view', {
area: { lat: 38.5799, lon: -121.4937, radius_km: 1 },
street_level: { provider: 'mapillary' },
});
const again = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
});
assert.deepEqual(again.data.view.street_level, first.data.view.street_level);
assert.deepEqual(again.data.view, first.data.view);
});
+24 -2
View File
@@ -4,7 +4,11 @@ import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js';
import { LayerBindings } from './layerBindings.js';
import { PanelChrome } from './panelChrome.js';
import { initMobileShell } from './mobileShell.js';
import {
initMobileShell,
MOBILE_SHELL_MEDIA_QUERY,
sheetForPanelId,
} from './mobileShell.js';
import { VisualSettings } from './visualSettings.js';
import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
@@ -1143,6 +1147,7 @@ export class StyleManager extends ShellFacade {
this._dataManager?.setLayerParams('street-level', params, options),
setPanelCollapsed: (collapsed, options) =>
this.setPanelCollapsed('street-level-panel', collapsed, options),
revealPanel: () => this.revealPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
@@ -1258,6 +1263,20 @@ export class StyleManager extends ShellFacade {
return this._panelChrome.setPanelCollapsed(...arguments);
}
/**
* Bring a panel into view on a phone, where the rails show only as tab
* sheets: opens the sheet that holds it. Does nothing above 720px.
* @param {string} panelId - DOM id of the panel.
* @returns {void}
*/
revealPanel(panelId) {
const sheet = sheetForPanelId(panelId);
if (!globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches) return;
if (sheet) this._mobileShell?.setSheet(sheet);
// The sheet opens at its top; a panel lower in the rail is scrolled to.
document.getElementById(panelId)?.scrollIntoView?.({ block: 'nearest' });
}
/**
* Toggles "clean view" mode which hides all UI panels via a CSS body class.
* @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle.
@@ -1364,9 +1383,12 @@ export class StyleManager extends ShellFacade {
};
}
if (!target.available) {
// The controller knows why (Google 3D: no Maps key; others: no ion token).
return {
ok: false,
error: `${target.label} requires a Cesium ion token`,
error:
target.unavailableReason ||
`${target.label} requires a Cesium ion token`,
activeStack: this.mapStackController.getActiveId(),
};
}
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { StyleManager } from './applicationShell.js';
function reveal({ phone }) {
const sheets = [];
const scrolls = [];
const panel = { scrollIntoView: (options) => scrolls.push(options) };
const saved = globalThis.document;
const savedMatchMedia = globalThis.matchMedia;
globalThis.document = {
getElementById: (id) => (id === 'street-level-panel' ? panel : null),
};
globalThis.matchMedia = (query) => ({
matches: phone && query === '(max-width: 720px)',
});
try {
StyleManager.prototype.revealPanel.call(
{ _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } },
'street-level-panel',
);
} finally {
globalThis.document = saved;
globalThis.matchMedia = savedMatchMedia;
}
return { sheets, scrolls };
}
test('revealing a panel opens its phone sheet and scrolls the panel into view', () => {
const { sheets, scrolls } = reveal({ phone: true });
assert.deepEqual(sheets, ['cameras']);
// The panel sits below the sheet's first screen at 375×812.
assert.deepEqual(scrolls, [{ block: 'nearest' }]);
});
test('revealing a panel above 720px neither opens a sheet nor scrolls the rail', () => {
assert.deepEqual(reveal({ phone: false }), { sheets: [], scrolls: [] });
});
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { StyleManager } from './applicationShell.js';
test('an unavailable map stack reports the controller’s own reason, not always an ion token', async () => {
const controller = {
getStacks: () => [
{
id: 'photoreal',
label: 'Google 3D',
available: false,
unavailableReason: 'Google 3D needs GOOGLE_MAPS_API_KEY',
},
{ id: 'bing-aerial', label: 'Bing aerial', available: false },
],
getActiveId: () => 'osm',
};
const shell = { mapStackController: controller };
const photoreal = await StyleManager.prototype.setMapStack.call(
shell,
'photoreal',
);
assert.deepEqual(photoreal, {
ok: false,
error: 'Google 3D needs GOOGLE_MAPS_API_KEY',
activeStack: 'osm',
});
const bing = await StyleManager.prototype.setMapStack.call(
shell,
'bing-aerial',
);
assert.equal(bing.error, 'Bing aerial requires a Cesium ion token');
});
+3 -1
View File
@@ -317,7 +317,9 @@ export class ShareRestoration {
// the shared flight having landed.
const vessel = this._sharedVessel(state);
if (vessel) void this._restoreSharedVessel(vessel, current);
settle({ status: 'settled', share, layers, tracking });
// The token tells listeners whether anyone navigated after the
// restore's flight landed (while the layers were still loading).
settle({ status: 'settled', share, layers, tracking, navigationToken });
} catch (error) {
if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken);
+9
View File
@@ -19,6 +19,8 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
const expanded = shallowRef(null);
/** False on a page with neither element fullscreen nor popovers. */
const expandAvailable = shallowRef(true);
/** Whether the photo is shown over the globe on an embedded page. */
const embedOverlay = shallowRef(false);
/** @type {{handlers: StreetLevelHandlers}|null} */
let owner = null;
@@ -47,6 +49,11 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
expandAvailable.value = Boolean(available);
}
/** @param {boolean} on */
function setEmbedOverlay(on) {
embedOverlay.value = Boolean(on);
}
/**
* Stop forwarding actions to the owner holding `token`. The panel keeps
* its last render, as the page did when its controls were wired by hand.
@@ -69,10 +76,12 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
view,
expanded,
expandAvailable,
embedOverlay,
attach,
publish,
setExpanded,
setExpandAvailable,
setEmbedOverlay,
detach,
act,
describeSinceStop,
+56 -2
View File
@@ -11,6 +11,11 @@ const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
/** True on an embedded page (`?embed=1` or the inline panel). */
function isEmbedPage() {
return globalThis.document?.body?.classList?.contains('ui-embed') === true;
}
/**
* Drives the Street Level panel body: provider chips, filters and viewer.
* The panel view renders what this presents (`gev-street-level` store); the
@@ -137,6 +142,15 @@ export class StreetLevelControls {
});
}
this.store.setExpandAvailable(Boolean(this._expandMode));
// Embed mode: Esc closes an open photo, since the rail it would go back to
// is hidden. Full screen keeps its own Esc (above), which leaves it first.
this.listen(document, 'keydown', (event) => {
if (event.key !== 'Escape' || !this._embedHome || this.isViewerExpanded())
return;
event.preventDefault();
event.stopPropagation();
this.layer.closeViewer?.();
});
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
@@ -271,8 +285,16 @@ export class StreetLevelControls {
this._modesObserver = null;
return;
}
// Embed mode keeps Clean View on for its globe while the photo is lifted
// over it: there only recording and the cockpit end EXPAND.
const leaveIfHiding = () => {
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
if (
PANEL_HIDING_MODES.some(
(mode) =>
body.classList.contains(mode) &&
!(mode === 'ui-clean-view' && isEmbedPage()),
)
)
this._exitExpanded();
};
if (!this._modesObserver && typeof MutationObserver === 'function') {
@@ -293,6 +315,31 @@ export class StreetLevelControls {
this._requestResize();
}
/**
* Embed mode hides the right rail: an open photo moves to <body> as an
* overlay on the globe (`sl-viewer-wrap-embed`, embed.css) and goes back
* when it closes. Vue keeps patching the wrap there: its siblings are static.
*/
_syncEmbedOverlay(open) {
const wrap = this._elements.viewerWrap;
if (!wrap || !document.body) return;
const lift = open === true && isEmbedPage();
this.store.setEmbedOverlay(lift);
if (lift && !this._embedHome) {
this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling };
document.body.appendChild(wrap);
wrap.classList.add('sl-viewer-wrap-embed');
this._requestResize();
} else if (!lift && this._embedHome) {
wrap.classList.remove('sl-viewer-wrap-embed');
const { parent, next } = this._embedHome;
this._embedHome = null;
if (parent?.isConnected)
parent.insertBefore(wrap, next?.parentNode === parent ? next : null);
this._requestResize();
}
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
@@ -348,8 +395,14 @@ export class StreetLevelControls {
// Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet.
const open = view.viewer.open;
if (open && !this._wasOpen) this.setCollapsed(false, { persist: false });
if (open && !this._wasOpen) {
this.setCollapsed(false, { persist: false });
// A phone shows the rail only as a tab sheet: open it, or the photo
// loads where nobody sees it. Embed mode lifts the photo instead.
if (!isEmbedPage()) this.actions.revealPanel?.();
}
this._wasOpen = open;
this._syncEmbedOverlay(open);
}
destroy() {
@@ -362,6 +415,7 @@ export class StreetLevelControls {
if (this._expandMode === 'popover')
this._elements.viewerWrap?.removeAttribute('popover');
this._watchPanelHidingModes(false);
this._syncEmbedOverlay(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
+180
View File
@@ -622,6 +622,36 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
}
}));
test('in embed mode, which keeps Clean View on for its globe, EXPAND stays; the cockpit still ends it', () =>
withDom(async (dom) => {
const saved = globalThis.MutationObserver;
const watch = fakeMutationObserver();
globalThis.MutationObserver = watch.FakeObserver;
try {
dom.document.body.classList.add('ui-embed');
dom.document.body.classList.add('ui-clean-view');
const { layer, controls } = await stubPanel(
dom,
uiState({ open: false }),
);
await layer.publish(uiState({ open: true }));
dom.document.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
watch.fire();
await settle();
assert.equal(controls.isViewerExpanded(), true, 'EXPAND stays open');
dom.document.body.classList.toggle('cockpit-mode', true);
watch.fire();
await settle();
await settle();
assert.equal(controls.isViewerExpanded(), false);
controls.destroy();
} finally {
globalThis.MutationObserver = saved;
}
}));
/* ── Restores and keyboard rules ───────────────────────────────────────── */
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
@@ -921,3 +951,153 @@ test('a window changed elsewhere under a focused SINCE thumb leaves its readout
assert.equal(since.getAttribute('aria-valuetext'), label());
controls.destroy();
}));
/** In embed mode, the viewer moved out of the hidden rail onto the globe. */
const overGlobe = (dom) =>
dom.wrap.parentNode === dom.document.body &&
dom.wrap.classList.contains('sl-viewer-wrap-embed');
const escape = () =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
test('in embed mode an open photo leaves the hidden rail for an overlay on the globe, and goes back when it closes', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const home = dom.wrap.parentNode;
const after = dom.wrap.nextSibling;
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
assert.equal(overGlobe(dom), false, 'nothing to show yet');
await layer.publish(uiState({ open: true }));
assert.equal(overGlobe(dom), true, 'shown over the globe');
assert.equal(dom.wrap.hidden, false, 'the panel view still renders it');
await layer.publish(uiState({ open: true, renderMode: 'fill' }));
assert.equal(overGlobe(dom), true, 'an update keeps it there');
assert.equal(
dom.wrap.querySelector('[aria-checked="true"]')?.textContent.trim(),
'FILL',
'and the panel view keeps patching it there',
);
await layer.publish(uiState({ open: false }));
assert.equal(overGlobe(dom), false);
assertSameNode(dom.wrap.parentNode, home, 'back in the panel');
assertSameNode(dom.wrap.nextSibling, after, 'in its own place');
assert.equal(dom.wrap.hidden, true);
controls.destroy();
}));
test('outside embed mode a photo stays in the panel', () =>
withDom(async (dom) => {
const home = dom.wrap.parentNode;
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
await layer.publish(uiState({ open: true }));
assert.equal(overGlobe(dom), false);
assertSameNode(dom.wrap.parentNode, home);
controls.destroy();
}));
test('an opening photo brings its panel into view (a phone’s tab sheet); embed mode lifts it instead', () =>
withDom(async (dom) => {
const revealed = [];
const { layer, controls } = await stubPanel(dom, uiState({ open: false }), {
revealPanel: () => revealed.push('street-level'),
});
await layer.publish(uiState({ open: true }));
assert.deepEqual(revealed, ['street-level'], 'once, as it opens');
await layer.publish(uiState({ open: true, renderMode: 'fill' }));
assert.equal(revealed.length, 1, 'not on every update');
await layer.publish(uiState({ open: false }));
dom.document.body.classList.add('ui-embed');
await layer.publish(uiState({ open: true }));
assert.equal(revealed.length, 1, 'embed mode shows it over the globe');
controls.destroy();
}));
test('in embed mode Esc closes the photo, and the next photo is shown over the globe again', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
let closed = 0;
layer.closeViewer = () => {
closed++;
layer.publish(uiState({ open: false }));
};
await layer.publish(uiState({ open: true }));
escape();
await nextTick();
assert.equal(closed, 1, 'Esc closed the photo');
assert.equal(overGlobe(dom), false);
await layer.publish(uiState({ open: true }));
assert.equal(overGlobe(dom), true, 'the next photo is shown');
controls.destroy();
}));
test('in embed mode Esc in full screen leaves full screen and keeps the photo', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
let closed = 0;
layer.closeViewer = () => closed++;
await layer.publish(uiState({ open: true }));
await dom.wrap.requestFullscreen();
await settle();
assert.equal(controls.isViewerExpanded(), true);
escape();
assert.equal(closed, 0, 'the photo stays open');
controls.destroy();
}));
test('outside embed mode Esc does not close a photo in the panel', () =>
withDom(async (dom) => {
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
let closed = 0;
layer.closeViewer = () => closed++;
escape();
assert.equal(closed, 0);
controls.destroy();
}));
test('destroying the panel in embed mode puts an open photo back in the panel', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const home = dom.wrap.parentNode;
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
await layer.publish(uiState({ open: true }));
controls.destroy();
assert.equal(overGlobe(dom), false);
assertSameNode(dom.wrap.parentNode, home);
}));
test('an embedded photo failure is shown inside the overlay, and only there', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
const failed = uiState({ open: true });
Object.assign(failed.street, {
imageId: null,
error: 'Image could not be opened',
});
await layer.publish(failed);
assert.equal(overGlobe(dom), true);
const error = dom.wrap.querySelector('#sl-viewer-error');
assert.ok(error, 'the overlay contains an error region');
assert.equal(error.hidden, false);
assert.match(error.textContent, /Image could not be opened/);
assert.equal(dom.root.querySelector('#sl-error').hidden, true);
await layer.publish(uiState({ open: true }));
assert.equal(error.hidden, true, 'a successful retry clears the error');
controls.destroy();
}));
test('outside embed mode a photo failure stays in the panel error region', () =>
withDom(async (dom) => {
const failed = uiState({ open: true });
failed.street.error = 'Image could not be opened';
const { controls } = await stubPanel(dom, failed);
const error = dom.wrap.querySelector('#sl-viewer-error');
assert.ok(error);
assert.equal(error.hidden, true, 'no duplicate error inside the photo');
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
controls.destroy();
}));
+24
View File
@@ -13,3 +13,27 @@ body.ui-embed #world-overlay-actions,
body.ui-embed #view-switcher {
display: none !important;
}
/* Street-level photos: the rail that holds the panel is hidden here, so an
open photo shows over the globe (streetLevelControls moves the viewer). */
body.ui-embed .sl-viewer-wrap-embed {
position: fixed;
inset: 12px;
z-index: 1000;
}
/* Cockpit, recording and scene playback own the screen, as they hide the
panel elsewhere: the lifted photo steps out of their way. */
body.ui-embed.cockpit-mode .sl-viewer-wrap-embed,
body.ui-embed.recording-mode .sl-viewer-wrap-embed,
body.ui-embed.scene-playback-mode .sl-viewer-wrap-embed {
display: none;
}
body.ui-embed .sl-viewer-wrap-embed .sl-viewer-stage,
body.ui-embed .sl-viewer-wrap-embed .sl-viewer {
flex: 1 1 auto;
height: auto;
min-height: 0;
aspect-ratio: auto;
}
+3 -2
View File
@@ -13,7 +13,7 @@ import UiSlider from '../../kit/UiSlider.js';
// Until it renders, the panel shows its initial markup. The viewer element
// belongs to the layer and EXPAND to the controls.
const store = useStreetLevelStore();
const { view, expanded, expandAvailable } = storeToRefs(store);
const { view, expanded, expandAvailable, embedOverlay } = storeToRefs(store);
const status = computed(
() =>
@@ -142,6 +142,7 @@ const vThumb = {
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
</div>
</div>
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!(embedOverlay || expanded) || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
@@ -158,7 +159,7 @@ const vThumb = {
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
</div>
<div id="sl-error" class="sl-error" role="alert" :hidden="!view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || expanded || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
+196 -7
View File
@@ -11,6 +11,7 @@ import {
decodeLayerStateParams,
encodeLayerStateParams,
} from '../data/layerState.js';
import { encodeIdSwitches } from '../data/idSwitches.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
/** Visual styles by internal name, with the name share links use. */
@@ -65,6 +66,94 @@ const FOLLOWABLE = Object.freeze({
export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE));
/**
* Street-level imagery sources a view can open, the layer that shows them and
* the map a source needs (Google's terms allow Street View beside Google maps
* only). Literal here: this module stays portable.
*/
const STREET_LEVEL_LAYER = 'street-level';
export const VIEW_STREET_LEVEL_PROVIDERS = Object.freeze([
'google',
'mapillary',
'kartaview',
'panoramax',
]);
const STREET_LEVEL_MAP = Object.freeze({ google: 'photoreal' });
const STREET_LEVEL_PARAM = 'sl';
const EARTH_RADIUS_M = 6_371_000;
/** Above this a camera frames a region, not a street: photos open under it. */
const LOOK_AT_MAX_ALTITUDE_M = 20_000;
/** Flatter than this the look-at point is at the horizon: use the camera's. */
const LOOK_AT_MIN_PITCH_DEG = -5;
/** Further ahead than this the camera frames a region: use its own point. */
const LOOK_AT_MAX_DISTANCE_M = 10_000;
/**
* The ground point a street-scale camera looks at: straight below it when it
* looks down, else ahead along its heading by altitude / tan(pitch). A camera
* with no altitude, above street scale, aimed at the horizon, or looking
* further than 10 km ahead gives its own point. Longitude wraps at the
* antimeridian.
*/
export function lookAtPoint(camera) {
const pitch = Number(camera.pitch_deg ?? -90);
const altitude = Number(camera.altitude_m);
if (
!(altitude > 0) ||
altitude > LOOK_AT_MAX_ALTITUDE_M ||
!(pitch > -89) ||
pitch > LOOK_AT_MIN_PITCH_DEG
)
return { lat: camera.lat, lon: camera.lon };
const ground = altitude / Math.tan((-pitch * Math.PI) / 180);
if (ground > LOOK_AT_MAX_DISTANCE_M)
return { lat: camera.lat, lon: camera.lon };
const heading = ((Number(camera.heading_deg) || 0) * Math.PI) / 180;
const dLat =
((ground * Math.cos(heading)) / EARTH_RADIUS_M) * (180 / Math.PI);
const cosLat = Math.cos((camera.lat * Math.PI) / 180) || 1e-6;
const dLon =
((ground * Math.sin(heading)) / (EARTH_RADIUS_M * cosLat)) *
(180 / Math.PI);
const lon = camera.lon + dLon;
return {
lat: clamp(camera.lat + dLat, -90, 90),
// Near a pole one step ahead can be several turns of the parallel.
lon: ((((lon + 180) % 360) + 360) % 360) - 180,
};
}
/**
* Where a tilted camera looks, measured from the ground under it: a view's
* altitude is above the ellipsoid, and a city a mile up would otherwise put
* the point kilometres too far ahead. `terrain` is a heights service
* (`getHeights`); null when it is missing or cannot say.
*/
export async function groundedLookAt(camera, terrain, signal) {
if (typeof terrain?.getHeights !== 'function') return null;
// Looking down or at the horizon the point is the camera's own: the ground
// under it changes nothing, so it is not asked for.
if (
!Number.isFinite(camera?.altitude_m) ||
!(camera.pitch_deg > -89) ||
camera.pitch_deg > LOOK_AT_MIN_PITCH_DEG
)
return null;
let heights;
try {
heights = await terrain.getHeights([{ lat: camera.lat, lon: camera.lon }], {
signal,
});
} catch (error) {
if (signal?.aborted) throw error;
return null;
}
const ground = Number(heights?.[0]?.ellipsoid);
if (!Number.isFinite(ground)) return null;
return lookAtPoint({ ...camera, altitude_m: camera.altitude_m - ground });
}
/** Annotation kinds the app draws, as in its annotate_map action. */
export const VIEW_ANNOTATION_TYPES = Object.freeze([
'pin',
@@ -162,6 +251,21 @@ export const VIEW_PROPERTIES = Object.freeze({
additionalProperties: false,
},
}),
street_level: Object.freeze({
type: 'object',
description:
'Open street-level photos at a point: Google Street View (google; the ' +
'map becomes Google 3D), Mapillary, KartaView or Panoramax. lat/lon ' +
'default to the spot the camera looks at. The app flies there and ' +
'opens the nearest photo; a plain link with google only arms it, ' +
'leaving the billed load to a click. Not with follow.',
properties: {
provider: { type: 'string', enum: [...VIEW_STREET_LEVEL_PROVIDERS] },
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
},
additionalProperties: false,
}),
follow: Object.freeze({
type: 'object',
description:
@@ -251,6 +355,7 @@ export function createView({
map = null,
follow = null,
annotations = [],
street_level: streetLevel = null,
} = {}) {
if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon))
throw new TypeError('A view needs a camera lat and lon');
@@ -264,10 +369,38 @@ export function createView({
throw new TypeError(`Cannot follow a ${follow.kind}`);
if (follow?.cockpit === true && !followed.cockpit)
throw new TypeError(`A ${follow.kind} has no cockpit view`);
let street = null;
if (streetLevel) {
if (follow)
throw new TypeError(
'A view cannot both follow an entity and open street-level photos',
);
const provider = streetLevel.provider ?? null;
if (provider !== null && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider))
throw new TypeError(`Unknown street-level source: ${provider}`);
const needs = provider ? STREET_LEVEL_MAP[provider] || null : null;
if (needs && map !== null && map !== needs)
throw new TypeError(
`${provider} street-level imagery shows only on the ${needs} map`,
);
if (needs) map = needs;
// The point defaults to where the camera looks: a view "of X" opens
// photos at X, also when the camera sits behind it. It is a pair: half
// of one is not mixed with the camera's other half.
const given = [latitude(streetLevel.lat), longitude(streetLevel.lon)];
const seen = lookAtPoint(camera);
const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given;
street = Object.freeze({
...(provider ? { provider } : {}),
lat: round(clamp(lat, -90, 90), 5),
lon: round(clamp(lon, -180, 180), 5),
});
}
const enabled = new Set(
layers.filter((layer) => REGISTERED_LAYER_IDS.includes(layer)),
);
if (followed) enabled.add(followed.layer);
if (street) enabled.add(STREET_LEVEL_LAYER);
return Object.freeze({
camera: Object.freeze({
lat: round(clamp(camera.lat, -90, 90), 4),
@@ -296,6 +429,7 @@ export function createView({
.map(annotationOf)
.filter(Boolean),
),
street_level: street,
});
}
@@ -321,8 +455,29 @@ export function viewToParams(view) {
...state.options[followed.owner],
[followed.key]: view.follow.id,
};
// The restored layer lights the source the photos come from.
const provider = view.street_level?.provider;
if (provider)
state.options[STREET_LEVEL_LAYER] = {
...state.options[STREET_LEVEL_LAYER],
mapillary: provider === 'mapillary',
providerSwitches: encodeIdSwitches(
VIEW_STREET_LEVEL_PROVIDERS.filter((id) => id !== 'mapillary').map(
(id) => [id, id === provider],
),
),
};
encodeLayerStateParams(params, state);
}
if (view.street_level)
params.set(
STREET_LEVEL_PARAM,
[
view.street_level.provider ?? '',
view.street_level.lat.toFixed(5),
view.street_level.lon.toFixed(5),
].join(','),
);
if (view.annotations.length) {
const encoded = JSON.stringify(view.annotations);
if (encoded.length <= MAX_ANNOTATION_PARAM_CHARS)
@@ -345,6 +500,40 @@ export function annotationsFromParams(params) {
}
}
/**
* The street-level photos a share link opens (`sl=<source>,<lat>,<lon>`, the
* source optional), or null.
*/
export function streetLevelFromParams(params) {
const raw = params.get(STREET_LEVEL_PARAM);
if (!raw) return null;
// Three fields, plain decimal degrees: that alone bounds the value.
const parts = raw.split(',');
if (parts.length !== 3) return null;
const [provider, lat, lon] = parts;
if (provider && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider)) return null;
// Plain decimal degrees only: no exponents, hex or blanks.
const DECIMAL = /^-?\d{1,3}(\.\d{1,8})?$/;
if (!DECIMAL.test(lat) || !DECIMAL.test(lon)) return null;
const point = { lat: latitude(Number(lat)), lon: longitude(Number(lon)) };
if (point.lat === undefined || point.lon === undefined) return null;
return { ...(provider ? { provider } : {}), ...point };
}
/** The entity a decoded layer state follows, or null. */
function followFromState(state) {
for (const [kind, followed] of Object.entries(FOLLOWABLE)) {
const id = state?.options?.[followed.owner]?.[followed.key];
if (id !== null && id !== undefined) return { kind, id: String(id) };
}
return null;
}
/** The entity a share link follows, or null. */
export function followFromParams(params) {
return followFromState(decodeLayerStateParams(params));
}
/** The view a set of share-link hash parameters restores, or null. */
export function viewFromParams(params) {
const lat = Number.parseFloat(params.get('lat'));
@@ -355,13 +544,12 @@ export function viewFromParams(params) {
return Number.isFinite(value) ? value : undefined;
};
const state = decodeLayerStateParams(params);
let follow = null;
for (const [kind, followed] of Object.entries(FOLLOWABLE)) {
const id = state?.options?.[followed.owner]?.[followed.key];
if (id !== null && id !== undefined && !follow)
follow = { kind, id: String(id) };
}
const follow = followFromState(state);
const map = params.get('map');
// A link is untrusted input: its photos win over a map their source cannot
// show on, and a followed entity keeps the camera.
const street = follow ? null : streetLevelFromParams(params);
const needs = street?.provider ? STREET_LEVEL_MAP[street.provider] : null;
return createView({
camera: {
lat,
@@ -376,9 +564,10 @@ export function viewFromParams(params) {
: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
? STYLES_BY_URL_NAME[params.get('style')]
: null,
map: VIEW_MAPS.includes(map) ? map : null,
map: needs ? needs : VIEW_MAPS.includes(map) ? map : null,
follow,
annotations: annotationsFromParams(params),
street_level: street,
});
}
+383
View File
@@ -127,6 +127,389 @@ test('annotations travel in the link, keeping only what the app draws', async ()
assert.equal(viewToParams(many).has('an'), false);
});
test('street-level photos travel in the link, light their source and bring their map', async () => {
const { streetLevelFromParams, VIEW_STREET_LEVEL_PROVIDERS } =
await import('./index.js');
const { decodeLayerStateParams } = await import('../data/layerState.js');
assert.deepEqual(VIEW_STREET_LEVEL_PROVIDERS, [
'google',
'mapillary',
'kartaview',
'panoramax',
]);
const view = createView({
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 400 },
street_level: { provider: 'google' },
});
assert.equal(view.map, 'photoreal', 'Street View brings Google 3D');
assert.deepEqual(view.layers, ['street-level']);
assert.deepEqual(view.street_level, {
provider: 'google',
lat: 38.5799,
lon: -121.4937,
});
const params = viewToParams(view);
assert.equal(params.get('sl'), 'google,38.57990,-121.49370');
assert.deepEqual(viewFromParams(params), view);
const state = decodeLayerStateParams(params);
assert.deepEqual(state.enabledLayerIds, ['street-level']);
assert.equal(state.options['street-level'].mapillary, false);
assert.equal(
state.options['street-level'].providerSwitches,
'google-1*kartaview-0*panoramax-0',
'Street View on, KartaView off',
);
const mapillary = createView({
camera: { lat: 1, lon: 2 },
map: 'osm',
street_level: { provider: 'mapillary', lat: 1.00001, lon: 2.00001 },
});
assert.equal(mapillary.map, 'osm', 'Mapillary shows on any map');
assert.deepEqual(viewFromParams(viewToParams(mapillary)), mapillary);
const lit = decodeLayerStateParams(viewToParams(mapillary)).options[
'street-level'
];
assert.equal(lit.mapillary, true);
assert.equal(
lit.providerSwitches,
'google-0*kartaview-0*panoramax-0',
'a Mapillary link lights Mapillary only',
);
const anySource = createView({
camera: { lat: 1, lon: 2 },
street_level: {},
});
assert.deepEqual(anySource.street_level, { lat: 1, lon: 2 });
assert.equal(viewToParams(anySource).get('sl'), ',1.00000,2.00000');
assert.deepEqual(viewFromParams(viewToParams(anySource)), anySource);
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
map: 'osm',
street_level: { provider: 'google' },
}),
/photoreal/,
);
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'openstreetcam' },
}),
/Unknown street-level source/,
);
assert.equal(
streetLevelFromParams(new URLSearchParams('sl=google,x,y')),
null,
);
assert.equal(streetLevelFromParams(new URLSearchParams('sl=bing,1,2')), null);
assert.equal(streetLevelFromParams(new URLSearchParams('lat=1')), null);
assert.equal(createView({ camera: { lat: 1, lon: 2 } }).street_level, null);
// Half a point is not mixed with the camera's other half.
const half = createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'mapillary', lat: 40 },
});
assert.deepEqual(half.street_level, {
provider: 'mapillary',
lat: 1,
lon: 2,
});
});
test('street-level photos: a tilted camera opens them where it looks, a link cannot pair them with the wrong map, and follow is refused', async () => {
const { streetLevelFromParams } = await import('./index.js');
// Looking north from 1,000 m at 45°: the ground point is ~1 km north.
const tilted = createView({
camera: {
lat: 48.8494,
lon: 2.2945,
altitude_m: 1000,
heading_deg: 0,
pitch_deg: -45,
},
street_level: { provider: 'google' },
});
assert.ok(
tilted.street_level.lat > 48.858 && tilted.street_level.lat < 48.859,
String(tilted.street_level.lat),
);
assert.equal(tilted.street_level.lon, 2.2945);
const down = createView({
camera: { lat: 1, lon: 2, altitude_m: 1000 },
street_level: {},
});
assert.deepEqual(down.street_level, { lat: 1, lon: 2 });
// A hand-edited link: the photos win over the map.
const link = viewFromParams(
new URLSearchParams('v=2&lat=1&lon=2&map=osm&sl=google,1,2'),
);
assert.equal(link.map, 'photoreal');
assert.deepEqual(link.street_level, { provider: 'google', lat: 1, lon: 2 });
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'aircraft', id: 'a1' },
street_level: {},
}),
/cannot both follow/,
);
// A followed link drops its photos rather than fight over the camera.
const followed = viewFromParams(
viewToParams(
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'satellite', id: '25544' },
}),
),
);
assert.equal(followed.street_level, null);
// Strict parsing: three fields, real numbers, in range, bounded length.
for (const bad of [
'google,1',
'google,1,2,3',
'google,1abc,2',
'google,91,0',
'google,,2',
'google,1,2'.padEnd(65, '0'),
])
assert.equal(
streetLevelFromParams(new URLSearchParams({ sl: bad })),
null,
bad,
);
});
test('the look-at default is street-scale only, follows the heading, and wraps at the antimeridian', async () => {
const { followFromParams, streetLevelFromParams } =
await import('./index.js');
const at = (camera) =>
createView({ camera, street_level: { provider: 'mapillary' } })
.street_level;
// Looking east from 1,000 m at 45° in Paris: ~1 km east is ~0.0137° of longitude.
const east = at({
lat: 48.85,
lon: 2.2945,
altitude_m: 1000,
heading_deg: 90,
pitch_deg: -45,
});
assert.ok(Math.abs(east.lon - (2.2945 + 0.01365)) < 0.0005, String(east.lon));
assert.ok(Math.abs(east.lat - 48.85) < 0.0002, String(east.lat));
// Looking west from the same place lands west.
assert.ok(
at({
lat: 48.85,
lon: 2.2945,
altitude_m: 1000,
heading_deg: 270,
pitch_deg: -45,
}).lon < 2.2945,
);
// At the horizon, with no altitude, or above street scale: the camera's own point.
assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 1000, pitch_deg: 0 }), {
provider: 'mapillary',
lat: 1,
lon: 2,
});
assert.deepEqual(at({ lat: 1, lon: 2, pitch_deg: -30 }), {
provider: 'mapillary',
lat: 1,
lon: 2,
});
assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 50_000, pitch_deg: -30 }), {
provider: 'mapillary',
lat: 1,
lon: 2,
});
// Across the antimeridian the longitude wraps instead of clamping.
const wrapped = at({
lat: 0,
lon: 179.999,
altitude_m: 1000,
heading_deg: 90,
pitch_deg: -45,
});
assert.ok(wrapped.lon < -179.9, String(wrapped.lon));
// A followed link keeps its camera: the photos are dropped even when both are in the hash.
const followed = viewToParams(
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'satellite', id: '25544' },
}),
);
followed.set('sl', 'google,1.00000,2.00000');
assert.deepEqual(followFromParams(followed), {
kind: 'satellite',
id: '25544',
});
assert.equal(viewFromParams(followed).street_level, null);
assert.equal(followFromParams(new URLSearchParams('v=2&lat=1&lon=2')), null);
// Strict decimal degrees: no exponents, hex, blanks or over-long values.
for (const bad of [
'google,1e1,2',
'google,0x10,2',
'google, 1,2',
'google,1.,2',
`google,${'1.0000000'.padEnd(60, '0')},2`,
])
assert.equal(
streetLevelFromParams(new URLSearchParams({ sl: bad })),
null,
bad,
);
assert.deepEqual(
streetLevelFromParams(new URLSearchParams({ sl: ',-1.5,2' })),
{ lat: -1.5, lon: 2 },
);
});
test('the look-at thresholds are pinned: 20 km up, -5° of pitch, 10 km ahead', () => {
const at = (camera) => createView({ camera, street_level: {} }).street_level;
const own = { lat: 1, lon: 2 };
const moved = (point) => point.lat !== own.lat || point.lon !== own.lon;
// Altitude ceiling, at a steep pitch that keeps the distance under 10 km.
assert.ok(
moved(at({ ...own, altitude_m: 19_999, pitch_deg: -70 })),
'19,999 m applies',
);
assert.ok(
!moved(at({ ...own, altitude_m: 20_001, pitch_deg: -70 })),
'20,001 m does not',
);
// Pitch cutoff, at 100 m so the distance cap cannot decide it (1.4 km ahead).
assert.ok(
moved(at({ ...own, altitude_m: 100, pitch_deg: -6 })),
'-6° applies',
);
assert.ok(
!moved(at({ ...own, altitude_m: 100, pitch_deg: -4 })),
'-4° does not',
);
// Distance cap, pinned either side of 10 km at 1,000 m: -5.8° looks 9.84 km
// ahead, -5.6° 10.2 km.
assert.ok(
moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.8 })),
'9.84 km ahead applies',
);
assert.ok(
!moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.6 })),
'10.2 km ahead does not',
);
assert.ok(
!moved(at({ ...own, altitude_m: 10_000, pitch_deg: -30 })),
'17 km ahead does not',
);
assert.ok(
moved(at({ ...own, altitude_m: 5_000, pitch_deg: -30 })),
'8.7 km ahead applies',
);
// Near-vertical stays put (no tan blow-up).
assert.ok(!moved(at({ ...own, altitude_m: 1000, pitch_deg: -89.5 })));
// Western wrap and the pole clamp.
const west = at({
lat: 0,
lon: -179.999,
altitude_m: 1000,
heading_deg: 270,
pitch_deg: -45,
});
assert.ok(west.lon > 179.9, String(west.lon));
const pole = at({
lat: 89.9995,
lon: 0,
altitude_m: 1000,
heading_deg: 0,
pitch_deg: -45,
});
assert.ok(pole.lat <= 90, String(pole.lat));
});
test('sl rejects a bad longitude slot and over-precise values, and no length bound hides the regex', async () => {
const { streetLevelFromParams } = await import('./index.js');
for (const bad of [
'google,1,1e1',
'google,1,0x10',
'google,1, 2',
'google,1,2.',
'google,1.123456789,2',
'google,1,2.123456789',
])
assert.equal(
streetLevelFromParams(new URLSearchParams({ sl: bad })),
null,
bad,
);
assert.deepEqual(
streetLevelFromParams(
new URLSearchParams({ sl: 'google,-89.12345678,179.12345678' }),
),
{ provider: 'google', lat: -89.12345678, lon: 179.12345678 },
);
});
test('KartaView photos travel in a link and light KartaView alone, on any map', async () => {
const { streetLevelFromParams } = await import('./index.js');
const { decodeLayerStateParams } = await import('../data/layerState.js');
const view = createView({
camera: { lat: 52.52, lon: 13.405, altitude_m: 400 },
map: 'osm',
street_level: { provider: 'kartaview' },
});
assert.equal(view.map, 'osm', 'KartaView needs no particular map');
assert.deepEqual(view.layers, ['street-level']);
const params = viewToParams(view);
assert.equal(params.get('sl'), 'kartaview,52.52000,13.40500');
assert.deepEqual(viewFromParams(params), view);
const lit = decodeLayerStateParams(params).options['street-level'];
assert.equal(lit.mapillary, false);
assert.equal(
lit.providerSwitches,
'google-0*kartaview-1*panoramax-0',
'KartaView on, the others off',
);
assert.deepEqual(
streetLevelFromParams(new URLSearchParams({ sl: 'kartaview,1,2' })),
{
provider: 'kartaview',
lat: 1,
lon: 2,
},
);
});
test('Panoramax photos travel in a link and light Panoramax alone, on any map', async () => {
const { streetLevelFromParams } = await import('./index.js');
const { decodeLayerStateParams } = await import('../data/layerState.js');
const view = createView({
camera: { lat: 48.8874, lon: 2.3495, altitude_m: 400 },
map: 'osm',
street_level: { provider: 'panoramax' },
});
assert.equal(view.map, 'osm', 'Panoramax needs no particular map');
assert.deepEqual(view.layers, ['street-level']);
const params = viewToParams(view);
assert.equal(params.get('sl'), 'panoramax,48.88740,2.34950');
assert.deepEqual(viewFromParams(params), view);
const lit = decodeLayerStateParams(params).options['street-level'];
assert.equal(lit.mapillary, false);
assert.equal(
lit.providerSwitches,
'google-0*kartaview-0*panoramax-1',
'Panoramax on, the others off',
);
assert.deepEqual(
streetLevelFromParams(new URLSearchParams({ sl: 'panoramax,1,2' })),
{ provider: 'panoramax', lat: 1, lon: 2 },
);
});
test('a view naming the retired Snow style opens on Normal', () => {
const camera = { lat: 1, lon: 2 };
assert.equal(createView({ camera, style: 'snow' }).style, 'normal');
+30
View File
@@ -107,6 +107,36 @@ const schemas = [
required: ['layerId'],
},
},
{
name: 'show_street_level',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
query: {
type: 'string',
maxLength: 160,
},
latitude: {
type: 'number',
minimum: -90,
maximum: 90,
},
longitude: {
type: 'number',
minimum: -180,
maximum: 180,
},
provider: {
type: 'string',
enum: ['google', 'mapillary', 'kartaview', 'panoramax'],
},
referent: {
type: 'integer',
},
},
},
},
{
name: 'adjust_camera_zoom',
parameters: {
+3 -1
View File
@@ -58,7 +58,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// control_cockpit states the own-layer entry rule (Contacts not required).
// move_camera tilt takes an optional pitchDeg angle, described as
// absolute with its -45 default, with steps sent as a direction.
'6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c',
// show_street_level (map, source, photo) adds one tool.
'0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468',
);
});
@@ -155,6 +156,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
'resolve_area',
'find_imagery',
'osm_query',
'show_street_level',
].includes(tool.name),
);
// Layer enums are generated from the voice layer manifest and pinned by
+3
View File
@@ -143,6 +143,8 @@ function resolveFly(args, { pointer, referents }) {
entity: {
layerId: target.layerId,
id: target.id,
// The answer names it, not its id ("near cam-9").
...(target.label ? { label: target.label } : {}),
...(hasPosition(target) ? { lat: target.lat, lon: target.lon } : {}),
},
},
@@ -345,6 +347,7 @@ function resolveCropPixels(args, { imageFrame, cameraKey }) {
const RESOLVERS = Object.freeze({
track_entity: resolveTrack,
fly_to_location: resolveFly,
show_street_level: resolveFly,
get_entity_context: resolveContext,
analyst_query: resolveAnalyst,
annotate_map: resolveAnnotate,
+8 -1
View File
@@ -104,7 +104,7 @@ test('referents: numbered items resolve for track, fly and context', () => {
const last = resolveDeicticArgs('track_entity', { query: 'x', referent: -1 }, { referents });
assert.equal(last.args.query, 'ccc333', '-1 is the last one');
const fly = resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents });
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111' } }, 'moving contacts are located at run time');
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111', label: 'SWA12' } }, 'moving contacts are located at run time');
const context = resolveDeicticArgs('get_entity_context', { referent: 3 }, { referents });
assert.equal(context.args.scope, 'target');
assert.equal(context.args.target.label, 'AAL9');
@@ -180,3 +180,10 @@ test('numbered custom-labeled pins retain their resolved coordinates', () => {
assert.deepEqual(resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents }).args,
{ latitude: 30.27, longitude: -97.74 });
});
test('show_street_level: "street view of this" opens photos at the pointed ground point', () => {
const out = resolveDeicticArgs('show_street_level', { query: 'pointer', provider: 'google' }, { pointer: ground });
assert.deepEqual(out.args, { provider: 'google', latitude: 30.27, longitude: -97.74 });
assert.equal(out.used.source, 'pointer');
assert.deepEqual(resolveDeicticArgs('show_street_level', { query: 'Eiffel Tower' }, { pointer: ground }).args, { query: 'Eiffel Tower' });
});
+482 -1
View File
@@ -15,6 +15,7 @@ import { applicationPinia } from '../ui/vueRuntime.js';
import * as Cesium from 'cesium';
import { CITY_POIS } from '../cityPois.js';
import { greatCircleKm } from '../geoDistance.js';
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
import {
hudViewportSamples,
placeLookupView,
@@ -35,6 +36,10 @@ import {
isContextRecordActive,
} from '../data/contextStore.js';
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
import {
NEAREST_RADIUS_MAX_M as STREET_LEVEL_WIDE_RADIUS_M,
STREET_LEVEL_LAYER_ID,
} from '../layers/streetLevel/policy.js';
import {
createAnalystEngine,
normalizeAnalystSpec,
@@ -55,6 +60,7 @@ import {
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { watchCameraOwner } from '../navigationPolicy.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
@@ -503,7 +509,7 @@ export function createGevActionRunner({
const pending = dataManager.getAll?.().find((l) => l.id === layerId);
reportProgress('layer', pending?.name || layerId);
}
const changeOptions = { origin: 'voice' };
const changeOptions = { origin: actionIntentOrigin(runOptions) };
if (runOptions.signal) changeOptions.signal = runOptions.signal;
let changed = false;
let changeError = null;
@@ -796,6 +802,20 @@ export function createGevActionRunner({
};
}
if (name === 'show_street_level') {
return showStreetLevel(args, {
viewer,
dataManager,
styleManager,
placeSearch,
runGevAction,
runOptions,
current,
reportProgress,
otherHandoffs,
});
}
if (name === 'set_visual_style') {
const style = normalizeStyle(args.style);
if (!style)
@@ -2936,6 +2956,29 @@ function stopAllTracking(viewer, dataManager) {
return { ok: true, action: 'stop_tracking', released };
}
/**
* The imagery filter the lit source honours when it is narrower than the
* default (360° or flat only, or SINCE), or null.
* @returns {{ pano?: string, sinceDays?: number, label: string } | null}
*/
export function activeImageryFilter(ui) {
const filter = ui?.filter || {};
const controls = ui?.filterControls || {};
const out = {};
const names = [];
if (controls.pano && (filter.pano === 'pano' || filter.pano === 'flat')) {
out.pano = filter.pano;
names.push(filter.pano === 'pano' ? '360°' : 'flat-photo');
}
if (controls.since && Number(filter.sinceDays) > 0) {
out.sinceDays = Number(filter.sinceDays);
names.push('SINCE');
}
if (!names.length) return null;
out.label = `${names.join(' and ')} filter${names.length > 1 ? 's' : ''}`;
return out;
}
/**
* Frames entities near the current view target with a cinematic pull-back:
* oblique high pitch for aircraft/ships, shallow wide pitch for satellites.
@@ -3709,6 +3752,425 @@ function normalizeStyle(value) {
return null;
}
/** The application panel that holds the street-level photo. */
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
/**
* The layer-state origin of an action's changes. The assistant's are the
* user's own choices and persist; a link replays someone else's view
* (`origin: 'programmatic'`) and never writes the recipient's preferences.
*/
function actionIntentOrigin(runOptions) {
return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice';
}
/**
* Whether cockpit, clean view, recording or scene playback hides the panel a
* street-level photo opens in. Embed mode uses clean view for its globe but
* lifts the photo out of the hidden rail.
*/
function streetLevelPanelHidden() {
const body = globalThis.document?.body;
const embedded = body?.classList?.contains('ui-embed') === true;
return EXCLUSIVE_SURFACE_CLASSES.some(
(mode) =>
body?.classList?.contains(mode) &&
!(embedded && mode === 'ui-clean-view'),
);
}
/**
* "Show me <place> on Street View / on Mapillary": fly there, light the source
* (and its map) and open the nearest image. Each stage reports its own
* failure, so speech can say which part did not happen. `autoOpen: false`
* (links) stops short of a billed load: the panel is armed for OPEN.
*/
export async function showStreetLevel(args, context) {
// After any await a newer turn, a navigation, a globe gesture or a mode
// that hides the panel may have taken over: each later stage checks again.
const owner = watchCameraOwner(context.viewer, context.styleManager);
// A photo held up while the request switches its source is let go however
// the request ends: replaced by then, or closed if its source stayed off.
let releaseViewer = null;
const holdViewer = (layerModule) => {
releaseViewer ??= layerModule.holdViewer?.() ?? null;
};
try {
return await requestStreetLevel(args, { ...context, owner, holdViewer });
} finally {
releaseViewer?.();
owner.stop();
}
}
async function requestStreetLevel(
args,
{
dataManager,
styleManager,
runGevAction,
owner,
holdViewer = () => {},
runOptions = {},
current = () => true,
reportProgress = () => {},
otherHandoffs = () => 0,
},
) {
const action = 'show_street_level';
// Navigations other than voice fly-tos (this request's own flight) so far.
const handoffs = otherHandoffs();
let provider = null;
let providerName = null;
let label = null;
const fail = (stage, error, extra = {}) => ({
ok: false,
action,
stage,
cancelled: !current(),
error,
...(provider ? { provider } : {}),
...(providerName ? { providerName } : {}),
...(label ? { label } : {}),
...(atViewCentre ? { atViewCentre } : {}),
...extra,
});
let atViewCentre = false;
const query = String(args.query || '').trim();
const latitude = Number(args.latitude);
const longitude = Number(args.longitude);
const coordinatesGiven = args.latitude != null && args.longitude != null;
const hasCoordinates =
coordinatesGiven && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180;
if (coordinatesGiven && !hasCoordinates)
return fail(
'location',
'Latitude must be within ±90° and longitude within ±180°',
);
// A referent that is a layer entity (deixis): located when the flight starts.
const entity =
args.entity && typeof args.entity === 'object' && args.entity.id
? args.entity
: null;
const layerModule =
dataManager?.layers?.get?.(STREET_LEVEL_LAYER_ID)?.module || null;
if (!layerModule || typeof layerModule.openNearest !== 'function')
return fail('provider', 'Street Level is unavailable');
const providerIds = Array.isArray(layerModule.providerIds)
? layerModule.providerIds
: [];
// The source: asked for, else the one lit now, else the first registered.
const requested = String(args.provider || '')
.trim()
.toLowerCase();
if (requested) {
if (!providerIds.includes(requested))
return fail(
'provider',
`${requested} is not a street-level imagery source here`,
);
provider = requested;
} else {
const params = dataManager.getLayerParams?.(STREET_LEVEL_LAYER_ID) || {};
provider =
providerIds.find((id) => params[id] === true) || providerIds[0] || null;
}
if (!provider)
return fail('provider', 'No street-level imagery source is configured');
const status = layerModule.providerStatus?.(provider) || null;
providerName = status?.name || provider;
// The key first: without one nothing changes, as with a chip.
if (status && (status.keyRequired === true || status.configured === false))
return fail('key', status.error || `${providerName} needs its key`, {
keyRejected: status.keyRejected === true,
keyAlternatives: layerModule.getUIState?.()?.keyAlternatives || [],
});
// A hidden panel refuses before the camera leaves a recording or scene.
const hidden = (extra = {}) =>
fail(
'view',
msg(
'Street-level photos are hidden in the current view. Leave it first.',
),
extra,
);
if (streetLevelPanelHidden()) return hidden();
const interrupted = (stage, extra = {}) => {
if (!current())
return fail(stage, 'Street-level request was cancelled', {
cancelled: true,
location,
...extra,
});
if (owner.taken())
return fail(stage, 'The camera moved on before the photo opened', {
cancelled: true,
superseded: true,
location,
...extra,
});
if (streetLevelPanelHidden()) return hidden({ location, ...extra });
return null;
};
// Then the place: a name that geocodes to nothing changes nothing else.
label =
query ||
(entity ? String(entity.label || entity.id) : '') ||
(hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') ||
'the view centre';
atViewCentre = !query && !hasCoordinates && !entity;
let point = null;
let location = null;
// No place at all: the layer looks at the ground under the screen centre,
// as the OPEN button does. `fly: false` (tests) keeps the camera for a
// point given by its coordinates.
if (atViewCentre || (args.fly === false && hasCoordinates)) {
if (hasCoordinates) point = { lat: latitude, lon: longitude };
} else {
try {
location = await runGevAction(
'fly_to_location',
{
...(entity
? { entity }
: hasCoordinates
? { latitude, longitude }
: { query }),
viewMode: 'close',
waitForArrival: true,
},
runOptions,
);
} catch (error) {
// A geocoder that throws (offline, a provider down) is this stage's
// failure too, not an error past the action.
return fail('location', `Could not arrive at ${label}`, {
cancelled: !current(),
detail: String(error?.message || error),
});
}
if (location?.ok !== true || !current())
return fail(
'location',
location?.error || `Could not arrive at ${location?.label || label}`,
{ cancelled: !current() || Boolean(location?.cancelled), location },
);
// An item, or a named place flown to by its coordinates, keeps its name:
// such a flight reports only coordinates.
label =
entity || (query && hasCoordinates) ? label : location.label || label;
if (
!Number.isFinite(location.latitude) ||
!Number.isFinite(location.longitude)
)
return fail(
'location',
`${label} has no position to look for imagery at`,
{ location },
);
point = { lat: location.latitude, lon: location.longitude };
}
// A mode that hides the panel may have started during the flight. The
// camera is the request's from where its flight landed, unless another
// navigation took it during the flight.
if (location && otherHandoffs() === handoffs) owner.claim();
const arrived = interrupted('view');
if (arrived) return arrived;
// The source's map, when it needs one (Street View: Google 3D only).
const requiredStack = layerModule.requiredMapStack?.(provider) || null;
const activeStack = styleManager?.mapStackController?.getActiveId?.() ?? null;
if (requiredStack && activeStack !== requiredStack) {
reportProgress('map', requiredStack);
const map = await runGevAction(
'set_map_stack',
{ stack: requiredStack },
runOptions,
);
if (map?.ok !== true && current())
return fail(
'map',
map?.error ||
`${providerName} needs the ${requiredStack} map, which is not available`,
{ map, location },
);
const stop = interrupted('map', { map });
if (stop) return stop;
}
// Light the source the way a chip click does, so the choice is kept. An
// open photo of another source stays up until this request replaces it.
const openPhoto = layerModule.getUIState?.()?.street;
if (openPhoto?.open === true && openPhoto.providerId !== provider)
holdViewer(layerModule);
const switches = Object.fromEntries(
providerIds.map((id) => [id, id === provider]),
);
let accepted = true;
if (typeof dataManager.setLayerParams === 'function')
accepted =
dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, {
origin: actionIntentOrigin(runOptions),
}) !== false;
else layerModule.setProviderEnabled?.(provider, true);
if (!accepted)
return fail('provider', `${providerName} could not be switched on`, {
location,
});
let layer = null;
if (!dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID)) {
layer = await runGevAction(
'set_layer_visibility',
{ layerId: STREET_LEVEL_LAYER_ID, enabled: true },
runOptions,
);
if (layer?.ok !== true && current())
return fail(
'layer',
layer?.error || 'Street Level could not be enabled',
{ cancelled: Boolean(layer?.cancelled), layer, location },
);
const stop = interrupted('layer', { layer });
if (stop) return stop;
}
// Listed now that its map is up, and lit.
const ui = layerModule.getUIState?.() || {};
const snapshot =
(ui.providers || []).find((entry) => entry.id === provider) || null;
if (!snapshot)
return fail(
'provider',
ui.coverage?.hint || `${providerName} is not available on this map`,
{ location },
);
if (snapshot.on !== true)
return fail('provider', `${providerName} could not be switched on`, {
location,
});
// A link never loads a billed photo by itself: it arms OPEN instead.
if (args.autoOpen === false && status?.billed === true) {
// A photo of another place must not stay up beside the armed request.
if (layerModule.getUIState?.()?.street?.open === true)
layerModule.closeViewer?.();
layerModule.armNearest?.(point, {
providerIds: [provider],
...(snapshot.groundClick === true
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
: {}),
});
return {
ok: true,
action,
provider,
providerName,
label,
armed: true,
flew: location !== null,
...(location ? { location: location.label } : {}),
};
}
// Street View has no coverage to click, so it searches wider: a geocoded
// landmark is rarely within 50 m of a street. An open photo stays up while
// this looks and closes if nothing opens; a found photo loads only while
// the camera and the panel are still the request's.
reportProgress('imagery', label);
const opened = await layerModule.openNearest(point, {
providerIds: [provider],
frame: false,
replace: true,
proceed: () => current() && !owner.taken() && !streetLevelPanelHidden(),
// Under the screen centre, not the middle of a tilted view's footprint;
// this request is the user's own, not a link's armed one.
...(point ? {} : { aim: 'screen-centre', armed: false }),
...(runOptions.signal ? { signal: runOptions.signal } : {}),
...(snapshot.groundClick === true
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
: {}),
});
const street = layerModule.getUIState?.()?.street || {};
// A photo that opened after the camera moved on, or into a panel hidden
// meanwhile, is closed again.
const late = interrupted('imagery');
if (late) {
if (opened === true) layerModule.closeViewer?.();
return late;
}
if (opened !== true) {
// Why nothing opened: the key, a change under the lookup, or no imagery.
const after = layerModule.providerStatus?.(provider) || status || {};
if (after.keyRequired === true || after.keyRejected === true)
return fail(
'key',
street.error || after.error || `${providerName} needs its key`,
{ keyRejected: after.keyRejected === true, location },
);
if (
!dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID) ||
after.on === false ||
after.available === false
)
return fail(
'imagery',
street.error || 'Street Level changed while it looked for imagery',
{ cancelled: true, location },
);
// The screen centre is sky: nothing was looked up.
if (street.nearestOutcome === 'sky')
return fail('imagery', street.error || 'Aim the view at a street', {
location,
aim: 'sky',
});
// The source or the viewer failed: an error, not missing coverage.
if (street.nearestOutcome !== 'empty')
return fail(
'imagery',
street.error || `${providerName} imagery could not be opened`,
{ location, imageryError: true },
);
// An imagery filter the source honours can hide every photo here; say
// so, or the answer reads as "no coverage" when one switch would do.
const filter = activeImageryFilter(layerModule.getUIState?.());
const reason = street.error || `No ${providerName} imagery near ${label}`;
return fail(
'imagery',
filter ? `${reason} with the ${filter.label} on` : reason,
{ location, noImagery: true, ...(filter ? { filter } : {}) },
);
}
// Every explicit request shows its photo, also one replacing a photo whose
// panel or phone sheet the user had closed. Embed mode lifts the photo.
if (!globalThis.document?.body?.classList?.contains('ui-embed')) {
styleManager?.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
persist: false,
});
styleManager?.revealPanel?.(STREET_LEVEL_PANEL_ID);
}
return {
ok: true,
action,
provider,
providerName,
label,
imageId: street.imageId ?? null,
title: street.title ?? null,
capturedAt: street.capturedAt ?? null,
capturedAtPrecision: street.capturedAtPrecision ?? null,
position: street.position ?? null,
externalUrl: street.externalUrl ?? null,
flew: location !== null,
...(atViewCentre ? { atViewCentre } : {}),
...(location ? { location: location.label } : {}),
};
}
async function flyToRequestedLocation(
viewer,
args,
@@ -3781,11 +4243,25 @@ async function flyToRequestedLocation(
);
if (result === false)
return cancelled(CITY_POIS[locationId]?.name || locationId);
// Where the camera went: the first POI for a close view, else the middle
// of the city's overview bounds.
const city = CITY_POIS[locationId];
const overview =
!(rangeM || args.viewMode === 'close') && Boolean(city?.viewBounds);
const centre = overview
? {
lat:
(city.viewBounds.southwest.lat + city.viewBounds.northeast.lat) / 2,
lon:
(city.viewBounds.southwest.lng + city.viewBounds.northeast.lng) / 2,
}
: city?.pois?.[0] || null;
const response = {
ok: Boolean(result),
action: 'fly_to_location',
locationId,
label: CITY_POIS[locationId]?.name || locationId,
...(centre ? { latitude: centre.lat, longitude: centre.lon } : {}),
rangeM: result?.range ? Math.round(result.range) : rangeM || null,
navigationMode: rangeM
? 'explicit-range'
@@ -3846,6 +4322,7 @@ async function flyToRequestedLocation(
action: 'fly_to_location',
query,
label: poi?.name || query,
...(poi ? { latitude: poi.lat, longitude: poi.lon } : {}),
navigationMode: rangeM ? 'preset-poi-range' : 'preset-poi',
rangeM: result?.range ? Math.round(result.range) : rangeM || null,
};
@@ -3876,6 +4353,10 @@ async function flyToRequestedLocation(
action: 'fly_to_location',
query,
label: destination?.label || query,
...(Number.isFinite(destination?.latitude) &&
Number.isFinite(destination?.longitude)
? { latitude: destination.latitude, longitude: destination.longitude }
: {}),
navigationMode: destination?.navigationMode || null,
rangeM: destination?.rangeM || rangeM || null,
};
+856
View File
@@ -30,6 +30,7 @@ import {
normalizeStackId,
} from './gevActions.js';
import { MAP_STACKS } from '../mapStackController.js';
import { KEY_REJECTED_MESSAGE } from '../layers/streetLevel/providers/google/policy.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { presentResult } from './resultDisplay.js';
@@ -6548,6 +6549,568 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
});
/** A Street Level layer stub, a map stack and a data manager for show_street_level. */
function createStreetLevelHarness({
activeStack = 'osm',
lit = { mapillary: true, google: false },
enabled = false,
keyRequired = false,
configured = !keyRequired,
nearest = 'pano-a',
paramsAccepted = true,
enableResult = true,
listGoogle = true,
duringLookup = null,
keyRejectedAtStart = false,
lightFails = false,
imageryError = null,
sky = false,
photoOpen = false,
duringMapSwitch = null,
duringEnable = null,
} = {}) {
const order = [];
const lookups = [];
const origins = [];
const revealed = [];
const { viewer, styleManager, currentGeneration, panelSearch } = createVoiceNavigationHarness();
// Who owns the camera: the shell's navigation stamps and globe gestures.
Object.defineProperty(styleManager, '_navigationGeneration', {
get: () => currentGeneration(),
});
viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 });
let completeFlight = null;
viewer.camera.lookAt = () => {};
viewer.camera.flyToBoundingSphere = (_sphere, options) => {
order.push('fly');
completeFlight = options.complete;
};
styleManager.runImmediateLocationNavigation = (navigate) =>
styleManager.runImmediateNavigation('location', navigate);
let stack = activeStack;
styleManager.mapStackController = { getActiveId: () => stack };
styleManager.setMapStack = async (id) => {
order.push(`map:${id}`);
stack = id;
duringMapSwitch?.();
return { ok: true, activeStack: id, error: null };
};
let params = { ...lit };
let keyRejected = keyRejectedAtStart;
const street = photoOpen
? { open: true, imageId: 'eiffel-photo', error: null }
: { open: false, imageId: null, error: null };
const names = { google: 'Google Street View', mapillary: 'Mapillary' };
const layer = {
providerIds: ['mapillary', 'google'],
requiredMapStack: (id) => (id === 'google' ? 'photoreal' : null),
providerStatus: (id) =>
names[id]
? {
id,
name: names[id],
on: params[id] === true,
configured,
keyRequired: keyRequired || keyRejected,
keyRejected,
error: keyRejected ? KEY_REJECTED_MESSAGE : keyRequired ? `${names[id]} needs its key` : null,
groundClick: id === 'google',
billed: id === 'google',
available: id !== 'google' || stack === 'photoreal',
}
: null,
getUIState: () => ({
providers: ['mapillary', 'google']
.filter((id) => id !== 'google' || (stack === 'photoreal' && listGoogle))
.map((id) => ({
id,
name: id === 'google' ? 'Google Street View' : 'Mapillary',
on: params[id] === true,
configured: !keyRequired,
keyRequired,
groundClick: id === 'google',
hint: keyRequired ? `${id} needs its key` : '',
error: null,
})),
keyAlternatives: [],
coverage: { hint: 'not on this map' },
street: { ...street },
}),
closeViewer() {
order.push('close');
Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null });
},
armNearest(point, options) {
order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
},
async openNearest(point, options) {
lookups.push({ point, ...options });
order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`);
if (options.signal?.aborted) return false;
if (duringLookup) {
const effect = duringLookup();
if (effect === 'key-rejected') {
keyRejected = true;
street.error = KEY_REJECTED_MESSAGE;
return false;
}
if (effect === 'layer-off') {
enabled = false;
return false;
}
}
// Like the layer: a lookup its caller gave up leaves the panel alone,
// and a replacing lookup that opens nothing closes the photo.
if (options.proceed && options.proceed() !== true) return false;
const closeReplaced = () => {
if (options.replace && street.open) layer.closeViewer();
};
if (imageryError) {
closeReplaced();
street.error = imageryError;
street.nearestOutcome = null;
return false;
}
if (!nearest) {
closeReplaced();
street.nearestOutcome = options.aim === 'screen-centre' && sky ? 'sky' : 'empty';
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
return false;
}
street.open = true;
street.imageId = nearest;
street.title = '10th St, Sacramento';
street.position = point;
return true;
},
};
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => enabled,
getLayerParams: () => ({ ...params }),
setLayerParams(id, next, { origin }) {
origins.push(origin);
order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`);
if (paramsAccepted === false) return false;
if (!lightFails) params = { ...params, ...next };
return true;
},
async setEnabled(id, _on, options) {
origins.push(options?.origin);
order.push(`enable:${id}`);
if (enableResult === false) return false;
enabled = true;
duringEnable?.();
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }],
};
styleManager.setPanelCollapsed = (id, collapsed) => revealed.push(`collapse:${id}:${collapsed}`);
styleManager.revealPanel = (id) => revealed.push(`reveal:${id}`);
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
return {
runner,
order,
finishFlight: () => completeFlight?.(),
layer,
dataManager,
lookups,
origins,
revealed,
street,
// The user searches or flies elsewhere (a stamp, as the shell's), or
// drags the globe.
navigate: panelSearch,
gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')),
};
}
/** A loaded CCTV camera the entity branch can locate. */
function withCamera(harness) {
harness.dataManager.layers.set('cctv', {
module: {
findByQuery: (id) => (id === 'cam-9' ? { latitude: 38.58, longitude: -121.5 } : null),
},
});
const wasEnabled = harness.dataManager.isEnabled;
harness.dataManager.isEnabled = (id) => id === 'cctv' || wasEnabled(id);
return harness;
}
test('show_street_level flies first, then switches the map, lights the source, turns the layer on and opens the nearest photo', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order, finishFlight } = createStreetLevelHarness();
const pending = runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(order, ['fly'], 'nothing changes until the place is reached');
finishFlight();
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.deepEqual(order, [
'fly',
'map:photoreal',
'params:google:voice',
'enable:street-level',
'open:google:250:false',
]);
assert.equal(result.provider, 'google');
assert.equal(result.providerName, 'Google Street View');
assert.equal(result.imageId, 'pano-a');
assert.equal(result.title, '10th St, Sacramento');
assert.equal(result.flew, true);
// A name given with its coordinates is what the answer says.
const named = createStreetLevelHarness();
const naming = named.runner('show_street_level', {
query: 'Capitol Mall', latitude: 38.5799, longitude: -121.4937, provider: 'google',
});
await new Promise((resolve) => setImmediate(resolve));
named.finishFlight();
assert.equal((await naming).label, 'Capitol Mall');
});
test('show_street_level on Mapillary keeps the map, searches at the default radius and skips what is already on', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order } = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const result = await runner('show_street_level', {
latitude: 38.58,
longitude: -121.49,
provider: 'mapillary',
fly: false,
});
assert.equal(result.ok, true, result.error);
assert.deepEqual(order, ['params:mapillary:voice', 'open:mapillary:default:false']);
assert.equal(result.flew, false);
});
test('show_street_level without a source uses the one lit in the panel', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order } = createStreetLevelHarness({
activeStack: 'photoreal',
lit: { mapillary: false, google: true },
enabled: true,
});
const result = await runner('show_street_level', { latitude: 1, longitude: 2, fly: false });
assert.equal(result.ok, true, result.error);
assert.equal(result.provider, 'google');
assert.deepEqual(order, ['params:google:voice', 'open:google:250:false']);
});
test('show_street_level reports each stage it could not pass', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const none = await centre.runner('show_street_level', { provider: 'google' });
assert.equal(none.ok, true, 'no place at all means the view centre');
assert.equal(none.label, 'the view centre');
assert.deepEqual(centre.order, ['params:google:voice', 'open:google:250:false:view-centre']);
const offGlobe = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const outOfRange = await offGlobe.runner('show_street_level', { latitude: 95, longitude: 2, provider: 'google' });
assert.deepEqual([outOfRange.ok, outOfRange.stage], [false, 'location']);
assert.deepEqual(offGlobe.order, [], 'nothing flies, switches or looks up');
const unknown = await createStreetLevelHarness().runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'openstreetcam', fly: false,
});
assert.deepEqual([unknown.ok, unknown.stage], [false, 'provider']);
assert.match(unknown.error, /openstreetcam/);
const keylessHarness = createStreetLevelHarness({ enabled: true, keyRequired: true });
const keyless = await keylessHarness.runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'google', fly: false,
});
assert.deepEqual([keyless.ok, keyless.stage], [false, 'key']);
assert.match(keyless.error, /needs its key/);
assert.equal(keyless.providerName, 'Google Street View');
assert.deepEqual(keylessHarness.order, [], 'a keyless source changes nothing: no flight, no map, no switch');
const refusedSwitch = await createStreetLevelHarness({ enabled: true, paramsAccepted: false }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
);
assert.deepEqual([refusedSwitch.ok, refusedSwitch.stage], [false, 'provider']);
assert.match(refusedSwitch.error, /could not be switched on/);
const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
);
assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']);
assert.match(empty.error, /within 250 m/);
});
test('show_street_level stops at the map when the source’s map is refused, before touching the panel', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { layer, order } = createStreetLevelHarness({ enabled: true });
const reason = 'Google 3D needs GOOGLE_MAPS_API_KEY';
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true, google: false }),
setLayerParams: () => order.push('params'),
getAll: () => [],
};
const { viewer, styleManager } = createVoiceNavigationHarness();
styleManager.mapStackController = { getActiveId: () => 'osm' };
styleManager.setMapStack = async () => ({ ok: false, activeStack: 'osm', error: reason });
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage, result.error], [false, 'map', reason]);
assert.deepEqual(order, [], 'the source is not switched when its map is refused');
});
test('show_street_level opens photos of a numbered item or a pointed spot through its entity', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order, finishFlight } = withCamera(
createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }),
);
const pending = runner('show_street_level', {
provider: 'google',
entity: { layerId: 'cctv', id: 'cam-9', label: 'Camera 9', lat: 38.58, lon: -121.5 },
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(order, ['fly'], 'the entity position is flown to');
finishFlight();
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.equal(result.label, 'Camera 9');
assert.deepEqual(order, ['fly', 'params:google:voice', 'open:google:250:false']);
});
test('show_street_level hands the point from a named place, a preset and a curated POI to the lookup', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const points = [];
const make = () => {
const harness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const open = harness.layer.openNearest;
harness.layer.openNearest = async (point, options) => {
points.push(point);
return open(point, options);
};
return harness;
};
// A geocoded name: the flight reports where it landed.
const named = make();
const viewerFor = () => ({ ...createVoiceNavigationHarness().viewer });
const runnerWithSearch = createGevActionRunner({
viewer: viewerFor(),
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
dataManager: {
layers: new Map([['street-level', { module: named.layer }]]),
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true, google: false }),
setLayerParams: () => true,
getAll: () => [],
},
searchNavigation: async (_viewer, query, options) => {
options.onComplete?.();
return { label: `${query}, CA`, latitude: 38.5799, longitude: -121.4937, navigationMode: 'precise-place', rangeM: 250 };
},
});
const geocoded = await runnerWithSearch('show_street_level', { query: '10th Street, Sacramento', provider: 'mapillary' });
assert.equal(geocoded.ok, true, geocoded.error);
assert.equal(geocoded.label, '10th Street, Sacramento, CA');
assert.deepEqual(points.at(-1), { lat: 38.5799, lon: -121.4937 });
// A curated POI and a city preset fly to their hand-tuned poses and report them.
for (const [args, expected] of [
[{ query: 'Golden Gate Bridge', provider: 'mapillary' }, { lat: 37.8199, lon: -122.4783 }],
[{ query: 'austin', provider: 'mapillary' }, null],
]) {
const harness = make();
const pending = harness.runner('show_street_level', args);
await new Promise((resolve) => setImmediate(resolve));
harness.finishFlight();
const result = await pending;
assert.equal(result.ok, true, `${args.query}: ${result.error}`);
const point = points.at(-1);
if (expected) assert.deepEqual(point, expected);
else assert.ok(Number.isFinite(point.lat) && Number.isFinite(point.lon), 'a preset flight reports its point');
}
});
test('show_street_level retires its lookup when the turn is superseded, and says so', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order, layer } = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const controller = new AbortController();
let currentTurn = true;
layer.openNearest = async (_point, options) => {
order.push(`open:signal:${Boolean(options.signal)}`);
controller.abort();
currentTurn = false;
return false;
};
const result = await runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
{ signal: controller.signal, isCurrent: () => currentTurn },
);
assert.deepEqual([result.ok, result.cancelled, result.stage], [false, true, 'imagery']);
assert.doesNotMatch(result.error, /No .* imagery/);
assert.ok(order.includes('open:signal:true'), 'the lookup gets the turn signal');
});
test('show_street_level with autoOpen:false arms a billed source and leaves the load to a click; a free source still opens', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const armedHarness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const armed = await armedHarness.runner('show_street_level', {
latitude: 38.5799, longitude: -121.4937, provider: 'google', fly: false, autoOpen: false,
});
assert.equal(armed.ok, true, armed.error);
assert.equal(armed.armed, true);
assert.equal(armed.imageId, undefined);
assert.deepEqual(
armedHarness.order,
['map:photoreal', 'params:google:voice', 'arm:google:250:38.5799,-121.4937'],
'map and source are ready, the lookup is armed at the point, nothing is loaded',
);
assert.match(armed.say, /^Google Street View is ready at .*press OPEN/);
assert.deepEqual(
{ ...armed, say: undefined, display: undefined },
{ ok: true, action: 'show_street_level', provider: 'google', providerName: 'Google Street View', label: '38.5799, -121.4937', armed: true, flew: false, say: undefined, display: undefined },
);
const free = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const opened = await free.runner('show_street_level', {
latitude: 38.5799, longitude: -121.4937, provider: 'mapillary', fly: false, autoOpen: false,
});
assert.equal(opened.ok, true, opened.error);
assert.equal(opened.armed, undefined);
assert.equal(opened.imageId, 'pano-a');
});
test('show_street_level tells a failed enable, a delisted source, a key refused mid-lookup and a layer switched off apart', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const at = { latitude: 1, longitude: 2, provider: 'google', fly: false };
const notEnabled = await createStreetLevelHarness({ activeStack: 'photoreal', enableResult: false }).runner('show_street_level', at);
assert.deepEqual([notEnabled.ok, notEnabled.stage, notEnabled.cancelled], [false, 'layer', false]);
assert.match(notEnabled.say, /Couldn't turn on Street Level/);
const delisted = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', listGoogle: false }).runner('show_street_level', at);
assert.deepEqual([delisted.ok, delisted.stage], [false, 'provider']);
assert.equal(delisted.error, 'not on this map');
const refused = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'key-rejected' }).runner('show_street_level', at);
assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]);
assert.equal(refused.say, 'Google Street View refused its key.');
const switchedOff = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'layer-off' }).runner('show_street_level', at);
assert.deepEqual([switchedOff.ok, switchedOff.stage, switchedOff.cancelled], [false, 'imagery', true]);
assert.equal(switchedOff.say, undefined, 'a request the user withdrew is not spoken');
// A source whose status call says it is not configured is key-gated even
// before its coverage reports a missing key.
const unconfigured = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', configured: false });
const gated = await unconfigured.runner('show_street_level', at);
assert.deepEqual([gated.ok, gated.stage], [false, 'key']);
assert.deepEqual(unconfigured.order, [], 'nothing changed');
});
test('show_street_level reports a geocoder that throws as its location stage', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const runner = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
dataManager: h.dataManager,
placeSearch: {
geocode: async () => {
throw new Error('geocoder unreachable');
},
},
});
const result = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary' });
assert.deepEqual([result.ok, result.stage], [false, 'location']);
assert.equal(result.error, 'Could not arrive at Capitol Mall');
assert.deepEqual(h.order.filter((step) => /^(params|open|arm)/.test(step)), [], 'nothing switched or looked up');
});
test('show_street_level reports a flight that lands without a position', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
// A flight that reports no coordinates cannot be followed by a lookup.
const noPoint = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
dataManager: hit.dataManager,
searchNavigation: async (_viewer, query, options) => {
options.onComplete?.();
return { label: query, navigationMode: 'city-overview', rangeM: null };
},
});
const unplaced = await noPoint('show_street_level', { query: 'Somewhere', provider: 'mapillary' });
assert.deepEqual([unplaced.ok, unplaced.stage], [false, 'location']);
assert.match(unplaced.error, /no position/);
});
test('fly_to_location reports the overview centre for a city framed as a whole, and the first POI for a close view', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { viewer, styleManager } = createVoiceNavigationHarness();
viewer.camera.lookAt = () => {};
viewer.camera.flyTo = (options) => options.complete?.();
viewer.camera.flyToBoundingSphere = (_s, options) => options.complete?.();
styleManager.runImmediateLocationNavigation = (navigate) => styleManager.runImmediateNavigation('location', navigate);
const runner = createGevActionRunner({ viewer, styleManager, dataManager: { layers: new Map(), getAll: () => [] } });
const overview = await runner('fly_to_location', { locationId: 'austin' });
assert.equal(overview.ok, true);
assert.ok(Math.abs(overview.latitude - 30.31) < 0.001, String(overview.latitude));
assert.ok(Math.abs(overview.longitude - -97.75) < 0.001, String(overview.longitude));
const close = await runner('fly_to_location', { locationId: 'austin', viewMode: 'close' });
assert.deepEqual([close.latitude, close.longitude], [30.2747, -97.7403]);
});
test('show_street_level arms the view centre when a billed link names no place, and keeps the flight label when it flew', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const armed = await centre.runner('show_street_level', { provider: 'google', autoOpen: false });
assert.equal(armed.ok, true, armed.error);
assert.equal(armed.armed, true);
assert.deepEqual(centre.order, ['params:google:voice', 'arm:google:250:view-centre'], 'armed at the view centre, nothing opened');
const flown = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const pending = flown.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google', autoOpen: false });
await new Promise((resolve) => setImmediate(resolve));
flown.finishFlight();
const result = await pending;
assert.equal(result.armed, true);
assert.equal(result.flew, true);
assert.equal(result.location, '38.5799, -121.4937');
assert.ok(!flown.order.some((entry) => entry.startsWith('open:')), 'no load after the flight');
});
test('show_street_level says "refused its key" when Google already refused it, and fails when the source will not light', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const refusedHarness = createStreetLevelHarness({ enabled: true, keyRejectedAtStart: true });
const refused = await refusedHarness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false });
assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]);
assert.equal(refused.error, KEY_REJECTED_MESSAGE);
assert.equal(refused.say, 'Google Street View refused its key.');
assert.deepEqual(refusedHarness.order, [], 'nothing changed');
const dark = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', lightFails: true }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
);
assert.deepEqual([dark.ok, dark.stage], [false, 'provider']);
assert.match(dark.error, /could not be switched on/);
});
test('activeImageryFilter reports only the narrowing filters the lit source honours', async () => {
const { activeImageryFilter } = await import('./gevActions.js');
const both = { pano: true, since: true };
assert.equal(activeImageryFilter({ filter: { pano: 'all', sinceDays: 0 }, filterControls: both }), null);
assert.deepEqual(activeImageryFilter({ filter: { pano: 'pano', sinceDays: 0 }, filterControls: both }), {
pano: 'pano',
label: '360° filter',
});
assert.deepEqual(activeImageryFilter({ filter: { pano: 'flat', sinceDays: 365 }, filterControls: both }), {
pano: 'flat',
sinceDays: 365,
label: 'flat-photo and SINCE filters',
});
assert.equal(
activeImageryFilter({ filter: { pano: 'pano', sinceDays: 365 }, filterControls: { pano: false, since: false } }),
null,
'Street View honours neither',
);
assert.equal(activeImageryFilter(undefined), null);
});
test('analyst item display strings are single-line and bounded for voice', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const rows = [{ id: 'stable-id', name: 'Şehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }];
@@ -6688,3 +7251,296 @@ test('a partial analyst card shows one partial chip', () => {
const labels = card.display.chips.map((chip) => chip.label);
assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels));
});
test('show_street_level superseded during its flight switches nothing', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
let current = true;
const pending = h.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' }, { isCurrent: () => current });
await new Promise((resolve) => setImmediate(resolve));
current = false;
h.finishFlight();
const result = await pending;
assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], 'the stale turn switched nothing');
assert.equal(result.cancelled, true);
});
test('show_street_level superseded during its map switch lights no source', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness({ enabled: true });
let current = true;
const runner = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
styleManager: {
mapStackController: { getActiveId: () => 'osm' },
setMapStack: async (id) => {
h.order.push(`map:${id}`);
current = false;
return { ok: true, activeStack: id };
},
},
dataManager: h.dataManager,
});
const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }, { isCurrent: () => current });
assert.deepEqual(h.order.filter((step) => /^(params|open|arm)/.test(step)), [], 'the stale turn lit nothing');
assert.deepEqual([result.stage, result.cancelled], ['map', true]);
});
test('show_street_level goes ahead while a source’s key status is still unknown', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
// A freshly loaded sl= link meets the source before its status call answers.
const { runner, order } = createStreetLevelHarness({ enabled: true, configured: null });
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.equal(result.ok, true);
assert.ok(order.some((step) => step.startsWith('open:mapillary')));
});
test('show_street_level in a view that hides its panel stops before anything loads unseen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
for (const mode of ['cockpit-mode', 'ui-clean-view', 'recording-mode', 'scene-playback-mode']) {
globalThis.document = { body: { classList: { contains: (name) => name === mode } } };
try {
const { runner, order } = createStreetLevelHarness({ enabled: true });
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.match(result.error, /hidden in the current view/);
assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`);
} finally {
globalThis.document = saved;
}
}
});
test('show_street_level checks the panel again after every wait: a mode entered meanwhile loads nothing unseen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
const classes = new Set();
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
try {
for (const [mode, when] of [
['ui-clean-view', 'duringEnable'],
['cockpit-mode', 'duringMapSwitch'],
]) {
classes.clear();
const h = createStreetLevelHarness({ [when]: () => classes.add(mode) });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.deepEqual(h.order.filter((step) => /^(open|arm)/.test(step)), [], `${mode}: no billed load`);
assert.deepEqual(h.revealed, [], `${mode}: nothing revealed`);
}
// Entered while the photo loaded: it is closed again, not reported as shown.
classes.clear();
const late = createStreetLevelHarness({ enabled: true });
const open = late.layer.openNearest;
late.layer.openNearest = async (...call) => {
const opened = await open(...call);
classes.add('recording-mode');
return opened;
};
const result = await late.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.equal(late.order.at(-1), 'close');
assert.deepEqual(late.revealed, []);
} finally {
globalThis.document = saved;
}
});
test('show_street_level refuses a hidden panel before the camera leaves a recording', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
globalThis.document = { body: { classList: { contains: (name) => name === 'recording-mode' } } };
try {
const { runner, order } = createStreetLevelHarness();
const result = await runner('show_street_level', { query: 'Eiffel Tower', provider: 'mapillary' });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.deepEqual(order, [], 'no flight');
} finally {
globalThis.document = saved;
}
});
test('show_street_level stops once the camera is someone else’s: a navigation or a gesture during the map switch', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
for (const take of ['navigate', 'gesture']) {
let h;
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.equal(result.ok, true, `${take}: Mapillary needs no map switch`);
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
const google = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([google.ok, google.stage, google.cancelled, google.superseded], [false, 'map', true, true], take);
assert.deepEqual(h.order.filter((step) => /^(params|enable|open|arm)/.test(step)), [], `${take}: nothing lit for the old place`);
}
// During the layer switch-on: the photo lookup never runs.
let h;
h = createStreetLevelHarness({ duringEnable: () => h.navigate() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'layer', true]);
assert.ok(!h.order.some((step) => step.startsWith('open:')));
});
test('show_street_level loads no photo it found once the camera or the panel moved on during the lookup', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
for (const take of ['navigate', 'gesture']) {
let h;
h = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => h[take]() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'imagery', true], take);
assert.equal(h.lookups.length, 1);
assert.equal(h.street.open, false, `${take}: the billed panorama never loads`);
assert.ok(!h.order.includes('close'), `${take}: nothing opened, nothing to close`);
}
const saved = globalThis.document;
const classes = new Set();
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
try {
const h = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => classes.add('ui-clean-view') });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.equal(h.street.open, false, 'nothing loads into the hidden panel');
} finally {
globalThis.document = saved;
}
});
test('show_street_level replaces an open photo in place: the viewer (and EXPAND) stays up while it looks', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: 'pano-b' });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.equal(result.ok, true);
assert.equal(h.lookups[0].replace, true, 'the lookup takes over the open photo');
assert.ok(!h.order.includes('close'), 'never closed on the way');
assert.deepEqual([h.street.open, h.street.imageId], [true, 'pano-b']);
});
test('show_street_level owns the camera from where its own flight lands', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary' });
await new Promise((resolve) => setTimeout(resolve, 0));
h.finishFlight();
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.ok(h.order.some((step) => step.startsWith('open:mapillary')));
});
// A camera taken during the action's own flight. 'navigate' is a
// location-panel search: a stamp that leaves the flight flying. 'gesture' is a
// press on the globe, which does not cancel a Cesium flight either.
for (const take of ['navigate', 'gesture']) {
test(`show_street_level stands down when the camera is taken during its own flight (${take})`, async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google' });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(h.order, ['fly'], 'the flight is under way');
h[take]();
h.finishFlight();
const result = await pending;
assert.deepEqual([result.ok, result.superseded], [false, true], result.error);
assert.deepEqual(
h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)),
[],
'no map switch, no source, no billed photo for the place the user left',
);
});
}
test('show_street_level closes the previous photo, so one that finds nothing leaves no other place on screen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const empty = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: null });
const result = await empty.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.noImagery], [false, true]);
assert.equal(empty.street.open, false, 'the Paris photo is gone');
assert.match(empty.street.error, /No street-level imagery/, 'the panel shows why');
// A link that only arms a billed source closes it too.
const armed = createStreetLevelHarness({ enabled: true, photoOpen: true, activeStack: 'photoreal' });
await armed.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'google', fly: false, autoOpen: false });
assert.deepEqual(armed.order.filter((step) => /^(close|arm)/.test(step)).map((step) => step.split(':')[0]), ['close', 'arm']);
});
test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
try {
for (const mode of [null, 'cockpit-mode', 'recording-mode', 'scene-playback-mode']) {
const classes = new Set(['ui-embed', 'ui-clean-view', mode]);
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
const { runner, order } = createStreetLevelHarness({ enabled: true });
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
if (mode) {
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`);
} else {
assert.equal(result.ok, true, 'embed clean view lifts the photo over the globe');
assert.ok(order.some((step) => step.startsWith('open:mapillary')));
}
}
} finally {
globalThis.document = saved;
}
});
test('show_street_level from a link keeps the recipient’s preferences: its switches are not the user’s', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const link = createStreetLevelHarness();
const linked = await link.runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
{ origin: 'programmatic' },
);
assert.equal(linked.ok, true, linked.error);
assert.deepEqual(link.origins, ['programmatic', 'programmatic'], 'source switch and layer on');
const asked = createStreetLevelHarness();
await asked.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.deepEqual(asked.origins, ['voice', 'voice'], 'the assistant’s request is the user’s choice');
});
test('show_street_level with no place looks under the screen centre, not a link’s armed point', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const here = createStreetLevelHarness({ enabled: true });
const result = await here.runner('show_street_level', { provider: 'mapillary' });
assert.equal(result.ok, true, result.error);
assert.equal(result.atViewCentre, true);
assert.equal(here.lookups[0].point, null);
assert.equal(here.lookups[0].aim, 'screen-centre');
assert.equal(here.lookups[0].armed, false);
const placed = createStreetLevelHarness({ enabled: true });
await placed.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.equal(placed.lookups[0].aim, undefined, 'a place is looked up where it is');
const sky = createStreetLevelHarness({ enabled: true, nearest: null, sky: true });
const refused = await sky.runner('show_street_level', { provider: 'mapillary' });
assert.deepEqual([refused.ok, refused.stage, refused.aim], [false, 'imagery', 'sky']);
});
test('show_street_level tells missing imagery from a failing source', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'mapillary', fly: false,
});
assert.deepEqual([empty.stage, empty.noImagery, empty.imageryError], ['imagery', true, undefined]);
const broken = await createStreetLevelHarness({ enabled: true, imageryError: 'Mapillary graph HTTP 500' }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
);
assert.deepEqual([broken.stage, broken.noImagery, broken.imageryError], ['imagery', undefined, true]);
assert.equal(broken.error, 'Mapillary graph HTTP 500');
});
test('show_street_level shows every photo it opens, also over a panel the user had closed', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const harness = createStreetLevelHarness({ enabled: true });
await harness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
await harness.runner('show_street_level', { latitude: 3, longitude: 4, provider: 'mapillary', fly: false });
assert.deepEqual(harness.revealed, [
'collapse:street-level-panel:false',
'reveal:street-level-panel',
'collapse:street-level-panel:false',
'reveal:street-level-panel',
]);
const missing = createStreetLevelHarness({ enabled: true, nearest: null });
await missing.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.deepEqual(missing.revealed, [], 'nothing opened, nothing revealed');
});
+4 -4
View File
@@ -714,13 +714,13 @@ test('without an operator Overpass the prompt never offers place search', () =>
});
test('the Realtime prompt stays within its size budget', () => {
// Instructions plus tool schemas, in characters, under the same 55,500
// Instructions plus tool schemas, in characters, under the same 56,500
// ceiling as the deixis prompt lint. Voice geometry adds resolve_area,
// find_imagery and the analyst area scopes (osm_query only with an
// operator Overpass).
// operator Overpass); show_street_level adds its tool and routing rule.
const size = (overpass) =>
realtimeInstructions(undefined, { overpass }).length +
JSON.stringify(realtimeTools({ overpass })).length;
assert.ok(size(false) <= 55_500, `default prompt ${size(false)} chars`);
assert.ok(size(true) <= 55_500, `with Overpass ${size(true)} chars`);
assert.ok(size(false) <= 56_500, `default prompt ${size(false)} chars`);
assert.ok(size(true) <= 56_500, `with Overpass ${size(true)} chars`);
});
+6 -1
View File
@@ -364,7 +364,12 @@ const layers = [
},
{
id: 'street-level',
aliases: ['street level', 'street-level imagery', 'mapillary'],
aliases: [
'street level',
'street-level imagery',
'mapillary',
'street view',
],
context: false,
noQuery: 'street-level imagery; no countable records',
},
+1
View File
@@ -119,6 +119,7 @@ test('spoken aliases resolve to real layers, keep every legacy phrase and never
'weather radar': 'weather-radar', lightning: 'weather-lightning', 'military bases': 'military-installations',
'recent imagery': 'recent-imagery', 'bike share': 'bikeshare', 'rocket launches': 'rocket-launches',
'street level': 'street-level', mapillary: 'street-level',
'street view': 'street-level',
wind: 'wind', directions: 'directions', 'fire perimeters': 'fire-perimeters',
'wildfire perimeters': 'fire-perimeters', 'burn areas': 'fire-perimeters',
};
+4 -12
View File
@@ -343,19 +343,11 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in
tool('annotate_map').annotations.items.properties.target.description,
/"pointer"/,
);
// Budget: the whole Realtime prompt (instructions + tools), measured in
// characters. It was 56,308 after the area/imagery/OSM merge; stating each
// policy once (WHERE precedence, AREAS routing, AFTER TOOLS speech) brought
// it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and
// the analyst area scopes) raised the ceiling to 55,500; tightening the
// AREAS rule and the area tool descriptions left it at 55,103 as measured
// below (every tool, osm_query included); main's Sea Marks and Ski Pistes
// layers bring it to 55,210, and the tilt pitchDeg argument (with its -45
// default) and main's cockpit entry rule to 55,339. integrationContract's
// Overpass prompt is the tightest, at 55,499 (1 under). A new tool or rule
// has to fit under this ceiling.
// Budget: the whole Realtime prompt (instructions + every tool, osm_query
// included), in characters. Every session pays for it in latency and cost,
// so a new tool or rule has to fit under this ceiling or earn a raise.
const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length;
assert.ok(size <= 55_500, `prompt size ${size}`);
assert.ok(size <= 56_500, `prompt size ${size}`);
});
test('after-tool speech allows returned items to answer requested lists and rankings', () => {
+1
View File
@@ -27,6 +27,7 @@ const CAMERA_TOOLS = new Set([
'fly_route',
'frame_overhead',
'select_nearest_aircraft',
'show_street_level',
'control_cockpit',
]);
+204
View File
@@ -24,6 +24,7 @@
import { voiceLayer } from './layerManifest.js';
import {
formatDate,
formatNumber,
getLocale,
msg,
@@ -178,9 +179,27 @@ const KNOWN_ERRORS = new Map(
),
msg('Could not arrive at the requested place'),
msg('The nearest airborne aircraft could not be selected'),
// show_street_level
msg('Latitude must be within ±90° and longitude within ±180°'),
msg('Street Level is unavailable'),
msg('No street-level imagery source is configured'),
msg('Street Level could not be enabled'),
msg('Street Level changed while it looked for imagery'),
msg('Aim the view at a street'),
].map((text) => [text, text]),
);
/** The imagery filters an empty street-level result names, as its chips say them. */
function shownImageryFilters(label) {
return [
label.includes('360°') ? '360°' : null,
label.includes('flat-photo') ? t('FLAT') : null,
label.includes('SINCE') ? t('SINCE') : null,
]
.filter(Boolean)
.join(', ');
}
function noneWithinView(target, radius) {
switch (target) {
case 'flights':
@@ -258,6 +277,69 @@ const ERROR_PATTERNS = [
/^No (\S+) within (\d+(?:\.\d+)?) km of the current view$/,
([, target, km]) => noneWithinView(target, shownKm(km)),
],
// show_street_level: the filter clause first, around the reason it extends.
[
/^(.+) with the (.+ filters?) on$/,
([, reason, filters]) =>
t('{reason} (filters on: {filters})', {
reason: shownError(reason),
filters: shownImageryFilters(filters),
}),
],
[
/^(.+) is not a street-level imagery source here$/,
([, source]) =>
t('{source} is not a street-level imagery source here', { source }),
],
[
/^(.+) needs its key$/,
([, source]) =>
t('{source} needs its key', { source: translateKnown(source) }),
],
[
/^(.+) needs the (.+) map, which is not available$/,
([, source, map]) =>
t('{source} needs the {map} map, which is not available', {
source: translateKnown(source),
map: map === 'photoreal' ? 'Google 3D' : map,
}),
],
[
/^(.+) could not be switched on$/,
([, source]) =>
t('{source} could not be switched on', {
source: translateKnown(source),
}),
],
[
/^(.+) is not available on this map$/,
([, source]) =>
t('{source} is not available on this map', {
source: translateKnown(source),
}),
],
[
/^(.+) imagery could not be opened$/,
([, source]) =>
t('{source} imagery could not be opened', {
source: translateKnown(source),
}),
],
[
/^(.+) has no position to look for imagery at$/,
([, place]) =>
t('{place} has no position to look for imagery at', {
place: localizePlaceName(place),
}),
],
[
/^No (.+) imagery near (.+)$/,
([, source, place]) =>
t('No {source} imagery near {place}', {
source: translateKnown(source),
place: localizePlaceName(place),
}),
],
];
/** A tool error as the card shows it. */
@@ -594,6 +676,98 @@ function selectNearestAircraft(result) {
};
}
/** Month and year an image was captured, or ''. */
function capturedMonth(capturedAt) {
const ms = Number(capturedAt);
if (!Number.isFinite(ms) || ms <= 0) return '';
try {
// Dates are UTC months (Google: the first of the month).
return formatDate(new Date(ms), {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
} catch {
return '';
}
}
function showStreetLevel(result) {
// `location` is the flight's own result on a failed flight; its label is
// the place, never the object.
const place = spokenLabel(
result?.label ||
(typeof result?.location === 'string'
? result.location
: result?.location?.label) ||
'there',
);
const source = spokenLabel(result?.providerName || 'Street Level');
// The card in the interface language; the spoken line stays English.
const shownSource = result?.providerName
? spokenLabel(result.providerName)
: t('Street Level');
// The view centre is a phrase, not a place name: the card says it in the
// interface language.
const shown = result?.atViewCentre
? t('the view centre')
: spokenLabel(localizePlaceName(result?.label || '')) || place;
if (result?.cancelled) return null;
if (!result?.ok) {
let say;
if (result?.stage === 'location') say = `Couldn't find ${place}.`;
else if (result?.stage === 'map')
say = `${source} needs the Google 3D map, which isn't available.`;
else if (result?.stage === 'key')
say = result?.keyRejected
? `${source} refused its key.`
: `${source} needs its key.`;
else if (result?.stage === 'layer') say = "Couldn't turn on Street Level.";
else if (result?.stage === 'view')
say = 'Street-level photos are hidden in this view. Leave it first.';
else if (result?.stage === 'imagery' && result?.aim === 'sky')
say = 'Aim the middle of the view at a street first.';
else if (result?.stage === 'imagery' && result?.filter?.label)
say = `No ${source} imagery near ${place} with the ${result.filter.label} on.`;
else if (result?.stage === 'imagery' && result?.noImagery === true)
say = `No ${source} imagery near ${place}.`;
// A service or viewer failure is not missing coverage.
else if (result?.stage === 'imagery')
say = `${source} could not open imagery near ${place}.`;
else say = `Couldn't open ${source}.`;
return {
say,
display: {
title: shownSource,
lines: errorLines(result),
sources: [],
},
};
}
if (result.armed === true)
return {
say: sentence(`${source} is ready at ${place}: press OPEN to load it`),
display: {
title: shownSource,
lines: [shown, t('Press OPEN to load the photo')],
sources: [{ label: shownSource }],
},
};
const title = spokenLabel(result.title || '', 60);
const month = capturedMonth(result.capturedAt);
return {
say: sentence(`${source} at ${title || place}`),
display: {
title: shownSource,
lines: [
title || shown,
month ? t('Captured {month}', { month }) : null,
].filter(Boolean),
sources: [{ label: shownSource }],
},
};
}
const SPOKEN_ROUTE = Object.freeze({
between: (origin, destination) => `${origin} to ${destination}`,
from: (origin) => `from ${origin}`,
@@ -801,6 +975,7 @@ export const SPEECH_BUILDERS = Object.freeze({
fly_to_location: flyToLocation,
frame_overhead: frameOverhead,
select_nearest_aircraft: selectNearestAircraft,
show_street_level: showStreetLevel,
get_entity_context: getEntityContext,
get_current_view_state: getCurrentViewState,
annotate_map: annotateMap,
@@ -856,6 +1031,14 @@ function annotationTargets(args) {
.filter(Boolean);
}
/** Street-level sources as a plan step names them. */
const STREET_LEVEL_SOURCE_NAMES = Object.freeze({
google: 'Street View',
mapillary: 'Mapillary',
kartaview: 'KartaView',
panoramax: 'Panoramax',
});
// frame_overhead targets as the schema names them.
function frameStepLabel(target) {
switch (target) {
@@ -927,6 +1110,17 @@ export function planStepLabel(name, args = {}) {
? t('Nearest aircraft to {place}', { place: shownPlace(place, 32) })
: t('Nearest aircraft to Here');
}
case 'show_street_level': {
const source = STREET_LEVEL_SOURCE_NAMES[args.provider];
const place = args.query
? shownPlace(args.query, 32)
: args.latitude != null
? t('this spot')
: t('the view centre');
return source
? t('{source} at {place}', { source, place })
: t('Street level at {place}', { place });
}
case 'set_layer_visibility': {
const on = args.enabled !== false;
const label =
@@ -988,6 +1182,8 @@ export function narrationLabel(name, args = {}) {
presetPlaceName(args.locationQuery || args.locationId || ''),
32,
);
case 'show_street_level':
return shownPlace(args.query || '', 32);
case 'resolve_area':
return displayName(args.query || '', 32);
case 'find_imagery':
@@ -1008,6 +1204,8 @@ const STEP_LABELS = Object.freeze({
layer: msg('Turning on layer'),
refresh: msg('Loading aircraft'),
nearest: msg('Picking nearest'),
map: msg('Switching map'),
imagery: msg('Opening imagery'),
draw: msg('Drawing outline'),
catalog: msg('Searching imagery catalog'),
drape: msg('Loading imagery'),
@@ -1056,6 +1254,12 @@ export function progressLine(step, label) {
return 'Loading aircraft there.';
case 'nearest':
return 'Picking the nearest aircraft.';
case 'map':
return place === 'photoreal'
? 'Switching to Google 3D.'
: 'Switching the map.';
case 'imagery':
return place ? `Looking for imagery at ${place}.` : null;
case 'still':
return place ? `Still working on ${place}.` : 'Still working on it.';
default:
+232
View File
@@ -536,6 +536,238 @@ test('labels are bounded and cleaned before they are spoken', () => {
);
});
test('the street-level card says "the view centre" in the interface language', () => {
setLocale('de');
try {
const out = envelope('show_street_level', {
ok: true,
providerName: 'Mapillary',
label: 'the view centre',
atViewCentre: true,
});
assert.equal(out.display.lines[0], 'die Bildmitte');
assert.match(out.say, /the view centre/, 'speech stays English');
} finally {
setLocale('en');
}
});
test('street-level failure cards are in the interface language; speech stays English', () => {
setLocale('de');
try {
const card = (error, extra = {}) =>
envelope('show_street_level', {
ok: false,
action: 'show_street_level',
providerName: 'Google Street View',
label: 'Paris',
error,
...extra,
});
const lines = (error, extra) => card(error, extra).display.lines;
assert.deepEqual(lines('Google Street View needs its key', { stage: 'key' }), [
'Google Street View braucht seinen Schlüssel',
]);
assert.deepEqual(
lines('Google Street View needs the photoreal map, which is not available', { stage: 'map' }),
['Google Street View braucht die Google 3D-Karte, die nicht verfügbar ist'],
);
assert.deepEqual(lines('Mapillary could not be switched on', { stage: 'provider' }), [
'Mapillary konnte nicht eingeschaltet werden',
]);
assert.deepEqual(lines('Street Level could not be enabled', { stage: 'layer' }), [
'Straßenebene konnte nicht eingeschaltet werden',
]);
assert.deepEqual(
lines('Latitude must be within ±90° and longitude within ±180°', { stage: 'location' }),
['Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen'],
);
assert.deepEqual(lines('Mapillary imagery could not be opened', { stage: 'imagery' }), [
'Bilder von Mapillary konnten nicht geöffnet werden',
]);
// The filter clause wraps the reason, already in the interface language,
// and names the filters as their chips do.
const filtered = card('No Mapillary imagery near Paris with the 360° and SINCE filters on', {
stage: 'imagery',
noImagery: true,
filter: { label: '360° and SINCE filters' },
});
assert.deepEqual(filtered.display.lines, [
'Keine Bilder von Mapillary in der Nähe von Paris (aktive Filter: 360°, SEIT)',
]);
assert.match(filtered.say, /with the 360° and SINCE filters on/, 'speech stays English');
} finally {
setLocale('en');
}
});
test('street-level speech names the source and the place, and says which step failed', () => {
const ok = envelope('show_street_level', {
ok: true,
action: 'show_street_level',
provider: 'google',
providerName: 'Google Street View',
label: '10th Street, Sacramento',
title: '1000 10th St, Sacramento, CA',
capturedAt: Date.UTC(2024, 4, 1), // Google dates: the first of the month, UTC
});
assert.equal(ok.say, 'Google Street View at 1000 10th St, Sacramento, CA.');
assertSpeakable(ok.say);
assert.equal(ok.display.title, 'Google Street View');
assert.equal(ok.display.lines[1], 'Captured May 2024');
const stages = {
location: "Couldn't find Nowhere.",
map: "Google Street View needs the Google 3D map, which isn't available.",
key: 'Google Street View needs its key.',
layer: "Couldn't turn on Street Level.",
// A service or viewer failure, not missing coverage.
imagery: 'Google Street View could not open imagery near Nowhere.',
};
for (const [stage, say] of Object.entries(stages)) {
const out = envelope('show_street_level', {
ok: false,
stage,
label: 'Nowhere',
providerName: 'Google Street View',
error: 'detail',
});
assert.equal(out.say, say, stage);
assertSpeakable(out.say);
assert.deepEqual(out.display.lines, ['detail']);
}
const empty = envelope('show_street_level', {
ok: false,
stage: 'imagery',
noImagery: true,
label: 'Nowhere',
providerName: 'Mapillary',
});
assert.equal(empty.say, 'No Mapillary imagery near Nowhere.');
const sky = envelope('show_street_level', {
ok: false,
stage: 'imagery',
aim: 'sky',
atViewCentre: true,
label: 'the view centre',
providerName: 'Mapillary',
});
assert.equal(sky.say, 'Aim the middle of the view at a street first.');
assertSpeakable(sky.say);
const armed = envelope('show_street_level', {
ok: true,
armed: true,
providerName: 'Google Street View',
label: '10th St, Sacramento',
});
assert.equal(armed.say, 'Google Street View is ready at 10th St, Sacramento: press OPEN to load it.');
assertSpeakable(armed.say);
assert.deepEqual(armed.display.lines, ['10th St, Sacramento', 'Press OPEN to load the photo']);
const unfound = envelope('show_street_level', {
ok: false,
stage: 'location',
location: { ok: false, action: 'fly_to_location', label: 'Nowhere Town' },
});
assert.equal(unfound.say, "Couldn't find Nowhere Town.");
assert.equal(unfound.display.title, 'Street Level');
const refused = envelope('show_street_level', {
ok: false,
stage: 'key',
keyRejected: true,
providerName: 'Google Street View',
});
assert.equal(refused.say, 'Google Street View refused its key.');
assert.equal(
envelope('show_street_level', { ok: false, cancelled: true }).say,
undefined,
);
assert.equal(
planStepLabel('show_street_level', {
query: 'Eiffel Tower',
provider: 'google',
}),
'Street View at Eiffel Tower',
);
assert.equal(
planStepLabel('show_street_level', {
query: 'Times Square',
provider: 'mapillary',
}),
'Mapillary at Times Square',
);
assert.equal(
progressStepLabel('imagery', 'Eiffel Tower'),
'Opening imagery: Eiffel Tower',
);
assert.equal(progressLine('map', 'photoreal'), 'Switching to Google 3D.');
assert.equal(
progressLine('imagery', 'Eiffel Tower'),
'Looking for imagery at Eiffel Tower.',
);
});
test('the capture month is a UTC month whatever the timezone, and labels name the place', async () => {
const { execFileSync } = await import('node:child_process');
const speech = new URL('./speech.js', import.meta.url).href;
// Google dates a panorama 2024-05 as the first of the month, UTC. West of
// Greenwich that instant is still April; the card must still say May.
const out = execFileSync(
process.execPath,
[
'--input-type=module',
'-e',
`import { attachVoiceResult } from ${JSON.stringify(speech)};
const r = attachVoiceResult('show_street_level', { ok: true, providerName: 'Google Street View', label: 'x', capturedAt: Date.UTC(2024, 4, 1) });
process.stdout.write(r.display.lines[1]);`,
],
{ env: { ...process.env, TZ: 'America/Los_Angeles' }, encoding: 'utf8' },
);
assert.equal(out, 'Captured May 2024');
const { narrationLabel } = await import('./speech.js');
assert.equal(narrationLabel('show_street_level', { query: 'Eiffel Tower' }), 'Eiffel Tower');
assert.equal(narrationLabel('show_street_level', {}), '');
assert.equal(
planStepLabel('show_street_level', { latitude: 1, longitude: 2 }),
'Street level at this spot',
);
assert.equal(planStepLabel('show_street_level', {}), 'Street level at the view centre');
});
test('the plan step names KartaView as a source', () => {
assert.equal(
planStepLabel('show_street_level', {
query: 'Alexanderplatz',
provider: 'kartaview',
}),
'KartaView at Alexanderplatz',
);
});
test('an empty street-level search names the imagery filter that hid the photos', () => {
const out = envelope('show_street_level', {
ok: false,
stage: 'imagery',
label: 'Alexanderplatz',
providerName: 'KartaView',
filter: { pano: 'pano', label: '360° filter' },
});
assert.equal(
out.say,
'No KartaView imagery near Alexanderplatz with the 360° filter on.',
);
assertSpeakable(out.say);
});
test('the plan step names Panoramax as a source', () => {
assert.equal(
planStepLabel('show_street_level', {
query: 'Montmartre',
provider: 'panoramax',
}),
'Panoramax at Montmartre',
);
});
test('progress step labels join through the catalog colon', () => {
setLocale('fr');
try {