diff --git a/docs/cinematic-overhaul.md b/docs/cinematic-overhaul.md index 187cf20..3058a81 100644 --- a/docs/cinematic-overhaul.md +++ b/docs/cinematic-overhaul.md @@ -11,3 +11,5 @@ Target Canada 0.15.0, Worldwide 0.6.0 and Canada Android 1.1.0. No public API changes. Existing topology and packet/audio clocks remain authoritative. Builds, tests and browser artifacts run in GitHub Actions only. + +The app supplies compact MapLibre canvas/gesture/attribution styling rather than shipping unused provider-control icons. The existing total byte limits remain unchanged. diff --git a/web/src/audio.ts b/web/src/audio.ts index 2fa3016..a21289a 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -322,6 +322,18 @@ export class RouteSonifier { return notes.length; } + /** Explicit preview gesture; does not enable or save live sound. */ + async preview(): Promise { + if (!this.supported() || this.paused) return; + const context = !this.context || this.context.state === 'closed' ? this.createContext() : this.context; + this.unlockMobileOutput(context); + if (context.state !== 'running') await context.resume(); + if (context.state !== 'running') return; + this.setMasterLevel(MASTER_LEVEL * this.volume); + const scene = SCENES[this.scene]; + this.schedule({ frequency: 440, startMS: 0, durationMS: Math.min(420,scene.maxDurationMS), brightness: Math.max(scene.minBrightness,1800*scene.brightnessScale), pan: 0, variation: 0, scene: this.scene, character: 'map' }, .8); + } + destroy(): void { this.enabled = false; this.stopActive(); diff --git a/web/src/cinematic.css b/web/src/cinematic.css index 7405800..a1d3a2a 100644 --- a/web/src/cinematic.css +++ b/web/src/cinematic.css @@ -50,3 +50,12 @@ .graph-focus select { min-height: 36px; background: var(--field-bg); color: var(--ink-strong); border: 0; } .graph-focus small:empty { display: none; } @media(max-width:900px) { .graph-focus { top: 106px; left: 8px; } } + +:root[data-cinematic] .controls > :is(.layers-disclosure,.find-control,.sound-control,.popover-control), :root[data-cinematic] .display-disclosure { position:static; } +:root[data-cinematic] #layers-panel { position:absolute; top:auto; right:0; bottom:calc(100% + 10px); width:min(370px,calc(100vw - 24px)); max-height:calc(100dvh - 150px); } +:root[data-cinematic] .maplibregl-ctrl-bottom-right { opacity:1; } +.gpu-packet-canvas { z-index:2; } +.sound-preview-actions { display:flex; gap:8px; } +:root[data-cinematic] .director-enabled .node-inspector-sheet { visibility:visible; pointer-events:auto; } +@media(max-width:900px),(pointer:coarse) { :root[data-cinematic] #layers-panel { width:100%; max-height:calc(100dvh - 185px); } } +@media(max-height:520px) and (orientation:landscape) { :root[data-cinematic] #layers-panel { max-height:calc(100dvh - 95px); } } diff --git a/web/src/main.ts b/web/src/main.ts index 36cdac6..cc7e30a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,3 +1,4 @@ +import { mountSoundPreview } from './soundPreview'; import { requestedNode } from './selection'; import { mountCinematicDock, mountLayerCombinations } from './cinematicChrome'; import { browserStorage } from './browserStorage'; @@ -6,7 +7,7 @@ import { mountDisplayControls } from './displayControls'; import { loadBasemapConfiguration } from './basemap'; import { attachMapNotice } from './mapNotice'; import { DEFAULT_DISPLAY, DISPLAY_EVENT, displayPreferences, initializeDisplay, updateDisplay, applyDisplayChrome } from './displayPreferences'; -import 'maplibre-gl/dist/maplibre-gl.css'; +import './maplibre-base.css'; import './styles.css'; import { fetchState, LiveFeed } from './api'; import { RouteSonifier, type SoundScene, type SoundStatus } from './audio'; @@ -252,6 +253,7 @@ async function start(): Promise { animator = liveAnimator; const routeSonifier = new RouteSonifier(liveAnimator.projection, packetCanvas); sonifier = routeSonifier; + mountSoundPreview(routeSonifier, soundPanel); soundVolume.value = String(Math.round(routeSonifier.getVolume() * 100)); soundVolumeOutput.value = `${soundVolume.value}%`; syncSoundScene(soundScene, routeSonifier.getScene()); diff --git a/web/src/maplibre-base.css b/web/src/maplibre-base.css new file mode 100644 index 0000000..43a9e48 --- /dev/null +++ b/web/src/maplibre-base.css @@ -0,0 +1,39 @@ +/* CartoLite supplies its own controls and inspector. Only MapLibre's canvas, + gestures and attribution need library chrome (MapLibre GL JS 6.4.1 DOM). */ +.maplibregl-map { position:relative; overflow:hidden; font:12px/20px system-ui,sans-serif; -webkit-tap-highlight-color:transparent; } +.maplibregl-canvas { position:absolute; left:0; top:0; } +.maplibregl-map:fullscreen { width:100%; height:100%; } +.maplibregl-interactive { cursor:grab; user-select:none; } +.maplibregl-interactive:active { cursor:grabbing; } +.maplibregl-track-pointer { cursor:pointer; } +.maplibregl-touch-zoom-rotate, .maplibregl-touch-zoom-rotate .maplibregl-canvas { touch-action:pan-x pan-y; } +.maplibregl-touch-drag-pan, .maplibregl-touch-drag-pan .maplibregl-canvas { touch-action:pinch-zoom; } +.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan, .maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas { touch-action:none; } +.maplibregl-canvas:focus-visible { outline:2px solid var(--focus-ring); outline-offset:-2px; } +.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-top-right,.maplibregl-ctrl-top-left { position:absolute; pointer-events:none; z-index:3; } +.maplibregl-ctrl-bottom-right { right:0; bottom:0; } +.maplibregl-ctrl-bottom-left { left:0; bottom:0; } +.maplibregl-ctrl-top-right { right:0; top:0; } +.maplibregl-ctrl-top-left { left:0; top:0; } +.maplibregl-ctrl { clear:both; pointer-events:auto; transform:translate(0,0); } +.maplibregl-ctrl-attrib { margin:0 6px 4px; padding:3px 8px; border-radius:6px; color:var(--ink); background:var(--panel-solid); font-size:10px; line-height:18px; max-width:calc(100vw - 12px); } +.maplibregl-ctrl-attrib a { color:inherit; text-decoration:none; } +.maplibregl-ctrl-attrib a:hover { text-decoration:underline; } +.maplibregl-ctrl-attrib-button { display:none; border:1px solid currentColor; border-radius:50%; background:var(--panel-solid); color:var(--ink); cursor:pointer; width:44px; height:44px; filter:none !important; } +.maplibregl-ctrl-attrib-button::before { content:'i'; font:bold 16px Georgia,serif; } +.maplibregl-compact { padding:0; min-height:44px; } +.maplibregl-compact .maplibregl-ctrl-attrib-button { display:block; float:right; } +.maplibregl-compact .maplibregl-ctrl-attrib-inner { display:none; } +.maplibregl-compact-show .maplibregl-ctrl-attrib-inner { display:block; padding:8px 48px 8px 10px; } +.maplibregl-ctrl-attrib.maplibregl-attrib-empty { display:none; } +.maplibregl-boxzoom { position:absolute; top:0; left:0; width:0; height:0; border:2px dashed var(--focus-ring); background:#72ffe118; } +.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive { cursor:crosshair; } +.maplibregl-popup { position:absolute; display:flex; top:0; left:0; pointer-events:none; } +.maplibregl-popup-content { position:relative; background:var(--panel-solid); border:1px solid var(--panel-border); border-radius:12px; pointer-events:auto; } +.maplibregl-popup-close-button { position:absolute; right:0; top:0; width:44px; height:44px; border:0; background:transparent; color:var(--ink); } +.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right { flex-direction:column; } +.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right { flex-direction:column-reverse; } +.maplibregl-popup-anchor-left { flex-direction:row; } +.maplibregl-popup-anchor-right { flex-direction:row-reverse; } +.maplibregl-popup-tip { display:none; } +@media(forced-colors:active) { .maplibregl-ctrl-attrib { border:1px solid CanvasText; } } diff --git a/web/src/netgraph/main.ts b/web/src/netgraph/main.ts index 9732b60..d15276b 100644 --- a/web/src/netgraph/main.ts +++ b/web/src/netgraph/main.ts @@ -1,3 +1,4 @@ +import { mountSoundPreview } from '../soundPreview'; import { requestedNode, rememberNode, replaceInspector } from '../selection'; import { mountCinematicDock } from '../cinematicChrome'; import { populateSoundScenes, syncSoundScene, SOUND_SCENES } from '../soundScenes'; @@ -107,6 +108,7 @@ async function start(): Promise { renderer = graph; const routeSonifier = new RouteSonifier(graph, stage); sonifier = routeSonifier; + mountSoundPreview(routeSonifier, soundPanel); configureSound(routeSonifier); graph.setRouteWindow(settings.routeWindow); diff --git a/web/src/soundPreview.ts b/web/src/soundPreview.ts new file mode 100644 index 0000000..55da959 --- /dev/null +++ b/web/src/soundPreview.ts @@ -0,0 +1,10 @@ +import type { RouteSonifier } from './audio'; +export function mountSoundPreview(sonifier: RouteSonifier, parent: HTMLElement): void { + const controls = document.createElement('div'); controls.className = 'sound-preview-actions'; + const preview = document.createElement('button'); preview.type='button'; preview.textContent='Preview voice'; preview.className='control-button'; + preview.disabled=!sonifier.supported(); + preview.addEventListener('click', async () => { preview.disabled=true; try { await sonifier.preview(); } catch { preview.textContent='Preview unavailable'; } finally { window.setTimeout(()=>{preview.disabled=!sonifier.supported();},700); } }); + const mute=document.createElement('button'); mute.type='button';mute.textContent='Mute';mute.className='control-button'; + mute.addEventListener('click',()=>{void sonifier.setEnabled(false);}); + controls.append(preview,mute);parent.append(controls); +}