diff --git a/build/panel.js b/build/panel.js index e10d0bbca..062475866 100644 --- a/build/panel.js +++ b/build/panel.js @@ -26,6 +26,8 @@ export function panelBuildConfig(config) { cssCodeSplit: false, rollupOptions: { ...config.build?.rollupOptions, + // The panel is the app alone: one page, so one inlined script. + input: undefined, output: { inlineDynamicImports: true }, }, }, diff --git a/build/vite.js b/build/vite.js index 4f2703e88..280687bab 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,9 +1,16 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +/** HTML documents a production build emits: the app and its mobile shell. */ +export const BROWSER_PAGES = Object.freeze({ + main: fileURLToPath(new URL('../index.html', import.meta.url)), + mobile: fileURLToPath(new URL('../mobile/index.html', import.meta.url)), +}); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the @@ -113,6 +120,9 @@ export function createBrowserViteConfig({ mapillaryToken ?? '', ), }, - build: { chunkSizeWarningLimit: 1500 }, + build: { + chunkSizeWarningLimit: 1500, + rollupOptions: { input: { ...BROWSER_PAGES } }, + }, }; } diff --git a/mobile/index.html b/mobile/index.html index 1c2120579..44b5190e7 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -22,7 +22,7 @@ - + diff --git a/mobile/styles/globe-ui.css b/mobile/styles/globe-ui.css index 0418cf879..f872ad6fc 100644 --- a/mobile/styles/globe-ui.css +++ b/mobile/styles/globe-ui.css @@ -82,11 +82,65 @@ animation: view-in var(--dur-3) var(--spring) both; } -.app.is-world .gc-readout, -.app.is-world .gc-attr { +.app.is-world .gc-readout { visibility: hidden; } +/* Attribution stays on screen whenever the globe is (provider terms). */ +.app.is-world .gc-attr { + top: calc(var(--safe-t) + 60px); +} + +.gc-attr.has-onscreen { + color: var(--ink-2); +} + +.gc-labels { + position: absolute; + inset: 0; + pointer-events: none !important; + overflow: hidden; + z-index: 0; +} + +.gc-labels span { + position: absolute; + left: 0; + top: 0; + display: inline-flex; + align-items: center; + gap: 4px; + margin: -9px 0 0 8px; + height: 18px; + padding: 0 6px 0 5px; + border-radius: 9px; + background: rgba(5, 7, 11, 0.58); + font-family: var(--mono); + font-size: 0.625rem; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + will-change: transform; +} + +.gc-labels i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--fam, var(--ink-3)); +} + +.tile.soon { + opacity: 0.5; + background: transparent; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} + +.tile.soon .tile-status { + font-style: normal; + color: var(--ink-4); +} + /* Lens popover */ .lens-pop { position: absolute; @@ -735,6 +789,12 @@ color: var(--fam); } +/* The headline number belongs to the peek detent; taller detents show it in + the labelled metrics row instead of twice. */ +.brief-sheet:not([data-detent='peek']) .peek-metric { + display: none; +} + .peek-metric small { font-size: 0.75rem; font-weight: 650; diff --git a/mobile/ui/feed.js b/mobile/ui/feed.js index 7505da684..095ee9518 100644 --- a/mobile/ui/feed.js +++ b/mobile/ui/feed.js @@ -136,7 +136,11 @@ export function createFeed( } return all .filter((s) => !isReconstruction(s) && !isAlwaysOn(s)) - .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + .sort( + (a, b) => + (b.severity ?? 0) - (a.severity ?? 0) || + new Date(b.updatedAt) - new Date(a.updatedAt), + ); } function render() { diff --git a/mobile/ui/globeChrome.js b/mobile/ui/globeChrome.js index 0536560aa..5bb4f8c03 100644 --- a/mobile/ui/globeChrome.js +++ b/mobile/ui/globeChrome.js @@ -96,6 +96,68 @@ export function createGlobeChrome({ onTime: (d) => globe.setTime(d), }); + /* ---------- HTML labels (engine draws none; ≤ 40, DESIGN.md §5) ---------- */ + const labelLayer = h(''); + chrome.prepend(labelLayer); + let labelRaf = 0; + const KIND_FAM_L = { + aircraft: 'sky', + helicopter: 'sky', + military: 'sky', + vessel: 'sea', + ship: 'sea', + satellite: 'orbit', + station: 'orbit', + launch: 'orbit', + earthquake: 'hazards', + quake: 'hazards', + fire: 'hazards', + cyclone: 'hazards', + camera: 'eyes', + cctv: 'eyes', + }; + function drawLabels() { + labelRaf = 0; + if (document.hidden || getComputedStyle(chrome).visibility === 'hidden') + return; + const list = globe.labels(40); + const W = chrome.clientWidth; + const html = list + .map((l) => { + const flip = l.x > W - 9 * Math.min(24, l.title.length) - 40; + const tf = `translate(${Math.round(l.x)}px,${Math.round(l.y)}px)${flip ? ' translateX(calc(-100% - 16px))' : ''}`; + return `${esc(l.title)}`; + }) + .join(''); + if (labelLayer.innerHTML !== html) labelLayer.innerHTML = html; + } + const queueLabels = () => { + if (!labelRaf) labelRaf = requestAnimationFrame(drawLabels); + }; + globe.on('camera', queueLabels); + globe.on('layers', queueLabels); + setInterval(queueLabels, 1000); + + /** Provider credits flagged onScreen (e.g. Google 3D Tiles) must stay visible. */ + let attrKey = ''; + function refreshAttribution() { + const list = globe.credits() || []; + const onScreen = list + .filter((c) => c.onScreen) + .map((c) => c.text || String(c.html || '').replace(/<[^>]+>/g, '')); + const text = ['Esri · Maxar · Earthstar · NASA', ...onScreen].join(' · '); + if (text === attrKey) return; + attrKey = text; + attribution.textContent = text; + attribution.classList.toggle('has-onscreen', onScreen.length > 0); + } + let attrTimer = 0; + globe.on('camera', () => { + clearTimeout(attrTimer); + attrTimer = setTimeout(refreshAttribution, 300); + }); + setTimeout(refreshAttribution, 500); + /* ---------- camera readout ---------- */ globe.on('camera', (c) => { if (!c) return; @@ -141,7 +203,7 @@ export function createGlobeChrome({ for (const l of layers) { const s = st[l.family]; if (!s) continue; - s.total += 1; + if (l.available !== false) s.total += 1; s.layers.push(l); if (l.on) { s.on += 1; @@ -216,6 +278,12 @@ export function createGlobeChrome({ grid.on('close', () => (gridFamily = null)); function tileHtml(l) { + if (l.available === false) + return ``; return `

`; } leadView.addEventListener('click', (e) => { + const soon = e.target.closest('[data-soon]'); + if (soon) { + toast( + `${soon.querySelector('.tile-name').textContent} · ${soon.dataset.msg}`, + { icon: icon('clock') }, + ); + return; + } const t = e.target.closest('[data-layer]'); if (t) { const on = t.getAttribute('aria-checked') !== 'true'; diff --git a/mobile/ui/globeHost.js b/mobile/ui/globeHost.js index ac1f016f6..d0edfc15e 100644 --- a/mobile/ui/globeHost.js +++ b/mobile/ui/globeHost.js @@ -119,6 +119,13 @@ function guard(engine, kind) { const v = call('credits', null); return Array.isArray(v) ? v : null; }, + /** Screen-space label candidates (engine draws none; the shell renders them). */ + labels: (max = 40) => { + const v = call('labels', [], max); + return Array.isArray(v) ? v : []; + }, + project: (lon, lat, alt) => call('project', null, lon, lat, alt), + time: () => call('time', null), }; } @@ -130,6 +137,11 @@ export async function mountGlobe(container, opts = {}) { const params = new URLSearchParams(location.search); const forceStub = opts.preferStub || params.get('engine') === 'stub'; if (!forceStub) { + // vite-plugin-cesium serves Workers/Assets at /cesium/ and passes the base + // as a build-time define, which the pre-bundled dev dependency never sees; + // Cesium then resolves them next to node_modules/.vite/deps and gets HTML. + if (typeof window !== 'undefined' && !window.CESIUM_BASE_URL) + window.CESIUM_BASE_URL = '/cesium/'; try { // A glob (not a bare dynamic import) so a checkout without the engine // still builds; Vite re-evaluates it when mobile/globe/index.js appears. diff --git a/mobile/ui/world.js b/mobile/ui/world.js index cc0d7f640..e36974709 100644 --- a/mobile/ui/world.js +++ b/mobile/ui/world.js @@ -148,9 +148,12 @@ export function createWorld({ } function contextEvidence(s) { - const span = Math.max(0.02, (s.camera?.height || 300e3) / 111e3 / 1.6); + const span = Math.min( + 12, + Math.max(0.02, (s.camera?.height || 300e3) / 111e3 / 1.6), + ); const frames = [ - { t: 'Region', span: span * 6 }, + { t: 'Region', span: Math.min(40, span * 6) }, { t: 'Area', span }, { t: 'Close', span: span / 5 }, ];