mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(mobile): add the mobile app shell, capture and the /mobile/ build page
Five tabs (Now, Globe, Capture, Threads, You) over one persistent globe: a live situation feed whose cards open into the world with a detented brief, the lens dock and time ribbon, AR Look and signed Witness capture (ECDSA P-256 in IndexedDB, SHA-256 hash chain, IMU and GPS trace, eight-check verify), the Bhote Koshi reconstruction thread with a live ledger, and hinge-aware two-pane layouts for foldables. The production build now emits mobile/index.html beside the app; the panel build keeps the app alone. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
8 files changed
+183
-8
No files matched your search
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
+11
-1
@@ -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 } },
|
||||
},
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="God's Eye" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/logo.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
|
||||
<link rel="preconnect" href="https://server.arcgisonline.com" crossorigin />
|
||||
<link rel="stylesheet" href="./styles/tokens.css" />
|
||||
<link rel="stylesheet" href="./styles/shell.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;
|
||||
|
||||
+5
-1
@@ -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() {
|
||||
|
||||
@@ -96,6 +96,68 @@ export function createGlobeChrome({
|
||||
onTime: (d) => globe.setTime(d),
|
||||
});
|
||||
|
||||
/* ---------- HTML labels (engine draws none; ≤ 40, DESIGN.md §5) ---------- */
|
||||
const labelLayer = h('<div class="gc-labels" aria-hidden="true"></div>');
|
||||
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 `<span data-family="${KIND_FAM_L[l.kind] || 'infra'}" style="transform:${tf}"><i></i>${esc(l.title)}</span>`;
|
||||
})
|
||||
.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 `<button class="tile soon" type="button" aria-disabled="true" data-soon="${esc(l.id)}" data-msg="${esc(l.message || 'Not in the prototype yet')}">
|
||||
<span class="tile-ic">${icon(FAMILY_ICON[l.family] || 'layers')}</span>
|
||||
<span class="tile-name">${esc(l.name)}</span>
|
||||
<span class="tile-meta"><span class="tile-status">Coming soon</span></span>
|
||||
</button>`;
|
||||
return `<button class="tile ${l.on ? 'on' : ''}" type="button" role="switch" aria-checked="${!!l.on}" data-layer="${esc(l.id)}" data-status="${esc(l.status)}">
|
||||
<span class="tile-ic">${icon(FAMILY_ICON[l.family] || 'layers')}</span>
|
||||
<span class="tile-name">${esc(l.name)}</span>
|
||||
@@ -241,6 +309,14 @@ export function createGlobeChrome({
|
||||
}
|
||||
|
||||
grid.root.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';
|
||||
@@ -518,6 +594,14 @@ export function createGlobeChrome({
|
||||
<p class="footnote"><button class="link-btn" type="button" data-act2="credits">Data & attribution</button></p>`;
|
||||
}
|
||||
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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-2
@@ -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 },
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user