feat: add guided visual tours for Rome, Paris, and Tokyo
Authored beat-based walkthroughs with popup controls and voice control_tour playback. Catalog/routing via toursProxy; generation deferred. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
759652207f
commit
f5c1d8a44c
|
|
@ -12,3 +12,4 @@ output/
|
|||
3d-models/
|
||||
pinokio/ENVIRONMENT
|
||||
pinokio/.installed
|
||||
.gev-tours/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Guided visual tours (`src/tours/`): authored Rome / Paris / Tokyo walkthroughs
|
||||
with beat clock, popup controls, and voice `control_tour` (play / pause /
|
||||
next / prev / random / autoplay). Catalog and routing via `toursProxy`; no
|
||||
AI tour generation in this PR.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mapped-site outages show their scheduled retry countdown and distinguish
|
||||
|
|
|
|||
|
|
@ -2149,7 +2149,7 @@ silently demoting every later lookup for the session.
|
|||
|
||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware holds `OPENAI_API_KEY` and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token.
|
||||
- **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.
|
||||
- **Twenty-eight tools** (schemas defined server-side in `vite.config.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_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`, and `next_iss_pass`.
|
||||
- **Twenty-nine tools** (schemas defined server-side in `vite.config.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_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_tour`, `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`, and `next_iss_pass`. Guided tours (`src/tours/`, voice `control_tour`) play authored Rome / Paris / Tokyo walkthroughs (plus any JSON already under `.gev-tours/generated`); gallery seek + optional autoplay; no AI tour generation in this release.
|
||||
> **Reading `npm test` totals:** the count depends on the Node major. The two
|
||||
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
|
||||
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import localDataLayers from './data/localLayers.js';
|
|||
import { LAYER_STATE_REGISTRY } from './data/layerState.js';
|
||||
import { registerDataCredits } from './data/dataCredits.js';
|
||||
import { SceneDirector } from './scenes/director.js';
|
||||
import { TourEngine } from './tours/tourEngine.js';
|
||||
import { initGevVoiceCommands } from './voice/gevRealtime.js';
|
||||
import { MapStackController } from './mapStackController.js';
|
||||
import { initAnnotations } from './annotations/index.js';
|
||||
|
|
@ -245,6 +246,10 @@ async function init() {
|
|||
// Initialize the voice "whiteboard" annotation engine (world-space renderer)
|
||||
const annotations = initAnnotations({ viewer, tileset });
|
||||
|
||||
// Guided tours: camera playback + establish annotations
|
||||
const tourEngine = new TourEngine(viewer, styleManager, { annotations });
|
||||
window.__gevTourEngine = tourEngine;
|
||||
|
||||
// Keep startup chrome truthful: a share is not restored until camera,
|
||||
// visual/map/panel lanes, and every requested layer have terminated.
|
||||
void Promise.all([
|
||||
|
|
@ -319,6 +324,7 @@ async function init() {
|
|||
tileset,
|
||||
dataManager,
|
||||
sceneDirector,
|
||||
tourEngine,
|
||||
mapStackController,
|
||||
annotations,
|
||||
weatherEffects,
|
||||
|
|
@ -326,7 +332,7 @@ async function init() {
|
|||
getRenderGovernorDiagnostics,
|
||||
requestRender: governorRequestRender,
|
||||
};
|
||||
window.__godsEyeView.voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations });
|
||||
window.__godsEyeView.voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations, tourEngine });
|
||||
|
||||
} catch (error) {
|
||||
console.error("God's Eye View initialization failed:", error);
|
||||
|
|
|
|||
|
|
@ -21,15 +21,16 @@ function realtimeTools() {
|
|||
return new Function(`return ${literal};`)();
|
||||
}
|
||||
|
||||
test('Realtime schema exposes the authoritative 28-tool inventory', () => {
|
||||
test('Realtime schema exposes the authoritative 29-tool inventory', () => {
|
||||
const tools = realtimeTools();
|
||||
assert.equal(tools.length, 28);
|
||||
assert.equal(tools.length, 29);
|
||||
const names = tools.map((tool) => tool.name);
|
||||
assert.equal(new Set(names).size, 28, 'tool names are unique');
|
||||
assert.equal(new Set(names).size, 29, 'tool names are unique');
|
||||
assert.ok(names.includes('set_context_mode'));
|
||||
assert.ok(names.includes('control_cockpit'));
|
||||
assert.ok(names.includes('select_nearest_aircraft'));
|
||||
assert.ok(names.includes('control_radio'));
|
||||
assert.ok(names.includes('control_tour'));
|
||||
// Every tool closes its parameter object: an open schema lets the model
|
||||
// invent arguments the runner silently drops.
|
||||
for (const tool of tools) {
|
||||
|
|
@ -174,6 +175,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
|||
'fly_to_location',
|
||||
'select_nearest_aircraft',
|
||||
'set_map_stack',
|
||||
'control_tour',
|
||||
]);
|
||||
const unchanged = realtimeTools()
|
||||
.filter((tool) => !TOUCHED.has(tool.name))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"id": "paris",
|
||||
"title": "Paris: River and Axis",
|
||||
"city": "Paris",
|
||||
"cityId": "paris",
|
||||
"durationTargetSec": 108,
|
||||
"beats": [
|
||||
{
|
||||
"id": "paris-establish",
|
||||
"kind": "establish",
|
||||
"title": "Approaching Paris",
|
||||
"durationSec": 8,
|
||||
"script": "Paris along the Seine. This pass follows a tourist day compressed: iron tower, triumphal arch, palace museum, and the island where the city began.",
|
||||
"place": { "name": "Paris", "lat": 48.8566, "lon": 2.312 },
|
||||
"camera": { "mode": "flyTo", "lat": 48.8566, "lon": 2.312, "alt": 9500, "heading": 80, "pitch": -40, "durationSec": 12, "approachSec": 12, "spaceAlt": 32000000 }
|
||||
},
|
||||
{
|
||||
"id": "paris-eiffel",
|
||||
"kind": "hold",
|
||||
"title": "Eiffel Tower",
|
||||
"durationSec": 16,
|
||||
"script": "The Eiffel Tower. Gustave Eiffel raised it for the 1889 Exposition — three hundred thirty metres of iron meant to be temporary. Paris kept it.",
|
||||
"place": { "name": "Eiffel Tower", "lat": 48.8584, "lon": 2.2945 },
|
||||
"camera": { "mode": "orbitHold", "lat": 48.8584, "lon": 2.2945, "rangeM": 750, "heading": 315, "pitch": -22, "buildingHeight": 150 }
|
||||
},
|
||||
{
|
||||
"id": "paris-to-arc",
|
||||
"kind": "transit",
|
||||
"title": "Toward the Étoile",
|
||||
"durationSec": 6,
|
||||
"script": "After a short metro ride toward the Étoile, the arch sits at the star of twelve avenues.",
|
||||
"scriptVariations": [
|
||||
"A cross-town hop on the rails, and you come up under the Arc de Triomphe.",
|
||||
"A straight drive up toward the arch along the axis Napoleon wanted you to see."
|
||||
],
|
||||
"travel": { "mode": "transit", "fromPlace": "Eiffel Tower", "toPlace": "Arc de Triomphe" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 6 },
|
||||
"place": { "name": "Arc de Triomphe", "lat": 48.8738, "lon": 2.295 }
|
||||
},
|
||||
{
|
||||
"id": "paris-arc",
|
||||
"kind": "hold",
|
||||
"title": "Arc de Triomphe",
|
||||
"durationSec": 14,
|
||||
"script": "Napoleon commissioned it in 1806; it was finished in 1836. Under the vault, the Tomb of the Unknown Soldier. The Champs-Élysées runs from here like a ruler.",
|
||||
"place": { "name": "Arc de Triomphe", "lat": 48.8738, "lon": 2.295 },
|
||||
"camera": { "mode": "lookAt", "lat": 48.8738, "lon": 2.295, "rangeM": 680, "heading": 90, "pitch": -16, "buildingHeight": 55 }
|
||||
},
|
||||
{
|
||||
"id": "paris-to-louvre",
|
||||
"kind": "transit",
|
||||
"title": "Toward the Louvre",
|
||||
"durationSec": 6,
|
||||
"script": "Following the fastest street route down the Champs-Élysées, you roll toward the palace that became a museum.",
|
||||
"scriptVariations": [
|
||||
"A metro hop toward the river and the Louvre courtyard opens.",
|
||||
"We stay on the axis a little longer, then cut to the palace grounds."
|
||||
],
|
||||
"travel": { "mode": "drive", "fromPlace": "Arc de Triomphe", "toPlace": "Louvre" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 6 },
|
||||
"place": { "name": "Louvre", "lat": 48.8606, "lon": 2.3376 }
|
||||
},
|
||||
{
|
||||
"id": "paris-louvre",
|
||||
"kind": "hold",
|
||||
"title": "The Louvre",
|
||||
"durationSec": 16,
|
||||
"script": "A fortress, then a palace, then a museum in 1793. I. M. Pei's glass pyramid from 1989 is the new door into the old royal courtyard.",
|
||||
"place": { "name": "Louvre Pyramid", "lat": 48.8606, "lon": 2.3376 },
|
||||
"camera": { "mode": "lookAt", "lat": 48.8606, "lon": 2.3376, "rangeM": 560, "heading": 0, "pitch": -28, "buildingHeight": 28 }
|
||||
},
|
||||
{
|
||||
"id": "paris-to-notre-dame",
|
||||
"kind": "transit",
|
||||
"title": "Along the Seine",
|
||||
"durationSec": 5,
|
||||
"script": "A walk along the river to the island — the medieval seed of Paris.",
|
||||
"scriptVariations": [
|
||||
"A short hop along the Seine, and Notre-Dame fills the Île de la Cité.",
|
||||
"We stay close to the water for this last stretch to the cathedral."
|
||||
],
|
||||
"travel": { "mode": "walk", "fromPlace": "Louvre", "toPlace": "Notre-Dame" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 5 },
|
||||
"place": { "name": "Notre-Dame", "lat": 48.853, "lon": 2.3499 }
|
||||
},
|
||||
{
|
||||
"id": "paris-notre-dame",
|
||||
"kind": "hold",
|
||||
"title": "Notre-Dame",
|
||||
"durationSec": 16,
|
||||
"script": "Work began in 1163. The 2019 fire took the spire and the roof. The cathedral reopened in 2024. This island is where Paris was a city before it was a capital.",
|
||||
"place": { "name": "Notre-Dame", "lat": 48.853, "lon": 2.3499 },
|
||||
"camera": { "mode": "lookAt", "lat": 48.853, "lon": 2.3499, "rangeM": 520, "heading": 225, "pitch": -22, "buildingHeight": 40 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"id": "rome",
|
||||
"title": "Rome: Heart of the Empire",
|
||||
"city": "Rome",
|
||||
"cityId": "rome",
|
||||
"durationTargetSec": 110,
|
||||
"beats": [
|
||||
{
|
||||
"id": "rome-establish",
|
||||
"kind": "establish",
|
||||
"title": "Approaching Rome",
|
||||
"durationSec": 8,
|
||||
"script": "Rome. City stacked on city. This pass stays in the ancient core: arena, forum, fountain, and a dome that is still unmatched.",
|
||||
"place": { "name": "Rome historic center", "lat": 41.8945, "lon": 12.4855 },
|
||||
"camera": { "mode": "flyTo", "lat": 41.8945, "lon": 12.4855, "alt": 9500, "heading": 20, "pitch": -42, "durationSec": 12, "approachSec": 12, "spaceAlt": 32000000 }
|
||||
},
|
||||
{
|
||||
"id": "rome-colosseum",
|
||||
"kind": "hold",
|
||||
"title": "The Colosseum",
|
||||
"durationSec": 18,
|
||||
"script": "The Flavian amphitheatre. Vespasian built it on Nero's lake as a gift back to the city. Titus opened it in 80 AD. Under the floor, cages and ramps. The scars in the stone are later Rome quarrying it for palaces.",
|
||||
"place": { "name": "Colosseum", "lat": 41.8902, "lon": 12.4922 },
|
||||
"camera": { "mode": "orbitHold", "lat": 41.8902, "lon": 12.4922, "rangeM": 820, "heading": 240, "pitch": -18, "buildingHeight": 52 }
|
||||
},
|
||||
{
|
||||
"id": "rome-to-forum",
|
||||
"kind": "transit",
|
||||
"title": "Walk to the Forum",
|
||||
"durationSec": 5,
|
||||
"script": "A few minutes on foot along the old triumphal axis, and the Forum opens under us.",
|
||||
"scriptVariations": [
|
||||
"We stay on the pavement; the Forum is the next valley over.",
|
||||
"No ride here — this is a short walk the processions used."
|
||||
],
|
||||
"travel": { "mode": "walk", "fromPlace": "Colosseum", "toPlace": "Arch of Titus" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 5 },
|
||||
"place": { "name": "Via dei Fori Imperiali", "lat": 41.8906, "lon": 12.4885 }
|
||||
},
|
||||
{
|
||||
"id": "rome-arch-titus",
|
||||
"kind": "hold",
|
||||
"title": "Arch of Titus",
|
||||
"durationSec": 12,
|
||||
"script": "Eighty-one AD. The Arch of Titus marks the siege of Jerusalem; the menorah in the relief still ties this street to another city.",
|
||||
"place": { "name": "Arch of Titus", "lat": 41.8906, "lon": 12.4885 },
|
||||
"camera": { "mode": "lookAt", "lat": 41.8906, "lon": 12.4885, "rangeM": 340, "heading": 250, "pitch": -20, "buildingHeight": 18 }
|
||||
},
|
||||
{
|
||||
"id": "rome-forum-curia",
|
||||
"kind": "hold",
|
||||
"title": "Forum and Curia",
|
||||
"durationSec": 12,
|
||||
"script": "The valley under us was the civic machine: speeches, markets, the Curia. This is why Rome became a government, not just a hill fort.",
|
||||
"place": { "name": "Roman Forum", "lat": 41.8925, "lon": 12.4853 },
|
||||
"camera": { "mode": "lookAt", "lat": 41.8925, "lon": 12.4853, "rangeM": 560, "heading": 210, "pitch": -30, "buildingHeight": 22 }
|
||||
},
|
||||
{
|
||||
"id": "rome-to-trevi",
|
||||
"kind": "transit",
|
||||
"title": "Walk to Trevi",
|
||||
"durationSec": 5,
|
||||
"script": "A short walk north through the lanes, and water starts to steal the scene.",
|
||||
"scriptVariations": [
|
||||
"On foot again — Trevi is only a neighborhood away.",
|
||||
"We keep walking. The next stop is a theatre built for an aqueduct."
|
||||
],
|
||||
"travel": { "mode": "walk", "fromPlace": "Roman Forum", "toPlace": "Trevi Fountain" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 5 },
|
||||
"place": { "name": "Trevi Fountain", "lat": 41.9009, "lon": 12.4833 }
|
||||
},
|
||||
{
|
||||
"id": "rome-trevi",
|
||||
"kind": "hold",
|
||||
"title": "Trevi Fountain",
|
||||
"durationSec": 15,
|
||||
"script": "Nicola Salvi's eighteenth-century theatre for water. It is the showy end of Aqua Virgo, a Roman aqueduct still feeding the city. The coins are a later ritual.",
|
||||
"place": { "name": "Trevi Fountain", "lat": 41.9009, "lon": 12.4833 },
|
||||
"camera": { "mode": "lookAt", "lat": 41.9009, "lon": 12.4833, "rangeM": 260, "heading": 5, "pitch": -16, "buildingHeight": 20 }
|
||||
},
|
||||
{
|
||||
"id": "rome-to-pantheon",
|
||||
"kind": "transit",
|
||||
"title": "Walk to the Pantheon",
|
||||
"durationSec": 5,
|
||||
"script": "Another few minutes on foot west, and the dome that still has no equal comes into view.",
|
||||
"scriptVariations": [
|
||||
"We stay walking. The Pantheon is the next courtyard over.",
|
||||
"No metro for this last stretch — just the old streets to Hadrian's dome."
|
||||
],
|
||||
"travel": { "mode": "walk", "fromPlace": "Trevi Fountain", "toPlace": "Pantheon" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 5 },
|
||||
"place": { "name": "Pantheon", "lat": 41.8986, "lon": 12.4769 }
|
||||
},
|
||||
{
|
||||
"id": "rome-pantheon",
|
||||
"kind": "hold",
|
||||
"title": "The Pantheon",
|
||||
"durationSec": 18,
|
||||
"script": "Hadrian's rebuild, about 126 AD. Largest unreinforced concrete dome on Earth. The oculus is the only light. Temple to all gods, then a church — which is why it is still standing.",
|
||||
"place": { "name": "Pantheon", "lat": 41.8986, "lon": 12.4769 },
|
||||
"camera": { "mode": "lookAt", "lat": 41.8986, "lon": 12.4769, "rangeM": 380, "heading": 215, "pitch": -16, "buildingHeight": 48 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"id": "tokyo",
|
||||
"title": "Tokyo: Tower, Palace, Old Town",
|
||||
"city": "Tokyo",
|
||||
"cityId": "tokyo",
|
||||
"durationTargetSec": 112,
|
||||
"beats": [
|
||||
{
|
||||
"id": "tokyo-establish",
|
||||
"kind": "establish",
|
||||
"title": "Approaching Tokyo",
|
||||
"durationSec": 8,
|
||||
"script": "Tokyo. This pass jumps clusters: a 1958 broadcast tower, the imperial moat, Asakusa's oldest temple, then the tower that replaced the first one's job.",
|
||||
"place": { "name": "Tokyo", "lat": 35.6586, "lon": 139.7454 },
|
||||
"camera": { "mode": "flyTo", "lat": 35.68, "lon": 139.77, "alt": 12000, "heading": 40, "pitch": -38, "durationSec": 12, "approachSec": 12, "spaceAlt": 32000000 }
|
||||
},
|
||||
{
|
||||
"id": "tokyo-tower",
|
||||
"kind": "hold",
|
||||
"title": "Tokyo Tower",
|
||||
"durationSec": 14,
|
||||
"script": "Tokyo Tower, 1958. Three hundred thirty-three metres. Eiffel as the model, painted orange and white for aviation. For decades this was the city's broadcast mast.",
|
||||
"place": { "name": "Tokyo Tower", "lat": 35.6586, "lon": 139.7454 },
|
||||
"camera": { "mode": "orbitHold", "lat": 35.6586, "lon": 139.7454, "rangeM": 850, "heading": 20, "pitch": -22, "buildingHeight": 110 }
|
||||
},
|
||||
{
|
||||
"id": "tokyo-to-palace",
|
||||
"kind": "transit",
|
||||
"title": "Metro to the palace",
|
||||
"durationSec": 6,
|
||||
"script": "A short subway ride north to the palace moat, and the city turns to walls and pine.",
|
||||
"scriptVariations": [
|
||||
"We take the train across the ward rather than crawl the surface streets, arriving at the East Gardens.",
|
||||
"A few stops on the metro and the inner city becomes a castle site."
|
||||
],
|
||||
"travel": { "mode": "transit", "fromPlace": "Tokyo Tower", "toPlace": "Imperial Palace" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 6 },
|
||||
"place": { "name": "Imperial Palace", "lat": 35.6852, "lon": 139.7528 }
|
||||
},
|
||||
{
|
||||
"id": "tokyo-palace",
|
||||
"kind": "hold",
|
||||
"title": "Imperial Palace",
|
||||
"durationSec": 14,
|
||||
"script": "The Imperial Palace sits on Edo Castle. The East Gardens are public. The inner palace is still the Emperor's — a moated blank in the middle of the capital.",
|
||||
"place": { "name": "Imperial Palace", "lat": 35.6852, "lon": 139.7528 },
|
||||
"camera": { "mode": "lookAt", "lat": 35.6852, "lon": 139.7528, "rangeM": 900, "heading": 0, "pitch": -32, "buildingHeight": 20 }
|
||||
},
|
||||
{
|
||||
"id": "tokyo-to-sensoji",
|
||||
"kind": "transit",
|
||||
"title": "Train to Asakusa",
|
||||
"durationSec": 6,
|
||||
"script": "Another rail hop east toward Asakusa — the old town the towers were built to look over.",
|
||||
"scriptVariations": [
|
||||
"We change lines toward the river and Senso-ji's gate.",
|
||||
"The next cluster is a temple town: a few stops on the train to Asakusa."
|
||||
],
|
||||
"travel": { "mode": "transit", "fromPlace": "Imperial Palace", "toPlace": "Senso-ji" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 6 },
|
||||
"place": { "name": "Senso-ji", "lat": 35.7148, "lon": 139.7967 }
|
||||
},
|
||||
{
|
||||
"id": "tokyo-sensoji",
|
||||
"kind": "hold",
|
||||
"title": "Senso-ji",
|
||||
"durationSec": 16,
|
||||
"script": "Senso-ji, Tokyo's oldest temple. Legend puts its founding in 628. Nakamise is the approach — a market street aimed at the gate like an arrow.",
|
||||
"place": { "name": "Senso-ji", "lat": 35.7148, "lon": 139.7967 },
|
||||
"camera": { "mode": "lookAt", "lat": 35.7148, "lon": 139.7967, "rangeM": 450, "heading": 0, "pitch": -18, "buildingHeight": 30 }
|
||||
},
|
||||
{
|
||||
"id": "tokyo-to-skytree",
|
||||
"kind": "transit",
|
||||
"title": "Hop to Skytree",
|
||||
"durationSec": 5,
|
||||
"script": "A short hop to Skytree — walk, taxi, or one train. The new mast is already in the skyline.",
|
||||
"scriptVariations": [
|
||||
"One more rail stop and the 2012 tower fills the east.",
|
||||
"We stay close: Skytree is the next landmark over the Sumida."
|
||||
],
|
||||
"travel": { "mode": "transit", "fromPlace": "Senso-ji", "toPlace": "Tokyo Skytree" },
|
||||
"camera": { "mode": "routeDolly", "compressToSec": 5 },
|
||||
"place": { "name": "Tokyo Skytree", "lat": 35.7101, "lon": 139.8107 }
|
||||
},
|
||||
{
|
||||
"id": "tokyo-skytree",
|
||||
"kind": "hold",
|
||||
"title": "Tokyo Skytree",
|
||||
"durationSec": 16,
|
||||
"script": "Tokyo Skytree, 2012. Six hundred thirty-four metres. Japan's tallest structure, and the broadcast tower that took Tokyo Tower's job.",
|
||||
"place": { "name": "Tokyo Skytree", "lat": 35.7101, "lon": 139.8107 },
|
||||
"camera": { "mode": "lookAt", "lat": 35.7101, "lon": 139.8107, "rangeM": 900, "heading": 220, "pitch": -20, "buildingHeight": 200 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Cinematic hold-shot palette for guided tours.
|
||||
* Playback overlay: shuffle on seek, cycle from the HUD. Not authored JSON modes.
|
||||
* @module tours/tourCameraShots
|
||||
*/
|
||||
|
||||
/** Fixed HUD cycle order (not the shuffle deck). */
|
||||
export const TOUR_SHOT_ORDER = Object.freeze([
|
||||
'orbit',
|
||||
'truck',
|
||||
'crane',
|
||||
'pushIn',
|
||||
'pullOut',
|
||||
'lockOff',
|
||||
'birdsEye',
|
||||
'lowAngle',
|
||||
]);
|
||||
|
||||
const SHOT_LABELS = Object.freeze({
|
||||
orbit: 'Orbit',
|
||||
truck: 'Truck',
|
||||
crane: 'Crane',
|
||||
pushIn: 'Push in',
|
||||
pullOut: 'Pull out',
|
||||
lockOff: 'Lock-off',
|
||||
birdsEye: "Bird's eye",
|
||||
lowAngle: 'Low angle',
|
||||
});
|
||||
|
||||
/** Shots that need close mesh; skipped when tiles are missing/sparse. */
|
||||
const MESH_SENSITIVE = new Set(['lowAngle', 'pushIn']);
|
||||
|
||||
/** Preferred draws when mesh is weak. */
|
||||
const MESH_SAFE = new Set(['birdsEye', 'pullOut', 'lockOff', 'orbit']);
|
||||
|
||||
export function shotLabel(shotId) {
|
||||
return SHOT_LABELS[shotId] || 'Camera';
|
||||
}
|
||||
|
||||
export function createShotDeck() {
|
||||
return {
|
||||
deck: [],
|
||||
lastId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function shuffleInPlace(list) {
|
||||
for (let i = list.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = list[i];
|
||||
list[i] = list[j];
|
||||
list[j] = tmp;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function eligibleIds(meshQuality) {
|
||||
const sparse = meshQuality === 'missing' || meshQuality === 'sparse';
|
||||
if (!sparse) return [...TOUR_SHOT_ORDER];
|
||||
return TOUR_SHOT_ORDER.filter((id) => !MESH_SENSITIVE.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the next shot without replacement. Reshuffles when empty.
|
||||
* Avoids repeating the same id across a reshuffle boundary.
|
||||
*/
|
||||
export function pickShuffledShot(state, { meshQuality = 'ok' } = {}) {
|
||||
if (!state) return 'orbit';
|
||||
const pool = eligibleIds(meshQuality);
|
||||
if (!state.deck.length) {
|
||||
state.deck = shuffleInPlace([...pool]);
|
||||
if (state.lastId && state.deck.length > 1 && state.deck[0] === state.lastId) {
|
||||
const swap = state.deck.findIndex((id) => id !== state.lastId);
|
||||
if (swap > 0) {
|
||||
const tmp = state.deck[0];
|
||||
state.deck[0] = state.deck[swap];
|
||||
state.deck[swap] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prefer mesh-safe ids when sparse by rotating a safe pick to front if present.
|
||||
const sparse = meshQuality === 'missing' || meshQuality === 'sparse';
|
||||
if (sparse && state.deck.length > 1) {
|
||||
const safeIdx = state.deck.findIndex((id) => MESH_SAFE.has(id));
|
||||
if (safeIdx > 0) {
|
||||
const [safe] = state.deck.splice(safeIdx, 1);
|
||||
state.deck.unshift(safe);
|
||||
}
|
||||
}
|
||||
const next = state.deck.shift() || 'orbit';
|
||||
state.lastId = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Next id in fixed catalog order (HUD cycle). */
|
||||
export function nextShotInOrder(currentId) {
|
||||
const idx = TOUR_SHOT_ORDER.indexOf(currentId);
|
||||
const next = TOUR_SHOT_ORDER[(idx + 1) % TOUR_SHOT_ORDER.length];
|
||||
return next || TOUR_SHOT_ORDER[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve framing offsets for a shot on top of authored hold framing.
|
||||
* @param {string} shotId
|
||||
* @param {{ rangeM: number, heading: number, pitch: number, buildingHeight: number }} base
|
||||
*/
|
||||
export function framingForShot(shotId, base) {
|
||||
const rangeM = Math.max(120, base.rangeM || 650);
|
||||
const heading = Number.isFinite(base.heading) ? base.heading : 0;
|
||||
const pitch = Number.isFinite(base.pitch) ? base.pitch : -26;
|
||||
const buildingHeight = Number.isFinite(base.buildingHeight) ? base.buildingHeight : 45;
|
||||
|
||||
switch (shotId) {
|
||||
case 'truck':
|
||||
return {
|
||||
rangeM,
|
||||
heading: heading + (Math.random() < 0.5 ? -18 : 18),
|
||||
pitch,
|
||||
buildingHeight,
|
||||
motion: { motion: 'pan', direction: Math.random() < 0.5 ? 'left' : 'right', mode: 'continuous', speed: 'slow' },
|
||||
};
|
||||
case 'crane':
|
||||
return {
|
||||
rangeM: rangeM * 1.08,
|
||||
heading,
|
||||
pitch: Math.max(-70, pitch - 8),
|
||||
buildingHeight,
|
||||
motion: { motion: 'tilt', direction: Math.random() < 0.5 ? 'up' : 'down', mode: 'continuous', speed: 'slow' },
|
||||
};
|
||||
case 'pushIn':
|
||||
return {
|
||||
rangeM: Math.max(160, rangeM * 0.62),
|
||||
heading,
|
||||
pitch: Math.min(-12, pitch + 4),
|
||||
buildingHeight,
|
||||
motion: null,
|
||||
secondaryRange: Math.max(140, rangeM * 0.62),
|
||||
};
|
||||
case 'pullOut':
|
||||
return {
|
||||
rangeM: rangeM * 1.55,
|
||||
heading,
|
||||
pitch: Math.max(-48, pitch - 6),
|
||||
buildingHeight,
|
||||
motion: null,
|
||||
};
|
||||
case 'lockOff':
|
||||
return {
|
||||
rangeM,
|
||||
heading,
|
||||
pitch,
|
||||
buildingHeight,
|
||||
motion: null,
|
||||
};
|
||||
case 'birdsEye':
|
||||
return {
|
||||
rangeM: rangeM * 1.7,
|
||||
heading,
|
||||
pitch: -56,
|
||||
buildingHeight,
|
||||
motion: { motion: 'orbit', direction: 'right', mode: 'continuous', speed: 'slow' },
|
||||
};
|
||||
case 'lowAngle':
|
||||
return {
|
||||
rangeM: Math.max(180, rangeM * 0.78),
|
||||
heading,
|
||||
pitch: -14,
|
||||
buildingHeight,
|
||||
motion: { motion: 'orbit', direction: Math.random() < 0.5 ? 'left' : 'right', mode: 'continuous', speed: 'slow' },
|
||||
};
|
||||
case 'orbit':
|
||||
default:
|
||||
return {
|
||||
rangeM,
|
||||
heading,
|
||||
pitch,
|
||||
buildingHeight,
|
||||
motion: { motion: 'orbit', direction: 'right', mode: 'continuous', speed: 'slow' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Client catalog for guided tours (authored + optional on-disk generated via /api/tours).
|
||||
* Tour generation and Q&A memory are not enabled in this build.
|
||||
* @module tours/tourCatalog
|
||||
*/
|
||||
|
||||
import { matchTourQuery, normalizeTour, summarizeTour } from './tourSchema.js';
|
||||
import rome from './data/rome.json';
|
||||
import paris from './data/paris.json';
|
||||
import tokyo from './data/tokyo.json';
|
||||
|
||||
const AUTHORED = [rome, paris, tokyo].map((raw) => normalizeTour(raw, { source: 'authored' }));
|
||||
|
||||
let _cache = null;
|
||||
let _cacheAt = 0;
|
||||
const CACHE_MS = 4000;
|
||||
|
||||
export async function fetchTourList({ force = false } = {}) {
|
||||
if (!force && _cache && Date.now() - _cacheAt < CACHE_MS) return _cache;
|
||||
try {
|
||||
const res = await fetch('/api/tours');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const tours = Array.isArray(data.tours) ? data.tours : [];
|
||||
if (tours.length) {
|
||||
_cache = tours;
|
||||
_cacheAt = Date.now();
|
||||
return tours;
|
||||
}
|
||||
} catch { /* fall back to shipped tours */ }
|
||||
_cache = AUTHORED.map(summarizeTour);
|
||||
_cacheAt = Date.now();
|
||||
return _cache;
|
||||
}
|
||||
|
||||
export function invalidateTourCache() {
|
||||
_cache = null;
|
||||
_cacheAt = 0;
|
||||
}
|
||||
|
||||
export async function fetchTourById(id) {
|
||||
try {
|
||||
const res = await fetch(`/api/tours/${encodeURIComponent(id)}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.ok && data.tour) return normalizeTour(data.tour, { source: data.tour.source });
|
||||
} catch { /* shipped fallback */ }
|
||||
return AUTHORED.find((tour) => tour.id === id || tour.cityId === id) || matchTourQuery(AUTHORED, id);
|
||||
}
|
||||
|
||||
export async function resolveTour(query) {
|
||||
const list = await fetchTourList();
|
||||
const summary = matchTourQuery(list, query);
|
||||
if (!summary) return null;
|
||||
return fetchTourById(summary.id);
|
||||
}
|
||||
|
||||
export async function pickRandomTour(excludeId = null) {
|
||||
const list = await fetchTourList({ force: true });
|
||||
if (!list.length) return { ok: false, empty: true, tours: [] };
|
||||
const pool = excludeId ? list.filter((tour) => tour.id !== excludeId) : list;
|
||||
const pick = (pool.length ? pool : list)[Math.floor(Math.random() * (pool.length ? pool.length : list.length))];
|
||||
const tour = await fetchTourById(pick.id);
|
||||
return { ok: Boolean(tour), tour, only: list.length === 1, tours: list.map(summarizeTour) };
|
||||
}
|
||||
|
||||
export async function fetchTourRoute(from, to) {
|
||||
if (!from || !to) return { ok: false, error: 'need from and to' };
|
||||
const params = new URLSearchParams({
|
||||
fromLat: String(from.lat),
|
||||
fromLon: String(from.lon),
|
||||
toLat: String(to.lat),
|
||||
toLon: String(to.lon),
|
||||
});
|
||||
const res = await fetch(`/api/tours/route?${params}`);
|
||||
return res.json().catch(() => ({ ok: false, error: 'route failed' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub: AI tour generation is not shipped in this build.
|
||||
* @param {string} [_query]
|
||||
* @param {{ confirmCost?: boolean }} [_opts]
|
||||
* @returns {Promise<{ ok: false, error: string }>}
|
||||
*/
|
||||
export async function generateTour(_query, _opts = {}) {
|
||||
return { ok: false, error: 'Tour generation is not enabled in this build' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub: Q&A tour memory is not shipped in this build.
|
||||
* @returns {Promise<{ ok: false, error: string, saved: false }>}
|
||||
*/
|
||||
export async function recordTourMemory(_entry) {
|
||||
return { ok: false, saved: false, error: 'Tour memory is not enabled in this build' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub: Q&A tour memory is not shipped in this build.
|
||||
* @returns {Promise<{ ok: false, error: string, memories: [] }>}
|
||||
*/
|
||||
export async function queryTourMemory(_bbox) {
|
||||
return { ok: false, memories: [], error: 'Tour memory is not enabled in this build' };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Full-screen locked loading overlay for tour generate / first camera move.
|
||||
* @module tours/tourLoading
|
||||
*/
|
||||
|
||||
export class TourLoadingOverlay {
|
||||
constructor() {
|
||||
this.root = document.getElementById('tour-loading');
|
||||
if (!this.root) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.id = 'tour-loading';
|
||||
this.root.className = 'tour-loading';
|
||||
this.root.hidden = true;
|
||||
this.root.setAttribute('role', 'alertdialog');
|
||||
this.root.setAttribute('aria-modal', 'true');
|
||||
this.root.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this.root);
|
||||
}
|
||||
this.root.innerHTML = `
|
||||
<div class="tour-loading-card">
|
||||
<div class="tour-loading-kicker">GUIDED TOUR</div>
|
||||
<div class="tour-loading-title" id="tour-loading-title">Preparing tour</div>
|
||||
<div class="tour-loading-status" id="tour-loading-status">Please wait…</div>
|
||||
<div class="tour-loading-bar" aria-hidden="true">
|
||||
<div class="tour-loading-bar-fill" id="tour-loading-bar-fill"></div>
|
||||
</div>
|
||||
<div class="tour-loading-pct" id="tour-loading-pct">0%</div>
|
||||
</div>
|
||||
`;
|
||||
this.titleEl = this.root.querySelector('#tour-loading-title');
|
||||
this.statusEl = this.root.querySelector('#tour-loading-status');
|
||||
this.fillEl = this.root.querySelector('#tour-loading-bar-fill');
|
||||
this.pctEl = this.root.querySelector('#tour-loading-pct');
|
||||
this._active = false;
|
||||
this._block = (event) => {
|
||||
if (!this._active) return;
|
||||
// Allow Escape only if we expose cancel later; for now trap pointer/keys.
|
||||
if (event.type === 'keydown' && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
show({ title = 'Preparing tour', status = 'Please wait…', progress = 0.05 } = {}) {
|
||||
this._active = true;
|
||||
this.root.hidden = false;
|
||||
document.body.classList.add('tour-loading-lock');
|
||||
document.addEventListener('keydown', this._block, true);
|
||||
this.update({ title, status, progress });
|
||||
}
|
||||
|
||||
update({ title, status, progress } = {}) {
|
||||
if (title != null && this.titleEl) this.titleEl.textContent = title;
|
||||
if (status != null && this.statusEl) this.statusEl.textContent = status;
|
||||
if (Number.isFinite(progress) && this.fillEl) {
|
||||
const pct = Math.max(0, Math.min(1, progress));
|
||||
this.fillEl.style.width = `${Math.round(pct * 100)}%`;
|
||||
if (this.pctEl) this.pctEl.textContent = `${Math.round(pct * 100)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this._active = false;
|
||||
this.root.hidden = true;
|
||||
document.body.classList.remove('tour-loading-lock');
|
||||
document.removeEventListener('keydown', this._block, true);
|
||||
}
|
||||
}
|
||||
|
||||
let _shared = null;
|
||||
export function getTourLoadingOverlay() {
|
||||
if (!_shared) _shared = new TourLoadingOverlay();
|
||||
return _shared;
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Progress popup + idle Surprise Me control for guided tours.
|
||||
* Gallery by default: prev/next/stop + Autoplay + Camera shot cycle.
|
||||
* Pause only when Autoplay is on.
|
||||
* @module tours/tourPopup
|
||||
*/
|
||||
|
||||
export class TourPopup {
|
||||
/**
|
||||
* @param {import('./tourEngine.js').TourEngine} engine
|
||||
*/
|
||||
constructor(engine) {
|
||||
this.engine = engine;
|
||||
this.root = document.getElementById('tour-popup');
|
||||
if (!this.root) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.id = 'tour-popup';
|
||||
this.root.className = 'tour-popup';
|
||||
this.root.hidden = true;
|
||||
document.body.appendChild(this.root);
|
||||
}
|
||||
this.root.innerHTML = `
|
||||
<div class="tour-popup-kicker">GUIDED TOUR</div>
|
||||
<div class="tour-popup-title" id="tour-popup-title">Tour</div>
|
||||
<div class="tour-popup-beat" id="tour-popup-beat">0 / 0</div>
|
||||
<div class="tour-popup-status" id="tour-popup-status" hidden></div>
|
||||
<div class="tour-popup-progress" id="tour-popup-progress" aria-hidden="true"><div id="tour-popup-progress-fill"></div></div>
|
||||
<div class="tour-popup-controls">
|
||||
<button type="button" id="tour-prev" title="Previous beat">⏮</button>
|
||||
<button type="button" id="tour-next" title="Next beat">⏭</button>
|
||||
<button type="button" id="tour-stop" title="Stop">⏹</button>
|
||||
<button type="button" id="tour-playpause" class="tour-popup-pause" title="Pause Autoplay" hidden>⏸</button>
|
||||
</div>
|
||||
<div class="tour-popup-toggles">
|
||||
<button type="button" id="tour-camera-shot" class="tour-popup-camera" title="Cycle camera shot">Camera</button>
|
||||
<button type="button" id="tour-autoplay" class="tour-popup-autoplay" aria-pressed="false">Autoplay</button>
|
||||
</div>
|
||||
`;
|
||||
this.titleEl = this.root.querySelector('#tour-popup-title');
|
||||
this.beatEl = this.root.querySelector('#tour-popup-beat');
|
||||
this.statusEl = this.root.querySelector('#tour-popup-status');
|
||||
this.fillEl = this.root.querySelector('#tour-popup-progress-fill');
|
||||
this.progressEl = this.root.querySelector('#tour-popup-progress');
|
||||
this.playPauseBtn = this.root.querySelector('#tour-playpause');
|
||||
this.autoplayBtn = this.root.querySelector('#tour-autoplay');
|
||||
this.cameraBtn = this.root.querySelector('#tour-camera-shot');
|
||||
this.root.querySelector('#tour-prev').addEventListener('click', () => { void this.engine.prev(); });
|
||||
this.root.querySelector('#tour-next').addEventListener('click', () => { void this.engine.next(); });
|
||||
this.root.querySelector('#tour-stop').addEventListener('click', () => this.engine.stop('Stopped'));
|
||||
this.playPauseBtn.addEventListener('click', () => {
|
||||
if (this.engine.paused) void this.engine.resume();
|
||||
else this.engine.pause('Paused');
|
||||
});
|
||||
this.autoplayBtn.addEventListener('click', () => {
|
||||
void this.engine.setAutoplay(!this.engine.autoplay);
|
||||
});
|
||||
this.cameraBtn.addEventListener('click', () => {
|
||||
void this.engine.cycleCameraShot();
|
||||
});
|
||||
this.progressEl.addEventListener('click', (event) => {
|
||||
const total = this.engine.tour?.beats?.length || 0;
|
||||
if (total < 2) return;
|
||||
const rect = this.progressEl.getBoundingClientRect();
|
||||
const t = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const index = Math.min(total - 1, Math.floor(t * total));
|
||||
void this.engine.seekTo(index, { wrap: false });
|
||||
});
|
||||
|
||||
this._onKeyDown = (event) => this._handleKeys(event);
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
|
||||
this._bindSurprise();
|
||||
engine.subscribe((status) => this.render(status));
|
||||
}
|
||||
|
||||
_handleKeys(event) {
|
||||
if (!this.engine.running) return;
|
||||
const tag = event.target?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || event.target?.isContentEditable) return;
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
void this.engine.prev();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
void this.engine.next();
|
||||
}
|
||||
}
|
||||
|
||||
_bindSurprise() {
|
||||
const host = document.getElementById('location-pills');
|
||||
let btn = document.getElementById('tour-surprise-btn');
|
||||
if (!btn && host) {
|
||||
btn = document.createElement('button');
|
||||
btn.id = 'tour-surprise-btn';
|
||||
btn.type = 'button';
|
||||
btn.className = 'location-pill location-pill-surprise';
|
||||
btn.textContent = 'Surprise Me';
|
||||
btn.title = 'Play a random saved tour';
|
||||
host.appendChild(btn);
|
||||
}
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
void this.engine.random();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render(status) {
|
||||
const running = !!status?.running;
|
||||
this.root.hidden = !running;
|
||||
document.body.classList.toggle('tour-playback-mode', running);
|
||||
if (!running) return;
|
||||
const total = Math.max(1, status.beatCount || 1);
|
||||
const index = Math.max(0, status.beatIndex || 0);
|
||||
this.titleEl.textContent = status.beatTitle || status.title || 'Tour';
|
||||
this.beatEl.textContent = `${index + 1} / ${total}`;
|
||||
const pct = ((index + 0.5) / total) * 100;
|
||||
if (this.fillEl) this.fillEl.style.width = `${Math.max(4, Math.min(100, pct))}%`;
|
||||
|
||||
const approaching = String(status.approachStatus || '').trim();
|
||||
this.statusEl.hidden = !approaching;
|
||||
this.statusEl.textContent = approaching;
|
||||
|
||||
const shotName = status.shotLabel || 'Camera';
|
||||
this.cameraBtn.textContent = shotName;
|
||||
this.cameraBtn.title = `Camera shot: ${shotName} — click to cycle`;
|
||||
|
||||
const autoplay = !!status.autoplay;
|
||||
this.autoplayBtn.setAttribute('aria-pressed', autoplay ? 'true' : 'false');
|
||||
this.autoplayBtn.classList.toggle('is-on', autoplay);
|
||||
this.autoplayBtn.textContent = autoplay ? 'Autoplay on' : 'Autoplay';
|
||||
this.playPauseBtn.hidden = !autoplay;
|
||||
this.playPauseBtn.innerHTML = status.paused ? '▶' : '⏸';
|
||||
this.playPauseBtn.title = status.paused ? 'Resume Autoplay' : 'Pause Autoplay';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Tour Review panel: beat scrubber + framing readout for agent/human QA.
|
||||
* Enable via ?tourReview=1 or the Review control next to Surprise Me.
|
||||
* @module tours/tourReview
|
||||
*/
|
||||
|
||||
function wantsReviewUi() {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).has('tourReview')
|
||||
|| window.localStorage?.getItem('gev.tourReview') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class TourReviewPanel {
|
||||
/**
|
||||
* @param {import('./tourEngine.js').TourEngine} engine
|
||||
*/
|
||||
constructor(engine) {
|
||||
this.engine = engine;
|
||||
this.root = document.getElementById('tour-review');
|
||||
if (!this.root) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.id = 'tour-review';
|
||||
this.root.className = 'tour-review';
|
||||
this.root.hidden = true;
|
||||
document.body.appendChild(this.root);
|
||||
}
|
||||
this.root.innerHTML = `
|
||||
<div class="tour-review-head">
|
||||
<div class="tour-review-kicker">TOUR REVIEW</div>
|
||||
<button type="button" id="tour-review-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="tour-review-row">
|
||||
<select id="tour-review-tour" aria-label="Tour"></select>
|
||||
<button type="button" id="tour-review-load">Load</button>
|
||||
</div>
|
||||
<div class="tour-review-readout" id="tour-review-readout">No tour loaded</div>
|
||||
<div class="tour-review-beats" id="tour-review-beats"></div>
|
||||
<div class="tour-review-actions">
|
||||
<button type="button" id="tour-review-prev">Prev</button>
|
||||
<button type="button" id="tour-review-apply">Apply camera</button>
|
||||
<button type="button" id="tour-review-next">Next</button>
|
||||
</div>
|
||||
`;
|
||||
this.tourSelect = this.root.querySelector('#tour-review-tour');
|
||||
this.beatsEl = this.root.querySelector('#tour-review-beats');
|
||||
this.readoutEl = this.root.querySelector('#tour-review-readout');
|
||||
this.root.querySelector('#tour-review-close').addEventListener('click', () => this.hide());
|
||||
this.root.querySelector('#tour-review-load').addEventListener('click', () => { void this._loadSelected(); });
|
||||
this.root.querySelector('#tour-review-prev').addEventListener('click', () => { void this._step(-1); });
|
||||
this.root.querySelector('#tour-review-next').addEventListener('click', () => { void this._step(1); });
|
||||
this.root.querySelector('#tour-review-apply').addEventListener('click', () => {
|
||||
void this.engine.previewBeat(this.engine.beatIndex || 0);
|
||||
});
|
||||
this._bindToggle();
|
||||
engine.subscribe((status) => this._onStatus(status));
|
||||
if (wantsReviewUi()) this.show();
|
||||
void this._refreshTourList();
|
||||
}
|
||||
|
||||
_bindToggle() {
|
||||
const host = document.getElementById('location-pills');
|
||||
let btn = document.getElementById('tour-review-btn');
|
||||
if (!btn && host) {
|
||||
btn = document.createElement('button');
|
||||
btn.id = 'tour-review-btn';
|
||||
btn.type = 'button';
|
||||
btn.className = 'location-pill location-pill-review';
|
||||
btn.textContent = 'Tour Review';
|
||||
btn.title = 'Open beat framing review';
|
||||
host.appendChild(btn);
|
||||
}
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
if (this.root.hidden) this.show();
|
||||
else this.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.root.hidden = false;
|
||||
document.body.classList.add('tour-review-open');
|
||||
try { window.localStorage?.setItem('gev.tourReview', '1'); } catch { /* ignore */ }
|
||||
void this._refreshTourList();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.root.hidden = true;
|
||||
document.body.classList.remove('tour-review-open');
|
||||
}
|
||||
|
||||
async _refreshTourList() {
|
||||
const tours = await this.engine.listTours().catch(() => []);
|
||||
const list = Array.isArray(tours) ? tours : [];
|
||||
const current = this.tourSelect.value;
|
||||
this.tourSelect.innerHTML = list.map((tour) => {
|
||||
const id = tour.id || tour.tourId || '';
|
||||
const label = tour.title || tour.city || id;
|
||||
return `<option value="${escapeAttr(id)}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
if (current) this.tourSelect.value = current;
|
||||
}
|
||||
|
||||
async _loadSelected() {
|
||||
const id = this.tourSelect.value;
|
||||
if (!id) return;
|
||||
const result = await this.engine.loadForReview(id);
|
||||
if (!result?.ok) {
|
||||
this.readoutEl.textContent = result?.error || 'Failed to load tour';
|
||||
return;
|
||||
}
|
||||
this._renderBeats();
|
||||
await this.engine.previewBeat(0);
|
||||
}
|
||||
|
||||
_renderBeats() {
|
||||
const beats = this.engine.tour?.beats || [];
|
||||
this.beatsEl.innerHTML = beats.map((beat, index) => {
|
||||
const cam = beat.camera || {};
|
||||
const meta = cam.mode === 'flyTo'
|
||||
? `alt ${cam.alt ?? '—'} · pitch ${cam.pitch ?? '—'}`
|
||||
: `range ${cam.rangeM ?? '—'} · h ${cam.buildingHeight ?? '—'}`;
|
||||
return `<button type="button" class="tour-review-beat" data-index="${index}">
|
||||
<span class="tour-review-beat-idx">${index + 1}</span>
|
||||
<span class="tour-review-beat-body">
|
||||
<strong>${escapeHtml(beat.title || beat.id || `Beat ${index + 1}`)}</strong>
|
||||
<em>${escapeHtml(beat.kind || '')} · ${escapeHtml(cam.mode || '')}</em>
|
||||
<small>${escapeHtml(meta)}</small>
|
||||
</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
this.beatsEl.querySelectorAll('.tour-review-beat').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = Number(btn.getAttribute('data-index'));
|
||||
void this.engine.previewBeat(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async _step(delta) {
|
||||
if (!this.engine.tour?.beats?.length) return;
|
||||
const next = Math.max(0, Math.min(this.engine.tour.beats.length - 1, (this.engine.beatIndex || 0) + delta));
|
||||
await this.engine.previewBeat(next);
|
||||
}
|
||||
|
||||
_onStatus(status) {
|
||||
const review = typeof window !== 'undefined' ? window.__gevTourReview : null;
|
||||
const beat = this.engine.tour?.beats?.[status?.beatIndex ?? 0];
|
||||
const cam = beat?.camera || review?.camera || {};
|
||||
if (this.engine.tour && this.beatsEl.children.length !== (this.engine.tour.beats?.length || 0)) {
|
||||
this._renderBeats();
|
||||
}
|
||||
this.beatsEl.querySelectorAll('.tour-review-beat').forEach((btn, index) => {
|
||||
btn.classList.toggle('is-active', index === (status?.beatIndex ?? review?.beatIndex));
|
||||
});
|
||||
if (!this.engine.tour) {
|
||||
this.readoutEl.textContent = 'No tour loaded';
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
`${this.engine.tour.title || this.engine.tour.id} · beat ${(status?.beatIndex ?? 0) + 1}/${this.engine.tour.beats.length}`,
|
||||
`${beat?.kind || '?'} · ${cam.mode || '?'} · ${review?.status || (status?.reviewing ? 'review' : status?.running ? 'playing' : 'idle')}`,
|
||||
`lat ${fmt(cam.lat ?? beat?.place?.lat)} lon ${fmt(cam.lon ?? beat?.place?.lon)}`,
|
||||
cam.mode === 'flyTo'
|
||||
? `alt ${fmt(cam.alt)} pitch ${fmt(cam.pitch)} heading ${fmt(cam.heading)}`
|
||||
: `rangeM ${fmt(cam.rangeM)} pitch ${fmt(cam.pitch)} heading ${fmt(cam.heading)} height ${fmt(cam.buildingHeight)}`,
|
||||
];
|
||||
this.readoutEl.textContent = lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(value) {
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
return Math.abs(value) >= 100 ? String(Math.round(value)) : value.toFixed(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/'/g, ''');
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* Guided visual tour schema: beats, keyframes, query matching, script variants.
|
||||
* @module tours/tourSchema
|
||||
*/
|
||||
|
||||
export const TOUR_SCHEMA_VERSION = 1;
|
||||
export const BEAT_KINDS = Object.freeze(['establish', 'transit', 'hold']);
|
||||
export const CAMERA_MODES = Object.freeze(['flyTo', 'lookAt', 'routeDolly', 'orbitHold']);
|
||||
export const TRAVEL_MODES = Object.freeze(['walk', 'bike', 'drive', 'transit', 'flight']);
|
||||
|
||||
const CAMERA_MODE_SET = new Set(CAMERA_MODES);
|
||||
const BEAT_KIND_SET = new Set(BEAT_KINDS);
|
||||
const TRAVEL_MODE_SET = new Set(TRAVEL_MODES);
|
||||
|
||||
function asNumber(value, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function asString(value, fallback = '') {
|
||||
const text = String(value ?? '').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
export function slugifyTourId(value) {
|
||||
return asString(value)
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 80) || 'tour';
|
||||
}
|
||||
|
||||
export function tourBounds(tour) {
|
||||
const pts = [];
|
||||
for (const beat of tour?.beats || []) {
|
||||
const lat = beat.place?.lat ?? beat.camera?.lat;
|
||||
const lon = beat.place?.lon ?? beat.camera?.lon;
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) pts.push({ lat, lon });
|
||||
for (const p of beat.travel?.polyline || []) {
|
||||
if (Number.isFinite(p.lat) && Number.isFinite(p.lon)) pts.push(p);
|
||||
}
|
||||
}
|
||||
if (!pts.length) return null;
|
||||
let west = 180;
|
||||
let east = -180;
|
||||
let south = 90;
|
||||
let north = -90;
|
||||
for (const p of pts) {
|
||||
west = Math.min(west, p.lon);
|
||||
east = Math.max(east, p.lon);
|
||||
south = Math.min(south, p.lat);
|
||||
north = Math.max(north, p.lat);
|
||||
}
|
||||
return { west, east, south, north };
|
||||
}
|
||||
|
||||
export function boxesOverlap(a, b, padDeg = 0) {
|
||||
if (!a || !b) return false;
|
||||
return a.west - padDeg <= b.east + padDeg
|
||||
&& a.east + padDeg >= b.west - padDeg
|
||||
&& a.south - padDeg <= b.north + padDeg
|
||||
&& a.north + padDeg >= b.south - padDeg;
|
||||
}
|
||||
|
||||
export function pointInBox(lat, lon, box, padDeg = 0.02) {
|
||||
if (!box || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
return lon >= box.west - padDeg
|
||||
&& lon <= box.east + padDeg
|
||||
&& lat >= box.south - padDeg
|
||||
&& lat <= box.north + padDeg;
|
||||
}
|
||||
|
||||
function normalizeCamera(raw = {}, beatKind = 'hold') {
|
||||
const inferred = beatKind === 'transit' ? 'routeDolly' : beatKind === 'establish' ? 'flyTo' : 'lookAt';
|
||||
const mode = CAMERA_MODE_SET.has(raw.mode) ? raw.mode : inferred;
|
||||
return {
|
||||
mode,
|
||||
lat: Number.isFinite(Number(raw.lat)) ? Number(raw.lat) : undefined,
|
||||
lon: Number.isFinite(Number(raw.lon)) ? Number(raw.lon) : undefined,
|
||||
alt: Number.isFinite(Number(raw.alt)) ? Number(raw.alt) : undefined,
|
||||
heading: asNumber(raw.heading, 0),
|
||||
pitch: asNumber(raw.pitch, -28),
|
||||
roll: asNumber(raw.roll, 0),
|
||||
rangeM: Number.isFinite(Number(raw.rangeM)) ? Number(raw.rangeM) : undefined,
|
||||
durationSec: Number.isFinite(Number(raw.durationSec)) ? Number(raw.durationSec) : undefined,
|
||||
approachSec: Number.isFinite(Number(raw.approachSec)) ? Number(raw.approachSec) : undefined,
|
||||
spaceAlt: Number.isFinite(Number(raw.spaceAlt)) ? Number(raw.spaceAlt) : undefined,
|
||||
compressToSec: Number.isFinite(Number(raw.compressToSec)) ? Number(raw.compressToSec) : undefined,
|
||||
buildingHeight: Number.isFinite(Number(raw.buildingHeight)) ? Number(raw.buildingHeight) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTravel(raw = {}) {
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const mode = TRAVEL_MODE_SET.has(raw.mode) ? raw.mode : 'walk';
|
||||
const polyline = Array.isArray(raw.polyline)
|
||||
? raw.polyline
|
||||
.filter((p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon))
|
||||
.map((p) => ({ lat: Number(p.lat), lon: Number(p.lon), height: Number.isFinite(p.height) ? Number(p.height) : 0 }))
|
||||
: [];
|
||||
return {
|
||||
mode,
|
||||
fromPlace: asString(raw.fromPlace) || undefined,
|
||||
toPlace: asString(raw.toPlace) || undefined,
|
||||
durationRealSec: asNumber(raw.durationRealSec, 0),
|
||||
durationPlaySec: asNumber(raw.durationPlaySec, 5),
|
||||
polyline,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBeat(raw = {}, index = 0) {
|
||||
const kind = BEAT_KIND_SET.has(raw.kind) ? raw.kind : 'hold';
|
||||
const variations = Array.isArray(raw.scriptVariations)
|
||||
? raw.scriptVariations.map((line) => asString(line)).filter(Boolean)
|
||||
: [];
|
||||
const place = raw.place && typeof raw.place === 'object'
|
||||
? {
|
||||
name: asString(raw.place.name),
|
||||
lat: asNumber(raw.place.lat),
|
||||
lon: asNumber(raw.place.lon),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id: asString(raw.id, `beat-${index + 1}`),
|
||||
title: asString(raw.title, `Beat ${index + 1}`),
|
||||
kind,
|
||||
script: asString(raw.script),
|
||||
scriptVariations: variations,
|
||||
durationSec: Math.max(1, asNumber(raw.durationSec, kind === 'transit' ? 5 : 14)),
|
||||
place,
|
||||
travel: kind === 'transit' ? normalizeTravel(raw.travel) : normalizeTravel(raw.travel) || undefined,
|
||||
camera: normalizeCamera(raw.camera, kind),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTour(raw = {}, { source = 'authored' } = {}) {
|
||||
const beats = Array.isArray(raw.beats) ? raw.beats.map((beat, i) => normalizeBeat(beat, i)) : [];
|
||||
const id = slugifyTourId(raw.id || raw.city || raw.title || 'tour');
|
||||
const bounds = raw.bounds && typeof raw.bounds === 'object'
|
||||
? {
|
||||
west: asNumber(raw.bounds.west),
|
||||
east: asNumber(raw.bounds.east),
|
||||
south: asNumber(raw.bounds.south),
|
||||
north: asNumber(raw.bounds.north),
|
||||
}
|
||||
: tourBounds({ beats });
|
||||
return {
|
||||
schemaVersion: TOUR_SCHEMA_VERSION,
|
||||
id,
|
||||
title: asString(raw.title, id),
|
||||
city: asString(raw.city, raw.title || id),
|
||||
cityId: asString(raw.cityId, id),
|
||||
durationTargetSec: asNumber(raw.durationTargetSec, beats.reduce((sum, beat) => sum + beat.durationSec, 0)),
|
||||
source,
|
||||
beats,
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeTour(tour) {
|
||||
if (!tour) return null;
|
||||
return {
|
||||
id: tour.id,
|
||||
title: tour.title,
|
||||
city: tour.city,
|
||||
cityId: tour.cityId,
|
||||
beatCount: tour.beats?.length || 0,
|
||||
durationTargetSec: tour.durationTargetSec,
|
||||
source: tour.source || 'authored',
|
||||
bounds: tour.bounds || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchTourQuery(tours, query) {
|
||||
const list = Array.isArray(tours) ? tours : [];
|
||||
const raw = asString(query).toLowerCase();
|
||||
if (!raw) return null;
|
||||
const slug = slugifyTourId(raw);
|
||||
const exact = list.find((tour) => tour.id === slug || tour.cityId === slug || String(tour.city || '').toLowerCase() === raw);
|
||||
if (exact) return exact;
|
||||
const token = (hay, needle) => {
|
||||
if (!needle) return false;
|
||||
if (needle.length < 4) return hay === needle || hay.split(/\s+/).includes(needle);
|
||||
return hay.includes(needle);
|
||||
};
|
||||
return list.find((tour) => {
|
||||
const city = String(tour.city || '').toLowerCase();
|
||||
const title = String(tour.title || '').toLowerCase();
|
||||
const id = String(tour.id || '').toLowerCase();
|
||||
return token(raw, city)
|
||||
|| token(raw, id)
|
||||
|| token(title, raw)
|
||||
|| slugifyTourId(tour.title) === slug;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
export function pickScript(beat, used = new Set()) {
|
||||
const variants = [beat?.script, ...(beat?.scriptVariations || [])].map((line) => asString(line)).filter(Boolean);
|
||||
if (!variants.length) return '';
|
||||
const unused = variants.filter((line) => !used.has(line));
|
||||
const pick = (unused.length ? unused : variants)[0];
|
||||
used.add(pick);
|
||||
return pick;
|
||||
}
|
||||
|
||||
export function transitConnectiveTemplates(mode) {
|
||||
const m = TRAVEL_MODE_SET.has(mode) ? mode : 'walk';
|
||||
if (m === 'transit') {
|
||||
return [
|
||||
'After a short metro hop across town, you come to',
|
||||
'A few stops on the rails and the next landmark is',
|
||||
'We take the train rather than crawl the surface streets, arriving at',
|
||||
];
|
||||
}
|
||||
if (m === 'drive') {
|
||||
return [
|
||||
'A straight drive from here brings you to',
|
||||
'Following the fastest street route, you roll up on',
|
||||
'We stay on the road for this one and pull in at',
|
||||
];
|
||||
}
|
||||
if (m === 'bike') {
|
||||
return [
|
||||
'A quick ride along the lanes lands you at',
|
||||
'On two wheels the next stop is close:',
|
||||
];
|
||||
}
|
||||
if (m === 'flight') {
|
||||
return [
|
||||
'This hop is too far for streets, so we lift over the city to',
|
||||
'A short aerial jump clears the distance to',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'A few minutes on foot and you reach',
|
||||
'We stay on the pavement for this stretch, walking to',
|
||||
'No ride here — a short walk brings you to',
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import rome from './data/rome.json' with { type: 'json' };
|
||||
import paris from './data/paris.json' with { type: 'json' };
|
||||
import tokyo from './data/tokyo.json' with { type: 'json' };
|
||||
import {
|
||||
boxesOverlap,
|
||||
matchTourQuery,
|
||||
normalizeTour,
|
||||
pickScript,
|
||||
pointInBox,
|
||||
slugifyTourId,
|
||||
tourBounds,
|
||||
transitConnectiveTemplates,
|
||||
} from './tourSchema.js';
|
||||
|
||||
test('authored tours normalize with unique transit scripts', () => {
|
||||
const tours = [rome, paris, tokyo].map((raw) => normalizeTour(raw));
|
||||
for (const tour of tours) {
|
||||
assert.ok(tour.beats.length >= 7, tour.id);
|
||||
assert.ok(tour.beats.some((beat) => beat.kind === 'establish'));
|
||||
assert.ok(tour.beats.some((beat) => beat.kind === 'hold'));
|
||||
assert.ok(tour.beats.some((beat) => beat.kind === 'transit'));
|
||||
const used = new Set();
|
||||
const transitLines = tour.beats.filter((beat) => beat.kind === 'transit').map((beat) => pickScript(beat, used));
|
||||
assert.equal(new Set(transitLines).size, transitLines.length, `${tour.id} transit lines must not repeat`);
|
||||
}
|
||||
assert.equal(matchTourQuery(tours, 'show me a tour of Rome')?.id, 'rome');
|
||||
assert.equal(matchTourQuery(tours, 'tokyo')?.cityId, 'tokyo');
|
||||
});
|
||||
|
||||
test('slugify and bbox overlap treat nested places as geographic', () => {
|
||||
assert.equal(slugifyTourId('Commercial Drive'), 'commercial-drive');
|
||||
const vancouver = { west: -123.27, east: -123.02, south: 49.2, north: 49.32 };
|
||||
const drive = { west: -123.08, east: -123.05, south: 49.27, north: 49.29 };
|
||||
const stanley = { west: -123.16, east: -123.12, south: 49.29, north: 49.32 };
|
||||
assert.equal(boxesOverlap(vancouver, drive), true);
|
||||
assert.equal(pointInBox(49.275, -123.069, drive), true);
|
||||
assert.equal(pointInBox(49.3, -123.14, drive), false);
|
||||
assert.ok(tourBounds({
|
||||
beats: [{ place: { lat: 49.28, lon: -123.07 } }, { place: { lat: 49.26, lon: -123.06 } }],
|
||||
}));
|
||||
assert.ok(transitConnectiveTemplates('transit').length >= 3);
|
||||
});
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
/**
|
||||
* Tour visual readiness: tile settle, mesh coverage probe, overlay preload, prefetch.
|
||||
* @module tours/tourTiles
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { cachedGroundFloor, warmGroundFloor, corridorFloorCells } from '../data/groundFloor.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
const PLAYBACK_HOLD = 'tour-playback';
|
||||
const SETTLE_HOLD = 'tour-tiles-settle';
|
||||
const PREFETCH_HOLD = 'tour-tiles-prefetch';
|
||||
|
||||
const MESH_SPARSE_DELTA_M = 7;
|
||||
const DEFAULT_SPACE_ALT_M = 3.2e7;
|
||||
const DEFAULT_ESTABLISH_ALT_M = 10000;
|
||||
const DEFAULT_APPROACH_SEC = 12;
|
||||
|
||||
/**
|
||||
* @param {import('cesium').Viewer} viewer
|
||||
* @returns {import('cesium').Cesium3DTileset | null}
|
||||
*/
|
||||
export function resolveTourTileset(viewer) {
|
||||
try {
|
||||
const fromWindow = typeof window !== 'undefined'
|
||||
? (window.__godsEyeView?.tileset || window.__godsEyeView?.mapStackController?.googleTileset)
|
||||
: null;
|
||||
if (fromWindow?.tilesLoaded !== undefined) return fromWindow;
|
||||
} catch { /* ignore */ }
|
||||
const primitives = viewer?.scene?.primitives;
|
||||
if (!primitives?.length) return null;
|
||||
for (let i = 0; i < primitives.length; i += 1) {
|
||||
const prim = primitives.get(i);
|
||||
if (prim && prim.tilesLoaded !== undefined && prim.show !== false) return prim;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function holdTourPlaybackRender() {
|
||||
holdContinuousRender(PLAYBACK_HOLD);
|
||||
}
|
||||
|
||||
export function releaseTourPlaybackRender() {
|
||||
releaseContinuousRender(PLAYBACK_HOLD);
|
||||
}
|
||||
|
||||
export function getSpaceApproachParams(cam = {}) {
|
||||
return {
|
||||
spaceAlt: Number.isFinite(cam.spaceAlt) ? cam.spaceAlt : DEFAULT_SPACE_ALT_M,
|
||||
establishAlt: Number.isFinite(cam.alt) ? cam.alt : DEFAULT_ESTABLISH_ALT_M,
|
||||
approachSec: Number.isFinite(cam.approachSec)
|
||||
? cam.approachSec
|
||||
: (Number.isFinite(cam.durationSec) && cam.durationSec >= 8 ? cam.durationSec : DEFAULT_APPROACH_SEC),
|
||||
heading: Number.isFinite(cam.heading) ? cam.heading : 20,
|
||||
pitch: Number.isFinite(cam.pitch) ? cam.pitch : -42,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* After the camera arrives, wait until photoreal tiles for the *current* view
|
||||
* report loaded and stay quiet for stableMs.
|
||||
*/
|
||||
export async function waitForTourVisuals(viewer, {
|
||||
timeoutMs = 14000,
|
||||
stableMs = 500,
|
||||
minWaitMs = 200,
|
||||
isCancelled = () => false,
|
||||
onSlow,
|
||||
} = {}) {
|
||||
const started = Date.now();
|
||||
let slowNotified = false;
|
||||
holdContinuousRender(SETTLE_HOLD);
|
||||
try {
|
||||
if (minWaitMs > 0) await sleep(minWaitMs);
|
||||
const tileset = resolveTourTileset(viewer);
|
||||
if (!tileset) {
|
||||
await sleep(350);
|
||||
return { ok: true, skipped: true, waitedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
let stableSince = null;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (isCancelled()) return { ok: false, cancelled: true, waitedMs: Date.now() - started };
|
||||
if (!slowNotified && Date.now() - started > 1500) {
|
||||
slowNotified = true;
|
||||
try { onSlow?.('Loading the view…'); } catch { /* ignore */ }
|
||||
}
|
||||
try { governorRequestRender('tour-tiles-settle'); } catch { /* optional */ }
|
||||
|
||||
const loaded = tileset.tilesLoaded === true;
|
||||
const pending = readPendingRequests(tileset);
|
||||
const quiet = loaded && (pending == null || pending === 0);
|
||||
|
||||
if (quiet) {
|
||||
if (stableSince == null) stableSince = Date.now();
|
||||
if (Date.now() - stableSince >= stableMs) {
|
||||
return { ok: true, waitedMs: Date.now() - started };
|
||||
}
|
||||
} else {
|
||||
stableSince = null;
|
||||
}
|
||||
await sleep(120);
|
||||
}
|
||||
return { ok: true, timedOut: true, waitedMs: Date.now() - started };
|
||||
} finally {
|
||||
releaseContinuousRender(SETTLE_HOLD);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft gate for app restore + enabled data layers so establish doesn't advance
|
||||
* while share/layer restore is still settling.
|
||||
*/
|
||||
export async function waitForTourAppReady({
|
||||
styleManager = null,
|
||||
timeoutMs = 5500,
|
||||
isCancelled = () => false,
|
||||
onStatus,
|
||||
} = {}) {
|
||||
const started = Date.now();
|
||||
const gev = typeof window !== 'undefined' ? window.__godsEyeView : null;
|
||||
const sm = styleManager || gev?.styleManager || null;
|
||||
const dataManager = sm?._dataManager || gev?.dataManager || null;
|
||||
|
||||
try { onStatus?.('Preparing layers…'); } catch { /* ignore */ }
|
||||
|
||||
const tasks = [];
|
||||
if (sm?.initialRestorePromise && typeof sm.initialRestorePromise.then === 'function') {
|
||||
tasks.push(Promise.race([
|
||||
sm.initialRestorePromise.catch(() => null),
|
||||
sleep(timeoutMs),
|
||||
]));
|
||||
}
|
||||
|
||||
if (dataManager && typeof dataManager.getEnabledLayerIds === 'function'
|
||||
&& typeof dataManager.waitForLayerSettled === 'function') {
|
||||
let enabled = [];
|
||||
try { enabled = dataManager.getEnabledLayerIds() || []; } catch { enabled = []; }
|
||||
// Cap how many layers we wait on — tour shouldn't block on every data feed.
|
||||
const waitIds = enabled.filter(Boolean).slice(0, 6);
|
||||
for (const layerId of waitIds) {
|
||||
tasks.push(Promise.race([
|
||||
dataManager.waitForLayerSettled(layerId).catch(() => null),
|
||||
sleep(Math.min(4000, timeoutMs)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if (!tasks.length) {
|
||||
await sleep(200);
|
||||
return { ok: true, skipped: true, waitedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
Promise.allSettled(tasks),
|
||||
sleep(timeoutMs),
|
||||
waitUntil(() => isCancelled(), timeoutMs),
|
||||
]);
|
||||
|
||||
if (isCancelled()) return { ok: false, cancelled: true, waitedMs: Date.now() - started };
|
||||
const timedOut = Date.now() - started >= timeoutMs - 50;
|
||||
if (timedOut) {
|
||||
try { onStatus?.('Still loading some layers…'); } catch { /* ignore */ }
|
||||
}
|
||||
return { ok: true, timedOut, waitedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether photoreal mesh at a hold is useful vs missing/sparse DEM skin.
|
||||
*/
|
||||
export async function probeTourMeshCoverage(viewer, { lat, lon } = {}) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !viewer?.scene) {
|
||||
return { quality: 'missing', reason: 'no_coords' };
|
||||
}
|
||||
try { warmGroundFloor([{ lat, lon }]); } catch { /* optional */ }
|
||||
await sleep(120);
|
||||
|
||||
const demG = readDem(lat, lon);
|
||||
const offsets = [
|
||||
[0, 0],
|
||||
[0.0007, 0],
|
||||
[-0.0007, 0],
|
||||
[0, 0.0007],
|
||||
[0, -0.0007],
|
||||
];
|
||||
const samples = [];
|
||||
for (const [dLat, dLon] of offsets) {
|
||||
const h = await sampleMeshHeight(viewer, lat + dLat, lon + dLon);
|
||||
if (Number.isFinite(h)) samples.push(h);
|
||||
}
|
||||
|
||||
if (!samples.length) {
|
||||
// One retry after a short stream window.
|
||||
await sleep(700);
|
||||
try { governorRequestRender('tour-mesh-probe'); } catch { /* ignore */ }
|
||||
for (const [dLat, dLon] of offsets.slice(0, 3)) {
|
||||
const h = await sampleMeshHeight(viewer, lat + dLat, lon + dLon);
|
||||
if (Number.isFinite(h)) samples.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
if (!samples.length) {
|
||||
return { quality: 'missing', demG, meshH: null, reason: 'no_mesh_sample' };
|
||||
}
|
||||
|
||||
const meshH = median(samples);
|
||||
if (!Number.isFinite(demG)) {
|
||||
return { quality: 'useful', demG, meshH, reason: 'mesh_without_dem' };
|
||||
}
|
||||
const delta = Math.abs(meshH - demG);
|
||||
if (delta < MESH_SPARSE_DELTA_M) {
|
||||
return { quality: 'sparse', demG, meshH, delta, reason: 'flat_vs_dem' };
|
||||
}
|
||||
return { quality: 'useful', demG, meshH, delta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch routes, annotations, and DEM corridor for a tour (mutates travel.polyline in memory).
|
||||
*/
|
||||
export async function preloadTourOverlays({
|
||||
tour,
|
||||
annotations = null,
|
||||
buildAnnotationRequests = null,
|
||||
fetchTourRoute = null,
|
||||
isCancelled = () => false,
|
||||
} = {}) {
|
||||
if (!tour?.beats?.length) return { ok: false, error: 'no_tour' };
|
||||
const result = { ok: true, routes: 0, annotations: false, dem: 0 };
|
||||
|
||||
// 1) Transit polylines
|
||||
if (typeof fetchTourRoute === 'function') {
|
||||
for (let i = 0; i < tour.beats.length; i += 1) {
|
||||
if (isCancelled()) return { ...result, ok: false, cancelled: true };
|
||||
const beat = tour.beats[i];
|
||||
const needsRoute = beat.kind === 'transit'
|
||||
|| beat.camera?.mode === 'routeDolly'
|
||||
|| beat.travel;
|
||||
if (!needsRoute) continue;
|
||||
if (Array.isArray(beat.travel?.polyline) && beat.travel.polyline.length >= 2) continue;
|
||||
const prev = tour.beats.slice(0, i).reverse().find((b) => Number.isFinite(b?.place?.lat));
|
||||
const from = prev?.place || (Number.isFinite(prev?.camera?.lat)
|
||||
? { lat: prev.camera.lat, lon: prev.camera.lon }
|
||||
: null);
|
||||
const to = beat.place || (Number.isFinite(beat.camera?.lat)
|
||||
? { lat: beat.camera.lat, lon: beat.camera.lon }
|
||||
: null);
|
||||
if (!from || !to) continue;
|
||||
try {
|
||||
const routed = await fetchTourRoute(from, to);
|
||||
if (routed?.ok && Array.isArray(routed.polyline) && routed.polyline.length >= 2) {
|
||||
if (!beat.travel) beat.travel = {};
|
||||
beat.travel.polyline = routed.polyline;
|
||||
if (routed.mode) beat.travel.mode = routed.mode;
|
||||
result.routes += 1;
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Establish annotations (warm resolver + draw early if engine provided)
|
||||
if (annotations?.annotate && typeof buildAnnotationRequests === 'function') {
|
||||
const establish = tour.beats.find((b) => b.kind === 'establish') || tour.beats[0];
|
||||
const authored = Array.isArray(establish?.annotations) ? establish.annotations : null;
|
||||
const requests = authored?.length ? authored : buildAnnotationRequests(tour, establish);
|
||||
if (requests?.length) {
|
||||
try {
|
||||
await annotations.annotate(requests, {
|
||||
persist: true,
|
||||
flyTo: false,
|
||||
clearPrevious: true,
|
||||
});
|
||||
result.annotations = true;
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 3) DEM corridor warm
|
||||
const places = collectUpcomingTourPlaces(tour, 0, { count: 8, routeSamples: 8 });
|
||||
try {
|
||||
warmGroundFloor(places);
|
||||
result.dem = places.length;
|
||||
const polys = tour.beats
|
||||
.map((b) => b.travel?.polyline)
|
||||
.filter((p) => Array.isArray(p) && p.length >= 2);
|
||||
for (const poly of polys.slice(0, 4)) {
|
||||
try {
|
||||
const cells = corridorFloorCells(poly);
|
||||
if (cells?.length) warmGroundFloor(cells);
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
} catch { /* optional */ }
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prioritize streaming for upcoming tour places while they are in frustum.
|
||||
*/
|
||||
export async function prefetchTourPlaces(viewer, places, {
|
||||
timeoutMs = 9000,
|
||||
isCancelled = () => false,
|
||||
} = {}) {
|
||||
const list = normalizePlaces(places);
|
||||
if (!list.length) return { ok: true, count: 0 };
|
||||
try { warmGroundFloor(list); } catch { /* optional */ }
|
||||
|
||||
const scene = viewer?.scene;
|
||||
if (!scene || typeof scene.sampleHeightMostDetailed !== 'function') {
|
||||
return { ok: true, count: list.length, demOnly: true };
|
||||
}
|
||||
|
||||
holdContinuousRender(PREFETCH_HOLD);
|
||||
try {
|
||||
if (isCancelled()) return { ok: false, cancelled: true };
|
||||
const cartos = list.map((p) => Cesium.Cartographic.fromDegrees(p.lon, p.lat));
|
||||
try { governorRequestRender('tour-tiles-prefetch'); } catch { /* optional */ }
|
||||
await Promise.race([
|
||||
scene.sampleHeightMostDetailed(cartos).catch(() => null),
|
||||
sleep(timeoutMs),
|
||||
waitUntil(() => isCancelled(), timeoutMs),
|
||||
]);
|
||||
return { ok: !isCancelled(), count: list.length };
|
||||
} finally {
|
||||
releaseContinuousRender(PREFETCH_HOLD);
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect lat/lon targets from upcoming beats (places + camera + route samples). */
|
||||
export function collectUpcomingTourPlaces(tour, fromIndex, { count = 4, routeSamples = 4 } = {}) {
|
||||
const beats = tour?.beats || [];
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const push = (lat, lon) => {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||
const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ lat, lon });
|
||||
};
|
||||
|
||||
for (let i = fromIndex; i < beats.length && out.length < count * 3; i += 1) {
|
||||
const beat = beats[i];
|
||||
push(beat?.place?.lat, beat?.place?.lon);
|
||||
push(beat?.camera?.lat, beat?.camera?.lon);
|
||||
const poly = beat?.travel?.polyline;
|
||||
if (Array.isArray(poly) && poly.length >= 2 && routeSamples > 0) {
|
||||
const step = Math.max(1, Math.floor((poly.length - 1) / routeSamples));
|
||||
for (let p = 0; p < poly.length; p += step) {
|
||||
push(poly[p]?.lat, poly[p]?.lon);
|
||||
}
|
||||
const last = poly[poly.length - 1];
|
||||
push(last?.lat, last?.lon);
|
||||
}
|
||||
}
|
||||
return out.slice(0, Math.max(count, routeSamples + count));
|
||||
}
|
||||
|
||||
export function isCloseHoldBeat(beat) {
|
||||
if (!beat) return false;
|
||||
if (beat.kind === 'establish') return false;
|
||||
const mode = beat.camera?.mode || (beat.kind === 'transit' ? 'routeDolly' : 'lookAt');
|
||||
if (mode === 'flyTo') {
|
||||
const alt = beat.camera?.alt;
|
||||
return Number.isFinite(alt) ? alt < 4000 : false;
|
||||
}
|
||||
// Gallery playback jumps to the place (no route dolly), so transit needs the
|
||||
// same mesh probe as holds before a close cinematic shot starts.
|
||||
if (mode === 'routeDolly') return beat.kind === 'transit' || beat.kind === 'hold';
|
||||
return mode === 'lookAt' || mode === 'orbitHold' || beat.kind === 'hold' || beat.kind === 'transit';
|
||||
}
|
||||
|
||||
function readDem(lat, lon) {
|
||||
try {
|
||||
const v = cachedGroundFloor?.(lat, lon);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sampleMeshHeight(viewer, lat, lon) {
|
||||
const scene = viewer?.scene;
|
||||
if (!scene) return null;
|
||||
try {
|
||||
if (typeof scene.sampleHeightMostDetailed === 'function') {
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
const result = await Promise.race([
|
||||
scene.sampleHeightMostDetailed([carto]),
|
||||
sleep(1500).then(() => null),
|
||||
]);
|
||||
const height = Array.isArray(result) ? result[0]?.height : carto.height;
|
||||
return Number.isFinite(height) ? height : null;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
if (typeof scene.sampleHeight === 'function') {
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
const height = scene.sampleHeight(carto);
|
||||
return Number.isFinite(height) ? height : null;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPendingRequests(tileset) {
|
||||
try {
|
||||
const stats = tileset.statistics;
|
||||
if (stats && Number.isFinite(stats.numberOfPendingRequests)) {
|
||||
return stats.numberOfPendingRequests;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePlaces(places) {
|
||||
if (!Array.isArray(places)) return [];
|
||||
return places.filter((p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon));
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitUntil(predicate, timeoutMs) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Dev/preview proxy for guided tours: catalog, individual fetch, and route helpers.
|
||||
* Authored tours ship from `src/tours/data`; optional on-disk JSON under `.gev-tours/generated`
|
||||
* is listed for future-compat (no generate or memory endpoints in this build).
|
||||
* @module tours/toursProxy
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { matchTourQuery, normalizeTour, summarizeTour } from './tourSchema.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const AUTHORED_DIR = path.join(__dirname, 'data');
|
||||
|
||||
function resolveToursRoot() {
|
||||
const candidates = [
|
||||
process.env.GEV_TOURS_DIR,
|
||||
path.join(process.cwd(), '.gev-tours'),
|
||||
].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
fs.mkdirSync(candidate, { recursive: true });
|
||||
return candidate;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return path.join(process.cwd(), '.gev-tours');
|
||||
}
|
||||
|
||||
const TOURS_ROOT = resolveToursRoot();
|
||||
const GENERATED_DIR = path.join(TOURS_ROOT, 'generated');
|
||||
|
||||
const FLIGHT_MIN_KM = 8;
|
||||
const FLIGHT_MIN_SEC = 25 * 60;
|
||||
|
||||
function json(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function decodePolyline(encoded) {
|
||||
if (!encoded) return [];
|
||||
const pts = [];
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lon = 0;
|
||||
while (index < encoded.length) {
|
||||
let shift = 0;
|
||||
let result = 0;
|
||||
let byte;
|
||||
do {
|
||||
byte = encoded.charCodeAt(index) - 63;
|
||||
index += 1;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
lat += (result & 1) ? ~(result >> 1) : (result >> 1);
|
||||
shift = 0;
|
||||
result = 0;
|
||||
do {
|
||||
byte = encoded.charCodeAt(index) - 63;
|
||||
index += 1;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
lon += (result & 1) ? ~(result >> 1) : (result >> 1);
|
||||
pts.push({ lat: lat / 1e5, lon: lon / 1e5, height: 0 });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
async function loadAuthoredTours() {
|
||||
const names = await fsp.readdir(AUTHORED_DIR).catch(() => []);
|
||||
const tours = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
try {
|
||||
const raw = JSON.parse(await fsp.readFile(path.join(AUTHORED_DIR, name), 'utf8'));
|
||||
tours.push(normalizeTour(raw, { source: 'authored' }));
|
||||
} catch (error) {
|
||||
console.warn('[tours] authored load failed', name, error?.message || error);
|
||||
}
|
||||
}
|
||||
return tours;
|
||||
}
|
||||
|
||||
async function loadGeneratedTours() {
|
||||
const names = await fsp.readdir(GENERATED_DIR).catch(() => []);
|
||||
const tours = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
try {
|
||||
const raw = JSON.parse(await fsp.readFile(path.join(GENERATED_DIR, name), 'utf8'));
|
||||
tours.push(normalizeTour(raw, { source: 'generated' }));
|
||||
} catch (error) {
|
||||
console.warn('[tours] generated load failed', name, error?.message || error);
|
||||
}
|
||||
}
|
||||
return tours;
|
||||
}
|
||||
|
||||
async function loadAllTours() {
|
||||
const [authored, generated] = await Promise.all([loadAuthoredTours(), loadGeneratedTours()]);
|
||||
const byId = new Map();
|
||||
for (const tour of [...authored, ...generated]) byId.set(tour.id, tour);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
async function fetchOsrm(from, to, profile) {
|
||||
const osrmProfile = profile === 'car' ? 'driving' : profile;
|
||||
const routed = profile === 'car' ? 'car' : profile;
|
||||
const coords = `${from.lon},${from.lat};${to.lon},${to.lat}`;
|
||||
const url = `https://routing.openstreetmap.de/routed-${routed}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'gods-eye-view/tours' } });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const route = data?.routes?.[0];
|
||||
const coordsOut = route?.geometry?.coordinates;
|
||||
if (data?.code !== 'Ok' || !Array.isArray(coordsOut) || !coordsOut.length) return null;
|
||||
return {
|
||||
mode: profile === 'car' ? 'drive' : profile === 'bike' ? 'bike' : 'walk',
|
||||
durationRealSec: Number(route.duration) || 0,
|
||||
distanceM: Number(route.distance) || 0,
|
||||
polyline: coordsOut.map(([lon, lat]) => ({ lat, lon, height: 0 })),
|
||||
source: 'osrm',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGoogleRoute(from, to, travelMode, apiKey) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const res = await fetch('https://routes.googleapis.com/directions/v2:computeRoutes', {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
origin: { location: { latLng: { latitude: from.lat, longitude: from.lon } } },
|
||||
destination: { location: { latLng: { latitude: to.lat, longitude: to.lon } } },
|
||||
travelMode,
|
||||
polylineQuality: 'OVERVIEW',
|
||||
computeAlternativeRoutes: false,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const route = data?.routes?.[0];
|
||||
if (!route?.polyline?.encodedPolyline) return null;
|
||||
const durationRealSec = Number(String(route.duration || '').replace('s', '')) || 0;
|
||||
const mode = travelMode === 'TRANSIT' ? 'transit'
|
||||
: travelMode === 'DRIVE' ? 'drive'
|
||||
: travelMode === 'BICYCLE' ? 'bike'
|
||||
: 'walk';
|
||||
return {
|
||||
mode,
|
||||
durationRealSec,
|
||||
distanceM: Number(route.distanceMeters) || 0,
|
||||
polyline: decodePolyline(route.polyline.encodedPolyline),
|
||||
source: 'google-routes',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function bestRoute(from, to) {
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
if (km > FLIGHT_MIN_KM) {
|
||||
const googleKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
const candidates = [];
|
||||
if (googleKey) {
|
||||
for (const mode of ['TRANSIT', 'DRIVE', 'WALK']) {
|
||||
const got = await fetchGoogleRoute(from, to, mode, googleKey);
|
||||
if (got) candidates.push(got);
|
||||
}
|
||||
}
|
||||
if (!candidates.length) {
|
||||
for (const profile of ['car', 'foot']) {
|
||||
const got = await fetchOsrm(from, to, profile);
|
||||
if (got) candidates.push(got);
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => a.durationRealSec - b.durationRealSec);
|
||||
const best = candidates[0];
|
||||
if (best && best.durationRealSec <= FLIGHT_MIN_SEC && km <= 80) return best;
|
||||
return {
|
||||
mode: 'flight',
|
||||
durationRealSec: Math.round((km / 700) * 3600),
|
||||
distanceM: km * 1000,
|
||||
polyline: [from, to],
|
||||
source: 'flight-hop',
|
||||
};
|
||||
}
|
||||
const googleKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
const candidates = [];
|
||||
if (googleKey) {
|
||||
for (const mode of ['WALK', 'TRANSIT', 'DRIVE', 'BICYCLE']) {
|
||||
const got = await fetchGoogleRoute(from, to, mode, googleKey);
|
||||
if (got) candidates.push(got);
|
||||
}
|
||||
}
|
||||
if (!candidates.length) {
|
||||
for (const profile of ['foot', 'bike', 'car']) {
|
||||
const got = await fetchOsrm(from, to, profile);
|
||||
if (got) candidates.push(got);
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => a.durationRealSec - b.durationRealSec);
|
||||
return candidates[0] || {
|
||||
mode: 'walk',
|
||||
durationRealSec: 0,
|
||||
distanceM: km * 1000,
|
||||
polyline: [from, to],
|
||||
source: 'straight',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin: serve /api/tours catalog, tour-by-id, and route helpers in dev/preview.
|
||||
* @returns {{ name: string, configureServer: Function, configurePreviewServer: Function }}
|
||||
*/
|
||||
export function toursProxy() {
|
||||
const install = (middlewares) => {
|
||||
middlewares.use('/api/tours', async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
let pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
if (pathname.startsWith('/api/tours')) pathname = pathname.slice('/api/tours'.length) || '/';
|
||||
if (!pathname.startsWith('/')) pathname = `/${pathname}`;
|
||||
|
||||
if (req.method === 'GET' && (pathname === '/' || pathname === '')) {
|
||||
const tours = await loadAllTours();
|
||||
return json(res, 200, { ok: true, tours: tours.map(summarizeTour) });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/route') {
|
||||
const from = { lat: Number(url.searchParams.get('fromLat')), lon: Number(url.searchParams.get('fromLon')) };
|
||||
const to = { lat: Number(url.searchParams.get('toLat')), lon: Number(url.searchParams.get('toLon')) };
|
||||
if (![from.lat, from.lon, to.lat, to.lon].every(Number.isFinite)) {
|
||||
return json(res, 200, { ok: false, error: 'need from/to coordinates' });
|
||||
}
|
||||
const routed = await bestRoute(from, to);
|
||||
return json(res, 200, { ok: true, ...routed });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname.length > 1) {
|
||||
const id = decodeURIComponent(pathname.slice(1));
|
||||
const tours = await loadAllTours();
|
||||
const tour = tours.find((item) => item.id === id) || matchTourQuery(tours, id);
|
||||
if (!tour) return json(res, 404, { ok: false, error: `No tour "${id}"` });
|
||||
return json(res, 200, { ok: true, tour });
|
||||
}
|
||||
|
||||
return json(res, 404, { ok: false, error: 'not found' });
|
||||
} catch (error) {
|
||||
console.error('[tours]', error?.message || error);
|
||||
return json(res, 200, { ok: false, error: error?.message || 'tours proxy error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'gev-tours-proxy',
|
||||
configureServer(server) { install(server.middlewares); },
|
||||
configurePreviewServer(server) { install(server.middlewares); },
|
||||
};
|
||||
}
|
||||
|
|
@ -287,7 +287,7 @@ export function readLayerLifecycleSummary(dataManager, layerId, { fallbackEnable
|
|||
};
|
||||
}
|
||||
|
||||
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null }) {
|
||||
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, tourEngine = null }) {
|
||||
installViewTargetPrewarm(viewer);
|
||||
initCameraVerbs(viewer, getViewTargetCartesian);
|
||||
return async function runGevAction(name, rawArgs = {}, runOptions = {}) {
|
||||
|
|
@ -818,6 +818,9 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||
}
|
||||
|
||||
if (name === 'move_camera') {
|
||||
if (String(args.motion || '').toLowerCase() === 'stop' && tourEngine?.running) {
|
||||
return tourEngine.stop('Stopped by voice');
|
||||
}
|
||||
return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation(
|
||||
styleManager, 'camera', 'move_camera', navigate, releaseOptions,
|
||||
));
|
||||
|
|
@ -838,7 +841,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||
}
|
||||
|
||||
if (name === 'get_current_view_state') {
|
||||
return getCurrentViewState(viewer, styleManager, dataManager, sceneDirector);
|
||||
return getCurrentViewState(viewer, styleManager, dataManager, sceneDirector, tourEngine);
|
||||
}
|
||||
|
||||
if (name === 'set_hud') {
|
||||
|
|
@ -894,6 +897,10 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||
return controlScene(sceneDirector, args);
|
||||
}
|
||||
|
||||
if (name === 'control_tour') {
|
||||
return controlTour(tourEngine, args);
|
||||
}
|
||||
|
||||
if (name === 'control_cctv') {
|
||||
return controlCctv(dataManager, args, styleManager);
|
||||
}
|
||||
|
|
@ -1048,6 +1055,43 @@ export function normalizeStackId(value) {
|
|||
return STACK_ALIASES.get(raw) || null;
|
||||
}
|
||||
|
||||
export async function controlTour(tourEngine, args = {}) {
|
||||
if (!tourEngine) {
|
||||
return { ok: false, action: 'control_tour', error: 'Tour engine unavailable' };
|
||||
}
|
||||
const action = String(args.action || '').toLowerCase();
|
||||
if (action === 'list') {
|
||||
return { ok: true, action: 'control_tour', tours: await tourEngine.listTours(), ...tourEngine.getPlaybackStatus() };
|
||||
}
|
||||
if (action === 'status') {
|
||||
return { ok: true, action: 'control_tour', ...tourEngine.getPlaybackStatus() };
|
||||
}
|
||||
if (action === 'stop') {
|
||||
return tourEngine.stop('Stopped by voice');
|
||||
}
|
||||
if (action === 'pause') {
|
||||
return tourEngine.pause('Paused by voice');
|
||||
}
|
||||
if (action === 'resume') {
|
||||
return tourEngine.resume();
|
||||
}
|
||||
if (action === 'autoplay') {
|
||||
const enabled = args.enabled != null ? !!args.enabled : true;
|
||||
return tourEngine.setAutoplay(enabled);
|
||||
}
|
||||
if (action === 'next' || action === 'prev') {
|
||||
return action === 'next' ? tourEngine.next() : tourEngine.prev();
|
||||
}
|
||||
if (action === 'random') {
|
||||
return tourEngine.random();
|
||||
}
|
||||
if (action === 'play') {
|
||||
const query = args.tourId || args.city || args.query || '';
|
||||
return tourEngine.play(query);
|
||||
}
|
||||
throw new Error(`Unknown tour action: ${args.action || 'missing'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice scene playback control. Playback is fire-and-forget: startScene
|
||||
* sequences shots for minutes and must not block the realtime tool loop.
|
||||
|
|
@ -2354,7 +2398,7 @@ function normalizeLocationId(value) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = null) {
|
||||
function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = null, tourEngine = null) {
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -2378,6 +2422,7 @@ function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector =
|
|||
: null,
|
||||
controls: typeof styleManager.getControlState === 'function' ? styleManager.getControlState() : null,
|
||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||
tourPlayback: tourEngine?.getPlaybackStatus?.() || null,
|
||||
tracked: collectTrackedEntities(dataManager),
|
||||
layers: dataManager.getAll().map((layer) => ({
|
||||
id: layer.id,
|
||||
|
|
|
|||
|
|
@ -193,14 +193,14 @@ export function silenceRadioForVoice({ duckRadio, pauseRadio } = {}) {
|
|||
*/
|
||||
const SUPERSEDED_RESPONSE_MEMORY = 8;
|
||||
|
||||
export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null }) {
|
||||
export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, tourEngine = null }) {
|
||||
if (window.__gevVoiceCommands && typeof window.__gevVoiceCommands.stop === 'function') {
|
||||
window.__gevVoiceCommands.stop({ removeUi: true });
|
||||
}
|
||||
const runner = createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector, annotations });
|
||||
const runner = createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector, annotations, tourEngine });
|
||||
const ui = createVoiceControl({ reset: true });
|
||||
const radioLayer = dataManager?.layers?.get('radio')?.module || null;
|
||||
const controller = new GevRealtimeController({ runner, ui, radioLayer, dataManager });
|
||||
const controller = new GevRealtimeController({ runner, ui, radioLayer, dataManager, tourEngine });
|
||||
// Deferred annotation outlines finish AFTER their tool result returned. Feed the
|
||||
// final outcome (resolved / failed) into the conversation so the model can honestly
|
||||
// confirm — or correct — what it narrated about a boundary it never saw land.
|
||||
|
|
@ -209,6 +209,10 @@ export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneD
|
|||
controller.notifyMapEvent({ type: 'map_annotation_outline', ...evt });
|
||||
});
|
||||
}
|
||||
if (tourEngine) {
|
||||
tourEngine.speakBeat = (beat) => controller.narrateTourBeat(beat);
|
||||
tourEngine.stopTourVoice = () => controller.cancelTourNarration();
|
||||
}
|
||||
controller.buttonHandler = () => {
|
||||
if (shouldIgnoreVoiceButtonClick(controller.spaceKeyHeld)) return;
|
||||
if (controller.isActive()) controller.stop();
|
||||
|
|
@ -226,11 +230,12 @@ export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneD
|
|||
}
|
||||
|
||||
export class GevRealtimeController {
|
||||
constructor({ runner, ui, radioLayer = null, dataManager = null }) {
|
||||
constructor({ runner, ui, radioLayer = null, dataManager = null, tourEngine = null }) {
|
||||
this.runner = runner;
|
||||
this.ui = ui;
|
||||
this.radioLayer = radioLayer;
|
||||
this.dataManager = dataManager;
|
||||
this.tourEngine = tourEngine;
|
||||
this.radioVoiceDucked = false;
|
||||
this.pc = null;
|
||||
this.dc = null;
|
||||
|
|
@ -999,6 +1004,30 @@ export class GevRealtimeController {
|
|||
if (!sent) this.responseCreatePending = false;
|
||||
}
|
||||
|
||||
narrateTourBeat(beat) {
|
||||
if (!this.dc || this.dc.readyState !== 'open') return false;
|
||||
if (this.tourEngine?.paused) return false;
|
||||
this.notifyMapEvent({ type: 'tour_beat', ...beat });
|
||||
this.queueResponseCreate(
|
||||
'A tour_beat system item was just added. Narrate that beat using the script field as your spoken guide. Stay close to those words. Do not mention tools, JSON, or that you are reading a script.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop in-flight tour narration (UI pause, seek, barge-in). Realtime cannot
|
||||
* resume a cancelled utterance; callers re-speak the beat on resume.
|
||||
*/
|
||||
cancelTourNarration() {
|
||||
this.pendingResponseInstructions = null;
|
||||
if (this.dc?.readyState === 'open' && (this.responseActive || this.responseCreatePending)) {
|
||||
this.sendRealtimeEvent({ type: 'response.cancel' }, 'client.response_cancel.tour');
|
||||
}
|
||||
this.supersedeActiveResponseForUserTurn();
|
||||
this.responseCreatePending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleRealtimeEvent(event) {
|
||||
let payload = null;
|
||||
try {
|
||||
|
|
@ -1064,6 +1093,10 @@ export class GevRealtimeController {
|
|||
this.pendingResponseInstructions = null;
|
||||
this.cancelRadioHandoff({ abortTools: true });
|
||||
this.setVoiceSpeaker('user');
|
||||
if (this.tourEngine?.running && !this.tourEngine.paused) {
|
||||
this.cancelTourNarration();
|
||||
this.tourEngine.pause('barge-in');
|
||||
}
|
||||
}
|
||||
this.updateResponseState(payload);
|
||||
// The spend cap may have just ended the session from inside the usage
|
||||
|
|
@ -2026,6 +2059,42 @@ function hasStructuredViewIdentity(result) {
|
|||
}
|
||||
|
||||
function responseInstructionForToolResult(result) {
|
||||
if (result?.action === 'control_tour') {
|
||||
if (result.empty || (!result.ok && /no saved tour/i.test(String(result.error || '')))) {
|
||||
return 'Say there is no saved tour for that place. Suggest Rome, Paris, or Tokyo, or ask them to list available tours. Do not claim a tour started.';
|
||||
}
|
||||
if (result.random && result.ok) {
|
||||
return 'Name the surprise pick out loud using the returned title or city. Do not read speakScript — a tour_beat item will carry the first narration.';
|
||||
}
|
||||
if (result.ok && result.loadingSpoken) {
|
||||
if (result.autoplay) {
|
||||
return 'Say one short line that the tour is starting and they can sit back — Autoplay is on. Do not read speakScript; a tour_beat item will carry the narration.';
|
||||
}
|
||||
return 'Say one short line that the tour is ready. They can seek beats with next and previous; Autoplay is off unless they ask for it. Do not read speakScript; a tour_beat item will carry the narration.';
|
||||
}
|
||||
if (result.ok && result.speakScript) {
|
||||
return 'Name the tour in one short clause using the returned title or city. Do not read speakScript; a tour_beat item will carry the narration.';
|
||||
}
|
||||
if (result.ok && result.paused) {
|
||||
return 'Briefly confirm the tour voice is paused. Do not continue the beat script.';
|
||||
}
|
||||
if (result.ok && result.seek) {
|
||||
return 'Do not narrate. A tour_beat item will carry this stop. At most name the beat title in a few words.';
|
||||
}
|
||||
if (result.ok && result.autoplay === true && result.action === 'control_tour') {
|
||||
return 'Briefly confirm Autoplay is on. Do not read speakScript.';
|
||||
}
|
||||
if (result.ok && result.autoplay === false && result.running) {
|
||||
return 'Briefly confirm Autoplay is off and they can seek beats. Do not read speakScript.';
|
||||
}
|
||||
if (result.ok && result.running === false) {
|
||||
return 'Briefly confirm the tour stopped.';
|
||||
}
|
||||
if (result.ok) {
|
||||
return 'Briefly confirm the tour action using the returned title. Do not invent stops that are not in the result.';
|
||||
}
|
||||
return `Tell the user the tour did not start and briefly state this error: ${result.error || 'unknown tour error'}. Suggest Rome, Paris, Tokyo, or list if the tour was missing.`;
|
||||
}
|
||||
if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) {
|
||||
if (result.audioState === 'paused') {
|
||||
return 'Briefly confirm the completed Radio action, then say that Radio remains paused as requested. Do not say the request was cancelled or that Radio is playing.';
|
||||
|
|
|
|||
404
style.css
404
style.css
|
|
@ -9520,3 +9520,407 @@ body.scene-playback-mode #key-setup {
|
|||
border-color: rgba(255, 170, 150, 0.65);
|
||||
outline: none;
|
||||
}
|
||||
.tour-popup {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 12010;
|
||||
min-width: 260px;
|
||||
max-width: min(420px, calc(100vw - 24px));
|
||||
padding: 12px 14px 10px;
|
||||
background: rgba(8, 14, 22, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tour-popup[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tour-popup-kicker {
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.6px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tour-popup-title {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tour-popup-beat {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tour-popup-progress {
|
||||
margin: 8px 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#tour-popup-progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.tour-popup-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tour-popup-controls button {
|
||||
width: 34px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tour-popup-controls button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tour-popup-status {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tour-popup-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tour-popup-autoplay {
|
||||
display: block;
|
||||
flex: 1;
|
||||
margin-top: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tour-popup-autoplay.is-on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tour-popup-toggles {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tour-popup-camera {
|
||||
display: block;
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tour-popup-camera:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tour-popup-pause[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#tour-popup-progress {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Locked full-screen overlay while a tour generates / first fly settles */
|
||||
.tour-loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 12000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 35%, rgba(20, 40, 70, 0.55), transparent 55%),
|
||||
rgba(4, 8, 14, 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: all;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tour-loading[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tour-loading-card {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
padding: 28px 26px 22px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(10, 16, 26, 0.92);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.tour-loading-kicker {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tour-loading-title {
|
||||
margin-top: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.tour-loading-status {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
min-height: 2.6em;
|
||||
}
|
||||
|
||||
.tour-loading-bar {
|
||||
margin: 18px auto 8px;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tour-loading-bar-fill {
|
||||
height: 100%;
|
||||
width: 8%;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(90deg, var(--accent), #7ec8ff);
|
||||
transition: width 0.45s ease;
|
||||
}
|
||||
|
||||
.tour-loading-pct {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
body.tour-loading-lock {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.tour-loading-lock #cesiumContainer,
|
||||
body.tour-loading-lock .cesium-viewer,
|
||||
body.tour-loading-lock #hud,
|
||||
body.tour-loading-lock #voice-dock,
|
||||
body.tour-loading-lock .location-pills,
|
||||
body.tour-loading-lock #tour-surprise-btn {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body.tour-loading-lock #tour-popup {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Tour Review framing panel */
|
||||
.tour-review {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 16px;
|
||||
z-index: 45;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 96px);
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(8, 14, 22, 0.9);
|
||||
backdrop-filter: blur(16px);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.tour-review[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tour-review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tour-review-kicker {
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.6px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#tour-review-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tour-review-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tour-review-row select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tour-review-row button,
|
||||
.tour-review-actions button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tour-review-row button:hover,
|
||||
.tour-review-actions button:hover,
|
||||
.tour-review-beat:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tour-review-readout {
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.tour-review-beats {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tour-review-beat {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tour-review-beat.is-active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(80, 140, 220, 0.12);
|
||||
}
|
||||
|
||||
.tour-review-beat-idx {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 11px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tour-review-beat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tour-review-beat-body strong {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tour-review-beat-body em,
|
||||
.tour-review-beat-body small {
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tour-review-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tour-review-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.location-pill-review {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
body.tour-playback-mode #first-run-launcher {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ import {
|
|||
validTerrainResult,
|
||||
} from './src/data/terrainHeightsProxy.js';
|
||||
import { VOICE_MODELS, isKnownVoiceTier, resolveVoiceModel } from './src/voice/voiceCost.js';
|
||||
import { toursProxy } from './src/tours/toursProxy.mjs';
|
||||
|
||||
/** Resolve __dirname for ESM context. */
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -5230,7 +5231,8 @@ export function openAiRealtimeProxy() {
|
|||
'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.',
|
||||
'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.',
|
||||
'For "what is this aircraft?" answers, read the callsign, operator, registration, type, and route only from get_entity_context selected.properties. Treat route, routeOrigin, and routeDestination as the only authoritative route fields. Every aircraft identity answer MUST explicitly cover operator, type, and route. When a route is present, repeat its endpoint codes exactly; do not expand airport codes into city names. For a missing field say exactly "Operator details are unavailable", "Aircraft type is unavailable", or "Route details are unavailable" as applicable. Never silently omit missing enrichment or infer it from the callsign.',
|
||||
'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking automatically stops the tracking (the result says so): mention it briefly.',
|
||||
'While a guided tour is playing, a bare "stop" means control_tour{action:stop}. Tours default to gallery mode: "next" / "go back" use control_tour next/prev (wraps). "Sit back" / "play by itself" / "autoplay" uses control_tour{action:autoplay, enabled:true}. "pause the tour" stops the tour voice (and pauses Autoplay if it is on). "continue" / "resume" uses resume. Surprise me / pick a random tour uses control_tour{action:random} — only tours already on disk (Rome/Paris/Tokyo and any others saved).',
|
||||
'While a camera motion or route flight is active AND no tour is playing, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking automatically stops the tracking (the result says so): mention it briefly.',
|
||||
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||
|
|
@ -5265,7 +5267,7 @@ export function openAiRealtimeProxy() {
|
|||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. Guided visual tours ("show me a tour of Rome", "pause the tour", "surprise me") use control_tour — play saved tours only (Rome/Paris/Tokyo and others on disk); surprise = random; there is no tour generation. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
|
||||
'"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.',
|
||||
|
|
@ -6026,6 +6028,23 @@ const GEV_REALTIME_TOOLS = [
|
|||
required: ['action'],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_tour',
|
||||
description: 'Guided visual tour playback. Default is gallery: play starts the first beat and waits; next/prev seek beats (wrap) and start that beat\'s voice. Autoplay (off by default) advances on a timer; pause stops spoken audio; resume re-narrates the current beat. Surprise me uses random (saved tours only).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list', 'play', 'pause', 'resume', 'stop', 'next', 'prev', 'status', 'random', 'autoplay'] },
|
||||
tourId: { type: 'string', description: 'Tour id or city name for play, e.g. rome.' },
|
||||
city: { type: 'string', description: 'City or place name for play when tourId is unknown.' },
|
||||
query: { type: 'string', description: 'Free-form play query such as Rome or Paris.' },
|
||||
enabled: { type: 'boolean', description: 'For action=autoplay: true to sit-back auto-advance, false for gallery seek.' },
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_cctv',
|
||||
|
|
@ -7760,6 +7779,7 @@ export default defineConfig(({ mode }) => {
|
|||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
keySetupEndpoint(),
|
||||
toursProxy(),
|
||||
],
|
||||
server: {
|
||||
host: env.HOST || 'localhost',
|
||||
|
|
|
|||
Loading…
Reference in New Issue