From 8b7fcb09dfb023bae7172bc62b91b0edec229a75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:49:03 +0000 Subject: [PATCH] feat(mobile): rooms, live and upload capture, and the altitude-band identity The Now feed mixes sensor, eye, track, number, witness and room cards across Orbit, Sky, Ground and Inside, ordered so neighbours differ, with composited thumbnails and live public-camera frames. Every situation has a room: typed, place- and time-anchored posts, evidence badges, bridging-rated notes and a cited Known / Disputed / Unknown local synthesis, with cross-tab presence over BroadcastChannel. Example posts are labelled as such. Capture adds Live (10 s segments sealed into a hash chain, shown on the globe as a view frustum) and Upload (EXIF placement, Unverified tier), and an attach sheet that files any capture into a nearby situation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH --- mobile/capture/db.js | 34 +- mobile/capture/exif.js | 145 +++++ mobile/capture/live.js | 155 +++++ mobile/index.html | 10 +- mobile/main.js | 193 +++++- mobile/styles/capture.css | 755 +++++++++++++++++++++- mobile/styles/cards.css | 853 +++++++++++++++++++++++++ mobile/styles/feed.css | 486 +++++--------- mobile/styles/globe-ui.css | 198 ++++++ mobile/styles/room.css | 1195 ++++++++++++++++++++++++++++++++++ mobile/styles/sheet.css | 101 ++- mobile/styles/shell.css | 13 +- mobile/styles/threads.css | 42 +- mobile/styles/tokens.css | 51 +- mobile/ui/attach.js | 336 ++++++++++ mobile/ui/bands.js | 224 +++++++ mobile/ui/capture.js | 466 +++++++++++++- mobile/ui/cards.js | 244 +++++++ mobile/ui/composer.js | 174 +++++ mobile/ui/eyeViewer.js | 92 +++ mobile/ui/feed.js | 727 ++++++++++++++++----- mobile/ui/globeChrome.js | 138 +++- mobile/ui/globeHost.js | 50 +- mobile/ui/globeStub.js | 647 +++++++------------ mobile/ui/icons.js | 15 + mobile/ui/liveViewer.js | 93 +++ mobile/ui/minimap.js | 114 ++++ mobile/ui/realtime.js | 182 ++++++ mobile/ui/room.js | 366 +++++++++++ mobile/ui/rooms.js | 1207 +++++++++++++++++++++++++++++++++++ mobile/ui/sheet.js | 2 + mobile/ui/stubCompositor.js | 419 ++++++++++++ mobile/ui/stubSituations.js | 657 +++++++++++++++++++ mobile/ui/threads.js | 53 +- mobile/ui/timeRibbon.js | 10 +- mobile/ui/witness.js | 94 +++ mobile/ui/world.js | 280 ++++++-- mobile/ui/you.js | 97 ++- 38 files changed, 9837 insertions(+), 1081 deletions(-) create mode 100644 mobile/capture/exif.js create mode 100644 mobile/capture/live.js create mode 100644 mobile/styles/cards.css create mode 100644 mobile/styles/room.css create mode 100644 mobile/ui/attach.js create mode 100644 mobile/ui/bands.js create mode 100644 mobile/ui/cards.js create mode 100644 mobile/ui/composer.js create mode 100644 mobile/ui/eyeViewer.js create mode 100644 mobile/ui/liveViewer.js create mode 100644 mobile/ui/minimap.js create mode 100644 mobile/ui/realtime.js create mode 100644 mobile/ui/room.js create mode 100644 mobile/ui/rooms.js create mode 100644 mobile/ui/stubCompositor.js create mode 100644 mobile/ui/stubSituations.js create mode 100644 mobile/ui/witness.js diff --git a/mobile/capture/db.js b/mobile/capture/db.js index b3f9677fa..778bc8065 100644 --- a/mobile/capture/db.js +++ b/mobile/capture/db.js @@ -1,7 +1,10 @@ -/** Minimal promise wrapper over the device's IndexedDB for keys and captures. */ +/** + * Minimal promise wrapper over the device's IndexedDB: signing keys, sealed + * captures (+ media), and round two's room posts, uploads and small state. + */ const DB_NAME = 'gev-mobile'; -const DB_VERSION = 1; +const DB_VERSION = 2; let dbp = null; export function openDb() { @@ -22,9 +25,27 @@ export function openDb() { } if (!db.objectStoreNames.contains('media')) db.createObjectStore('media', { keyPath: 'id' }); + // v2: rooms and uploads. + if (!db.objectStoreNames.contains('posts')) { + const p = db.createObjectStore('posts', { keyPath: 'id' }); + p.createIndex('room', 'room'); + } + if (!db.objectStoreNames.contains('uploads')) + db.createObjectStore('uploads', { keyPath: 'id' }); + if (!db.objectStoreNames.contains('kv')) + db.createObjectStore('kv', { keyPath: 'id' }); + }; + req.onsuccess = () => { + const db = req.result; + // Another tab upgraded the schema: let it, and reopen next time. + db.onversionchange = () => { + db.close(); + dbp = null; + }; + resolve(db); }; - req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); + req.onblocked = () => reject(new Error('IndexedDB upgrade blocked')); }); dbp.catch(() => (dbp = null)); return dbp; @@ -91,3 +112,10 @@ export async function captureBySeq(seq) { db.transaction('captures').objectStore('captures').index('seq').get(seq), ); } + +export async function byIndex(store, index, key) { + const db = await openDb(); + return wrap( + db.transaction(store).objectStore(store).index(index).getAll(key), + ); +} diff --git a/mobile/capture/exif.js b/mobile/capture/exif.js new file mode 100644 index 000000000..9b7caf9e8 --- /dev/null +++ b/mobile/capture/exif.js @@ -0,0 +1,145 @@ +/** + * Tiny, dependency-free EXIF reader for JPEG: GPS position, altitude and + * image direction, DateTimeOriginal (+ offset), orientation, make/model. + * Returns null for anything that is not a JPEG with an Exif APP1 segment. + */ + +const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 }; + +function readIfd(view, tiff, offset, le) { + const out = {}; + if (offset + 2 > view.byteLength) return out; + const n = view.getUint16(offset, le); + for (let i = 0; i < n; i += 1) { + const e = offset + 2 + i * 12; + if (e + 12 > view.byteLength) break; + const tag = view.getUint16(e, le); + const type = view.getUint16(e + 2, le); + const count = view.getUint32(e + 4, le); + const size = (TYPE_SIZE[type] || 1) * count; + const at = size > 4 ? tiff + view.getUint32(e + 8, le) : e + 8; + if (at + size > view.byteLength) continue; + out[tag] = readValue(view, at, type, count, le); + } + return out; +} + +function readValue(view, at, type, count, le) { + const one = (k) => { + const p = at + k * (TYPE_SIZE[type] || 1); + switch (type) { + case 1: + case 7: + return view.getUint8(p); + case 3: + return view.getUint16(p, le); + case 4: + return view.getUint32(p, le); + case 9: + return view.getInt32(p, le); + case 5: { + const d = view.getUint32(p + 4, le); + return d ? view.getUint32(p, le) / d : 0; + } + case 10: { + const d = view.getInt32(p + 4, le); + return d ? view.getInt32(p, le) / d : 0; + } + default: + return null; + } + }; + if (type === 2) { + let s = ''; + for (let k = 0; k < count; k += 1) { + const c = view.getUint8(at + k); + if (!c) break; + s += String.fromCharCode(c); + } + return s.trim(); + } + if (count === 1) return one(0); + return Array.from({ length: count }, (_, k) => one(k)); +} + +const dms = (v) => + Array.isArray(v) ? v[0] + (v[1] || 0) / 60 + (v[2] || 0) / 3600 : Number(v); + +/** "2026:10:18 20:53:37" (+ "-05:00") → ms epoch; local time if no offset. */ +function exifDate(s, offset) { + const m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s || ''); + if (!m) return null; + const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : ''}`; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : null; +} + +/** + * @param {Blob|ArrayBuffer} input + * @returns {Promise} + */ +export async function readExif(input) { + const buf = + input instanceof Blob + ? await input.slice(0, 256 * 1024).arrayBuffer() + : input; + const view = new DataView(buf); + if (view.byteLength < 4 || view.getUint16(0) !== 0xffd8) return null; + let p = 2; + while (p + 4 <= view.byteLength) { + if (view.getUint8(p) !== 0xff) return null; + const marker = view.getUint8(p + 1); + const len = view.getUint16(p + 2); + if (marker === 0xda || marker === 0xd9) return null; // start of scan: no EXIF before it + if ( + marker === 0xe1 && + p + 10 <= view.byteLength && + view.getUint32(p + 4) === 0x45786966 + ) { + const tiff = p + 10; + const le = view.getUint16(tiff) === 0x4949; + if (view.getUint16(tiff + 2, le) !== 42) return null; + const ifd0 = readIfd(view, tiff, tiff + view.getUint32(tiff + 4, le), le); + const exif = ifd0[0x8769] + ? readIfd(view, tiff, tiff + ifd0[0x8769], le) + : {}; + const gps = ifd0[0x8825] + ? readIfd(view, tiff, tiff + ifd0[0x8825], le) + : {}; + const out = { raw: { ifd0, exif, gps } }; + if (gps[2] != null && gps[4] != null) { + let lat = dms(gps[2]); + let lon = dms(gps[4]); + if (String(gps[1]).toUpperCase() === 'S') lat = -lat; + if (String(gps[3]).toUpperCase() === 'W') lon = -lon; + if ( + Number.isFinite(lat) && + Number.isFinite(lon) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180 && + (lat || lon) + ) { + out.lat = lat; + out.lon = lon; + } + } + if (gps[6] != null) out.altM = (gps[5] === 1 ? -1 : 1) * Number(gps[6]); + if (gps[0x11] != null) out.headingDeg = Number(gps[0x11]); + const offset = exif[0x9011] || exif[0x9010]; + const taken = exifDate( + exif[0x9003] || exif[0x9004] || ifd0[0x0132], + offset, + ); + if (taken) { + out.takenAt = taken; + out.takenAtHasZone = !!offset; + } + if (ifd0[0x0112]) out.orientation = ifd0[0x0112]; + if (ifd0[0x010f]) out.make = ifd0[0x010f]; + if (ifd0[0x0110]) out.model = ifd0[0x0110]; + return out; + } + p += 2 + len; + } + return null; +} diff --git a/mobile/capture/live.js b/mobile/capture/live.js new file mode 100644 index 000000000..27e3a1641 --- /dev/null +++ b/mobile/capture/live.js @@ -0,0 +1,155 @@ +/** + * Live broadcast session (DESIGN.md §11.4 "Live"): MediaRecorder timeslice + * chunks are hashed and sealed every 10 s into a hash chain of segments, + * each carrying the pose and position at that moment. Stopping returns the + * whole recording, the chain and the sensor session, ready for `seal()`. + * + * Segment i covers bytes [offset, offset + bytes) of the final file, so a + * verifier can re-split the file and recompute every link. + */ + +import { sha256Hex, canonical } from './provenance.js'; + +const TYPES = [ + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + 'video/webm', + 'video/mp4;codecs=avc1', + 'video/mp4', +]; + +export function canBroadcast(stream) { + return !!stream && typeof MediaRecorder !== 'undefined'; +} + +/** + * @param {{ stream: MediaStream, sensors: object, pose: () => object, + * fix: () => object|null, segmentMs?: number, onSegment?: (seg) => void }} o + */ +export function createLiveSession(o) { + const segmentMs = o.segmentMs || 10000; + const mimeType = TYPES.find((t) => MediaRecorder.isTypeSupported?.(t)); + const chunks = []; + let pending = []; + const segments = []; + let recorder = null; + let timer = 0; + let prev = null; + let offset = 0; + let t0 = 0; + let segT0 = 0; + let sealing = Promise.resolve(); + + async function sealSegment() { + const parts = pending; + pending = []; + if (!parts.length) return null; + const blob = new Blob(parts, { type: mimeType || 'video/webm' }); + const t1 = Date.now(); + const p = o.pose?.() || {}; + const f = o.fix?.() || null; + const seg = { + i: segments.length, + t0: segT0, + t1, + offset, + bytes: blob.size, + media: await sha256Hex(blob), + pose: { + headingDeg: Number.isFinite(p.az) ? Math.round(p.az * 10) / 10 : null, + pitchDeg: Number.isFinite(p.el) ? Math.round(p.el * 10) / 10 : null, + }, + gps: f + ? { + lat: +f.lat.toFixed(6), + lon: +f.lon.toFixed(6), + acc: Math.round(f.acc || 0), + } + : null, + prev, + }; + seg.hash = await sha256Hex(canonical(seg)); + prev = seg.hash; + offset += blob.size; + segT0 = t1; + segments.push(seg); + o.onSegment?.(seg); + return seg; + } + + function start() { + recorder = new MediaRecorder( + o.stream, + mimeType ? { mimeType, videoBitsPerSecond: 2.5e6 } : undefined, + ); + recorder.ondataavailable = (e) => { + if (!e.data?.size) return; + chunks.push(e.data); + pending.push(e.data); + }; + recorder.start(1000); + t0 = Date.now(); + segT0 = t0; + o.sensors?.beginSession?.(); + timer = setInterval(() => { + sealing = sealing + .then(sealSegment) + .catch((err) => console.warn('segment', err)); + }, segmentMs); + } + + async function stop() { + clearInterval(timer); + if (recorder && recorder.state !== 'inactive') + await new Promise((resolve) => { + recorder.onstop = resolve; + recorder.stop(); + }); + await sealing; + await sealSegment(); + const session = o.sensors?.endSession?.() || { + start: new Date(t0).toISOString(), + end: new Date().toISOString(), + imu: [], + gps: [], + heading: null, + }; + return { + blob: new Blob(chunks, { type: mimeType || 'video/webm' }), + segments, + session, + startedAt: t0, + endedAt: Date.now(), + mimeType: mimeType || 'video/webm', + }; + } + + return { + start, + stop, + get segments() { + return segments; + }, + get startedAt() { + return t0; + }, + get active() { + return !!recorder && recorder.state === 'recording'; + }, + }; +} + +/** Re-split a live file at the sealed offsets and recompute the chain. */ +export async function verifySegments(blob, segments) { + let prev = null; + for (const s of segments) { + const part = blob.slice(s.offset, s.offset + s.bytes); + const media = await sha256Hex(part); + const { hash, ...core } = s; + const h = await sha256Hex(canonical(core)); + if (media !== s.media || h !== hash || s.prev !== prev) + return { ok: false, at: s.i }; + prev = hash; + } + return { ok: true, n: segments.length, head: prev }; +} diff --git a/mobile/index.html b/mobile/index.html index 44b5190e7..8a2c5c6f0 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -11,7 +11,7 @@ name="description" content="The window through which you see the world, and the eye through which the world sees itself." /> - + @@ -24,6 +24,12 @@ + + + @@ -31,6 +37,8 @@ + + diff --git a/mobile/main.js b/mobile/main.js index 1d8a731b2..f0fbff14e 100644 --- a/mobile/main.js +++ b/mobile/main.js @@ -24,6 +24,11 @@ import { createYou } from './ui/you.js'; import { createSearch } from './ui/search.js'; import { createInspector } from './ui/inspector.js'; import { createNarrative } from './ui/narrative.js'; +import { createRealtime } from './ui/realtime.js'; +import { createRooms } from './ui/rooms.js'; +import { createAttach } from './ui/attach.js'; +import { createLiveViewer } from './ui/liveViewer.js'; +import { BAND_COLOR, bandOf } from './ui/bands.js'; const app = $('#app'); const stage = $('#stage'); @@ -56,7 +61,17 @@ const globeReady = mountGlobe($('#globe'), { quality: store.get('quality') }) async function getSituations({ fresh = false } = {}) { const g = await globeReady; - if (!situationsCache || fresh) situationsCache = await g.situations(); + if (!situationsCache || fresh) { + const [live, places] = await Promise.all([ + g.situations(userLoc ? { observer: userLoc } : undefined), + rooms.places().catch(() => []), + ]); + // Places people started from a capture have rooms too. + situationsCache = [ + ...live, + ...places.filter((p) => !live.some((s) => String(s.id) === p.id)), + ]; + } return situationsCache; } const situationById = (id) => @@ -87,6 +102,16 @@ let threads = null; let search = null; let inspector = null; let narrative = null; +let liveViewer = null; + +/** After posting, offer the way into the room. */ +function toastOpen(s, p) { + setTimeout(() => { + if (current !== 'capture') return; + const s2 = situationById(s.id) || s; + openSituation(s2, null, { tab: 'room', focusPost: p.id }); + }, 900); +} function applyTab(tab) { const prev = current; @@ -107,6 +132,7 @@ function applyTab(tab) { updateGlobeActivity(); } if (tab === 'you') you?.render(); + if (tab === 'threads') threads?.refresh?.(); if (tab === 'globe' && two) chrome?.renderLeadView(); if (tab !== 'globe') chrome?.hidePick(); } @@ -146,32 +172,86 @@ new MutationObserver(updateGlobeActivity).observe(app, { attributeFilter: ['class'], }); +/* ---------------- Realtime + rooms ---------------- */ +const rt = createRealtime(); +const rooms = createRooms({ rt }); + +/** The feed is built before the globe mounts; it talks to it lazily. */ +const lazyGlobe = { + thumbnail: async (s, o) => (await globeReady).thumbnail(s, o), + eyeFrameUrl: (id) => globe?.eyeFrameUrl(id) ?? null, + setViewer: (v) => globeReady.then((g) => g.setViewer(v)).catch(() => {}), +}; + +function locate({ quiet = false } = {}) { + return new Promise((resolve) => { + if (!navigator.geolocation) return resolve(null); + navigator.geolocation.getCurrentPosition( + (p) => { + const loc = { + lat: p.coords.latitude, + lon: p.coords.longitude, + acc: p.coords.accuracy, + }; + userLoc = loc; + resolve(loc); + }, + () => { + if (!quiet) + toast('Location unavailable — distances are hidden', { + icon: icon('location'), + }); + resolve(null); + }, + { maximumAge: 600000, timeout: 10000 }, + ); + }); +} +let userLoc = null; + /* ---------------- Feed ---------------- */ const feed = createFeed(views.now, { getSituations: () => getSituations({ fresh: true }), onOpen: (s, card) => openSituation(s, card), - onLocate: () => - new Promise((resolve) => { - if (!navigator.geolocation) return resolve(null); - navigator.geolocation.getCurrentPosition( - (p) => resolve({ lat: p.coords.latitude, lon: p.coords.longitude }), - () => { - toast('Location unavailable — sorting from San Francisco', { - icon: icon('location'), - }); - resolve(null); - }, - { maximumAge: 600000, timeout: 10000 }, - ); - }), + onOpenRoom: (s, card) => openSituation(s, card, { tab: 'room' }), + onWitness: (w, card) => openWitness(w, card), + onCapture: () => selectTab('capture'), + onLocate: () => locate(), bindScroll: (el) => tabbar.bindScroll(el), + globe: lazyGlobe, + rooms, + rt, }); -function openSituation(s, card = null) { +// Distances appear by themselves when location was already granted. +navigator.permissions + ?.query({ name: 'geolocation' }) + .then((st) => { + if (st.state === 'granted') + locate({ quiet: true }).then((l) => l && feed.setLocation(l)); + }) + .catch(() => {}); + +function openSituation(s, card = null, opts = {}) { if (!world) return; if (current === 'capture') applyTab('now'); if (narrative?.active) narrative.stop(); - world.open(s, card); + world.open(s, card, opts); +} + +async function openWitness(w, card) { + if (w.liveNow) { + world?.watchLive?.(w); + return; + } + const sid = w.post?.room; + const s = sid ? situationById(sid) : null; + if (s) return openSituation(s, card, { tab: 'room', focusPost: w.post.id }); + if (w.bundle) inspector?.open(w.bundle); + else + toast('Unverified upload · post it to a room from Capture', { + icon: icon('upload'), + }); } function refreshAccessory() { @@ -191,6 +271,12 @@ globeReady isTwoPane, onThread: (s) => openThreadFor(s), onFollowChange: refreshAccessory, + rooms, + rt, + getChrome: () => chrome, + getLocation: () => userLoc, + onCapture: (s, o) => captureFor(s, o), + onWatchLive: (w) => liveViewer?.open(w), }); world.on('open', (s) => feed.select(s.id)); world.on('close', () => feed.select(null)); @@ -203,6 +289,15 @@ globeReady onThread: (s) => openThreadFor(s), getSituations, isTwoPane, + onPin: (p) => { + const m = /^post:(.+)$/.exec(p.id || ''); + if (!m) return false; + const sid = m[1].split('~')[0]; + const s = situationById(sid); + if (!s) return false; + openSituation(s, null, { tab: 'room', focusPost: m[1] }); + return true; + }, }); narrative = createNarrative({ app, @@ -213,6 +308,10 @@ globeReady }); threads = createThreads(views.threads, { bindScroll: (el) => tabbar.bindScroll(el), + rooms, + rt, + getSituations, + onOpenRoom: (s) => openSituation(s, null, { tab: 'room' }), onPlay: (thread) => { if (world.current) world.close(); narrative.play(thread); @@ -241,6 +340,11 @@ globeReady onOpenSituation: (s) => openSituation(s), onVerify: (b) => inspector.open(b), onCredits: () => chrome.openCredits(), + rooms, + rt, + onOpenRoom: (s, postId) => + openSituation(s, null, { tab: 'room', focusPost: postId }), + onLocate: () => locate(), }); search = createSearch({ getSituations, @@ -257,14 +361,51 @@ globeReady toast('Lens on', { icon: icon('layers') }); }, }); + liveViewer = createLiveViewer({ rt, globe: g }); + const attach = createAttach({ + rooms, + globe: g, + getSituations, + onPosted: async (s, p) => { + situationsCache = null; + await getSituations(); + feed.refreshSocial(); + threads?.refresh?.(); + if (current === 'you') you.render(); + toastOpen(s, p); + }, + }); const { createCapture } = await import('./ui/capture.js'); capture = createCapture(views.capture, { globe: g, + rt, + attach, + getLocation: () => userLoc, onSealed: () => { + feed.refreshSocial(); if (current === 'you') you.render(); }, onVerify: (b) => inspector.open(b), }); + // Holograms and witness posts you left stay pinned on the globe. + rooms + .myPosts() + .then((list) => { + for (const p of list) + if ( + p.anchor && + Number.isFinite(p.anchor.lat) && + (p.hologram || p.evidence?.sealed) + ) + g.addPin({ + id: `post:${p.id}`, + lon: p.anchor.lon, + lat: p.anchor.lat, + kind: p.hologram ? 'hologram' : 'witness', + label: p.hologram ? 'Hologram' : 'Your witness', + }); + }) + .catch(() => {}); // The back chevron also stops a playing narrative. $('.world-back').addEventListener( @@ -286,16 +427,7 @@ globeReady (s.events || []).map((ev) => ({ at: +new Date(ev.at), label: ev.label, - color: { - sky: '#7CC4FF', - sea: '#38E1C6', - orbit: '#C9A7FF', - hazards: '#FF6A3D', - eyes: '#FFD166', - ground: '#9BE15D', - weather: '#8AB4F8', - infra: '#B8C0CC', - }[s.family], + color: BAND_COLOR[bandOf(s)], })), ), ); @@ -320,6 +452,13 @@ globeReady toast('The globe could not start on this device'); }); +/** Reply with evidence: open Capture with this situation pre-attached. */ +async function captureFor(s, opts = {}) { + if (world?.current && !isTwoPane()) await world.close(); + selectTab('capture'); + capture?.attachTo?.(s, opts); +} + async function openThreadFor(s) { if (!threads) return; if (world?.current) await world.close(); diff --git a/mobile/styles/capture.css b/mobile/styles/capture.css index 7bf56f9ba..403b6e542 100644 --- a/mobile/styles/capture.css +++ b/mobile/styles/capture.css @@ -63,7 +63,7 @@ position: relative; width: min(340px, 100%); height: 46px; - border-radius: 23px; + border-radius: 6px; overflow: hidden; } @@ -82,7 +82,7 @@ height: 0; margin-left: -5px; border: 5px solid transparent; - border-top: 6px solid var(--eyes); + border-top: 6px solid var(--b-inside); } .cap-caret::after { @@ -100,8 +100,10 @@ max-width: 460px; margin-top: 8px; padding: 10px 14px 10px; - border-radius: 22px; - font-size: var(--t-cap); + border-radius: 8px; + font-family: var(--mono); + font-size: 0.625rem; + letter-spacing: 0.04em; } .cap[data-mode='witness'] .hud { @@ -158,7 +160,7 @@ font-size: 0.5625rem; font-weight: 700; letter-spacing: 0.06em; - color: var(--eyes); + color: var(--b-inside); opacity: 0.8; } @@ -218,9 +220,9 @@ .cap-level.level i { background: linear-gradient( 90deg, - var(--eyes) 0 38%, + var(--b-inside) 0 38%, transparent 38% 62%, - var(--eyes) 62% + var(--b-inside) 62% ); } @@ -261,31 +263,37 @@ .cap-modes { display: flex; - gap: 6px; - padding: 4px; - border-radius: var(--r-pill); - background: rgba(0, 0, 0, 0.35); + gap: 2px; + padding: 3px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.45); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); } .cap-modes button { min-height: 34px; - padding: 0 14px; - border-radius: var(--r-pill); - font-size: var(--t-cap); - font-weight: 750; - letter-spacing: 0.1em; + padding: 0 12px; + border-radius: 6px; + font-family: var(--mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; text-transform: uppercase; - color: rgba(255, 255, 255, 0.75); + color: rgba(255, 255, 255, 0.7); transition: color var(--dur-1) linear, background var(--dur-2) linear; } .cap-modes button[aria-selected='true'] { - color: var(--eyes); - background: rgba(255, 209, 102, 0.14); + color: var(--b-inside); + background: rgba(235, 199, 119, 0.14); +} + +.cap-modes button[data-mode='live'][aria-selected='true'] { + color: #fff; + background: rgba(255, 255, 255, 0.14); } .cap-controls { @@ -391,6 +399,693 @@ visibility: hidden; } +/* Live: the shutter becomes a broadcast button. */ +.shutter-label { + display: none; + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + font-size: 0.5625rem; + color: #fff; + white-space: nowrap; + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8); +} + +.cap[data-mode='live'] .shutter-core { + width: 58px; + height: 58px; + background: var(--b-inside); + box-shadow: inset 0 0 0 6px rgba(29, 20, 6, 0.25); +} + +.cap[data-mode='live'] .shutter-ring { + border-color: var(--b-inside); +} + +.cap.is-live .shutter-core { + transform: scale(0.52); + border-radius: 8px; + background: #fff; +} + +.cap.is-live .shutter-ring { + animation: live-ring 2s ease-in-out infinite; +} + +@keyframes live-ring { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(235, 199, 119, 0.5); + } + 50% { + box-shadow: 0 0 0 10px rgba(235, 199, 119, 0); + } +} + +.cap[data-mode='upload'] .shutter { + visibility: hidden; +} + +.live-hud { + display: none; + position: absolute; + z-index: 4; + left: calc(14px + var(--safe-l)); + right: calc(14px + var(--safe-r)); + top: calc(var(--safe-t) + 64px); + bottom: calc(var(--chrome-bottom) + 150px); + pointer-events: none; +} + +.cap[data-mode='live'] .live-hud { + display: block; +} + +.cap[data-mode='live'] .hud { + display: none; +} + +.lh-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.lh-pill { + display: inline-flex; + align-items: center; + gap: 7px; + height: 28px; + padding: 0 10px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.55); + font-size: 0.6875rem; + color: var(--ink-2); +} + +.lh-pill b { + font-weight: 700; + color: var(--ink-3); +} + +.lh-pill i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--ink-4); +} + +.cap.is-live .lh-pill { + background: var(--b-inside); + color: #1d1406; +} + +.cap.is-live .lh-pill b { + color: #1d1406; +} + +.cap.is-live .lh-pill i { + background: #1d1406; + animation: breathe 1.2s ease-in-out infinite; +} + +.lh-view { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.55); + font-size: 0.625rem; +} + +.lh-chain { + position: absolute; + left: 0; + bottom: 26px; + display: flex; + flex-direction: column; + gap: 3px; +} + +.seg { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 7px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.55); + font-size: 0.5625rem; + color: var(--b-inside); + animation: view-in 300ms var(--ease-out); +} + +.seg i { + width: 6px; + height: 6px; + background: var(--b-inside); + clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); +} + +.seg.pending { + color: var(--ink-3); +} + +.seg.pending i { + background: var(--ink-4); +} + +.lh-tele { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 6px 8px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.55); + font-size: 0.5625rem; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Reply target chip */ +.cap-target { + position: absolute; + z-index: 5; + left: 50%; + top: calc(var(--safe-t) + 112px); + transform: translateX(-50%); + max-width: calc(100% - 32px); + display: inline-flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 10px 0 12px; + border-radius: 6px; + background: rgba(235, 199, 119, 0.92); + color: #1d1406; + font-size: 0.8125rem; + white-space: nowrap; +} + +.cap-target span { + overflow: hidden; + text-overflow: ellipsis; +} + +.cap-target[hidden] { + display: none; +} + +.cap[data-mode='live'] .cap-target { + top: calc(var(--safe-t) + 100px); +} + +/* Upload */ +.cap-upload { + position: absolute; + z-index: 6; + inset: 0; + overflow-y: auto; + padding: calc(var(--safe-t) + 70px) calc(18px + var(--safe-r)) + calc(var(--chrome-bottom) + 70px) calc(18px + var(--safe-l)); + background: + repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.018) 0 8px, + transparent 8px 16px + ), + #060708; +} + +.cap-upload[hidden] { + display: none; +} + +.up-pick { + max-width: 440px; + margin: 8vh auto 0; +} + +.up-pick[hidden] { + display: none; +} + +.up-ic { + display: grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: 10px; + box-shadow: inset 0 0 0 1px var(--hairline-strong); + font-size: 1.5rem; +} + +.up-pick h2 { + margin: 16px 0 8px; + font-size: 1.5rem; + letter-spacing: -0.025em; +} + +.up-pick p { + margin: 0 0 22px; + color: var(--ink-3); + font-size: 0.9375rem; + line-height: 1.45; +} + +.up-pick p b { + color: var(--ink); +} + +.up-btn { + display: inline-flex; + cursor: pointer; +} + +.up-place { + max-width: 520px; + margin: 0 auto; +} + +.up-place[hidden] { + display: none; +} + +.up-head { + display: flex; + align-items: center; + gap: 12px; +} + +.up-thumb { + flex: none; + width: 64px; + height: 64px; + border-radius: 6px; + overflow: hidden; + background: var(--ground-3); +} + +.up-thumb img, +.up-thumb video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.up-name { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.up-name b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.up-name .tele { + font-size: 0.5625rem; + color: var(--ink-3); +} + +.up-exif { + display: grid; + grid-template-columns: auto 1fr; + gap: 5px 12px; + margin: 14px 0 4px; + padding: 10px 0; + border-top: 0.5px solid var(--hairline); + border-bottom: 0.5px solid var(--hairline); + font-size: 0.8125rem; +} + +.up-exif dt { + font-size: 0.5625rem; + color: var(--ink-3); + align-self: center; +} + +.up-exif dd { + margin: 0; + color: var(--ink-2); +} + +.up-h { + display: flex; + align-items: center; + gap: 6px; + margin: 14px 0 8px; + font-size: 0.5625rem; + color: var(--ink-2); +} + +.up-time { + width: 100%; + min-height: 44px; + padding: 0 12px; + border: 0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + font-family: var(--mono); + font-size: 0.875rem; + color-scheme: dark; +} + +.up-why { + display: flex; + gap: 8px; + margin: 14px 0 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--ink-3); +} + +.up-why .i { + flex: none; + margin-top: 2px; +} + +.up-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* Mini-map */ +.minimap { + position: relative; + height: 220px; + border-radius: 8px; + overflow: hidden; + background: #0c0e10; + cursor: grab; + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.minimap.dragging { + cursor: grabbing; +} + +.mm-pane { + position: absolute; + inset: 0; +} + +.mm-pane img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.mm-next { + opacity: 0; +} + +.mm-pin { + position: absolute; + z-index: 2; + left: 50%; + top: 50%; + transform: translate(-50%, -100%); + color: var(--b-inside); + font-size: 1.8rem; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.7)); + pointer-events: none; +} + +.mm-pin .i path:first-child { + fill: rgba(29, 20, 6, 0.6); +} + +.mm-cross { + position: absolute; + z-index: 1; + left: 50%; + top: 50%; + width: 7px; + height: 7px; + margin: -3.5px 0 0 -3.5px; + border-radius: 50%; + background: var(--b-inside); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.6); +} + +.mm-zoom { + position: absolute; + z-index: 2; + right: 8px; + top: 8px; + display: flex; + flex-direction: column; + border-radius: 6px; + overflow: hidden; + background: rgba(0, 0, 0, 0.6); +} + +.mm-zoom button { + display: grid; + place-items: center; + width: 36px; + height: 36px; +} + +.mm-zoom button + button { + border-top: 0.5px solid var(--hairline-strong); +} + +.mm-strip { + position: absolute; + z-index: 2; + left: 0; + right: 0; + bottom: 0; + padding: 14px 10px 7px; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent); + font-size: 0.5625rem; + color: #fff; +} + +.mm-label { + position: absolute; + z-index: 2; + left: 10px; + top: 10px; + padding: 3px 6px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.6); + font-size: 0.5rem; + color: var(--b-inside); +} + +/* Attach sheet */ +.att-head { + margin-top: 6px; + display: flex; + gap: 12px; + align-items: flex-start; +} + +.att-thumb { + position: relative; + flex: none; + width: 58px; + height: 58px; + border-radius: 6px; + overflow: hidden; + background: var(--ground-3); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--b-inside) 50%, transparent); +} + +.att-thumb.un { + box-shadow: inset 0 0 0 1px var(--ink-3); +} + +.att-thumb img, +.att-thumb video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.att-thumb > span { + position: absolute; + right: 3px; + bottom: 3px; + color: var(--b-inside); + --seal-ink: #1d1406; + font-size: 0.95rem; +} + +.att-thumb.un > span { + color: #fff; +} + +.att-tele { + font-size: 0.5625rem; + color: var(--b-inside); +} + +.att-sub { + margin-top: 2px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.attach-sheet .sheet-body { + overflow-y: auto; + padding-top: 12px; +} + +.att-list { + display: flex; + flex-direction: column; + border-top: 0.5px solid var(--hairline); +} + +.att-row { + display: flex; + align-items: center; + gap: 12px; + min-height: 56px; + padding: 8px 2px; + border-bottom: 0.5px solid var(--hairline); + text-align: left; +} + +.ar-dot { + flex: none; + width: 18px; + height: 18px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px var(--ink-3); +} + +.att-row[aria-checked='true'] .ar-dot { + box-shadow: + inset 0 0 0 1.5px var(--band), + inset 0 0 0 5px var(--ground-1), + inset 0 0 0 9px var(--band); +} + +.ar-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ar-main b { + font-size: 0.9375rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ar-main .tele { + font-size: 0.5625rem; + color: var(--ink-3); +} + +.att-row .bdg { + flex: none; +} + +.att-row.new .i { + color: var(--ink-3); +} + +.att-name { + margin: 8px 0 0; + min-height: 44px; + padding: 0 12px; + border: 0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.att-name[hidden] { + display: none; +} + +.att-h { + margin: 16px 0 8px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.att-note { + min-height: 80px; + margin-top: 10px; +} + +.att-fine { + margin: 8px 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--ink-4); +} + +.att-holo { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + padding: 12px; + border-radius: 8px; + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.ah-ic { + flex: none; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 6px; + background: rgba(235, 199, 119, 0.12); + color: var(--b-inside); + font-size: 1.2rem; +} + +.ah-t { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ah-t b { + font-size: 0.875rem; +} + +.ah-t span { + font-size: 0.75rem; + color: var(--ink-3); +} + +.att-post { + width: 100%; + min-height: 52px; + margin-top: 16px; +} + .cap-legend { display: none; flex-wrap: wrap; @@ -428,8 +1123,9 @@ font-size: var(--t-cap2); } -.cap[data-mode='look'] .cap-controls { - margin-top: -80px; +.cap[data-mode='look'] .cap-controls, +.cap[data-mode='upload'] .cap-controls { + height: 60px; } /* Permission explainer */ @@ -509,7 +1205,7 @@ .gate-card li > .i { font-size: 1.4rem; - color: var(--eyes); + color: var(--b-inside); margin-top: 2px; } @@ -732,7 +1428,7 @@ .seal-spark path { fill: none; - stroke: var(--eyes); + stroke: var(--b-inside); stroke-width: 1.3; vector-effect: non-scaling-stroke; } @@ -771,7 +1467,8 @@ .seal-actions { display: flex; - gap: 10px; + flex-wrap: wrap; + gap: 8px; width: 100%; margin-top: 12px; animation: view-in 400ms 1100ms var(--ease-out) both; @@ -779,5 +1476,13 @@ .seal-actions .pill-btn { flex: 1; - min-height: 48px; + min-height: 46px; +} + +.seal-actions [data-seal='post'] { + flex-basis: 100%; +} + +.att-holo .switch { + --fam: var(--b-inside); } diff --git a/mobile/styles/cards.css b/mobile/styles/cards.css new file mode 100644 index 000000000..65f212bfb --- /dev/null +++ b/mobile/styles/cards.css @@ -0,0 +1,853 @@ +/* ------------------------------------------------------------------ */ +/* Card archetypes: Sensor · Eye · Track · Number · Witness · Room */ +/* Sensor framing: corner brackets, hairline reticle, one strip. */ +/* ------------------------------------------------------------------ */ + +.card { + position: relative; + border-radius: 6px; + background: var(--ground-1); + overflow: hidden; + content-visibility: auto; + contain-intrinsic-size: auto 300px; + transition: transform var(--dur-2) var(--spring); + box-shadow: inset 0 0 0 0.5px var(--hairline); +} + +.card:has(.card-hit:active) { + transform: scale(0.97); +} + +.card.is-selected { + box-shadow: + inset 0 0 0 1.5px var(--band), + 0 0 0 3px color-mix(in srgb, var(--band) 22%, transparent); +} + +.card:has(.card-hit:focus-visible) { + outline: 2.5px solid var(--tint); + outline-offset: 2px; +} + +.card-hit { + position: absolute; + inset: 0; + z-index: 5; + border-radius: inherit; +} + +.card-hit:focus-visible { + outline: none; +} + +.card:not([data-band]) { + --band: var(--ink-3); +} + +.card-media { + position: relative; + overflow: hidden; + background: + radial-gradient( + 120% 90% at 50% 40%, + color-mix(in srgb, var(--band) 10%, #0c0e11), + #07080a + ), + #07080a; +} + +.card-media > img, +.card-media > video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transform: scale(1.03); + transition: + opacity 520ms var(--ease-out), + transform 900ms var(--ease-out); +} + +.card-media > img.loaded, +.card-media > video.loaded { + opacity: 1; + transform: none; +} + +.card-media > img.failed { + display: none; +} + +/* The plain still waits, desaturated, for its composited frame. */ +.card-media > img.still { + filter: saturate(0.4) brightness(0.62); +} + +.card-media > img.composited { + animation: composite-in 600ms var(--ease-out); +} + +@keyframes composite-in { + from { + filter: saturate(0.4) brightness(0.62); + } + to { + filter: none; + } +} + +.card.is-stale .card-media img { + filter: saturate(0.25) brightness(0.8); +} + +/* Corner brackets */ +.fr { + position: absolute; + inset: 8px; + z-index: 2; + pointer-events: none; +} + +.fr i { + position: absolute; + width: 11px; + height: 11px; + border: 0 solid color-mix(in srgb, var(--band) 70%, #fff); + opacity: 0.85; +} + +.fr i:nth-child(1) { + left: 0; + top: 0; + border-left-width: 1.25px; + border-top-width: 1.25px; +} + +.fr i:nth-child(2) { + right: 0; + top: 0; + border-right-width: 1.25px; + border-top-width: 1.25px; +} + +.fr i:nth-child(3) { + left: 0; + bottom: 0; + border-left-width: 1.25px; + border-bottom-width: 1.25px; +} + +.fr i:nth-child(4) { + right: 0; + bottom: 0; + border-right-width: 1.25px; + border-bottom-width: 1.25px; +} + +/* Hairline reticle: a 16 px cross with an open centre. */ +.reticle { + position: absolute; + z-index: 2; + left: 50%; + top: 50%; + width: 17px; + height: 17px; + margin: -8.5px 0 0 -8.5px; + opacity: 0.5; + pointer-events: none; + background: + linear-gradient(#fff, #fff) center top / 1px 6px no-repeat, + linear-gradient(#fff, #fff) center bottom / 1px 6px no-repeat, + linear-gradient(#fff, #fff) left center / 6px 1px no-repeat, + linear-gradient(#fff, #fff) right center / 6px 1px no-repeat; +} + +.c-top { + position: absolute; + z-index: 3; + left: 14px; + right: 14px; + top: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: rgba(255, 255, 255, 0.86); + font-size: 0.5625rem; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7); + pointer-events: none; +} + +.c-top .tele { + font-size: 0.5625rem; +} + +.c-sid { + display: inline-flex; + align-items: center; + gap: 4px; + opacity: 0.8; +} + +.c-sid .i { + font-size: 0.75rem; +} + +.c-live { + display: inline-flex; + align-items: center; + gap: 5px; + color: #fff; + font-weight: 600; +} + +.c-live i { + position: relative; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--band); + box-shadow: 0 0 6px var(--band); +} + +.c-live i::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: inherit; + animation: pulse-ring 1.8s var(--ease-out) infinite; +} + +.c-live.recon { + color: var(--b-inside); +} + +.c-live.onair i { + background: var(--b-inside); +} + +/* The telemetry strip, burned into the frame. */ +.c-strip { + position: absolute; + z-index: 3; + left: 0; + right: 0; + bottom: 0; + padding: 18px 12px 8px; + background: linear-gradient(to top, rgba(3, 4, 5, 0.82), rgba(3, 4, 5, 0)); + color: rgba(255, 255, 255, 0.82); + font-size: 0.5625rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.card-body { + padding: 9px 10px 11px; +} + +.c-band { + display: flex; + align-items: center; + gap: 5px; + color: var(--ink-3); + font-size: 0.5625rem; + white-space: nowrap; + overflow: hidden; +} + +.c-band b { + font-weight: 400; + color: var(--ink-4); +} + +.c-band .bd { + flex: none; + width: 5px; + height: 5px; + border-radius: 1px; + background: var(--band); +} + +.card-title { + margin: 5px 0 0; + font-size: 0.875rem; + font-weight: 650; + line-height: 1.26; + letter-spacing: -0.012em; + min-height: 2.52em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-place { + display: flex; + align-items: center; + gap: 6px; + margin-top: 3px; + font-size: 0.6875rem; + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; +} + +.card-place span { + overflow: hidden; + text-overflow: ellipsis; +} + +.c-stale { + flex: none; + color: var(--ink-4); + font-size: 0.5rem; +} + +.c-cta { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + margin-top: 8px; + padding: 0 9px; + border-radius: 3px; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--band) 55%, transparent); + color: var(--band); + font-size: 0.5625rem; +} + +.c-cta .i { + font-size: 0.8125rem; +} + +/* ---------- Track: one moving thing, a big readout ---------- */ +.c-readout { + position: absolute; + z-index: 3; + left: 14px; + right: 14px; + bottom: 30px; + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 4px; + color: #fff; + text-shadow: 0 1px 12px rgba(0, 0, 0, 0.6); + pointer-events: none; +} + +.c-readout b { + font-family: var(--mono); + font-size: 1.75rem; + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1; +} + +.c-readout small { + font-family: var(--mono); + font-size: 0.75rem; + opacity: 0.8; +} + +.c-readout .tele { + flex-basis: 100%; + margin-top: 4px; + color: var(--band); + font-size: 0.5625rem; +} + +.c-track.is-harm .c-readout b, +.c-track.is-harm .c-readout .tele { + color: var(--harm); +} + +.c-track.is-harm .fr i { + border-color: var(--harm); +} + +/* ---------- Number: typography is the picture ---------- */ +.c-number .card-media { + background: #07080a; +} + +.c-number .card-media > img.c-num-bg.loaded { + opacity: 0.2; + filter: grayscale(1) contrast(1.2); +} + +.c-number .fr i { + opacity: 0.45; +} + +.c-num { + position: absolute; + z-index: 3; + inset: 16px 14px 28px; + display: flex; + flex-direction: column; + pointer-events: none; +} + +.c-big { + margin-top: auto; + font-family: var(--font-display); + font-size: clamp(2.2rem, 11vw, 3rem); + font-weight: 800; + letter-spacing: -0.045em; + line-height: 0.95; + color: #fff; +} + +@media (min-width: 600px) { + .c-big { + font-size: 2.6rem; + } +} + +.c-big small { + margin-left: 3px; + font-size: 0.4em; + font-weight: 650; + letter-spacing: 0; + color: var(--band); +} + +.c-big-l { + margin-top: 4px; + font-size: 0.75rem; + font-weight: 600; + color: var(--ink-2); +} + +.c-spark { + width: 100%; + height: 42px; + margin-top: 10px; + overflow: visible; +} + +.c-spark .a { + fill: color-mix(in srgb, var(--band) 16%, transparent); +} + +.c-spark .l { + fill: none; + stroke: var(--band); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.c-spark-l { + margin-top: 3px; + font-size: 0.5rem; + color: var(--ink-4); +} + +.c-number .card-title { + min-height: 0; + margin-top: 0; + -webkit-line-clamp: 2; +} + +/* ---------- Eye: the street, live ---------- */ +.c-eye .card-media > img { + transform: none; + transition: opacity 700ms var(--ease-out); +} + +.c-eye .card-media > img.eye-b { + opacity: 0; +} + +.c-eye .card-media::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: + linear-gradient(to top, rgba(3, 4, 5, 0.78) 0, rgba(3, 4, 5, 0) 48%), + linear-gradient(to bottom, rgba(3, 4, 5, 0.45) 0, rgba(3, 4, 5, 0) 22%); +} + +.c-eye.flash .c-live { + animation: eye-tick 900ms var(--ease-out); +} + +@keyframes eye-tick { + 0% { + color: var(--b-ground); + } + 100% { + color: #fff; + } +} + +.c-eye-cap { + position: absolute; + z-index: 3; + left: 14px; + right: 14px; + bottom: 30px; + display: flex; + align-items: flex-end; + gap: 10px; + pointer-events: none; +} + +.c-eye-cap .card-title { + min-height: 0; + margin: 0; + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.02em; + color: #fff; + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.6); + -webkit-line-clamp: 1; +} + +.c-eye-cap .card-place { + color: rgba(255, 255, 255, 0.7); +} + +/* View direction: a compass ring with the camera's cone. */ +.c-fov { + flex: none; + position: relative; + width: 28px; + height: 28px; + border-radius: 50%; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45); +} + +.c-fov::before { + content: 'N'; + position: absolute; + top: -1px; + left: 50%; + transform: translate(-50%, -100%); + font-family: var(--mono); + font-size: 7px; + color: rgba(255, 255, 255, 0.6); +} + +.c-fov i { + position: absolute; + inset: 0; + border-radius: 50%; + background: conic-gradient( + from calc(var(--hd) - 25deg), + color-mix(in srgb, var(--b-ground) 80%, transparent) 0 50deg, + transparent 50deg + ); + -webkit-mask: radial-gradient(circle, #000 0 62%, transparent 63%); + mask: radial-gradient(circle, #000 0 62%, transparent 63%); +} + +.c-eye .card-body.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 12px 10px; +} + +.c-src { + flex: none; + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ink-4); + font-size: 0.5rem; +} + +.eye-offline .c-live { + opacity: 0.5; +} + +/* ---------- Witness: you, sealed ---------- */ +.c-witness .fr i { + border-color: var(--b-inside); +} + +.c-seal { + display: grid; + place-items: center; + width: 22px; + height: 22px; + color: var(--b-inside); + --seal-ink: #1d1406; + filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.6)); +} + +.c-seal .i { + font-size: 1.1rem; +} + +.c-seal.un { + color: var(--ink-2); +} + +.c-witness.is-unverified .fr i { + border-color: var(--ink-3); + border-style: dashed; +} + +.c-witness.is-onair { + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--b-inside) 60%, transparent); +} + +/* The prompt: an empty frame that asks you to fill it. */ +.c-prompt .card-media { + background: + repeating-linear-gradient( + 135deg, + rgba(235, 199, 119, 0.035) 0 6px, + transparent 6px 12px + ), + #08090a; +} + +.c-prompt .fr i { + border-color: var(--b-inside); +} + +.c-prompt-in { + position: absolute; + inset: 22px 18px 34px; + z-index: 3; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 6px; + pointer-events: none; +} + +.c-prompt-ic { + position: absolute; + top: 0; + left: 0; + color: var(--b-inside); + font-size: 1.5rem; +} + +.c-prompt-in b { + font-size: 0.9375rem; + line-height: 1.25; + letter-spacing: -0.015em; +} + +.c-prompt-in > span:last-child { + font-size: 0.71875rem; + color: var(--ink-3); + line-height: 1.35; +} + +.c-prompt .card-body { + padding-top: 0; +} + +.c-prompt .c-cta { + --band: var(--b-inside); + margin-top: 8px; +} + +/* ---------- Room: a conversation heating up ---------- */ +.c-room { + background: + radial-gradient( + 120% 140% at 0% 0%, + rgba(235, 199, 119, 0.06), + rgba(235, 199, 119, 0) 60% + ), + var(--ground-1); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.c-room-in { + padding: 13px 14px 12px; +} + +.c-room-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.c-room-head .c-band { + color: var(--ink-2); +} + +.c-watch { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ink-2); + font-size: 0.5625rem; +} + +.c-watch i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--b-inside); + animation: breathe 1.6s ease-in-out infinite; +} + +.c-claim { + margin: 10px 0 10px; + padding: 0 0 0 12px; + border-left: 2px solid var(--b-inside); + font-size: 1.0625rem; + font-weight: 600; + line-height: 1.32; + letter-spacing: -0.016em; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.c-claim.is-example { + border-left-style: dashed; + color: var(--ink-2); +} + +.c-room[data-status='disputed'] .c-claim { + border-left-style: dashed; + border-left-color: var(--ink-3); +} + +.c-room-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} + +.c-status { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 8px; + border-radius: 3px; + font-size: 0.5625rem; + color: var(--b-inside); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--b-inside) 50%, transparent); + --seal-ink: #1d1406; +} + +.c-status .i { + font-size: 0.8125rem; +} + +.c-status[data-status='disputed'] { + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--ink-3); + background: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05) 0 4px, + transparent 4px 8px + ); +} + +.c-status[data-status='open'] { + color: var(--ink-3); + box-shadow: inset 0 0 0 1px var(--hairline-strong); +} + +.c-kind { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ink-3); + font-size: 0.5625rem; +} + +.c-kind .i { + font-size: 0.8125rem; +} + +.c-room-foot { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding-top: 10px; + border-top: 0.5px solid var(--hairline); + color: var(--ink-3); +} + +.c-room-foot .tele { + flex: 1; + font-size: 0.5625rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c-join { + color: var(--ink-2); +} + +.avs { + display: inline-flex; + padding-left: 6px; +} + +.av { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + margin-left: -6px; + border-radius: 50%; + background: hsl(var(--h) 28% 30%); + box-shadow: 0 0 0 1.5px var(--ground-1); + font-style: normal; + font-size: 0.625rem; + font-weight: 700; + color: hsl(var(--h) 60% 86%); +} + +.av.self { + background: var(--b-inside); + color: #1d1406; +} + +.av.live { + box-shadow: + 0 0 0 1.5px var(--ground-1), + 0 0 0 3px var(--b-inside); +} + +.card.pulse { + animation: card-pulse 1.6s var(--ease-out); +} + +@keyframes card-pulse { + 0% { + box-shadow: inset 0 0 0 1.5px var(--band); + } + 100% { + box-shadow: inset 0 0 0 0.5px var(--hairline); + } +} + +@media (prefers-reduced-motion: reduce) { + .c-live i::after, + .c-watch i { + animation: none; + } +} diff --git a/mobile/styles/feed.css b/mobile/styles/feed.css index 03e709172..a6c75a1fa 100644 --- a/mobile/styles/feed.css +++ b/mobile/styles/feed.css @@ -1,44 +1,81 @@ /* ------------------------------------------------------------------ */ -/* Now feed */ +/* Now feed: header, altitude filter, masonry + wide rows */ /* ------------------------------------------------------------------ */ #view-now { - background: - radial-gradient( - 140% 50% at 50% -10%, - rgba(60, 110, 190, 0.16), - rgba(5, 7, 11, 0) 60% - ), - var(--ground); + background: var(--ground); } .feed-inner { will-change: transform; } -.feed-tabs { +.feed-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding-right: calc(var(--gutter) + var(--safe-r)); +} + +.feed-hero .large-title { + letter-spacing: -0.035em; +} + +.feed-clock { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + padding-bottom: 6px; + color: var(--ink-3); + white-space: nowrap; +} + +.feed-clock b { + color: var(--ink); + font-weight: 600; + font-size: 0.75rem; + letter-spacing: 0.04em; +} + +#view-now .title-sub { + gap: 7px; + color: var(--ink-3); + font-size: 0.625rem; +} + +#view-now .title-sub .live-dot { + --fam: var(--ink); + width: 5px; + height: 5px; +} + +/* Altitude filter: an altimeter, not a segmented control. */ +.bandbar { position: sticky; top: calc(var(--safe-t) + 52px); z-index: 4; display: flex; - gap: 0; - margin-top: 12px; - padding: 0 calc(6px + var(--safe-r)) 0 calc(6px + var(--safe-l)); + align-items: stretch; + gap: 2px; + margin-top: 14px; + padding: 0 calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l)); overflow-x: auto; scrollbar-width: none; white-space: nowrap; } -.feed-tabs::-webkit-scrollbar { +.bandbar::-webkit-scrollbar { display: none; } -.feed-tabs::before { +.bandbar::before { content: ''; position: absolute; inset: 0; z-index: -1; - background: rgba(8, 11, 17, 0.62); + background: rgba(5, 6, 7, 0.82); -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); border-bottom: 0.5px solid var(--hairline); @@ -46,7 +83,7 @@ transition: opacity var(--dur-1) linear; } -#view-now.is-scrolled .feed-tabs::before { +#view-now.is-scrolled .bandbar::before { opacity: 1; } @@ -54,38 +91,98 @@ border-bottom-color: transparent; } -.feed-tabs button { +.bandbar [data-band] { position: relative; flex: none; - height: 44px; - padding: 0 10px; - font-size: var(--t-sub); - font-weight: 600; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 3px; + min-height: 50px; + padding: 7px 10px 8px; color: var(--ink-3); - transition: - color var(--dur-1) linear, - transform var(--dur-1) var(--ease-out); + transition: color var(--dur-1) linear; } -.feed-tabs button[aria-selected='true'] { - color: var(--ink); - font-weight: 700; -} - -.feed-tabs-ink { +.bandbar [data-band]::before { + content: ''; position: absolute; - left: 0; - bottom: 6px; - height: 3px; - border-radius: 2px; - background: var(--ink); + left: 10px; + right: 10px; + top: 0; + height: 1px; + background: var(--hairline-strong); transition: - transform var(--dur-3) var(--spring), - width var(--dur-3) var(--spring); + height var(--dur-2) var(--ease-out), + background var(--dur-2) linear; +} + +.bandbar [data-band][aria-selected='true'] { + color: var(--ink); +} + +.bandbar [data-band][aria-selected='true']::before { + height: 2px; + background: var(--band, var(--ink)); +} + +.bb-n { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.875rem; + font-weight: 650; + letter-spacing: -0.01em; +} + +.bb-n i { + width: 6px; + height: 6px; + border-radius: 1px; + background: var(--band); +} + +.bb-r { + font-size: 0.5625rem; + color: var(--ink-4); +} + +[aria-selected='true'] > .bb-r { + color: var(--ink-3); +} + +.navbar .nav-trailing { + gap: 6px; +} + +.near-chip { + flex: none; + align-self: center; + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: var(--r-pill); + box-shadow: inset 0 0 0 1px var(--hairline-strong); + color: var(--ink-2); + font-size: 0.8125rem; + font-weight: 600; +} + +.near-chip .i { + font-size: 0.875rem; +} + +.near-chip[aria-pressed='true'] { + background: var(--ink); + color: var(--ground); + box-shadow: none; } .feed-note { - padding: 8px calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l)); + padding: 10px calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l)); } .near-prompt { @@ -93,13 +190,15 @@ align-items: center; gap: 12px; padding: 12px 12px 12px 14px; - border-radius: 20px; + border-radius: 10px; font-size: var(--t-foot); + background: var(--ground-2); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); } .near-prompt > .i { - font-size: 1.3rem; - color: var(--tint); + font-size: 1.2rem; + color: var(--ink); } .near-prompt div { @@ -121,15 +220,18 @@ font-size: var(--t-foot); } +.feed-body { + padding: 10px calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l)); +} + +.feed-body.swap { + animation: view-in 320ms var(--ease-out) both; +} + .masonry { display: flex; align-items: flex-start; - gap: 7px; - padding: 10px calc(7px + var(--safe-r)) 0 calc(7px + var(--safe-l)); -} - -.masonry.swap { - animation: view-in 320ms var(--ease-out) both; + gap: 8px; } .mcol { @@ -137,268 +239,28 @@ min-width: 0; display: flex; flex-direction: column; - gap: 7px; + gap: 8px; } -.card { - position: relative; - border-radius: 18px; - background: var(--ground-2); - overflow: hidden; - content-visibility: auto; - contain-intrinsic-size: auto 300px; - transition: transform var(--dur-2) var(--spring); - box-shadow: inset 0 0 0 0.5px var(--hairline); +.masonry + .wide-row, +.wide-row + .masonry, +.wide-row + .wide-row { + margin-top: 8px; } -.card:has(.card-hit:active) { - transform: scale(0.965); -} - -.card.is-selected { - box-shadow: - inset 0 0 0 2px var(--fam), - 0 0 0 4px color-mix(in srgb, var(--fam) 25%, transparent); -} - -.card:has(.card-hit:focus-visible) { - outline: 2.5px solid var(--tint); - outline-offset: 2px; -} - -.card-hit { - position: absolute; - inset: 0; - z-index: 3; - border-radius: inherit; -} - -.card-hit:focus-visible { - outline: none; -} - -.card-media { - position: relative; - overflow: hidden; - background: radial-gradient( - 120% 90% at 30% 20%, - color-mix(in srgb, var(--fam, #345) 26%, #0e1520), - #0b1019 - ); -} - -.card-media img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - opacity: 0; - transform: scale(1.03); - transition: - opacity 500ms var(--ease-out), - transform 900ms var(--ease-out); -} - -.card-media img.loaded { - opacity: 1; - transform: none; -} - -.card-media img.failed { - display: none; -} - -.card-media::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - to top, - rgba(4, 6, 10, 0.78) 0%, - rgba(4, 6, 10, 0.25) 32%, - rgba(4, 6, 10, 0) 52% - ); - pointer-events: none; -} - -.card.is-stale .card-media img { - filter: saturate(0.25) brightness(0.8); -} - -.card.is-stale { - box-shadow: inset 0 0 0 1px transparent; - outline: 1.25px dotted var(--ink-4); - outline-offset: -1.25px; -} - -.card-badges { - position: absolute; - z-index: 2; - top: 8px; - left: 8px; - right: 8px; - display: flex; - justify-content: space-between; - align-items: center; - gap: 6px; -} - -.glass-tag { - background: rgba(6, 9, 14, 0.5); - -webkit-backdrop-filter: blur(12px) saturate(1.5); - backdrop-filter: blur(12px) saturate(1.5); - box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.2); - font-size: 0.625rem; - height: 22px; - min-width: 0; - overflow: hidden; -} - -.live-pill { - display: inline-flex; - align-items: center; - gap: 5px; - height: 20px; - padding: 0 7px; - border-radius: var(--r-pill); - background: rgba(255, 69, 58, 0.92); - color: #fff; - font-size: 0.5625rem; - font-weight: 800; - letter-spacing: 0.08em; - flex: none; -} - -.live-pill .live-dot { - --fam: #fff; - width: 5px; - height: 5px; -} - -.live-pill.recon { - background: rgba(201, 167, 255, 0.92); - color: #1a1030; -} - -.live-pill.recon .i { - font-size: 0.75rem; -} - -.card-metric { - position: absolute; - z-index: 2; - left: 10px; - right: 10px; - bottom: 9px; - display: flex; - align-items: baseline; - flex-wrap: wrap; - column-gap: 3px; - color: #fff; - text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5); -} - -.card-metric b { - font-family: var(--font-display); - font-size: 1.375rem; - font-weight: 750; - letter-spacing: -0.03em; - line-height: 1; -} - -.card-metric small { - font-size: 0.75rem; - font-weight: 650; - opacity: 0.85; -} - -.card-metric span { - flex-basis: 100%; - font-size: 0.5625rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--fam); - margin-top: 3px; -} - -.card-body { - padding: 9px 11px 11px; -} - -.card-title { - margin: 0; - font-size: 0.875rem; - font-weight: 650; - line-height: 1.28; - letter-spacing: -0.012em; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.card-place { - display: flex; - align-items: center; - gap: 3px; - margin-top: 4px; - font-size: 0.6875rem; - color: var(--ink-3); - white-space: nowrap; - overflow: hidden; -} - -.card-place span { - overflow: hidden; - text-overflow: ellipsis; -} - -.card-place .i { - flex: none; - font-size: 0.75rem; -} - -.card-foot { - display: flex; - justify-content: space-between; - gap: 6px; - margin-top: 7px; - font-size: 0.625rem; +.feed-empty { + padding: 60px 0; + text-align: center; color: var(--ink-4); } -.card-foot .src { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.card-foot .ago { - flex: none; -} - -.card.pulse { - animation: card-pulse 1.6s var(--ease-out); -} - -@keyframes card-pulse { - 0% { - box-shadow: inset 0 0 0 1.5px var(--fam); - } - 100% { - box-shadow: inset 0 0 0 0.5px var(--hairline); - } -} - /* Skeleton */ .card.skel .card-media, .sk-line { background: linear-gradient( 100deg, rgba(255, 255, 255, 0) 30%, - rgba(255, 255, 255, 0.07) 50%, + rgba(255, 255, 255, 0.06) 50%, rgba(255, 255, 255, 0) 70% ) var(--ground-3); @@ -407,8 +269,8 @@ } .sk-line { - height: 11px; - border-radius: 6px; + height: 10px; + border-radius: 2px; margin: 3px 0 8px; } @@ -420,11 +282,10 @@ .feed-end { text-align: center; color: var(--ink-4); - font-size: var(--t-foot); - padding: 26px 0 0; + padding: 28px 0 0; } -/* Pull to refresh */ +/* Pull to refresh: an orbit of eight ticks. */ .ptr { position: absolute; z-index: 3; @@ -448,20 +309,25 @@ .ptr-spinner i { position: absolute; - left: 12.5px; - top: 12.5px; - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--c); - transform: rotate(calc(var(--i) * 45deg)) translateY(-12px); + left: 14px; + top: 12px; + width: 2px; + height: 6px; + border-radius: 1px; + background: var(--ink); + transform: rotate(calc(var(--i) * 45deg)) translateY(-11px); opacity: clamp(0, calc((var(--p) * 8 - var(--i)) * 1), 1); } .ptr.spinning .ptr-spinner { - animation: spin 900ms linear infinite; + animation: spin 900ms steps(8) infinite; } .ptr.spinning .ptr-spinner i { - opacity: 1; + opacity: calc(0.25 + var(--i) * 0.1); +} + +/* The Near lens: the feed becomes a distance-ordered list around you. */ +#view-now.is-near .feed-count::after { + content: ' · nearest first'; } diff --git a/mobile/styles/globe-ui.css b/mobile/styles/globe-ui.css index f872ad6fc..29431aae2 100644 --- a/mobile/styles/globe-ui.css +++ b/mobile/styles/globe-ui.css @@ -806,3 +806,201 @@ .brief-sheet .sheet-head { padding-bottom: 4px; } + +/* ------------------------------------------------------------------ */ +/* Round two: instrument chrome over the globe */ +/* ------------------------------------------------------------------ */ + +.gc-readout { + height: auto; + min-height: 44px; + padding: 7px 11px 7px 10px; + border-radius: 6px; + background: rgba(5, 6, 7, 0.66); + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + gap: 3px; +} + +.gc-alt { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.6875rem; + font-weight: 600; + color: var(--band, var(--ink)); +} + +.gc-alt i { + width: 6px; + height: 6px; + border-radius: 1px; + background: var(--band, var(--ink)); +} + +.gc-ll { + font-size: 0.5625rem; + color: var(--ink-2); +} + +.gc-buttons .icon-btn.glass-strong, +.world-back.glass-strong { + border-radius: 10px; + background: rgba(5, 6, 7, 0.66); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.gc-buttons .liquid::after, +.world-back.liquid::after { + display: none; +} + +.gc-attr { + font-family: var(--mono); + font-size: 0.5rem; + letter-spacing: 0.04em; + top: calc(var(--safe-t) + 62px); +} + +/* Altimeter rail */ +.alt-rail { + position: absolute; + right: calc(14px + var(--safe-r)); + top: calc(var(--safe-t) + 190px); + height: min(34vh, 260px); + width: 64px; + pointer-events: auto; +} + +.alt-rail::before { + content: ''; + position: absolute; + right: 6px; + top: 0; + bottom: 0; + width: 1px; + background: linear-gradient( + to bottom, + var(--b-orbit) 0 40%, + var(--b-sky) 40% 72%, + var(--b-ground) 72% 100% + ); + opacity: 0.55; +} + +.alt-ticks i { + position: absolute; + right: 3px; + top: var(--y); + width: 7px; + height: 1px; + background: var(--ink-3); +} + +.alt-rail [data-alt] { + position: absolute; + right: 14px; + top: var(--y); + transform: translateY(-50%); + min-height: 32px; + padding: 0 2px; + color: var(--ink-3); +} + +.alt-rail [data-alt] span { + display: inline-block; + padding: 2px 4px; + border-radius: 2px; + background: rgba(5, 6, 7, 0.62); + font-size: 0.5rem; +} + +.alt-rail[data-band='orbit'] [data-alt='orbit'], +.alt-rail[data-band='sky'] [data-alt='sky'], +.alt-rail[data-band='ground'] [data-alt='ground'] { + color: var(--band); +} + +.alt-mark { + position: absolute; + right: 0; + top: var(--h, 0%); + transform: translateY(-50%); + transition: top 300ms var(--ease-out); +} + +.alt-mark i { + display: block; + width: 0; + height: 0; + border: 5px solid transparent; + border-right: 7px solid var(--band, var(--ink)); + filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.8)); +} + +.app.is-world .alt-rail { + display: none; +} + +/* Family chips as instrument keys */ +.fam-chip { + border-radius: 6px; + height: 36px; + background: rgba(5, 6, 7, 0.7); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + font-size: 0.8125rem; +} + +.fam-chip.on { + background: rgba(5, 6, 7, 0.82); + box-shadow: + inset 0 0 0 0.5px var(--hairline-strong), + inset 0 -2px 0 var(--fam); +} + +.fam-count { + font-family: var(--mono); + font-weight: 500; + font-size: 0.625rem; +} + +/* Ribbon: white is time, not alarm. */ +.ribbon { + border-radius: 10px; + background: rgba(5, 6, 7, 0.72); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.ribbon.liquid::after { + display: none; +} + +.rb-live { + border-radius: 6px; + font-family: var(--mono); + font-weight: 600; +} + +.rb-live.is-live { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.rb-live.is-live .live-dot { + --fam: #fff; +} + +.ribbon.is-live .rb-head { + background: #fff; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.6); +} + +.rb-head { + background: var(--b-inside); + box-shadow: 0 0 10px rgba(235, 199, 119, 0.7); +} + +.gc-labels span { + border-radius: 3px; +} diff --git a/mobile/styles/room.css b/mobile/styles/room.css new file mode 100644 index 000000000..faa9c5f41 --- /dev/null +++ b/mobile/styles/room.css @@ -0,0 +1,1195 @@ +/* ------------------------------------------------------------------ */ +/* Situation sheet: tabs, strip; Room: synthesis, posts, notes */ +/* ------------------------------------------------------------------ */ + +.brief-tabs { + display: flex; + align-items: center; + gap: 18px; + margin: 10px 0 0; + border-bottom: 0.5px solid var(--hairline); +} + +.brief-tabs [role='tab'] { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 40px; + font-size: 0.9375rem; + font-weight: 650; + color: var(--ink-3); +} + +.brief-tabs [role='tab'][aria-selected='true'] { + color: var(--ink); +} + +.brief-tabs [role='tab'][aria-selected='true']::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -0.5px; + height: 2px; + background: var(--band, var(--ink)); +} + +.bt-n { + min-width: 18px; + height: 16px; + padding: 0 4px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.08); + font-size: 0.5625rem; + line-height: 16px; + text-align: center; + color: var(--ink-2); +} + +.bt-n:empty { + display: none; +} + +.bt-here { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.bt-here .i { + font-size: 0.75rem; +} + +.brief-sheet .sheet-body { + padding-top: 12px; +} + +.brief-strip { + position: relative; + display: flex; + gap: 10px; + align-items: center; + min-height: 36px; + padding: 0 14px; + margin-top: 2px; + color: var(--ink-2); + font-size: 0.5625rem; + white-space: nowrap; + overflow: hidden; +} + +.brief-strip span:last-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.brief-strip span:first-of-type { + color: var(--band); +} + +.brief-strip .fr { + inset: 2px 0; +} + +/* Dock pinned to the bottom of the visible sheet. */ +.sheet-dock { + position: absolute; + z-index: 3; + left: 0; + right: 0; + bottom: var(--off, 0px); + pointer-events: none; + transition: bottom 520ms var(--spring); +} + +.sheet-layer.dragging .sheet-dock { + transition: none; +} + +.sheet-dock > * { + pointer-events: auto; +} + +.brief-sheet[data-tab='room'] .sheet-body { + padding-bottom: calc(var(--safe-b) + 110px); +} + +.brief-sheet[data-detent='peek'] .sheet-dock { + opacity: 0; + pointer-events: none; +} + +.reply-bar { + display: flex; + gap: 8px; + padding: 10px 14px calc(10px + var(--safe-b)); + background: linear-gradient( + to top, + rgba(8, 9, 11, 0.98) 60%, + rgba(8, 9, 11, 0) + ); +} + +.rb-cap { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + min-height: 52px; + padding: 0 14px; + border-radius: 10px; + background: var(--b-inside); + color: #1d1406; + text-align: left; +} + +.rb-cap .i { + font-size: 1.4rem; +} + +.rb-cap span { + display: flex; + flex-direction: column; + line-height: 1.15; +} + +.rb-cap b { + font-size: 0.9375rem; +} + +.rb-cap small { + font-size: 0.6875rem; + opacity: 0.75; +} + +.rb-write { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 52px; + padding: 0 16px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + font-weight: 650; + font-size: 0.875rem; +} + +.rb-react { + display: grid; + place-items: center; + width: 52px; + min-height: 52px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + font-size: 1.2rem; + color: var(--ink-2); +} + +.react-open .rb-react { + color: var(--b-inside); +} + +.react-rail { + position: absolute; + right: 20px; + bottom: calc(100% - 4px); + display: flex; + flex-direction: column; + gap: 6px; + opacity: 0; + transform: translateY(12px); + pointer-events: none; + transition: + opacity var(--dur-2) var(--ease-out), + transform var(--dur-2) var(--spring); +} + +.react-open .react-rail { + opacity: 1; + transform: none; + pointer-events: auto; +} + +.react-rail button { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(14, 16, 19, 0.88); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + color: var(--ink-2); + font-size: 1.05rem; +} + +.react-rail button:active { + transform: scale(0.9); + color: var(--b-inside); +} + +.react-float { + position: absolute; + z-index: 2; + right: 34px; + bottom: 84px; + color: var(--b-inside); + font-size: 1.35rem; + pointer-events: none; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6)); +} + +/* ---------- room ---------- */ +.room-loading { + padding: 30px 0; + color: var(--ink-4); +} + +.presence { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.presence .pr-t { + flex: 1; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.presence .pr-t b { + color: var(--b-inside); + font-weight: 600; +} + +.presence .avs.ex .av { + opacity: 0.55; +} + +.presence .av { + box-shadow: 0 0 0 1.5px var(--ground-1); +} + +.presence .av.live { + box-shadow: + 0 0 0 1.5px var(--ground-1), + 0 0 0 3px color-mix(in srgb, var(--b-inside) 80%, transparent); +} + +/* Synthesis: pinned, cited, honest about being local. */ +.synth { + position: relative; + margin: 0 0 14px; + padding: 12px 14px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.sy-top { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 4px 8px; + margin-bottom: 8px; + font-size: 0.5625rem; +} + +.sy-top > .tele:first-child { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ink); +} + +.sy-meta { + color: var(--ink-4); +} + +.sy-sec + .sy-sec { + margin-top: 8px; +} + +.sy-h { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.5625rem; + color: var(--ink-2); +} + +.sy-h i { + width: 8px; + height: 8px; + border-radius: 1px; +} + +.sy-h span { + color: var(--ink-4); +} + +[data-sy='known'] .sy-h i { + background: var(--ink); +} + +[data-sy='disputed'] .sy-h i { + background: repeating-linear-gradient( + 135deg, + var(--ink) 0 2px, + transparent 2px 4px + ); + box-shadow: inset 0 0 0 1px var(--ink-2); +} + +[data-sy='unknown'] .sy-h i { + box-shadow: inset 0 0 0 1px var(--ink-3); +} + +.synth ul { + margin: 4px 0 0; + padding: 0; + list-style: none; +} + +.synth li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + padding: 5px 0 5px 14px; + border-left: 1px solid var(--hairline-strong); + font-size: 0.8125rem; + line-height: 1.38; + color: var(--ink-2); +} + +[data-sy='known'] li { + color: var(--ink); +} + +.sy-cites { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} + +.room .cite, +.synth .cite { + min-height: 20px; + padding: 0 5px; + border-radius: 3px; + font-size: 0.5625rem; + font-weight: 600; + background: rgba(255, 255, 255, 0.08); +} + +.synth .cite::before { + content: ''; +} + +.sy-foot { + margin: 8px 0 0; + font-size: 0.6875rem; + line-height: 1.4; + color: var(--ink-4); +} + +.room .example-note { + border-radius: 8px; + background: rgba(255, 209, 102, 0.06); +} + +.room-h { + display: flex; + align-items: center; + gap: 6px; + margin: 16px 0 6px; + font-size: 0.5625rem; + color: var(--ink-2); + --seal-ink: var(--ground); +} + +.room-h span { + color: var(--ink-4); +} + +.room-h .i { + font-size: 0.8rem; +} + +/* Posts */ +.post { + position: relative; + padding: 12px 0 10px; + border-top: 0.5px solid var(--hairline); +} + +.post .post { + margin-left: 14px; + padding-left: 12px; + border-top: 0; + border-left: 1px solid var(--hairline-strong); +} + +.post.flash { + animation: post-flash 1.6s var(--ease-out); +} + +@keyframes post-flash { + 0% { + background: rgba(235, 199, 119, 0.14); + } + 100% { + background: transparent; + } +} + +.p-head { + display: flex; + align-items: center; + gap: 9px; +} + +.p-kind { + flex: none; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + font-size: 0.95rem; + color: var(--ink); +} + +.k-witness .p-kind { + background: color-mix(in srgb, var(--b-inside) 18%, transparent); + color: var(--b-inside); +} + +.k-question .p-kind { + box-shadow: inset 0 0 0 1px var(--ink-3); + background: transparent; +} + +.k-correction .p-kind { + background: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.1) 0 3px, + transparent 3px 6px + ); +} + +.k-prediction .p-kind { + background: transparent; + box-shadow: inset 0 0 0 1px var(--hairline-strong); + color: var(--ink-2); +} + +.p-meta { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 7px; +} + +.p-meta b { + font-size: 0.5625rem; + color: var(--ink-2); +} + +.p-who { + font-size: 0.8125rem; + font-weight: 650; +} + +.is-mine .p-who { + color: var(--b-inside); +} + +.p-when { + font-size: 0.5625rem; + color: var(--ink-4); +} + +.p-text { + margin: 7px 0 0; + font-size: 0.9375rem; + line-height: 1.42; +} + +.p-media { + position: relative; + margin-top: 8px; + max-width: 260px; + aspect-ratio: 4 / 3; + border-radius: 6px; + overflow: hidden; + background: var(--ground-3); +} + +.p-media img, +.p-media video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.p-seal { + position: absolute; + left: 8px; + top: 8px; + color: var(--b-inside); + font-size: 1.1rem; + --seal-ink: #1d1406; +} + +.p-unv { + position: absolute; + left: 8px; + top: 8px; + padding: 2px 6px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.7); + font-size: 0.5rem; +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} + +.bdg { + display: inline-flex; + align-items: center; + gap: 4px; + height: 20px; + padding: 0 7px; + border-radius: 3px; + font-family: var(--mono); + font-size: 0.5625rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-2); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + --seal-ink: #1d1406; +} + +.bdg .i { + font-size: 0.7rem; +} + +.bdg em { + font-style: normal; + color: var(--ink-4); +} + +.bdg[data-b='there'], +.bdg[data-b='sealed'] { + color: var(--b-inside); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--b-inside) 45%, transparent); +} + +.bdg[data-b='unverified'] { + color: var(--ink-2); + box-shadow: inset 0 0 0 1px var(--ink-3); + background: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05) 0 4px, + transparent 4px 8px + ); +} + +.bdg.ex { + box-shadow: inset 0 0 0 1px var(--hairline-strong); + border: 0.5px dashed var(--ink-4); +} + +.p-actions { + display: flex; + align-items: center; + gap: 4px; + margin-top: 8px; +} + +.p-sp { + flex: 1; +} + +.anchor { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 32px; + padding: 0 8px 0 6px; + border-radius: 4px; + font-size: 0.5625rem; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.05); +} + +.anchor .i { + font-size: 0.8rem; +} + +.room .vote { + min-height: 32px; + min-width: 40px; + border-radius: 4px; + background: transparent; + color: var(--ink-3); + font-family: var(--mono); + font-size: 0.6875rem; + font-weight: 500; +} + +.room .vote.on { + color: var(--ink); + background: rgba(255, 255, 255, 0.1); +} + +.room .vote.dis.on { + color: var(--ink); + background: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.12) 0 3px, + transparent 3px 6px + ); +} + +/* Community notes + bridging */ +.note { + margin-top: 8px; + padding: 9px 11px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.note.shown { + box-shadow: inset 2px 0 0 var(--ink); +} + +.note.pending { + background: transparent; + box-shadow: inset 0 0 0 0.5px var(--hairline); + border: 0.5px dashed var(--ink-4); +} + +.note-h { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.5625rem; + color: var(--ink-2); +} + +.note p { + margin: 5px 0 0; + font-size: 0.8125rem; + line-height: 1.4; + color: var(--ink-2); +} + +.note details summary { + margin-top: 4px; + font-size: 0.75rem; + color: var(--ink-3); + cursor: pointer; +} + +.bridge { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + margin-top: 8px; +} + +.bridge > .tele { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.br { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: var(--mono); + font-size: 0.5625rem; + color: var(--ink-3); +} + +.br b { + color: var(--ink); +} + +.br i { + position: relative; + width: 34px; + height: 4px; + border-radius: 1px; + background: rgba(255, 255, 255, 0.1); + overflow: hidden; +} + +.br i::after { + content: ''; + position: absolute; + inset: 0; + width: var(--p); + background: var(--ink-2); +} + +.br em { + font-style: normal; +} + +.bridge .why { + flex-basis: 100%; + font-size: 0.6875rem; + color: var(--ink-4); +} + +.note-rate { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.note-rate .tele { + font-size: 0.5rem; + color: var(--ink-3); +} + +.pill-btn.sm { + min-height: 30px; + padding: 0 11px; + font-size: 0.75rem; +} + +.room-end { + padding: 18px 0 6px; + text-align: center; + font-size: 0.5rem; + color: var(--ink-4); +} + +/* ---------- composer ---------- */ +.composer-sheet .sheet-head { + padding-top: 2px; +} + +.cmp-quote { + margin: 8px 0 2px; + padding: 8px 10px; + border-left: 2px solid var(--hairline-strong); + font-size: 0.8125rem; + color: var(--ink-3); +} + +.cmp-quote b { + display: block; + margin-bottom: 3px; + font-size: 0.5625rem; + color: var(--ink-2); +} + +.composer-sheet .sheet-body { + overflow-y: auto; + padding-top: 14px; +} + +.cmp-capture { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 14px; + border-radius: 10px; + text-align: left; + background: + repeating-linear-gradient( + 135deg, + rgba(235, 199, 119, 0.05) 0 6px, + transparent 6px 12px + ), + rgba(235, 199, 119, 0.06); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--b-inside) 55%, transparent); +} + +.cc-ic { + display: grid; + place-items: center; + flex: none; + width: 42px; + height: 42px; + border-radius: 8px; + background: var(--b-inside); + color: #1d1406; + font-size: 1.35rem; +} + +.cc-t { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; +} + +.cc-t b { + font-size: 0.9375rem; +} + +.cc-t span { + font-size: 0.75rem; + line-height: 1.38; + color: var(--ink-3); +} + +.cmp-capture .chev { + color: var(--b-inside); +} + +.cmp-or { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0 10px; + font-size: 0.5625rem; + color: var(--ink-4); +} + +.cmp-or::before, +.cmp-or::after { + content: ''; + flex: 1; + height: 0.5px; + background: var(--hairline-strong); +} + +.kind-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; +} + +.kind-chip { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 10px 0 8px; + border-radius: 8px; + font-size: 0.65625rem; + font-weight: 600; + color: var(--ink-3); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.kind-chip .i { + font-size: 1.2rem; +} + +.kind-chip[aria-checked='true'] { + color: var(--ground); + background: var(--ink); + box-shadow: none; +} + +.kind-hint { + margin: 8px 0 8px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.cmp-text { + width: 100%; + min-height: 108px; + padding: 12px; + border: 0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + font-size: 1rem; + line-height: 1.4; + resize: vertical; +} + +.cmp-text:focus { + outline: none; + box-shadow: inset 0 0 0 1px var(--ink-3); +} + +.cmp-anchor { + margin-top: 12px; +} + +.ca-h { + display: flex; + align-items: center; + gap: 5px; + margin: 10px 0 6px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.seg-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.seg-chips button { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 40px; + padding: 4px 11px; + border-radius: 6px; + font-size: 0.8125rem; + font-weight: 600; + color: var(--ink-2); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.seg-chips button small { + font-family: var(--mono); + font-size: 0.5625rem; + font-weight: 500; + color: var(--ink-4); +} + +.seg-chips button[aria-pressed='true'] { + background: rgba(255, 255, 255, 0.1); + box-shadow: inset 0 0 0 1px var(--ink-2); + color: var(--ink); +} + +.cmp-foot { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.cmp-foot .tele { + flex: 1; + font-size: 0.5rem; + color: var(--ink-4); +} + +.cmp-foot .pill-btn { + min-width: 110px; +} + +/* ---------- eye viewer ---------- */ +.eye-sheet .sheet-head .c-band { + margin-bottom: 4px; +} + +.ev-frame { + position: relative; + aspect-ratio: 16 / 9; + margin-top: 12px; + border-radius: 6px; + overflow: hidden; + background: #07080a; +} + +.ev-frame img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 600ms var(--ease-out); +} + +.ev-frame img.front { + opacity: 1; +} + +.ev-frame.offline::after { + content: 'Camera unreachable from here'; + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-family: var(--mono); + font-size: 0.625rem; + color: var(--ink-3); +} + +.ev-facts { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.ev-facts b { + color: var(--ink); + font-weight: 500; +} + +.ev-actions { + display: flex; + gap: 8px; + margin-top: 14px; +} + +/* ---------- hero lock-on ---------- */ +.hero-lock { + position: fixed; + z-index: 92; + pointer-events: none; +} + +.hero-lock .fr { + inset: 0; +} + +.hero-lock .fr i { + width: 18px; + height: 18px; + border-color: var(--band); +} + +.hl-tele { + position: absolute; + left: 2px; + bottom: -18px; + font-size: 0.5625rem; + color: var(--band); + white-space: nowrap; + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8); +} + +/* Pin labels on the globe */ +.gc-labels .lb-pin { + font-family: var(--mono); + font-size: 0.5625rem; + letter-spacing: 0.04em; + color: var(--band); +} + +@media (prefers-reduced-motion: reduce) { + .sheet-dock { + transition: none; + } +} + +/* ---------- Threads hub: rooms ---------- */ +.th-rooms .section-h small { + font-family: var(--mono); + font-size: 0.5625rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.rm-list { + margin: 0 calc(var(--gutter) + var(--safe-r)) 0 + calc(var(--gutter) + var(--safe-l)); + border-top: 0.5px solid var(--hairline); +} + +.rm-row { + display: block; + width: 100%; + padding: 12px 0 12px; + border-bottom: 0.5px solid var(--hairline); + text-align: left; +} + +.rm-row:active { + background: rgba(255, 255, 255, 0.03); +} + +.rm-top { + display: flex; + justify-content: space-between; + align-items: center; +} + +.rm-row h3 { + margin: 4px 0 0; + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.015em; +} + +.rm-claim { + display: flex; + gap: 8px; + margin: 6px 0 0; + font-size: 0.875rem; + line-height: 1.38; + color: var(--ink-2); +} + +.rm-claim .i { + flex: none; + margin-top: 2px; + color: var(--ink-3); +} + +.rm-claim span { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rm-foot { + display: flex; + align-items: center; + gap: 10px; + margin-top: 9px; +} + +.rm-n { + margin-left: auto; + font-size: 0.5625rem; + color: var(--ink-3); +} + +/* You: live bundles and uploads */ +.ct-live { + position: absolute; + left: 6px; + top: 6px; + padding: 2px 5px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.65); + font-size: 0.5rem; + color: var(--b-inside); +} + +.capture-tile.is-unverified { + box-shadow: inset 0 0 0 1px var(--ink-3); +} + +.capture-tile.is-unverified .ct-live { + color: var(--ink); +} diff --git a/mobile/styles/sheet.css b/mobile/styles/sheet.css index d0b652452..9310819f1 100644 --- a/mobile/styles/sheet.css +++ b/mobile/styles/sheet.css @@ -35,14 +35,14 @@ bottom: 0; display: flex; flex-direction: column; - border-radius: 30px 30px 0 0; - background: var(--glass-specular), rgba(16, 20, 28, 0.78); - -webkit-backdrop-filter: blur(30px) saturate(1.7); - backdrop-filter: blur(30px) saturate(1.7); + border-radius: 18px 18px 0 0; + background: rgba(8, 9, 11, 0.86); + -webkit-backdrop-filter: blur(28px) saturate(1.4); + backdrop-filter: blur(28px) saturate(1.4); box-shadow: - inset 0 0.5px 0 rgba(255, 255, 255, 0.3), - inset 0 0 0 0.5px rgba(255, 255, 255, 0.08), - 0 -12px 40px rgba(0, 0, 0, 0.35); + inset 0 0.5px 0 rgba(255, 255, 255, 0.22), + inset 0 0 0 0.5px rgba(255, 255, 255, 0.07), + 0 -12px 40px rgba(0, 0, 0, 0.45); pointer-events: auto; will-change: transform; transform: translate3d(0, 110%, 0); @@ -63,6 +63,15 @@ touch-action: none; } +.sheet-grabber:focus-visible { + outline: none; +} + +.sheet-grabber:focus-visible span { + outline: 2px solid var(--tint); + outline-offset: 3px; +} + .sheet-grabber span { width: 38px; height: 5px; @@ -142,13 +151,25 @@ .brief-top { display: flex; align-items: center; - gap: 8px; + gap: 7px; height: 22px; - font-size: var(--t-cap); - font-weight: 650; + font-size: 0.625rem; color: var(--ink-3); - letter-spacing: 0.02em; padding-right: 40px; + white-space: nowrap; + overflow: hidden; +} + +.brief-top .bd { + flex: none; + width: 6px; + height: 6px; + border-radius: 1px; + background: var(--band); +} + +.brief-top > span:nth-child(2) { + color: var(--band); } .fam-tag { @@ -178,7 +199,7 @@ } .live-tag .live-dot { - --fam: var(--live); + --fam: var(--band, var(--ink)); width: 6px; height: 6px; } @@ -200,18 +221,29 @@ .metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; + gap: 0; margin: 12px 0 4px; + border-top: 0.5px solid var(--hairline-strong); + border-bottom: 0.5px solid var(--hairline-strong); } .metric { min-width: 0; + padding: 10px 10px 9px; +} + +.metric + .metric { + border-left: 0.5px solid var(--hairline); +} + +.metric:first-child { + padding-left: 0; } .metric-v { - font-family: var(--font-display); - font-size: 1.5rem; - font-weight: 700; + font-family: var(--mono); + font-size: 1.25rem; + font-weight: 600; letter-spacing: -0.03em; line-height: 1.05; font-variant-numeric: tabular-nums; @@ -229,12 +261,13 @@ } .metric-l { - font-size: var(--t-cap2); - font-weight: 600; + font-family: var(--mono); + font-size: 0.5625rem; + font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); - margin-top: 3px; + margin-top: 4px; } .metric.long .metric-v { @@ -243,13 +276,14 @@ } .metric:first-child .metric-v { - color: var(--fam); + color: var(--band, var(--fam)); } .actions { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 8px; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 6px; margin: 14px 0 6px; } @@ -259,19 +293,21 @@ align-items: center; gap: 6px; padding: 10px 0 9px; - border-radius: 16px; - background: rgba(255, 255, 255, 0.07); - font-size: var(--t-cap2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + box-shadow: inset 0 0 0 0.5px var(--hairline); + font-size: 0.65625rem; font-weight: 600; color: var(--ink-2); + text-align: center; transition: transform var(--dur-1) var(--ease-out), background var(--dur-1) linear; } .action .i { - font-size: 1.35rem; - color: var(--tint); + font-size: 1.25rem; + color: var(--ink); } .action:active { @@ -322,12 +358,13 @@ align-items: center; gap: 14px; padding: 12px 14px; - border-radius: 16px; - background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 0.5px var(--hairline); } .spark-row svg { - color: var(--fam); + color: var(--band, var(--fam)); flex: 1; height: 42px; min-width: 0; @@ -335,7 +372,7 @@ .spark-row .spark-line { fill: none; - stroke: var(--fam); + stroke: var(--band, var(--fam)); stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; @@ -347,7 +384,7 @@ } .spark-row .spark-end { - fill: var(--fam); + fill: var(--band, var(--fam)); } .spark-label { diff --git a/mobile/styles/shell.css b/mobile/styles/shell.css index eb5ec939c..35c9a0913 100644 --- a/mobile/styles/shell.css +++ b/mobile/styles/shell.css @@ -540,7 +540,11 @@ } .tb-tab[aria-selected='true'] { - color: var(--tint); + color: var(--ink); +} + +.tb-tab[data-tab='capture'][aria-selected='true'] { + color: var(--b-inside); } .tb-tab[aria-selected='true'] .i .f { @@ -765,12 +769,13 @@ width: 30px; height: 30px; flex: none; - border-radius: 8px; + border-radius: 6px; display: grid; place-items: center; font-size: 1.05rem; - color: #05070b; - background: var(--fam, var(--tint)); + color: var(--fam, var(--ink)); + background: rgba(255, 255, 255, 0.06); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); } .row-main { diff --git a/mobile/styles/threads.css b/mobile/styles/threads.css index daa4ad80e..bc7cc6cf5 100644 --- a/mobile/styles/threads.css +++ b/mobile/styles/threads.css @@ -20,7 +20,7 @@ display: block; width: 100%; text-align: left; - border-radius: 24px; + border-radius: 8px; overflow: hidden; background: var(--ground-2); box-shadow: inset 0 0 0 0.5px var(--hairline); @@ -200,7 +200,7 @@ .th-ba.big { height: auto; aspect-ratio: 16 / 10; - border-radius: 20px; + border-radius: 8px; overflow: hidden; margin: 4px 0 14px; } @@ -311,7 +311,7 @@ } .obs-list { - border-radius: 18px; + border-radius: 8px; background: var(--ground-2); overflow: hidden; } @@ -443,7 +443,7 @@ .hyp { padding: 12px 14px; - border-radius: 18px; + border-radius: 8px; background: var(--ground-2); box-shadow: inset 3px 0 0 var(--orbit); } @@ -607,7 +607,7 @@ min-height: 36px; min-width: 44px; padding: 0 8px; - border-radius: 18px; + border-radius: 8px; font-size: var(--t-cap); font-weight: 700; color: var(--ink-2); @@ -631,7 +631,7 @@ .composer { margin-top: 14px; padding: 12px; - border-radius: 18px; + border-radius: 8px; background: linear-gradient( 160deg, @@ -686,7 +686,7 @@ align-items: center; gap: 14px; padding: 12px; - border-radius: 20px; + border-radius: 8px; background: linear-gradient(120deg, rgba(255, 209, 102, 0.14), transparent), var(--ground-2); @@ -784,7 +784,7 @@ .ledger { position: relative; - border-radius: 18px; + border-radius: 8px; background: var(--ground-1); box-shadow: inset 0 0 0 0.5px var(--hairline); padding: 4px 0; @@ -997,17 +997,13 @@ display: grid; place-items: center; font-size: 1.6rem; - color: #05070b; - background: conic-gradient( - from 200deg, - var(--sky), - var(--orbit), - var(--hazards), - var(--eyes), - var(--ground-sig), - var(--sea), - var(--sky) - ); + color: var(--b-inside); + background: + radial-gradient(circle, rgba(235, 199, 119, 0.12), transparent 70%), + var(--ground-2); + box-shadow: + inset 0 0 0 1.5px var(--b-inside), + 0 0 0 4px rgba(235, 199, 119, 0.08); } .you-id b { @@ -1026,7 +1022,7 @@ gap: 4px; margin: 0 calc(var(--gutter) + var(--safe-r)) 0 calc(var(--gutter) + var(--safe-l)); - border-radius: 18px; + border-radius: 8px; overflow: hidden; } @@ -1069,7 +1065,7 @@ margin: 0 calc(var(--gutter) + var(--safe-r)) 0 calc(var(--gutter) + var(--safe-l)); padding: 22px 18px; - border-radius: 18px; + border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.12); text-align: center; color: var(--ink-3); @@ -1086,7 +1082,7 @@ margin: 0 calc(var(--gutter) + var(--safe-r)) 0 calc(var(--gutter) + var(--safe-l)); padding: 16px; - border-radius: 18px; + border-radius: 8px; display: flex; gap: 14px; align-items: center; @@ -1167,7 +1163,7 @@ gap: 12px; align-items: flex-start; padding: 14px; - border-radius: 18px; + border-radius: 8px; font-size: var(--t-cap); color: var(--ink-3); line-height: 1.4; diff --git a/mobile/styles/tokens.css b/mobile/styles/tokens.css index 2f4c3f817..9752163dc 100644 --- a/mobile/styles/tokens.css +++ b/mobile/styles/tokens.css @@ -7,14 +7,22 @@ :root { color-scheme: dark; - /* Ground */ - --ground: #05070b; - --ground-1: #0a0e15; - --ground-2: #10151e; - --ground-3: #171d28; + /* Ground: near-black, the globe is the only light source. */ + --ground: #050607; + --ground-1: #0a0b0d; + --ground-2: #101215; + --ground-3: #171a1e; --hairline: rgba(255, 255, 255, 0.08); --hairline-strong: rgba(255, 255, 255, 0.14); + /* Altitude bands (DESIGN.md §11.1): one signal hue per band. */ + --b-orbit: #b39dff; + --b-sky: #a3d8ff; + --b-ground: #ffb547; + --b-inside: #ebc777; + /* Hazard red is reserved for things that hurt people. */ + --harm: #ff4436; + /* Ink */ --ink: #f4f7fb; --ink-2: rgba(235, 241, 250, 0.72); @@ -36,7 +44,7 @@ --ok: #30d158; --warn: #ffd60a; --bad: #ff453a; - --tint: #7cc4ff; + --tint: #a3d8ff; /* Glass */ --glass-bg: rgba(22, 27, 37, 0.52); @@ -79,7 +87,10 @@ 'Segoe UI', sans-serif; --font-rounded: ui-rounded, 'SF Pro Rounded', system-ui, sans-serif; --mono: - ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, 'Roboto Mono', monospace; + 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, 'Roboto Mono', monospace; + /* The voice of the instruments: small caps, tabular, tracked. */ + --tele-size: 0.625rem; + --tele-track: 0.06em; --t-large: 2.125rem; /* 34 pt */ --t-title2: 1.375rem; --t-title3: 1.25rem; @@ -295,6 +306,32 @@ svg.i { --fam: var(--infra); } +/* Band colour binding (wins over family where both are set). */ +[data-band='orbit'] { + --band: var(--b-orbit); +} +[data-band='sky'] { + --band: var(--b-sky); +} +[data-band='ground'] { + --band: var(--b-ground); +} +[data-band='inside'] { + --band: var(--b-inside); +} + +/* Telemetry: the instrument voice. */ +.tele { + font-family: var(--mono); + font-size: var(--tele-size); + font-weight: 500; + letter-spacing: var(--tele-track); + text-transform: uppercase; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum', 'zero'; + line-height: 1.25; +} + @keyframes pulse-ring { 0% { transform: scale(0.6); diff --git a/mobile/ui/attach.js b/mobile/ui/attach.js new file mode 100644 index 000000000..79a8a5caf --- /dev/null +++ b/mobile/ui/attach.js @@ -0,0 +1,336 @@ +/** + * Attach sheet (DESIGN.md §11.4): after any capture, suggest where it + * belongs from distance and time ("Typhoon · 4.2 km from track", "Times + * Square camera · 180 m"), or a new place; pick a post kind, add a note, + * choose how precisely to share the location, and optionally leave it as a + * hologram. Posting creates a room post, a Witness card and a globe pin. + */ + +import { icon } from './icons.js'; +import { $, $$, esc, haptic, toast } from './dom.js'; +import { createSheet } from './sheet.js'; +import { KINDS, KIND, wasThere } from './rooms.js'; +import { + BAND_NAME, + bandOf, + distKm, + fmtKm, + llShort, + presenceRadiusKm, + utc, +} from './bands.js'; + +const PRECISION = [ + { id: 'exact', label: 'Exact', round: null }, + { id: '100m', label: '100 m', round: 3 }, + { id: '1km', label: '1 km', round: 2 }, +]; + +function hash(s) { + let x = 2166136261; + for (let i = 0; i < s.length; i += 1) + x = Math.imul(x ^ s.charCodeAt(i), 16777619); + return (x >>> 0).toString(36); +} + +export function createAttach({ rooms, globe, getSituations, onPosted }) { + const sheet = createSheet({ + label: 'Post this capture', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'attach-sheet opaque', + heights: { large: (H) => Math.round(Math.min(H * 0.92, 880)) }, + }); + let ctx = null; + + async function suggestions(item, preset) { + const sits = (await getSituations()).filter((s) => Number.isFinite(s.lat)); + const fix = item.fix; + const scored = sits.map((s) => { + const km = fix ? distKm(fix, s) : NaN; + const there = + item.sealed && fix ? wasThere(s, fix, item.at).there : false; + return { s, km, there }; + }); + let list; + if (fix) + list = scored + .filter( + (x) => + x.km < Math.max(presenceRadiusKm(x.s) * 4, 60) || + x.s.id === preset?.id, + ) + .sort((a, b) => a.km - b.km) + .slice(0, 4); + else + list = [...scored] + .sort((a, b) => (b.s.severity || 0) - (a.s.severity || 0)) + .slice(0, 4); + if (preset && !list.some((x) => x.s.id === preset.id)) { + const p = scored.find((x) => x.s.id === preset.id) || { + s: preset, + km: fix ? distKm(fix, preset) : NaN, + there: false, + }; + list.unshift(p); + } else if (preset) + list.sort((a, b) => + a.s.id === preset.id ? -1 : b.s.id === preset.id ? 1 : 0, + ); + // The nearest public camera is a place too. + if (fix) { + const eye = await globe.nearestEye(fix.lon, fix.lat, 3).catch(() => null); + if (eye && !list.some((x) => x.s.media?.cameraId === eye.id)) { + const s = { + id: `eye-${eye.id}`, + kind: 'camera', + family: 'eyes', + band: 'ground', + archetype: 'eye', + title: `${eye.title} camera`, + place: [eye.city, eye.provider].filter(Boolean).join(' · '), + lat: eye.lat, + lon: eye.lon, + live: true, + updatedAt: Date.now(), + source: eye.provider + ? `${eye.provider} public camera` + : 'Public camera', + metrics: [], + media: { + kind: 'image', + url: eye.imageUrl, + cameraId: eye.id, + headingDeg: eye.headingDeg, + refreshMs: 15000, + city: eye.city, + provider: eye.provider, + }, + camera: { + lon: eye.lon, + lat: eye.lat, + height: 900, + heading: eye.headingDeg || 0, + pitch: -30, + }, + layers: [], + }; + list.push({ + s, + km: distKm(fix, s), + there: item.sealed && distKm(fix, s) <= 1.5, + eye: true, + }); + } + } + return list; + } + + function row(x, i) { + const s = x.s; + const band = bandOf(s); + const near = Number.isFinite(x.km) + ? `${fmtKm(x.km)} from ${/cyclone|typhoon|hurricane/.test(s.kind) ? 'track' : x.eye ? 'camera' : 'it'}` + : 'no location on this capture'; + return ``; + } + + function render() { + const it = ctx.item; + const head = it.unverified + ? 'UPLOAD · UNVERIFIED' + : it.kind === 'live' + ? `WITNESS · SEALED LIVE · ${it.segments || 0} SEGMENTS` + : `WITNESS · SEALED${it.seq ? ` #${it.seq}` : ''}`; + sheet.head.innerHTML = `
+ ${it.previewUrl ? (it.isVideo ? `` : ``) : ''}${icon(it.unverified ? 'upload' : 'sealFill')} +
${esc(head)}
+
${it.fix ? `${esc(llShort(it.fix.lat, it.fix.lon, 4))}${Number.isFinite(it.fix.acc) ? ` ±${Math.round(it.fix.acc)} M` : ''}` : 'NO POSITION'} · ${esc(utc(new Date(it.at), { seconds: false }))}
+

Where does this belong?

+
`; + sheet.body.innerHTML = ` +
+ ${ctx.list.map(row).join('')} + + +
+
Post as
+
${KINDS.map((k) => ``).join('')}
+ +
Share location at
+
${PRECISION.map((p) => ``).join('')}
+

${it.unverified ? 'Uploads carry no capture-time seal: they are marked Unverified everywhere and can be cited, never treated as an observation.' : 'The seal still proves the exact position to verifiers without publishing it.'}

+ + `; + } + + async function open(item, { preset = null, replyTo = null } = {}) { + ctx = { + item, + list: [], + sel: 0, + kind: replyTo + ? replyTo.kind === 'question' + ? 'witness' + : 'correction' + : 'witness', + precision: item.unverified ? '100m' : 'exact', + hologram: false, + replyTo, + preset, + }; + ctx.list = await suggestions(item, preset); + if (!ctx.list.length) ctx.sel = 'new'; + render(); + sheet.open('large'); + } + + sheet.root.addEventListener('click', async (e) => { + if (!ctx) return; + const r = e.target.closest('.att-row'); + if (r) { + ctx.sel = r.dataset.i === 'new' ? 'new' : Number(r.dataset.i); + $$('.att-row', sheet.body).forEach((b) => + b.setAttribute('aria-checked', String(b === r)), + ); + const name = $('.att-name', sheet.body); + name.hidden = ctx.sel !== 'new'; + if (!name.hidden) name.focus(); + haptic('tick'); + return; + } + const k = e.target.closest('[data-kind]'); + if (k) { + ctx.kind = k.dataset.kind; + $$('[data-kind]', sheet.body).forEach((b) => + b.setAttribute('aria-checked', String(b === k)), + ); + haptic('tick'); + return; + } + const pr = e.target.closest('[data-prec] button'); + if (pr) { + ctx.precision = pr.dataset.v; + $$('[data-prec] button', sheet.body).forEach((b) => + b.setAttribute('aria-pressed', String(b === pr)), + ); + haptic('tick'); + return; + } + const holo = e.target.closest('[data-holo]'); + if (holo) { + e.preventDefault(); + ctx.hologram = !ctx.hologram; + holo.setAttribute('aria-checked', String(ctx.hologram)); + haptic('tick'); + return; + } + if (e.target.closest('[data-act="post"]')) await post(); + }); + + async function post() { + const it = ctx.item; + let s; + if (ctx.sel === 'new') { + const name = $('.att-name', sheet.body).value.trim(); + const ll = it.fix || { lat: 0, lon: 0 }; + s = await rooms.addPlace({ + id: `place-${hash(`${ll.lat.toFixed(3)},${ll.lon.toFixed(3)},${name}`)}`, + kind: 'place', + family: 'eyes', + band: 'inside', + archetype: 'sensor', + title: name || `Place near ${llShort(ll.lat, ll.lon, 2)}`, + place: llShort(ll.lat, ll.lon, 3), + subtitle: 'Started by a witness on this device', + lat: ll.lat, + lon: ll.lon, + live: true, + updatedAt: Date.now(), + source: 'Your capture', + metrics: [], + layers: ['eyes.signed-captures'], + camera: { + lon: ll.lon, + lat: ll.lat, + height: 3000, + heading: 0, + pitch: -45, + }, + local: true, + }); + } else { + s = ctx.list[ctx.sel].s; + if (ctx.list[ctx.sel].eye) await rooms.addPlace(s); + } + const p = PRECISION.find((x) => x.id === ctx.precision); + const round = (v) => (p.round == null ? v : Number(v.toFixed(p.round))); + const fix = it.fix; + const there = + it.sealed && fix + ? wasThere(s, fix, it.at) + : { there: false, km: fix ? distKm(fix, s) : NaN }; + const text = $('.att-note', sheet.body).value.trim() || defaultText(it, s); + const postRec = await rooms.post(s, { + kind: ctx.kind, + text, + replyTo: ctx.replyTo?.id || null, + hologram: ctx.hologram, + anchor: fix + ? { + lat: round(fix.lat), + lon: round(fix.lon), + t: it.at, + precision: ctx.precision, + } + : { t: it.at }, + evidence: { + captureId: it.unverified ? undefined : it.id, + uploadId: it.unverified ? it.id : undefined, + sealed: !!it.sealed, + live: it.kind === 'live', + unverified: !!it.unverified, + wasThere: !!there.there, + distanceKm: Number.isFinite(there.km) + ? Math.round(there.km * 100) / 100 + : null, + precision: ctx.precision, + headingDeg: it.headingDeg ?? null, + video: !!it.isVideo, + }, + }); + if (fix) + globe.addPin({ + id: `post:${postRec.id}`, + lon: round(fix.lon), + lat: round(fix.lat), + kind: ctx.hologram ? 'hologram' : 'witness', + label: ctx.hologram ? 'Hologram' : `${KIND[ctx.kind].name} · you`, + headingDeg: it.headingDeg ?? undefined, + }); + haptic('success'); + sheet.close(); + toast(`Posted to ${s.title}${there.there ? ' · Was there' : ''}`, { + icon: icon(it.unverified ? 'upload' : 'sealFill'), + }); + onPosted?.(s, postRec); + } + + function defaultText(it, s) { + if (it.unverified) + return `Uploaded a ${it.isVideo ? 'video' : 'photo'} for ${s.title}.`; + if (it.kind === 'live') + return `Broadcast live from here: ${it.segments || 0} sealed segments.`; + return `Sealed ${it.isVideo ? 'clip' : 'photo'} from here.`; + } + + return { open, close: () => sheet.close() }; +} diff --git a/mobile/ui/bands.js b/mobile/ui/bands.js new file mode 100644 index 000000000..680080e08 --- /dev/null +++ b/mobile/ui/bands.js @@ -0,0 +1,224 @@ +/** + * Altitude bands (DESIGN.md §11.1): the app's subject is where the sensor is + * and how high it is, from orbit to sky to ground to inside (you). + * Shared helpers for band, archetype, telemetry strips, MGRS and distance. + */ + +export const BANDS = [ + { id: 'orbit', name: 'Orbit', range: '400–36,000 km', short: 'ORB' }, + { id: 'sky', name: 'Sky', range: '0–15 km', short: 'SKY' }, + { id: 'ground', name: 'Ground', range: '0 m', short: 'GND' }, + { id: 'inside', name: 'Inside', range: 'you', short: 'INS' }, +]; +export const BAND_NAME = Object.fromEntries(BANDS.map((b) => [b.id, b.name])); +export const BAND_COLOR = { + orbit: '#b39dff', + sky: '#a3d8ff', + ground: '#ffb547', + inside: '#ebc777', +}; + +const KIND_BAND = [ + [ + /iss|station|satellite|starlink|launch|pass|orbit|cyclone|typhoon|hurricane|aurora|cloud|ir\b/i, + 'orbit', + ], + [ + /flight|aircraft|squawk|military|heli|airport|holding|lightning|radar|storm|rain|gnss|jam|jet|wind/i, + 'sky', + ], + [/reconstruction|witness|capture|live|room|hologram/i, 'inside'], +]; + +/** The altitude band of the sensor that sees a situation. */ +export function bandOf(s) { + if (!s) return 'ground'; + if (s.band && BAND_NAME[s.band]) return s.band; + const k = `${s.kind || ''}`; + for (const [re, b] of KIND_BAND) if (re.test(k)) return b; + if (s.family === 'orbit') return 'orbit'; + if (s.family === 'sky' || s.family === 'weather') return 'sky'; + return 'ground'; +} + +/** Card archetype: engine-provided, else inferred honestly from the data. */ +export function archetypeOf(s) { + if (s.archetype) return s.archetype; + if (s.media?.url || s.media?.frameUrl) return 'eye'; + const k = `${s.kind || ''}`; + if (/squawk|track|heli|iss|pass|launch/.test(k)) return 'track'; + if (realSeries(s).length >= 4 && /gnss|lightning|queue|count|strait/.test(k)) + return 'number'; + return 'sensor'; +} + +/** A series is drawn only when it is real: [{t,v}] from the engine or numbers. */ +export function realSeries(s) { + const raw = Array.isArray(s?.series) ? s.series : []; + return raw + .map((p) => (typeof p === 'number' ? p : Number(p?.v))) + .filter(Number.isFinite); +} + +/** The one big number for track/number cards. */ +export function headlineOf(s) { + if (s.headline && s.headline.value != null) return s.headline; + const m = s.metrics?.[0]; + return m ? { value: m.value, unit: m.unit || '', label: m.label } : null; +} + +const pad = (n, w = 2) => String(n).padStart(w, '0'); + +/** 14:02:11Z */ +export function utc(d = new Date(), { seconds = true } = {}) { + const t = d instanceof Date ? d : new Date(d); + return `${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}${seconds ? `:${pad(t.getUTCSeconds())}` : ''}Z`; +} + +/** 37.77N 122.42W */ +export function llShort(lat, lon, d = 2) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return ''; + return `${Math.abs(lat).toFixed(d)}${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(d)}${lon >= 0 ? 'E' : 'W'}`; +} + +const CARD16 = [ + 'N', + 'NNE', + 'NE', + 'ENE', + 'E', + 'ESE', + 'SE', + 'SSE', + 'S', + 'SSW', + 'SW', + 'WSW', + 'W', + 'WNW', + 'NW', + 'NNW', +]; +export const cardinal = (deg) => + Number.isFinite(deg) + ? CARD16[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16] + : ''; + +/* ---------------- MGRS (WGS84, no Norway/Svalbard exceptions) ------------- */ + +export function mgrs(lat, lon, digits = 4) { + if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -80 || lat > 84) + return ''; + const a = 6378137; + const f = 1 / 298.257223563; + const k0 = 0.9996; + const e2 = f * (2 - f); + const ep2 = e2 / (1 - e2); + const zone = Math.min(60, Math.floor((lon + 180) / 6) + 1); + const lon0 = ((zone - 1) * 6 - 180 + 3) * (Math.PI / 180); + const φ = (lat * Math.PI) / 180; + const λ = (lon * Math.PI) / 180; + const N = a / Math.sqrt(1 - e2 * Math.sin(φ) ** 2); + const T = Math.tan(φ) ** 2; + const C = ep2 * Math.cos(φ) ** 2; + const A = Math.cos(φ) * (λ - lon0); + const M = + a * + ((1 - e2 / 4 - (3 * e2 ** 2) / 64 - (5 * e2 ** 3) / 256) * φ - + ((3 * e2) / 8 + (3 * e2 ** 2) / 32 + (45 * e2 ** 3) / 1024) * + Math.sin(2 * φ) + + ((15 * e2 ** 2) / 256 + (45 * e2 ** 3) / 1024) * Math.sin(4 * φ) - + ((35 * e2 ** 3) / 3072) * Math.sin(6 * φ)); + const E = + k0 * + N * + (A + + ((1 - T + C) * A ** 3) / 6 + + ((5 - 18 * T + T ** 2 + 72 * C - 58 * ep2) * A ** 5) / 120) + + 500000; + let Nn = + k0 * + (M + + N * + Math.tan(φ) * + (A ** 2 / 2 + + ((5 - T + 9 * C + 4 * C ** 2) * A ** 4) / 24 + + ((61 - 58 * T + T ** 2 + 600 * C - 330 * ep2) * A ** 6) / 720)); + if (lat < 0) Nn += 10000000; + const bandL = 'CDEFGHJKLMNPQRSTUVWXX'[Math.floor((lat + 80) / 8)]; + const set = ((zone - 1) % 6) + 1; + const cols = ['ABCDEFGH', 'JKLMNPQR', 'STUVWXYZ'][(set - 1) % 3]; + const rows = 'ABCDEFGHJKLMNPQRSTUV'; + const col = cols[Math.floor(E / 100000) - 1] || '?'; + const row = + rows[(Math.floor(Nn / 100000) + (set % 2 === 0 ? 5 : 0)) % 20] || '?'; + const div = 10 ** (5 - digits); + const e = pad(Math.floor((E % 100000) / div), digits); + const n = pad(Math.floor((Nn % 100000) / div), digits); + return `${zone}${bandL} ${col}${row} ${e} ${n}`; +} + +/* ---------------- Distance ---------------- */ + +export function distKm(a, b) { + if (!a || !b || !Number.isFinite(a.lat) || !Number.isFinite(b.lat)) + return NaN; + const R = 6371; + const dLat = ((b.lat - a.lat) * Math.PI) / 180; + const dLon = ((b.lon - a.lon) * Math.PI) / 180; + const x = + Math.sin(dLat / 2) ** 2 + + Math.cos((a.lat * Math.PI) / 180) * + Math.cos((b.lat * Math.PI) / 180) * + Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(x))); +} + +/** "180 m", "3.4 km", "1,240 km" */ +export function fmtKm(km) { + if (!Number.isFinite(km)) return ''; + if (km < 1) return `${Math.max(10, Math.round((km * 1000) / 10) * 10)} m`; + if (km < 100) return `${km.toFixed(1)} km`; + return `${Math.round(km).toLocaleString()} km`; +} + +/** Radius inside which a person counts as "there" for a situation. */ +export function presenceRadiusKm(s) { + const k = `${s?.kind || ''}`; + if (/cyclone|typhoon|hurricane/.test(k)) return 400; + if (/quake/.test(k)) return 150; + if (/aurora|gnss|military|lightning|storm|rain/.test(k)) return 120; + if (/fire/.test(k)) return 40; + if (archetypeOf(s) === 'eye' || /cam|cctv/.test(k)) return 1.5; + if (/airport|port|transit|strait|queue/.test(k)) return 20; + return 30; +} + +/** Telemetry strip for a situation (the engine's when present). */ +export function telemetryOf(s) { + if (s.telemetry) return String(s.telemetry).toUpperCase(); + const band = bandOf(s); + const ll = llShort(s.lat, s.lon, 2); + const hl = headlineOf(s); + const hv = + hl && typeof hl.value !== 'object' + ? `${hl.value}${hl.unit ? ` ${hl.unit}` : ''}`.toUpperCase() + : ''; + if (band === 'orbit') return ['ORBIT', hv, ll].filter(Boolean).join(' · '); + if (band === 'sky') return ['SKY', hv, ll].filter(Boolean).join(' · '); + if (band === 'inside') return ['INSIDE', hv, ll].filter(Boolean).join(' · '); + return ['GROUND', hv, mgrs(s.lat, s.lon, 3) || ll] + .filter(Boolean) + .join(' · '); +} + +/** Sensor id-ish label for the top-right of a frame. */ +export function sensorId(s) { + if (s.media?.cameraId) return `CAM ${String(s.media.cameraId).slice(-6)}`; + const band = bandOf(s); + const h = Math.abs( + [...String(s.id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 7), + ); + const tag = { orbit: 'SAT', sky: 'ADS', ground: 'GND', inside: 'WIT' }[band]; + return `${tag} ${String(h % 10000).padStart(4, '0')}`; +} diff --git a/mobile/ui/capture.js b/mobile/ui/capture.js index 304c0ef3c..1a300fb02 100644 --- a/mobile/ui/capture.js +++ b/mobile/ui/capture.js @@ -1,9 +1,14 @@ /** - * Capture tab: one dial, two modes. + * Capture tab: one dial, four modes (DESIGN.md §11.4). * Look — the God's-eye view projected around you (aircraft, satellites, * ships) on the camera, aligned by compass + gyro. Drag to look on - * devices without motion sensors. + * devices without motion sensors. Nothing is recorded. * Witness — signed photo/video with IMU, GPS, heading and a hash chain. + * Live — a broadcast: you appear on the globe as an eye with a frustum, + * segments are sealed every 10 s into a hash chain. + * Upload — a photo or video from the library, EXIF read, placed on a + * mini-map, and marked Unverified everywhere. + * After any capture comes the attach sheet. */ import { icon } from './icons.js'; @@ -28,7 +33,12 @@ import { projectDir, dirFromAzEl, } from '../capture/sensors.js'; -import { seal, networkTimeOffset } from '../capture/provenance.js'; +import { seal, networkTimeOffset, sha256Hex } from '../capture/provenance.js'; +import { createLiveSession, canBroadcast } from '../capture/live.js'; +import { readExif } from '../capture/exif.js'; +import { put } from '../capture/db.js'; +import { createMiniMap } from './minimap.js'; +import { llShort, cardinal } from './bands.js'; const KIND_COLOR = { aircraft: '#7CC4FF', @@ -53,7 +63,10 @@ const KIND_LABEL = { const FALLBACK_POS = { lat: 37.7749, lon: -122.4194, alt: 16, simulated: true }; const GATE_KEY = 'gev.mobile.capture.gate'; -export function createCapture(view, { globe, onSealed, onVerify }) { +export function createCapture( + view, + { globe, onSealed, onVerify, rt, attach, getLocation }, +) { view.innerHTML = `
@@ -72,14 +85,31 @@ export function createCapture(view, { globe, onSealed, onVerify }) {
+ +
+
OFF AIRtap ● to go live${icon('users')}0 watching
+
+
STANDBY
+
+
+ +
- +
@@ -131,7 +161,13 @@ export function createCapture(view, { globe, onSealed, onVerify }) { let dpr = 1; let lastCapture = null; let netOffset = null; + let target = null; // { s, replyTo } — the room this capture answers + let live = null; // { session, id, t0, pin, frameTimer } const VFOV = 62; + const MODES = ['look', 'witness', 'live', 'upload']; + const upload = $('.cap-upload', view); + const liveHud = $('.live-hud', view); + const targetChip = $('.cap-target', view); /* ---------- lifecycle ---------- */ function resize() { @@ -203,6 +239,12 @@ export function createCapture(view, { globe, onSealed, onVerify }) { } function leave() { + if (live) { + endLive(); + toast('Broadcast ended and sealed — find it in You', { + icon: icon('sealFill'), + }); + } active = false; cancelAnimationFrame(raf); raf = 0; @@ -227,7 +269,11 @@ export function createCapture(view, { globe, onSealed, onVerify }) { /* ---------- mode dial ---------- */ function setMode(m) { - if (m === mode) return; + if (m === mode || !MODES.includes(m)) return; + if (live && m !== 'live') { + showStatus('End the broadcast first'); + return; + } mode = m; root.dataset.mode = m; view @@ -237,7 +283,19 @@ export function createCapture(view, { globe, onSealed, onVerify }) { ); haptic('tick'); if (m === 'witness' && started) sensors.beginSession(); - if (m === 'look') sensors.endSession(); + if (m === 'look' || m === 'upload') sensors.endSession(); + upload.hidden = m !== 'upload'; + const lbl = $('.shutter-label', view); + lbl.textContent = m === 'live' ? 'GO LIVE' : ''; + shutter.setAttribute( + 'aria-label', + m === 'live' + ? 'Go live' + : m === 'upload' + ? 'Choose a photo or video' + : 'Seal a photo. Hold for video', + ); + if (m === 'live') updateLiveHud(pose()); } $('.cap-modes', view).addEventListener('click', (e) => { const b = e.target.closest('[data-mode]'); @@ -248,7 +306,9 @@ export function createCapture(view, { globe, onSealed, onVerify }) { root.addEventListener( 'touchstart', (e) => - (swipe = e.target.closest('.cap-bottom, .cap-seal, .cap-gate') + (swipe = e.target.closest( + '.cap-bottom, .cap-seal, .cap-gate, .cap-upload, .minimap', + ) ? null : { x: e.touches[0].clientX, y: e.touches[0].clientY }), { passive: true }, @@ -259,8 +319,10 @@ export function createCapture(view, { globe, onSealed, onVerify }) { if (!swipe || mode === 'look') return (swipe = null); const t = e.changedTouches[0]; const dx = t.clientX - swipe.x; - if (Math.abs(dx) > 70 && Math.abs(t.clientY - swipe.y) < 50) - setMode(dx > 0 ? 'look' : 'witness'); + if (Math.abs(dx) > 70 && Math.abs(t.clientY - swipe.y) < 50) { + const i = MODES.indexOf(mode) + (dx > 0 ? -1 : 1); + setMode(MODES[Math.max(0, Math.min(MODES.length - 1, i))]); + } swipe = null; }, { passive: true }, @@ -302,7 +364,11 @@ export function createCapture(view, { globe, onSealed, onVerify }) { if (!active) return; const p = pose(); if (mode === 'look') drawLook(p); - else drawWitness(p); + else if (mode === 'upload') { + actx.setTransform(dpr, 0, 0, dpr, 0, 0); + actx.clearRect(0, 0, W, H); + } else drawWitness(p); + if (mode === 'live') updateLiveHud(p); drawTape(p); raf = requestAnimationFrame(frame); }; @@ -699,12 +765,12 @@ export function createCapture(view, { globe, onSealed, onVerify }) { let hold = null; let recording = null; shutter.addEventListener('pointerdown', (e) => { - if (mode === 'look') return; + if (mode === 'look' || mode === 'live' || mode === 'upload') return; e.preventDefault(); hold = setTimeout(startVideo, 380); }); const release = async () => { - if (mode === 'look') return; + if (mode === 'look' || mode === 'live' || mode === 'upload') return; if (hold) { clearTimeout(hold); hold = null; @@ -719,6 +785,15 @@ export function createCapture(view, { globe, onSealed, onVerify }) { setMode('witness'); return; } + if (mode === 'live') { + if (live) endLive(); + else goLive(); + return; + } + if (mode === 'upload') { + $('.up-btn input', view).click(); + return; + } if (e.detail === 0) takePhoto(); }); @@ -787,14 +862,15 @@ export function createCapture(view, { globe, onSealed, onVerify }) { }).finished.then(() => f.remove()); } - async function sealMedia(blob, kind, test = false) { - const session = sensors.endSession() || { - start: new Date().toISOString(), - end: new Date().toISOString(), - imu: [], - gps: [], - heading: null, - }; + async function sealMedia(blob, kind, test = false, preset = null) { + const session = preset || + sensors.endSession() || { + start: new Date().toISOString(), + end: new Date().toISOString(), + imu: [], + gps: [], + heading: null, + }; if (!session.heading) { const p = pose(); session.heading = { @@ -836,7 +912,7 @@ export function createCapture(view, { globe, onSealed, onVerify }) { try { const bundle = await seal({ media: blob, session, context }); lastCapture = { bundle, url }; - setThumb(url, kind); + setThumb(url, kind === 'live' ? 'video' : kind); showSealed(bundle, url, kind, test); onSealed?.(bundle); } catch (err) { @@ -870,7 +946,7 @@ export function createCapture(view, { globe, onSealed, onVerify }) { function showSealing(url, kind, test) { sealEl.hidden = false; sealEl.innerHTML = `
-
${kind === 'video' ? `` : `Captured frame`}${test ? 'Test frame' : ''}
+
${kind === 'video' || kind === 'live' ? `` : `Captured frame`}${test ? 'Test frame' : ''}
Sealing…
@@ -906,7 +982,9 @@ export function createCapture(view, { globe, onSealed, onVerify }) { card.classList.add('sealed'); haptic('seal'); $('.seal-state', sealEl).innerHTML = - `Sealed · capture #${bundle.seq}`; + bundle.session?.mode === 'live' + ? `Sealed live · ${bundle.session.segments?.length || 0} segments · capture #${bundle.seq}` + : `Sealed · capture #${bundle.seq}`; scramble($('.seal-hash', sealEl), bundle.hash); const imu = bundle.session.imu; const mags = imu.map((s) => Math.hypot(s.ax ?? 0, s.ay ?? 0, s.az ?? 0)); @@ -923,7 +1001,7 @@ export function createCapture(view, { globe, onSealed, onVerify }) {
Heading
${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}
Media
${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB
-
`, +
`, ); } @@ -931,8 +1009,39 @@ export function createCapture(view, { globe, onSealed, onVerify }) { const a = e.target.closest('[data-seal]')?.dataset.seal; if (a === 'done') sealEl.hidden = true; if (a === 'verify' && lastCapture) onVerify?.(lastCapture.bundle); + if (a === 'post' && lastCapture) { + sealEl.hidden = true; + openAttach(lastCapture.bundle, lastCapture.url); + } }); + /** Hand a sealed bundle to the attach sheet. */ + function openAttach(bundle, url) { + const g = bundle.session?.gps || []; + const f = g[g.length - 1]; + const sim = observer(); + const fix = f + ? { lat: f.lat, lon: f.lon, acc: f.acc } + : sim.simulated + ? null + : { lat: sim.lat, lon: sim.lon, acc: sim.acc }; + attach?.open( + { + kind: bundle.session?.mode === 'live' ? 'live' : 'capture', + id: bundle.id, + seq: bundle.seq, + at: Date.parse(bundle.session?.end || bundle.sealedAt), + fix, + sealed: true, + previewUrl: url, + isVideo: /video/.test(bundle.media?.type || ''), + headingDeg: bundle.session?.heading?.deg ?? null, + segments: bundle.session?.segments?.length || 0, + }, + { preset: target?.s || null, replyTo: target?.replyTo || null }, + ); + } + $('.cap-thumb', view).addEventListener('click', () => { if (lastCapture) onVerify?.(lastCapture.bundle); else toast('No captures yet — switch to Witness and tap the shutter'); @@ -945,9 +1054,316 @@ export function createCapture(view, { globe, onSealed, onVerify }) { ); }); + /* ---------- live broadcast ---------- */ + let hudLiveT = 0; + function updateLiveHud(p) { + const now = performance.now(); + if (now - hudLiveT < 250) return; + hudLiveT = now; + const o = observer(); + const q = (k) => liveHud.querySelector(`[data-lh="${k}"]`); + q('state').textContent = live ? 'LIVE' : 'OFF AIR'; + if (!live) q('elapsed').textContent = 'tap the shutter to go live'; + if (live) { + const sec = Math.round((Date.now() - live.t0) / 1000); + q('elapsed').textContent = + `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`; + q('viewers').textContent = String(rt.viewersOf(live.id)); + } + q('tele').textContent = [ + `HDG ${String(Math.round(p.az)).padStart(3, '0')}° ${cardinal(p.az)}`, + `PITCH ${p.el >= 0 ? '+' : ''}${Math.round(p.el)}°`, + `${llShort(o.lat, o.lon, 4)}${o.simulated ? ' SIM' : ` ±${Math.round(o.acc || 0)} M`}`, + `IMU ${sensors.state.hasMotion ? 'ON' : 'OFF'}`, + live ? `SEG ${live.session.segments.length}` : 'STANDBY', + ].join(' · '); + } + + async function goLive() { + const stream = video.srcObject; + if (!camOk || !canBroadcast(stream)) { + showStatus('Live needs a camera on this device'); + return; + } + const id = `${rt.tab}-${Date.now().toString(36)}`; + const session = createLiveSession({ + stream, + sensors, + pose, + fix: () => { + const o = observer(); + return o.simulated ? null : o; + }, + onSegment: addSegment, + }); + try { + session.start(); + } catch (err) { + showStatus(`Could not start: ${err.message}`); + return; + } + live = { id, session, t0: Date.now(), pin: null, lastHd: null, timer: 0 }; + root.classList.add('is-live'); + $('.shutter-label', view).textContent = 'END'; + shutter.setAttribute('aria-label', 'End broadcast'); + $('.lh-chain', view).innerHTML = + 'sealing every 10 s'; + haptic('medium'); + broadcastTick(); + live.timer = setInterval(broadcastTick, 1000); + } + + function broadcastTick() { + if (!live) return; + const p = pose(); + const o = observer(); + const hd = Math.round(p.az / 5) * 5; + if (live.lastHd !== hd) { + if (live.pin) globe.removePin(live.pin); + live.pin = globe.addPin({ + id: `live:${live.id}`, + kind: 'live', + lon: o.lon, + lat: o.lat, + headingDeg: p.az, + fovDeg: VFOV, + label: 'You · live', + }); + live.lastHd = hd; + } + let frame = null; + try { + const vw = video.videoWidth; + const vh = video.videoHeight; + if (vw && vh) { + const c = + live.canvas || (live.canvas = document.createElement('canvas')); + c.width = 320; + c.height = 180; + const x = c.getContext('2d'); + const sc = Math.max(320 / vw, 180 / vh); + x.drawImage( + video, + (320 - vw * sc) / 2, + (180 - vh * sc) / 2, + vw * sc, + vh * sc, + ); + frame = c.toDataURL('image/jpeg', 0.6); + } + } catch { + frame = null; + } + rt.liveTick( + { + id: live.id, + handle: rt.me.handle, + startedAt: live.t0, + lat: o.lat, + lon: o.lon, + acc: o.acc, + simulated: !!o.simulated, + headingDeg: p.az, + segments: live.session.segments.length, + viewers: rt.viewersOf(live.id), + situationTitle: target?.s?.title || null, + }, + frame, + ); + } + + function addSegment(seg) { + const chain = $('.lh-chain', view); + chain.querySelector('.pending')?.remove(); + chain.appendChild( + h( + `#${seg.i + 1} ${esc(seg.hash.slice(0, 6))}${seg.prev ? ` ← ${esc(seg.prev.slice(0, 4))}` : ''}`, + ), + ); + while (chain.children.length > 4) chain.firstElementChild.remove(); + haptic('tick'); + } + + async function endLive() { + const l = live; + if (!l) return; + live = null; + clearInterval(l.timer); + root.classList.remove('is-live'); + $('.shutter-label', view).textContent = 'GO LIVE'; + shutter.setAttribute('aria-label', 'Go live'); + rt.liveEnd(l.id); + if (l.pin) globe.removePin(l.pin); + showStatus('Sealing the broadcast…', 2500); + const out = await l.session.stop(); + const p = pose(); + const session = { + ...out.session, + heading: out.session.heading || { + deg: Math.round(p.az * 10) / 10, + pitch: Math.round(p.el * 10) / 10, + roll: 0, + source: sensors.state.hasOrientation ? 'relative' : 'simulated', + }, + segments: out.segments, + liveId: l.id, + }; + if (out.blob.size) await sealMedia(out.blob, 'live', false, session); + } + + /* ---------- upload ---------- */ + const fileInput = $('.up-btn input', view); + fileInput.addEventListener('change', async () => { + const f = fileInput.files?.[0]; + fileInput.value = ''; + if (f) await placeUpload(f); + }); + + const toLocalInput = (ms) => { + const d = new Date(ms - new Date(ms).getTimezoneOffset() * 60000); + return d.toISOString().slice(0, 16); + }; + + async function placeUpload(file) { + const isJpeg = /jpe?g/i.test(file.type) || /\.jpe?g$/i.test(file.name); + const exif = isJpeg ? await readExif(file).catch(() => null) : null; + const url = URL.createObjectURL(file); + const isVideo = /^video/.test(file.type); + const me = getLocation?.() || sensors.state.fix; + const loc = + exif?.lat != null + ? { lat: exif.lat, lon: exif.lon, src: 'EXIF GPS' } + : target?.s + ? { lat: target.s.lat, lon: target.s.lon, src: 'ROOM LOCATION' } + : me + ? { lat: me.lat, lon: me.lon, src: 'YOUR LOCATION' } + : { lat: FALLBACK_POS.lat, lon: FALLBACK_POS.lon, src: 'DEFAULT' }; + const taken = exif?.takenAt || file.lastModified || Date.now(); + const place = $('.up-place', view); + $('.up-pick', view).hidden = true; + place.hidden = false; + const rows = [ + [ + 'Position', + exif?.lat != null + ? `${llShort(exif.lat, exif.lon, 5)} (EXIF)` + : 'none in file', + ], + [ + 'Taken', + exif?.takenAt + ? `${new Date(exif.takenAt).toLocaleString()}${exif.takenAtHasZone ? '' : ' (no time zone in file)'}` + : `unknown · file date ${new Date(file.lastModified).toLocaleDateString()}`, + ], + [ + 'Camera', + (exif?.model && exif?.make && exif.model.startsWith(exif.make) + ? exif.model + : [exif?.make, exif?.model].filter(Boolean).join(' ')) || '—', + ], + [ + 'Direction', + Number.isFinite(exif?.headingDeg) + ? `${Math.round(exif.headingDeg)}° ${cardinal(exif.headingDeg)}` + : '—', + ], + ]; + place.innerHTML = ` +
${isVideo ? `` : `Chosen file`} +
${esc(file.name || 'Untitled')}${esc((file.type || 'file').toUpperCase())} · ${(file.size / 1024).toFixed(0)} KB
+ ${icon('hazards')}Unverified
+
${rows.map(([k, v]) => `
${k}
${esc(v)}
`).join('')}
+
${icon('pin')}Place it — drag the map under the pin
+
+
${icon('clock')}When was it taken
+ +

${icon('info')}Marked Unverified everywhere. EXIF is easy to edit and nothing was sealed when this was taken, so it can be cited by a hypothesis but never counted as an observation or earn Was there.

+
`; + let moved = false; + const map = createMiniMap($('.up-map', place), { + lat: loc.lat, + lon: loc.lon, + spanDeg: exif?.lat != null ? 0.008 : 0.05, + label: loc.src, + onChange: () => (moved = true), + }); + place.onclick = async (e) => { + const a = e.target.closest('[data-up]')?.dataset.up; + if (a === 'cancel') return resetUpload(url); + if (a !== 'next') return; + const c = map.center; + const t = Date.parse($('.up-time', place).value) || taken; + const rec = { + id: `up-${Date.now().toString(36)}`, + name: file.name, + type: file.type, + size: file.size, + sha256: await sha256Hex(file), + lat: c.lat, + lon: c.lon, + takenAt: t, + placedBy: moved ? 'hand' : loc.src.toLowerCase(), + exif: exif + ? { + lat: exif.lat, + lon: exif.lon, + takenAt: exif.takenAt, + make: exif.make, + model: exif.model, + headingDeg: exif.headingDeg, + } + : null, + addedAt: Date.now(), + blob: file, + }; + await put('uploads', rec).catch((err) => + console.warn('upload not stored', err), + ); + resetUpload(); + onSealed?.(null); + attach?.open( + { + kind: 'upload', + id: rec.id, + at: t, + fix: { lat: c.lat, lon: c.lon }, + unverified: true, + sealed: false, + previewUrl: url, + isVideo, + headingDeg: exif?.headingDeg ?? null, + }, + { preset: target?.s || null, replyTo: target?.replyTo || null }, + ); + }; + } + + function resetUpload(url) { + if (url) URL.revokeObjectURL(url); + const place = $('.up-place', view); + place.hidden = true; + place.innerHTML = ''; + $('.up-pick', view).hidden = false; + } + + /* ---------- reply target ---------- */ + function attachTo(s, { replyTo = null } = {}) { + target = s ? { s, replyTo } : null; + targetChip.hidden = !target; + if (target) { + targetChip.innerHTML = `${icon('reply')}Answering in ${esc(s.title)}${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''}${icon('close')}`; + if (mode === 'look') setMode('witness'); + } + } + targetChip.addEventListener('click', () => attachTo(null)); + return { enter, leave, + attachTo, + get live() { + return !!live; + }, setMode, get mode() { return mode; diff --git a/mobile/ui/cards.js b/mobile/ui/cards.js new file mode 100644 index 000000000..1b0f691cb --- /dev/null +++ b/mobile/ui/cards.js @@ -0,0 +1,244 @@ +/** + * Feed card archetypes (DESIGN.md §11.2): Sensor, Eye, Track, Number, + * Witness, Room. Every image card carries sensor framing (corner brackets, + * a hairline reticle) and one monospace telemetry strip burned into the + * bottom of the frame. Restraint: one strip per card, never a full HUD. + */ + +import { icon } from './icons.js'; +import { esc, fmtNum, ago, sparkPath } from './dom.js'; +import { + BAND_NAME, + bandOf, + archetypeOf, + telemetryOf, + headlineOf, + realSeries, + sensorId, + cardinal, + fmtKm, + utc, +} from './bands.js'; +import { KIND } from './rooms.js'; + +export const frame = () => + ''; +const reticle = () => ''; + +function fmtVal(v) { + if (typeof v === 'number') return fmtNum(v, v % 1 ? 1 : 0); + return String(v ?? ''); +} + +function bandLine(s, extra = []) { + const band = bandOf(s); + const parts = [BAND_NAME[band], ...extra].filter(Boolean); + return `
${parts.map(esc).join('·')}
`; +} + +function distBits(s) { + return Number.isFinite(s.distanceKm) ? [`${fmtKm(s.distanceKm)} away`] : []; +} + +function liveBit(s) { + return s.live + ? '' + : `${s.kind === 'reconstruction' ? '4D · archive' : 'stale'}`; +} + +function body(s, { cta = '' } = {}) { + return `
+ ${bandLine(s, [...distBits(s), ago(s.updatedAt)])} +

${esc(s.title)}

+
${esc(s.place || '')}${liveBit(s)}
+ ${cta} +
`; +} + +/** The shared image frame for a situation card. */ +function media(s, ar, { strip = true, inner = '', eye = false } = {}) { + return `
+ + ${eye ? '' : ''} + ${frame()}${reticle()} +
${esc(sensorId(s))}${s.live ? `${eye ? `LIVE ` : 'LIVE'}` : s.kind === 'reconstruction' ? '4D' : ''}
+ ${inner} + ${strip ? `
${esc(telemetryOf(s))}
` : ''} +
`; +} + +const hit = (label) => + ``; + +/* ---------------- Situation archetypes ---------------- */ + +function sensorCard(s, ar) { + return `
+ ${hit(`Open ${s.title}, ${s.place || ''}`)} + ${media(s, ar)} + ${body(s)} +
`; +} + +function trackCard(s, ar) { + const hl = headlineOf(s); + const harm = /squawk/.test(s.kind) && /7[5-7]00/.test(String(hl?.value)); + return `
+ ${hit(`Open ${s.title}`)} + ${media(s, ar, { + inner: hl + ? `
${esc(fmtVal(hl.value))}${hl.unit ? `${esc(hl.unit)}` : ''}${esc(hl.label || '')}
` + : '', + })} + ${body(s, { cta: `${icon('ride')}Ride along` })} +
`; +} + +function numberCard(s, ar) { + const hl = headlineOf(s); + const ser = realSeries(s); + const W = 200; + const H = 54; + const d = ser.length >= 3 ? sparkPath(ser, W, H, 3) : ''; + return `
+ ${hit(`Open ${s.title}`)} +
+ + ${frame()} +
+ ${bandLine(s, [s.kind === 'gnss' ? 'ADS-B' : '', ...distBits(s)])} +
${esc(fmtVal(hl?.value))}${esc(hl?.unit || '')}
+
${esc(hl?.label || '')}
+ ${d ? `
${esc(s.seriesLabel || 'trend')} · ${ser.length} samples
` : ''} +
+
${esc(telemetryOf(s))}
+
+
+

${esc(s.title)}

+
${esc(s.place || '')} · ${esc(ago(s.updatedAt))}
+
+
`; +} + +function eyeCard(s) { + const hd = s.media?.headingDeg; + return `
+ ${hit(`Open live camera, ${s.title}`)} +
+ + + ${frame()}${reticle()} +
LIVE ${esc(sensorId(s))}
+
+ ${Number.isFinite(hd) ? `` : ''} +

${esc(s.title)}

+
${esc(s.place || '')}
+
+
${esc(telemetryOf(s))}${Number.isFinite(hd) ? ` · LOOKING ${cardinal(hd)} ${Math.round(hd)}°` : ''}
+
+
+ ${bandLine(s, [...distBits(s), 'Public camera'])} + ${esc(s.media?.provider || s.source || '')} +
+
`; +} + +/* ---------------- Witness (local captures & live sessions) ---------------- */ + +function witnessCard(w, ar) { + const sealed = w.sealed; + const live = w.type === 'live' || w.liveNow; + const tele = [ + live + ? 'WITNESS · LIVE' + : w.unverified + ? 'UPLOAD · UNVERIFIED' + : 'WITNESS · SEALED', + w.seq ? `#${w.seq}` : '', + Number.isFinite(w.acc) ? `±${Math.round(w.acc)} M` : '', + Number.isFinite(w.km) && w.situation + ? `${fmtKm(w.km).toUpperCase()} FROM EVENT` + : '', + ] + .filter(Boolean) + .join(' · '); + return `
+ ${hit(w.liveNow ? `Watch ${w.handle}'s live broadcast` : `Open your ${w.type}`)} +
+ ${w.isVideo && !w.frame ? `` : ``} + ${frame()} +
${w.liveNow ? `LIVE ${icon('users')}${w.viewers || 0}` : `${icon(sealed ? 'sealFill' : 'upload')}`}
+
${esc(tele)}
+
+
+
${esc(w.liveNow ? 'Live from a person' : w.unverified ? 'Your upload' : 'Your witness')}·${esc(ago(w.at))}
+

${esc(w.title)}

+
${esc(w.sub || '')}
+
+
`; +} + +function promptCard(p, ar) { + return `
+ ${hit('Open Capture')} +
+ ${frame()} +
+ ${icon('viewfinder')} +
Inside · you
+ ${esc(p.title)} + ${esc(p.text)} +
+
${esc(p.tele)}
+
+
${icon('viewfinder')}Open Capture
+
`; +} + +/* ---------------- Room ---------------- */ + +function avatar(a, i) { + const h = + [...(a.handle || '?')].reduce((x, c) => x * 31 + c.charCodeAt(0), 7) >>> 0; + const hue = a.self ? 42 : h % 360; + return `${esc((a.handle || '?').replace(/^@/, '')[0].toUpperCase())}`; +} + +function roomCard(r, watching = 0) { + const s = r.situation; + const top = r.top; + const k = top ? KIND[top.kind] : null; + return `
+ ${hit(`Join the room for ${s.title}`)} +
+
+
Room·${esc(s.title)}
+ ${top?.example ? 'Example conversation' : `${watching > 1 ? `${watching} here` : ''}`} +
+ ${top ? `
${esc(top.text)}
` : ''} +
+ ${icon(r.status.id === 'disputed' ? 'dispute' : r.status.id === 'open' ? 'question' : 'sealFill')}${esc(r.status.label)} + ${top ? `${icon(k?.icon || 'note')}${esc(k?.name || '')} · ${esc(top.author.handle)}` : ''} +
+
+ ${r.participants.slice(0, 5).map(avatar).join('')} + ${r.participants.length} people · ${r.posts.length} posts${r.openQ.length ? ` · ${r.openQ.length} open question${r.openQ.length > 1 ? 's' : ''}` : ''} + ${icon('chevronRight')} +
+
+
`; +} + +export function cardHtml(item, ar) { + if (item.type === 'room') return roomCard(item.room, item.watching); + if (item.type === 'witness') return witnessCard(item.w, ar); + if (item.type === 'prompt') return promptCard(item.p, ar); + const s = item.s; + const a = item.archetype || archetypeOf(s); + if (a === 'eye') return eyeCard(s); + if (a === 'track') return trackCard(s, ar); + if (a === 'number') return numberCard(s, ar); + return sensorCard(s, ar); +} + +export { avatar, utc }; diff --git a/mobile/ui/composer.js b/mobile/ui/composer.js new file mode 100644 index 000000000..4d0e23817 --- /dev/null +++ b/mobile/ui/composer.js @@ -0,0 +1,174 @@ +/** + * Post composer: Capture first, text second (DESIGN.md §11.3 "Reply with + * evidence"). Structured kinds, an optional place and time anchor. + */ + +import { icon } from './icons.js'; +import { $, $$, esc, haptic, toast } from './dom.js'; +import { createSheet } from './sheet.js'; +import { KINDS, KIND } from './rooms.js'; +import { llShort, utc } from './bands.js'; + +export function createComposer({ + rooms, + getTime, + getLocation, + getView, + onCapture, + onPosted, +}) { + const sheet = createSheet({ + label: 'New post', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'composer-sheet opaque', + heights: { large: (H) => Math.round(Math.min(H * 0.9, 820)) }, + }); + let ctx = null; // { s, replyTo, kind, place, time } + + function placeOptions() { + const s = ctx.s; + const opts = [ + { id: 'none', label: 'No place' }, + { + id: 'situation', + label: 'At the situation', + ll: { lat: s.lat, lon: s.lon }, + }, + ]; + const me = getLocation(); + if (me) opts.push({ id: 'me', label: 'Where I am', ll: me }); + const v = getView(); + if ( + v && + Number.isFinite(v.lat) && + Math.abs(v.lat - s.lat) + Math.abs(v.lon - s.lon) > 0.0005 + ) + opts.push({ id: 'view', label: 'Globe centre', ll: v }); + return opts; + } + + function render() { + const s = ctx.s; + const r = ctx.replyTo; + sheet.head.innerHTML = `
Room·${esc(s.title)}
+

${r ? 'Reply' : 'Post to the room'}

+ ${r ? `
${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)}${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}
` : ''}`; + const places = placeOptions(); + const t = getTime(); + sheet.body.innerHTML = ` + +
or write
+
+ ${KINDS.map((k) => ``).join('')} +
+

${esc(KIND[ctx.kind].hint)}

+ +
+
${icon('pin')}Anchor in place
+
${places.map((p) => ``).join('')}
+
${icon('clock')}Anchor in time
+
${t ? `` : ''}
+
+
Stored on this device · shared live with your other open tabs
`; + } + + function placeholder(kind) { + return { + witness: 'What did you see or hear, from where, and when?', + context: 'Background that helps people read the data…', + question: 'What can’t the sensors tell you?', + correction: 'What is wrong, and what does the evidence show instead?', + prediction: 'What happens next? It stays on the record.', + }[kind]; + } + + function open(s, { replyTo = null, kind } = {}) { + ctx = { + s, + replyTo, + kind: + kind || + (replyTo + ? replyTo.kind === 'question' + ? 'context' + : 'correction' + : 'witness'), + place: 'situation', + time: 'now', + }; + render(); + sheet.open('large'); + setTimeout( + () => $('.cmp-text', sheet.body)?.focus({ preventScroll: true }), + 380, + ); + } + + sheet.root.addEventListener('click', async (e) => { + if (!ctx) return; + const k = e.target.closest('[data-kind]'); + if (k) { + ctx.kind = k.dataset.kind; + $$('[data-kind]', sheet.body).forEach((b) => + b.setAttribute('aria-checked', String(b === k)), + ); + $('.kind-hint', sheet.body).textContent = KIND[ctx.kind].hint; + $('.cmp-text', sheet.body).placeholder = placeholder(ctx.kind); + haptic('tick'); + return; + } + const chip = e.target.closest('[data-anchor] button'); + if (chip) { + const group = chip.parentElement.dataset.anchor; + ctx[group] = chip.dataset.v; + $$('button', chip.parentElement).forEach((b) => + b.setAttribute('aria-pressed', String(b === chip)), + ); + haptic('tick'); + return; + } + const act = e.target.closest('[data-act]')?.dataset.act; + if (act === 'capture') { + sheet.close(); + onCapture(ctx.s, { replyTo: ctx.replyTo }); + } else if (act === 'post') { + const text = $('.cmp-text', sheet.body).value.trim(); + if (!text) { + $('.cmp-text', sheet.body).focus(); + toast('Write something first'); + return; + } + const place = placeOptions().find((p) => p.id === ctx.place)?.ll || null; + const t = + ctx.time === 'now' + ? Date.now() + : ctx.time === 'ribbon' + ? +getTime() + : null; + const anchor = + place || t ? { lat: place?.lat, lon: place?.lon, t } : null; + const p = await rooms.post(ctx.s, { + kind: ctx.kind, + text, + replyTo: ctx.replyTo?.id || null, + anchor, + evidence: {}, + }); + haptic('success'); + sheet.close(); + toast(`Posted to the room · ${KIND[p.kind].name}`, { + icon: icon(KIND[p.kind].icon), + }); + onPosted?.(ctx.s, p); + } + }); + + return { open, close: () => sheet.close() }; +} diff --git a/mobile/ui/eyeViewer.js b/mobile/ui/eyeViewer.js new file mode 100644 index 000000000..55ce19765 --- /dev/null +++ b/mobile/ui/eyeViewer.js @@ -0,0 +1,92 @@ +/** + * Eye viewer: the nearest public camera's live frame (DESIGN.md §11.2 Eye), + * re-requested every refresh interval while open, with a LIVE timecode, + * the camera id and the direction it looks. + */ + +import { icon } from './icons.js'; +import { esc, haptic } from './dom.js'; +import { createSheet } from './sheet.js'; +import { frame } from './cards.js'; +import { cardinal, fmtKm, utc, llShort } from './bands.js'; + +export function createEyeViewer({ globe, onFly }) { + const sheet = createSheet({ + label: 'Live camera', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'eye-sheet opaque', + heights: { large: (H) => Math.round(Math.min(H * 0.86, 760)) }, + }); + let timer = 0; + let clock = 0; + let cur = null; + + function url() { + return globe.eyeFrameUrl(cur.id) || cur.imageUrl || ''; + } + + function refresh() { + const a = sheet.body.querySelector('.ev-frame img.front'); + const b = sheet.body.querySelector('.ev-frame img:not(.front)'); + if (!a || !b) return; + b.onload = () => { + b.classList.add('front'); + a.classList.remove('front'); + sheet.body.querySelector('.ev-age').textContent = 'just now'; + }; + b.src = url(); + } + + function open(eye, { from } = {}) { + if (!eye) return; + cur = eye; + const hd = Number(eye.headingDeg); + sheet.head.innerHTML = `
Ground·Public camera${Number.isFinite(eye.distanceKm) ? `·${esc(fmtKm(eye.distanceKm))} from ${esc(from || 'here')}` : ''}
+

${esc(eye.title || eye.id)}

+
${esc([eye.city, eye.provider].filter(Boolean).join(' · '))}
`; + sheet.body.innerHTML = `
+ Live frame from ${esc(eye.title || 'camera')} + ${frame()} +
LIVE ${utc()}CAM ${esc(String(eye.id).slice(-8).toUpperCase())}
+
GROUND · ${esc(llShort(eye.lat, eye.lon, 4))}${Number.isFinite(hd) ? ` · LOOKING ${cardinal(hd)} ${Math.round(hd)}°` : ''}
+
+
Refreshed just nowevery 15 s while open
+
+

Public traffic camera via the app’s CCTV proxy. Frames are stills, not video; the time burned into the frame is the camera’s local clock.

`; + const front = sheet.body.querySelector('.ev-frame img.front'); + front.addEventListener( + 'error', + () => sheet.body.querySelector('.ev-frame')?.classList.add('offline'), + { once: true }, + ); + sheet.open('large'); + haptic('light'); + clearInterval(timer); + clearInterval(clock); + timer = setInterval(refresh, 15000); + let n = 0; + clock = setInterval(() => { + n += 1; + const c = sheet.body.querySelector('[data-ev-clock]'); + if (c) c.textContent = utc(); + const age = sheet.body.querySelector('.ev-age'); + if (age && n % 5 === 0) age.textContent = `${n % 15} s ago`; + }, 1000); + } + + sheet.on('close', () => { + clearInterval(timer); + clearInterval(clock); + }); + sheet.root.addEventListener('click', (e) => { + if (e.target.closest('[data-ev="fly"]') && cur) { + sheet.close(); + onFly?.(cur); + } + }); + + return { open, close: () => sheet.close() }; +} diff --git a/mobile/ui/feed.js b/mobile/ui/feed.js index 095ee9518..2be5bc270 100644 --- a/mobile/ui/feed.js +++ b/mobile/ui/feed.js @@ -1,20 +1,32 @@ /** - * Now: a two-column, RedNote-style masonry of live situations with a large - * collapsing title, section tabs, pull-to-refresh and skeleton loading. + * Now: the global equivalent of a neighbourhood safety feed (DESIGN.md §11.2). + * Mixed card archetypes across the four altitude bands, ordered so that + * neighbours differ, with Room and Witness cards woven in, an altitude + * filter, distance from you, and a "Near you" lens. Two-column masonry with + * full-width rows for live cameras and rooms; columns are balanced exactly + * before every wide row. */ -import { icon, FAMILY_ICON, FAMILIES } from './icons.js'; -import { $, $$, esc, ago, fmtNum, clamp, haptic } from './dom.js'; +import { icon } from './icons.js'; +import { $, $$, esc, ago, clamp, haptic, reducedMotion } from './dom.js'; +import { cardHtml } from './cards.js'; +import { localWitness } from './witness.js'; +import { + BANDS, + bandOf, + archetypeOf, + distKm, + fmtKm, + utc, + presenceRadiusKm, +} from './bands.js'; -const SECTIONS = [ - { id: 'now', label: 'Happening now' }, - { id: 'near', label: 'Near you' }, - { id: 'recon', label: 'Reconstructions' }, - { id: 'always', label: 'Always on' }, -]; - -const ASPECTS = [1.25, 1, 1.33, 0.82, 1.18, 0.95]; -const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name])); +const BAND_RANGE = { + orbit: '36,000 km', + sky: '15 km', + ground: '0 m', + inside: 'you', +}; function hashStr(s) { let x = 2166136261; @@ -23,14 +35,6 @@ function hashStr(s) { return x >>> 0; } -/** Height/width ratio for a card: from the thumbnail URL when known, else stable per id. */ -function aspectFor(s) { - const m = /size=(\d+),(\d+)/.exec(s.thumbnail || ''); - if (m) return clamp(Number(m[2]) / Number(m[1]), 0.72, 1.4); - if (s.kind === 'reconstruction') return 1.3; - return ASPECTS[hashStr(String(s.id)) % ASPECTS.length]; -} - export function isReconstruction(s) { return ( s.section === 'reconstructions' || s.kind === 'reconstruction' || !!s.thread @@ -40,150 +44,498 @@ export function isAlwaysOn(s) { return s.section === 'always' || !!s.alwaysOn; } -function distKm(a, b) { - const R = 6371; - const dLat = ((b.lat - a.lat) * Math.PI) / 180; - const dLon = ((b.lon - a.lon) * Math.PI) / 180; - const x = - Math.sin(dLat / 2) ** 2 + - Math.cos((a.lat * Math.PI) / 180) * - Math.cos((b.lat * Math.PI) / 180) * - Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.min(1, Math.sqrt(x))); +const WIDE = new Set(['eye', 'room']); +const BODY_H = { + sensor: 84, + track: 110, + number: 62, + witness: 78, + prompt: 46, +}; +const GAP = 8; + +function aspect(item) { + const a = item.archetype; + if (a === 'number') return 1.06; + if (a === 'track') return 1; + if (a === 'witness') return item.w?.liveNow ? 1.3 : 1.22; + if (a === 'prompt') return 1.08; + const opts = [1.18, 1.32, 1.0, 1.42, 1.12]; + return opts[hashStr(String(item.key)) % opts.length]; +} + +/** Diversity-aware ordering: keep priority, avoid same archetype/band neighbours. */ +export function diversify(list) { + const pool = [...list]; + const out = []; + let sinceWide = 9; + while (pool.length) { + let best = 0; + let bs = -Infinity; + for (let i = 0; i < Math.min(pool.length, 7); i += 1) { + const it = pool[i]; + const p1 = out[out.length - 1]; + const p2 = out[out.length - 2]; + let sc = -i * 1.15; + if (p1?.archetype === it.archetype) sc -= 6; + if (p2?.archetype === it.archetype) sc -= 2.5; + if (p1?.band === it.band) sc -= 4; + if (p2?.band === it.band) sc -= 1.5; + if (WIDE.has(it.archetype) && sinceWide < 3) sc -= 5; + if (sc > bs) { + bs = sc; + best = i; + } + } + const it = pool.splice(best, 1)[0]; + sinceWide = WIDE.has(it.archetype) ? 0 : sinceWide + 1; + out.push(it); + } + return out; } export function createFeed( view, - { getSituations, onOpen, onLocate, bindScroll }, + { + getSituations, + onOpen, + onOpenRoom, + onWitness, + onCapture, + onLocate, + bindScroll, + globe, + rooms, + rt, + }, ) { view.innerHTML = ` - +
-

Now

-
Loading the planet…
-
- ${SECTIONS.map((s, i) => ``).join('')} - +
+

Now

+ +
+
Acquiring the planet…
+
+ + ${BANDS.map((b) => ``).join('')}
-
- +
+
`; const inner = $('.feed-inner', view); - const cols = $$('.mcol', view); - const masonry = $('.masonry', view); - const tabs = $('.feed-tabs', view); - const ink = $('.feed-tabs-ink', view); + const fbody = $('.feed-body', view); + const bandbar = $('.bandbar', view); const count = $('.feed-count', view); const note = $('.feed-note', view); const ptr = $('.ptr', view); - let section = 'now'; + let band = 'all'; + let near = false; let all = []; let userLoc = null; + let hot = []; + let witness = []; const cardEls = new Map(); let selectedId = null; + const itemsByKey = new Map(); + + /* ---------- clocks ---------- */ + function tickClocks() { + const now = new Date(); + const u = utc(now); + const hd = $('[data-utc]', view); + if (hd) hd.textContent = `${u} UTC`.replace('Z UTC', ' UTC'); + const dt = $('[data-date]', view); + if (dt) + dt.textContent = now + .toLocaleDateString('en-GB', { + weekday: 'short', + day: '2-digit', + month: 'short', + }) + .toUpperCase(); + if (view.hidden) return; + $$('[data-clock]', fbody).forEach((el) => (el.textContent = u)); + $$('[data-since]', fbody).forEach((el) => { + const s = Math.max( + 0, + Math.round((Date.now() - Number(el.dataset.since)) / 1000), + ); + el.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; + }); + } + setInterval(tickClocks, 1000); + tickClocks(); function skeleton() { - cols.forEach((c, ci) => { - c.innerHTML = [0, 1, 2] - .map( - (i) => - `
`, - ) - .join(''); + fbody.innerHTML = `
${[0, 1] + .map( + (ci) => + `
${[0, 1, 2] + .map( + (i) => + `
`, + ) + .join('')}
`, + ) + .join('')}
`; + } + + /* ---------- items ---------- */ + function withDistance(list) { + return list.map((s) => { + if (!userLoc || !Number.isFinite(s.lat)) return s; + const d = Number.isFinite(s.distanceKm) + ? s.distanceKm + : distKm(userLoc, s); + return { ...s, distanceKm: d }; }); } - function cardHtml(s) { - const m = s.metrics?.[0]; - const ar = aspectFor(s); - const fam = FAM_NAME[s.family] || s.family; - return `
- -
- -
- ${icon(FAMILY_ICON[s.family] || 'now')}${esc(fam)} - ${s.live ? 'LIVE' : isReconstruction(s) ? `${icon('threads')}4D` : ''} -
- ${m ? `
${esc(fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0))}${m.unit ? `${esc(m.unit)}` : ''}${esc(m.label)}
` : ''} -
-
-

${esc(s.title)}

-
${icon('pin')}${esc(s.place || '')}
-
${esc(shortSource(s.source))}${esc(ago(s.updatedAt))}
-
-
`; + function situationItems() { + const sits = withDistance(all); + const items = sits.map((s, i) => ({ + key: `s:${s.id}`, + type: 'situation', + s, + archetype: archetypeOf(s), + band: bandOf(s), + prio: (s.severity || 0) * 10 - i * 0.6 + (s.live ? 2 : 0), + })); + // The street is the most Citizen-like card: one live eye right after + // the two most severe situations. + const ranked = [...items].sort((a, b) => b.prio - a.prio); + const eye = ranked.find((x) => x.archetype === 'eye'); + if (eye && ranked[1] && ranked.indexOf(eye) > 2) + eye.prio = ranked[1].prio - 0.1; + return items; } - function shortSource(src = '') { - return String(src).split(/[·(]/)[0].trim() || 'Live feed'; + function liveWitness() { + return rt.lives().map((l) => ({ + id: `live:${l.id}`, + liveId: l.id, + type: 'live', + liveNow: true, + sealed: true, + at: l.startedAt, + startedAt: l.startedAt, + handle: l.handle, + viewers: l.viewers || 0, + frame: l.frame, + title: `${l.handle} is live${l.situationTitle ? ` near ${l.situationTitle}` : ''}`, + sub: `${l.segments || 0} sealed segments · heading ${Math.round(l.headingDeg || 0)}°`, + fix: Number.isFinite(l.lat) ? { lat: l.lat, lon: l.lon } : null, + acc: l.acc, + })); } - function filtered() { - if (section === 'recon') return all.filter(isReconstruction); - if (section === 'always') return all.filter(isAlwaysOn); - if (section === 'near') { - const at = userLoc || { lon: -122.42, lat: 37.77 }; - return [...all] - .filter((s) => Number.isFinite(s.lon)) - .sort((a, b) => distKm(at, a) - distKm(at, b)) - .slice(0, 8); + function buildItems() { + let items = situationItems(); + const lives = liveWitness(); + const wit = [...lives, ...witness.slice(0, 4)].map((w) => ({ + key: `w:${w.id}`, + type: 'witness', + w, + archetype: 'witness', + band: 'inside', + prio: w.liveNow ? 100 : Date.now() - w.at < 6 * 3600e3 ? 30 : 0, + })); + const roomItems = hot.map((r, i) => ({ + key: `r:${r.id}`, + type: 'room', + room: r, + watching: rt.presence(r.id).length, + archetype: 'room', + band: 'inside', + prio: 40 - i * 12, + })); + if (!witness.length && !lives.length) { + const nearest = userLoc + ? [...withDistance(all)] + .filter((s) => Number.isFinite(s.distanceKm)) + .sort((a, b) => a.distanceKm - b.distanceKm)[0] + : null; + wit.push({ + key: 'prompt', + type: 'prompt', + archetype: 'prompt', + band: 'inside', + prio: 28, + p: nearest + ? { + title: `You’re ${fmtKm(nearest.distanceKm)} from ${nearest.title}`, + text: + nearest.distanceKm <= presenceRadiusKm(nearest) + ? 'A sealed photo from where you stand is the strongest post its room can get.' + : 'Your phone is a sensor too. Seal what you see and it joins the room for the nearest situation.', + tele: `INSIDE · YOU · ${fmtKm(nearest.distanceKm).toUpperCase()} · ${utc(new Date(), { seconds: false })}`, + } + : { + title: 'Be the sensor', + text: 'Seal a photo, go live or upload. Your phone becomes an eye for the God’s eye, with its motion and position signed in.', + tele: `INSIDE · YOU · ${utc(new Date(), { seconds: false })}`, + }, + }); } - return all - .filter((s) => !isReconstruction(s) && !isAlwaysOn(s)) - .sort( - (a, b) => - (b.severity ?? 0) - (a.severity ?? 0) || - new Date(b.updatedAt) - new Date(a.updatedAt), - ); + if (near) { + items = items + .filter((x) => Number.isFinite(x.s.distanceKm)) + .sort((a, b) => a.s.distanceKm - b.s.distanceKm) + .slice(0, 14) + .map((x, i) => ({ ...x, prio: 100 - i * 5 })); + } + let list = [...items, ...roomItems, ...wit]; + if (band !== 'all') list = list.filter((x) => x.band === band); + list.sort((a, b) => b.prio - a.prio); + return near ? list : diversify(list); + } + + /* ---------- layout ---------- */ + function layout(list) { + const W = fbody.clientWidth || view.clientWidth || 400; + const colW = (W - 16 - GAP) / 2; + const blocks = []; + let cols = [[], []]; + let h = [0, 0]; + const pending = []; + const canFlush = () => { + const d = Math.abs(h[0] - h[1]); + if (d < 4) return true; + const short = h[0] < h[1] ? 0 : 1; + const last = cols[short].at(-1); + return !!last && d < last.ar * colW * 0.55; + }; + const flush = () => { + if (!cols[0].length && !cols[1].length) return; + const d = Math.abs(h[0] - h[1]); + const short = h[0] < h[1] ? 0 : 1; + const last = cols[short].at(-1); + if (last && d >= 4) last.ar = (last.ar * colW + d) / colW; + blocks.push({ kind: 'mason', cols }); + cols = [[], []]; + h = [0, 0]; + }; + for (const it of list) { + if (WIDE.has(it.archetype)) { + if (canFlush()) { + flush(); + blocks.push({ kind: 'wide', it }); + } else pending.push(it); + continue; + } + const c = h[0] <= h[1] ? 0 : 1; + const ar = aspect(it); + const cell = { it, ar }; + cols[c].push(cell); + h[c] += ar * colW + (BODY_H[it.archetype] || 84) + GAP; + while (pending.length && canFlush()) { + flush(); + blocks.push({ kind: 'wide', it: pending.shift() }); + } + } + // A lonely trailing card would leave a hole: let the last wide row go first. + if (pending.length) { + flush(); + for (const it of pending) blocks.push({ kind: 'wide', it }); + } else if (cols[0].length + cols[1].length) + blocks.push({ kind: 'mason', cols }); + return blocks; } function render() { - const list = filtered(); + const list = buildItems(); + itemsByKey.clear(); + list.forEach((it) => itemsByKey.set(it.key, it)); + const blocks = layout(list); + const keyed = (it, ar) => + cardHtml(it, ar).replace( + '
+ b.kind === 'wide' + ? `
${keyed(b.it, 0.5625)}
` + : `
${b.cols.map((c) => `
${c.map((cell) => keyed(cell.it, cell.ar)).join('')}
`).join('')}
`, + ) + .join(''); cardEls.clear(); - const heights = [0, 0]; - const parts = [[], []]; - for (const s of list) { - const col = heights[0] <= heights[1] ? 0 : 1; - parts[col].push(cardHtml(s)); - heights[col] += aspectFor(s) + 0.42; - } - cols.forEach((c, i) => (c.innerHTML = parts[i].join(''))); - $$('.card[data-id]', masonry).forEach((el) => { + $$('.card[data-id]', fbody).forEach((el) => { cardEls.set(el.dataset.id, el); el.classList.toggle('is-selected', el.dataset.id === selectedId); }); - masonry.setAttribute('aria-busy', 'false'); + fbody.setAttribute('aria-busy', 'false'); $('.feed-end', view).hidden = !list.length; + if (!list.length) + fbody.innerHTML = `
Nothing in this band right now${near ? ' near you' : ''}.
`; + // counts + const sit = situationItems(); + const counts = { all: sit.length + hot.length + witness.length }; + for (const b of BANDS) + counts[b.id] = sit.filter((x) => x.band === b.id).length; + counts.inside += hot.length + witness.length; + for (const [k, v] of Object.entries(counts)) { + const el = $(`[data-count="${k}"]`, bandbar); + if (el) el.textContent = String(v); + } const live = all.filter((s) => s.live).length; - count.textContent = `${live} live · ${all.length} situations · ${new Date().toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })}`; - if (section === 'near' && !userLoc) { + const eyes = all.filter((s) => archetypeOf(s) === 'eye').length; + count.textContent = `${live} live · ${eyes} eyes open · ${hot.reduce((a, r) => a + r.posts.length, 0)} posts in ${hot.length} hot rooms`; + if (near && !userLoc) { note.hidden = false; - note.innerHTML = `
${icon('location')}
See what’s around youSorted from San Francisco until you share your location. It stays on this device.
`; + note.innerHTML = `
${icon('location')}
See what’s around youDistances and the Near lens need your location. It stays on this device.
`; } else note.hidden = true; - // Images fade in when decoded. - $$('img', masonry).forEach((img) => { - if (img.complete) img.classList.add('loaded'); - else - img.addEventListener('load', () => img.classList.add('loaded'), { - once: true, - }); + hydrate(); + } + + /* ---------- images: composited thumbnails, live eyes ---------- */ + const thumbIO = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + thumbIO.unobserve(e.target); + loadThumb(e.target); + } + }, + { root: view, rootMargin: '700px 0px' }, + ); + const visibleEyes = new Set(); + const eyeIO = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) visibleEyes.add(e.target); + else visibleEyes.delete(e.target); + } + }, + { root: view, threshold: 0.15 }, + ); + + function markLoaded(img) { + const done = () => img.classList.add('loaded'); + if (img.complete && img.naturalWidth) done(); + else { + img.addEventListener('load', done, { once: true }); img.addEventListener('error', () => img.classList.add('failed'), { once: true, }); - }); + } } - function placeInk() { - const btn = $(`[data-sec="${section}"]`, tabs); - if (!btn) return; - ink.style.width = `${btn.offsetWidth - 20}px`; - ink.style.transform = `translateX(${btn.offsetLeft + 10}px)`; + async function loadThumb(img) { + const card = img.closest('.card'); + const it = itemsByKey.get(card?.dataset.key); + const s = it?.s; + if (!s) return; + const media = img.closest('.card-media'); + const r = media.getBoundingClientRect(); + const w = 360; + const hgt = + Math.round((w * (r.height / Math.max(1, r.width))) / 40) * 40 || 480; + // The plain still shows at once; the composited frame replaces it. + if (s.thumbnail && !img.getAttribute('src')) { + img.src = s.thumbnail; + img.classList.add('still'); + markLoaded(img); + } + let url = ''; + try { + url = await globe.thumbnail(s, { width: w, height: hgt, dpr: 2 }); + } catch { + url = ''; + } + if (!img.isConnected || !url || url === img.getAttribute('src')) return; + const pre = new Image(); + pre.decoding = 'async'; + pre.onload = () => { + if (!img.isConnected) return; + img.src = url; + img.classList.remove('still'); + img.classList.add('loaded', 'composited'); + }; + pre.src = url; + } + + function hydrate() { + $$('img[data-thumb]', fbody).forEach((img) => thumbIO.observe(img)); + $$('.c-eye', fbody).forEach((card) => { + eyeIO.observe(card); + card._next = Date.now() + Number(card.dataset.refresh || 15000); + const img = $('img[data-eye]', card); + markLoaded(img); + img.addEventListener( + 'error', + () => { + const it = itemsByKey.get(card.dataset.key); + if (it?.s?.thumbnail && !img.dataset.fellBack) { + img.dataset.fellBack = '1'; + img.src = it.s.thumbnail; + card.classList.add('eye-offline'); + } + }, + { once: true }, + ); + }); + $$('.c-witness img, .c-witness video', fbody).forEach((m) => { + if (m.tagName === 'VIDEO') m.classList.add('loaded'); + else markLoaded(m); + }); + tickClocks(); + } + + function freshFrame(card) { + const id = card.dataset.cam; + const fromEngine = id ? globe.eyeFrameUrl(id) : null; + if (fromEngine) return fromEngine; + const cur = $('img[data-eye]', card).src; + try { + const u = new URL(cur, location.href); + u.searchParams.set('ts', String(Math.floor(Date.now() / 1000))); + return u.pathname + u.search; + } catch { + return cur; + } + } + + // Auto-refresh camera frames while on screen; cross-fade between buffers. + setInterval(() => { + if (view.hidden || document.hidden) return; + const now = Date.now(); + for (const card of visibleEyes) { + if (card.classList.contains('eye-offline') || now < (card._next || 0)) + continue; + card._next = now + Number(card.dataset.refresh || 15000); + const a = $('img[data-eye]', card); + const b = $('img.eye-b', card); + b.onload = () => { + b.classList.add('loaded', 'front'); + a.classList.remove('front'); + // swap roles + a.removeAttribute('data-eye'); + a.classList.add('eye-b'); + b.classList.remove('eye-b'); + b.setAttribute('data-eye', ''); + card.classList.remove('flash'); + void card.offsetWidth; + card.classList.add('flash'); + }; + b.src = freshFrame(card); + } + }, 1000); + + /* ---------- load ---------- */ + async function refreshSocial() { + const vis = all.filter((s) => s.kind !== 'reconstruction'); + hot = vis.length + ? (await rooms.hot(vis, 3).catch(() => [])).filter(Boolean) + : []; + witness = await localWitness({ rooms, situations: all }).catch(() => []); } async function load({ quiet = false } = {}) { @@ -191,49 +543,74 @@ export function createFeed( try { const prev = new Map(all.map((s) => [s.id, JSON.stringify(s.metrics)])); all = await getSituations(); + await refreshSocial(); render(); if (quiet) for (const s of all) if (prev.has(s.id) && prev.get(s.id) !== JSON.stringify(s.metrics)) - cardEls.get(s.id)?.classList.add('pulse'); + cardEls.get(String(s.id))?.classList.add('pulse'); } catch (err) { console.error(err); count.textContent = 'Could not reach the live feeds'; } } - tabs.addEventListener('click', (e) => { - const b = e.target.closest('[data-sec]'); - if (!b || b.dataset.sec === section) return; - section = b.dataset.sec; - $$('[data-sec]', tabs).forEach((x) => + async function toggleNear(nb) { + near = !near; + nb.setAttribute('aria-pressed', String(near)); + view.classList.toggle('is-near', near); + haptic('tick'); + if (near && !userLoc) { + const loc = await onLocate(); + if (loc) setLocation(loc); + } + view.scrollTo({ top: 0 }); + swap(); + } + + bandbar.addEventListener('click', async (e) => { + const b = e.target.closest('[data-band]'); + if (!b || b.dataset.band === band) return; + band = b.dataset.band; + $$('[data-band]', bandbar).forEach((x) => x.setAttribute('aria-selected', String(x === b)), ); + bandbar.dataset.sel = band; haptic('tick'); - placeInk(); b.scrollIntoView({ inline: 'nearest', block: 'nearest', - behavior: 'smooth', + behavior: reducedMotion() ? 'auto' : 'smooth', }); - masonry.classList.remove('swap'); - void masonry.offsetWidth; - masonry.classList.add('swap'); - render(); + swap(); }); + function swap() { + fbody.classList.remove('swap'); + void fbody.offsetWidth; + fbody.classList.add('swap'); + render(); + } + view.addEventListener('click', async (e) => { - const hit = e.target.closest('.card-hit'); - if (hit) { - const card = hit.closest('.card'); - const s = all.find((x) => String(x.id) === card.dataset.id); - if (s) onOpen(s, card); + const hitEl = e.target.closest('.card-hit'); + if (hitEl) { + const card = hitEl.closest('.card'); + const it = itemsByKey.get(card.dataset.key); + if (!it) return; + haptic('light'); + if (it.type === 'situation') onOpen(it.s, card); + else if (it.type === 'room') onOpenRoom(it.room.situation, card); + else if (it.type === 'witness') onWitness(it.w, card); + else if (it.type === 'prompt') onCapture(); return; } const act = e.target.closest('[data-act]')?.dataset.act; + if (act === 'near') return toggleNear(e.target.closest('[data-act]')); if (act === 'refresh') refresh(); if (act === 'locate') { - userLoc = await onLocate(); + const loc = await onLocate(); + if (loc) setLocation(loc); render(); } }); @@ -300,8 +677,7 @@ export function createFeed( ); function setPull(d, animate = false) { - const t = animate ? 'transform 420ms var(--spring)' : 'none'; - inner.style.transition = t; + inner.style.transition = animate ? 'transform 420ms var(--spring)' : 'none'; inner.style.transform = d ? `translate3d(0, ${d}px, 0)` : ''; ptr.style.transition = animate ? 'opacity 200ms linear' : 'none'; ptr.style.opacity = String(clamp(d / THRESH, 0, 1)); @@ -324,20 +700,74 @@ export function createFeed( }, wait); } - // Keep "2 min ago" honest. + // Keep relative times honest. setInterval(() => { - $$('.ago[data-ts]', view).forEach((el) => { - if (el.dataset.ts) - el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts); - }); + if (!view.hidden && !document.hidden) { + $$('.ago[data-ts]', view).forEach((el) => { + if (el.dataset.ts) + el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts); + }); + } }, 30000); - new ResizeObserver(placeInk).observe(tabs); - requestAnimationFrame(placeInk); + // Social changes re-render quietly (posts from other tabs, live broadcasts). + let socialTimer = 0; + const socialChanged = () => { + clearTimeout(socialTimer); + socialTimer = setTimeout(async () => { + await refreshSocial(); + if (!view.hidden) { + const y = view.scrollTop; + render(); + view.scrollTop = y; + } + }, 250); + }; + rooms.on('post', socialChanged); + let liveKey = ''; + rt.on('live', (l) => { + const card = $(`[data-key="w:live:${CSS.escape(l.id)}"]`, fbody); + if (card) { + const img = $('img', card); + if (img && l.frame) img.src = l.frame; + const v = $('[data-viewers]', card); + if (v) v.textContent = String(l.viewers || 0); + if (!view.hidden) rt.watch(l.id); + } + const key = rt + .lives() + .map((x) => x.id) + .join(','); + if (key !== liveKey) { + liveKey = key; + socialChanged(); + } + }); + rt.on('live-end', () => { + liveKey = rt + .lives() + .map((x) => x.id) + .join(','); + socialChanged(); + }); + rt.on('presence', () => { + for (const r of hot) { + const el = $(`[data-room-watch="${CSS.escape(r.id)}"]`, fbody); + const n = rt.presence(r.id).length; + if (el) el.innerHTML = n > 1 ? `${n} here` : ''; + } + }); + + function setLocation(loc) { + userLoc = loc; + if (loc) globe.setViewer?.({ lon: loc.lon, lat: loc.lat }); + } return { load, refresh, + render, + refreshSocial: socialChanged, cardFor: (id) => cardEls.get(String(id)) || null, situations: () => all, select(id) { @@ -347,8 +777,11 @@ export function createFeed( ); }, setLocation(loc) { - userLoc = loc; - if (section === 'near') render(); + setLocation(loc); + render(); + }, + get location() { + return userLoc; }, }; } diff --git a/mobile/ui/globeChrome.js b/mobile/ui/globeChrome.js index 5bb4f8c03..34309ab78 100644 --- a/mobile/ui/globeChrome.js +++ b/mobile/ui/globeChrome.js @@ -18,6 +18,21 @@ import { } from './dom.js'; import { createSheet } from './sheet.js'; import { createTimeRibbon } from './timeRibbon.js'; +import { createEyeViewer } from './eyeViewer.js'; +import { BAND_NAME, llShort, mgrs } from './bands.js'; + +/** Altitude → band: where the virtual sensor is. */ +const bandAt = (h) => (h > 100e3 ? 'orbit' : h > 2500 ? 'sky' : 'ground'); +const ALT_MAX = 36000e3; +const ALT_MIN = 100; +const altFrac = (h) => + Math.min( + 1, + Math.max( + 0, + Math.log(Math.max(ALT_MIN, h) / ALT_MIN) / Math.log(ALT_MAX / ALT_MIN), + ), + ); const LENSES = [ { @@ -63,16 +78,82 @@ export function createGlobeChrome({ onThread, getSituations, isTwoPane, + onPin, }) { + const eyeViewer = createEyeViewer({ + globe, + onFly: (e) => { + globe.flyTo({ + lon: e.lon, + lat: e.lat, + height: 900, + heading: e.headingDeg || 0, + pitch: -30, + duration: 2, + }); + const id = globe.addPin({ + id: `eye:${e.id}`, + kind: 'eye', + lon: e.lon, + lat: e.lat, + headingDeg: e.headingDeg || 0, + fovDeg: 50, + label: e.title, + }); + if (id) setTimeout(() => globe.removePin(id), 60000); + }, + }); + + /** Nearest public camera to a point, opened live. */ + async function nearestEye(lon, lat, from, km = 60) { + const eye = await globe.nearestEye(lon, lat, km); + if (eye) eyeViewer.open(eye, { from }); + else toast(`No public camera within ${km} km`, { icon: icon('video') }); + return eye; + } + /* ---------- top chrome ---------- */ const top = h(`
-
——
+
——
`); + // The altimeter: orbit → sky → ground, tap a band to descend or climb. + const alt = h(`
+ ${[ + ['orbit', 'ORBIT', 9000e3, 1], + ['sky', 'SKY', 60e3, 0.5], + ['ground', 'GROUND', 1500, 0.2], + ] + .map( + ([b, l, hgt, top]) => + ``, + ) + .join('')} + + +
`); + alt.addEventListener('click', (e) => { + const b = e.target.closest('[data-alt]'); + if (!b) return; + haptic('tick'); + const c = lastCam || { lon: 0, lat: 20 }; + const hgt = Number(b.dataset.h); + globe.flyTo({ + lon: c.lon, + lat: c.lat, + height: hgt, + pitch: + b.dataset.alt === 'ground' ? -35 : b.dataset.alt === 'sky' ? -60 : -90, + duration: 2, + }); + }); + let lastCam = null; const attribution = h( '', ); @@ -88,7 +169,7 @@ export function createGlobeChrome({ ``, ); const bottom = h('
'); - chrome.append(top, attribution, lensPop, pick, bottom); + chrome.append(top, attribution, alt, lensPop, pick, bottom); bottom.append(dock); const ribbon = createTimeRibbon(bottom, { @@ -115,6 +196,11 @@ export function createGlobeChrome({ cyclone: 'hazards', camera: 'eyes', cctv: 'eyes', + post: 'inside', + witness: 'inside', + hologram: 'inside', + live: 'inside', + eye: 'ground', }; function drawLabels() { labelRaf = 0; @@ -126,7 +212,8 @@ export function createGlobeChrome({ .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)}`; + const pin = l.layer === 'pins'; + return `${esc(l.title)}`; }) .join(''); if (labelLayer.innerHTML !== html) labelLayer.innerHTML = html; @@ -161,13 +248,22 @@ export function createGlobeChrome({ /* ---------- camera readout ---------- */ globe.on('camera', (c) => { if (!c) return; - const alt = + lastCam = c; + const altTxt = c.height > 10000 - ? `${fmtNum(Math.round(c.height / 1000))} km` - : `${fmtNum(Math.round(c.height))} m`; - $('.gc-alt', top).textContent = alt; + ? `${fmtNum(Math.round(c.height / 1000))} KM` + : `${fmtNum(Math.round(c.height))} M`; + const band = bandAt(c.height); + const ro = $('.gc-readout', top); + ro.dataset.band = band; + alt.dataset.band = band; + alt.style.setProperty('--h', `${(1 - altFrac(c.height)) * 100}%`); + $('.gc-alt', top).innerHTML = + `${BAND_NAME[band].toUpperCase()} · ${altTxt}`; $('.gc-ll', top).textContent = - `${Math.abs(c.lat).toFixed(2)}°${c.lat >= 0 ? 'N' : 'S'} ${Math.abs(c.lon).toFixed(2)}°${c.lon >= 0 ? 'E' : 'W'}`; + c.height < 300e3 + ? `${llShort(c.lat, c.lon, 3)} · ${mgrs(c.lat, c.lon, 3)}` + : llShort(c.lat, c.lon, 2); if (Number.isFinite(c.heading)) $('.compass-n', top).style.transform = `rotate(${-c.heading}deg)`; }); @@ -485,6 +581,10 @@ export function createGlobeChrome({ } async function showPick(p) { if (!p) return hidePick(); + if (p.kind === 'pin') { + if (onPin?.(p)) return; + if (p.pinKind === 'eye') return nearestEye(p.lon, p.lat, p.title, 1); + } if (p.kind === 'situation') { const s = (await getSituations()).find( (x) => String(x.id) === String(p.id), @@ -559,13 +659,7 @@ export function createGlobeChrome({ icon: icon('ride'), }); } else if (a === 'eye') { - const cams = globe - .contactsNear(p.lon, p.lat, 60) - .filter((c) => /cam|cctv/i.test(c.kind)); - if (cams.length) { - globe.flyTo({ lon: cams[0].lon, lat: cams[0].lat, height: 2500 }); - toast(`Nearest eye: ${cams[0].title}`); - } else toast('No public camera within 60 km', { icon: icon('video') }); + await nearestEye(p.lon, p.lat, p.title); } else if (a === 'thread') onThread?.({ id: `contact:${p.id}`, @@ -619,6 +713,8 @@ export function createGlobeChrome({ }); refreshDock(); + let basePips = []; + let extraPips = []; return { ribbon, @@ -628,6 +724,16 @@ export function createGlobeChrome({ showPick, openGrid, openCredits, - setPips: (p) => ribbon.setPips(p), + setPips(p) { + basePips = p || []; + ribbon.setPips([...basePips, ...extraPips]); + }, + /** Room posts anchored in time (cleared with []). */ + setExtraPips(p) { + extraPips = p || []; + ribbon.setPips([...basePips, ...extraPips]); + }, + showEye: (...a) => eyeViewer.open(...a), + nearestEye, }; } diff --git a/mobile/ui/globeHost.js b/mobile/ui/globeHost.js index d0edfc15e..6fb42b001 100644 --- a/mobile/ui/globeHost.js +++ b/mobile/ui/globeHost.js @@ -26,6 +26,18 @@ const REQUIRED = [ 'situations', ]; +/** Round-two additions (mobile/globe/API.md), optional. */ +const ROUND_TWO = [ + 'setViewer', + 'viewer', + 'thumbnail', + 'addPin', + 'removePin', + 'pins', + 'nearestEye', + 'eyeFrameUrl', +]; + export const contractReport = { engine: 'none', missing: [], @@ -111,10 +123,38 @@ function guard(engine, kind) { const v = call('contactsNear', [], lon, lat, km); return Array.isArray(v) ? v : []; }, - situations: async () => { - const v = await asyncCall('situations', []); + situations: async (opts) => { + const v = await asyncCall('situations', [], opts); return Array.isArray(v) ? v.map(normSituation) : []; }, + /* ---- round two (mobile/globe/API.md); every one feature-detected ---- */ + has: (name) => typeof engine[name] === 'function', + setViewer: (v) => call('setViewer', undefined, v), + viewer: () => call('viewer', null), + /** Composited card image (blob: URL); falls back to the plain still. */ + thumbnail: async (s, o) => { + if (typeof engine.thumbnail !== 'function') return s?.thumbnail || ''; + try { + return (await engine.thumbnail(s, o)) || s?.thumbnail || ''; + } catch { + return s?.thumbnail || ''; + } + }, + addPin: (p) => call('addPin', null, p), + removePin: (id) => call('removePin', false, id), + pins: () => { + const v = call('pins', []); + return Array.isArray(v) ? v : []; + }, + nearestEye: async (lon, lat, km = 50) => { + if (typeof engine.nearestEye === 'function') + return asyncCall('nearestEye', null, lon, lat, km); + const cams = (call('contactsNear', [], lon, lat, km) || []).filter((c) => + /cam|cctv|eye/i.test(c.kind), + ); + return cams[0] ? { ...cams[0], imageUrl: null } : null; + }, + eyeFrameUrl: (id) => call('eyeFrameUrl', null, id), credits: () => { const v = call('credits', null); return Array.isArray(v) ? v : null; @@ -163,6 +203,9 @@ export async function mountGlobe(container, opts = {}) { contractReport.notes.push( 'credits() not provided (optional, used for attribution sheet)', ); + contractReport.roundTwo = Object.fromEntries( + ROUND_TWO.map((m) => [m, typeof engine?.[m] === 'function']), + ); if (contractReport.missing.length) console.warn('[globe] contract gaps', contractReport.missing); return guard(engine, 'cesium'); @@ -179,5 +222,8 @@ export async function mountGlobe(container, opts = {}) { quality: opts.quality || 'auto', }); contractReport.engine = 'stub'; + contractReport.roundTwo = Object.fromEntries( + ROUND_TWO.map((m) => [m, typeof engine?.[m] === 'function']), + ); return guard(engine, 'stub'); } diff --git a/mobile/ui/globeStub.js b/mobile/ui/globeStub.js index 78d569351..e2b4c20f3 100644 --- a/mobile/ui/globeStub.js +++ b/mobile/ui/globeStub.js @@ -8,7 +8,12 @@ * pick from this engine says so. */ -import { esriThumb } from './dom.js'; +import { + buildStubSituations, + STUB_CAMS, + camFrameUrl, +} from './stubSituations.js'; +import { stubThumbnail } from './stubCompositor.js'; const R_EARTH = 6371008.8; const D2R = Math.PI / 180; @@ -399,415 +404,6 @@ function propagate(c, t0, t) { /* Situations */ /* ------------------------------------------------------------------ */ -function buildSituations(now) { - const min = 60e3; - const S = (o) => ({ - live: true, - source: SIM, - updatedAt: now - (o.age ?? 2) * min, - time: o.time ?? { start: now - 6 * 3600e3, end: now + 24 * 3600e3 }, - camera: o.camera ?? { - lon: o.lon, - lat: o.lat, - height: o.height ?? 400e3, - heading: 0, - pitch: -90, - }, - thumbnail: esriThumb(o.lon, o.lat, o.span ?? 1, 480, o.th ?? 600), - ...o, - }); - return [ - S({ - id: 'cyclone-wpac', - kind: 'cyclone', - family: 'hazards', - title: 'Typhoon tracking toward Luzon', - subtitle: 'Category 4 · eyewall replacement under way', - place: 'Philippine Sea', - lon: 126.5, - lat: 15.4, - span: 9, - th: 640, - height: 2200e3, - layers: ['hazards.tropical-cyclones', 'weather.clouds', 'weather.wind'], - metrics: [ - { label: 'Wind', value: 185, unit: 'km/h' }, - { label: 'Pressure', value: 935, unit: 'hPa' }, - { label: 'Moving', value: 'WNW 18', unit: 'km/h' }, - ], - series: [120, 130, 140, 150, 165, 175, 185, 185, 180, 185], - age: 2, - time: { start: now - 72 * 3600e3, end: now + 120 * 3600e3 }, - events: [{ at: now + 30 * 3600e3, label: 'Landfall (fcst)' }], - }), - S({ - id: 'quake-24h', - kind: 'quake', - family: 'hazards', - title: 'M6.4 · largest quake in 24 h', - subtitle: 'Shallow thrust event, aftershocks continuing', - place: 'Off Hokkaido, Japan', - lon: 144.2, - lat: 42.1, - span: 3.5, - th: 480, - height: 900e3, - layers: ['hazards.earthquakes'], - metrics: [ - { label: 'Magnitude', value: 6.4, unit: 'Mw' }, - { label: 'Depth', value: 38, unit: 'km' }, - { label: 'Aftershocks', value: 27, unit: '' }, - ], - series: [0, 0, 6.4, 4.1, 3.8, 4.4, 3.2, 3.9, 3.1, 3.3], - age: 11, - events: [{ at: now - 14 * 3600e3, label: 'M6.4' }], - }), - S({ - id: 'fires-sierra', - kind: 'fires', - family: 'hazards', - title: 'Fire cluster in the Sierra foothills', - subtitle: 'FIRMS hotspots doubling since dawn', - place: 'Nevada County, California', - lon: -120.9, - lat: 39.05, - span: 1.6, - th: 560, - height: 260e3, - layers: [ - 'hazards.active-fires', - 'hazards.fire-perimeters', - 'weather.wind', - ], - metrics: [ - { label: 'Hotspots', value: 214, unit: '' }, - { label: 'FRP', value: 1840, unit: 'MW' }, - { label: 'Wind', value: 32, unit: 'km/h' }, - ], - series: [12, 18, 30, 44, 61, 88, 120, 160, 190, 214], - age: 4, - }), - S({ - id: 'iss-pass', - kind: 'pass', - family: 'orbit', - title: 'ISS passes over you tonight', - subtitle: 'Bright pass · max elevation 71°', - place: 'Low Earth orbit', - lon: -40, - lat: 30, - span: 30, - th: 520, - height: 9000e3, - layers: ['orbit.iss-crewed', 'orbit.ground-tracks'], - metrics: [ - { label: 'Altitude', value: 418, unit: 'km' }, - { label: 'Speed', value: 27600, unit: 'km/h' }, - { label: 'Next pass', value: '21:42', unit: '' }, - ], - age: 1, - alwaysOn: true, - }), - S({ - id: 'atl-rush', - kind: 'airport', - family: 'sky', - title: 'Busiest airport right now', - subtitle: 'Hartsfield–Jackson arrival bank', - place: 'Atlanta, Georgia', - lon: -84.428, - lat: 33.6407, - span: 0.09, - th: 600, - height: 18e3, - layers: [ - 'sky.commercial-flights', - 'sky.holding-patterns', - 'sky.airport-boards', - ], - metrics: [ - { label: 'Movements', value: 182, unit: '/h' }, - { label: 'Holding', value: 6, unit: '' }, - { label: 'Delay', value: 14, unit: 'min' }, - ], - series: [120, 140, 166, 170, 182, 176, 181, 182], - age: 1, - alwaysOn: true, - }), - S({ - id: 'novosel', - kind: 'heliport', - family: 'sky', - title: 'Helicopter school circuits', - subtitle: 'Rotor training pattern over Wiregrass', - place: 'Fort Novosel, Alabama', - lon: -85.72, - lat: 31.36, - span: 0.22, - th: 420, - height: 40e3, - layers: ['sky.helicopters'], - metrics: [ - { label: 'Airborne', value: 23, unit: 'rotorcraft' }, - { label: 'Pattern alt', value: 700, unit: 'ft' }, - ], - series: [4, 9, 14, 18, 23, 21, 23], - age: 3, - alwaysOn: true, - }), - S({ - id: 'bhote-koshi-2026', - kind: 'reconstruction', - family: 'hazards', - title: 'Bhote Koshi outburst flood', - subtitle: '16 sourced observations · 3 open hypotheses', - place: 'Rasuwa, Nepal ↔ Gyirong, Tibet', - lon: 85.33, - lat: 28.15, - span: 0.3, - th: 640, - height: 30e3, - camera: { - lon: 85.3548, - lat: 28.207, - height: 14e3, - heading: 344, - pitch: -39, - }, - layers: [ - 'hazards.floods', - 'infra.recent-imagery', - 'eyes.signed-captures', - ], - metrics: [ - { label: 'Observations', value: 16, unit: '' }, - { label: 'Corridor', value: 145, unit: 'pts' }, - { label: 'Hypotheses', value: 3, unit: '' }, - ], - live: false, - age: 60 * 24 * 7, - source: 'GeoPera reconstruction · Vantor Open Data (CC BY-NC 4.0)', - time: { start: Date.UTC(2026, 7, 26, 0), end: Date.UTC(2026, 7, 27, 12) }, - thread: 'bhote-koshi-2026', - thumbnail: '/events/bhote-koshi-2026/post.webp', - }), - S({ - id: 'starbase', - kind: 'launch', - family: 'orbit', - title: 'Launch countdown · Starbase', - subtitle: 'Window opens 06:00 local · trajectory east', - place: 'Boca Chica, Texas', - lon: -97.157, - lat: 25.997, - span: 0.06, - th: 380, - height: 12e3, - layers: ['orbit.launches', 'orbit.ground-tracks'], - metrics: [ - { label: 'T−', value: '06:12:00', unit: '' }, - { label: 'Weather go', value: 80, unit: '%' }, - ], - age: 6, - events: [{ at: now + 6.2 * 3600e3, label: 'T-0' }], - }), - S({ - id: 'suez-queue', - kind: 'queue', - family: 'sea', - title: 'Ships queuing at Suez', - subtitle: 'Southbound convoy holding in the anchorage', - place: 'Gulf of Suez, Egypt', - lon: 32.55, - lat: 29.88, - span: 0.35, - th: 560, - height: 60e3, - layers: ['sea.ais-vessels', 'sea.tankers-cargo'], - metrics: [ - { label: 'Waiting', value: 41, unit: 'vessels' }, - { label: 'Median wait', value: 19, unit: 'h' }, - ], - series: [28, 30, 33, 35, 39, 41], - age: 5, - }), - S({ - id: 'gnss-baltic', - kind: 'gnss', - family: 'infra', - title: 'GNSS interference over the Baltic', - subtitle: 'Aircraft reporting degraded NIC', - place: 'Baltic Sea', - lon: 21.0, - lat: 56.5, - span: 6, - th: 480, - height: 1500e3, - layers: ['infra.gnss-interference', 'sky.commercial-flights'], - metrics: [ - { label: 'Affected', value: 312, unit: 'aircraft' }, - { label: 'Area', value: 84, unit: 'k km²' }, - ], - series: [120, 180, 240, 260, 300, 312], - age: 9, - }), - S({ - id: 'singapore', - kind: 'strait', - family: 'sea', - title: 'Singapore Strait traffic', - subtitle: 'Eastbound lane at capacity', - place: 'Singapore Strait', - lon: 103.85, - lat: 1.22, - span: 0.4, - th: 420, - height: 70e3, - layers: ['sea.ais-vessels'], - metrics: [ - { label: 'Transits', value: 22, unit: '/h' }, - { label: 'At anchor', value: 318, unit: '' }, - ], - age: 3, - alwaysOn: true, - }), - S({ - id: 'tokyo-rush', - kind: 'transit', - family: 'ground', - title: 'Rush hour · Tokyo rail', - subtitle: 'Yamanote line headways at 2 min 30 s', - place: 'Shinjuku, Tokyo', - lon: 139.7, - lat: 35.69, - span: 0.05, - th: 600, - height: 9e3, - layers: ['ground.transit-vehicles', 'ground.rail'], - metrics: [ - { label: 'Trains moving', value: 1840, unit: '' }, - { label: 'On time', value: 98.6, unit: '%' }, - ], - age: 2, - alwaysOn: true, - }), - S({ - id: 'rotterdam', - kind: 'port', - family: 'sea', - title: 'Port of Rotterdam', - subtitle: 'Europe’s busiest port, always on', - place: 'Maasvlakte, Netherlands', - lon: 4.03, - lat: 51.96, - span: 0.12, - th: 380, - height: 30e3, - layers: ['sea.ais-vessels', 'sea.ports'], - metrics: [ - { label: 'Berthed', value: 96, unit: '' }, - { label: 'Arriving 6 h', value: 34, unit: '' }, - ], - age: 4, - alwaysOn: true, - }), - S({ - id: 'north-sea-mil', - kind: 'military', - family: 'sky', - title: 'Military air activity · North Sea', - subtitle: 'Tanker tracks and fighter CAPs', - place: 'North Sea', - lon: 3.5, - lat: 55.5, - span: 5, - th: 520, - height: 900e3, - layers: ['sky.military-aircraft'], - metrics: [ - { label: 'Aircraft', value: 14, unit: '' }, - { label: 'Tankers', value: 3, unit: '' }, - ], - age: 7, - }), - S({ - id: 'aurora', - kind: 'aurora', - family: 'weather', - title: 'Aurora nowcast · Tromsø', - subtitle: 'Kp 6 storm, clear skies after 22:00', - place: 'Tromsø, Norway', - lon: 18.95, - lat: 69.65, - span: 0.5, - th: 600, - height: 300e3, - layers: ['weather.clouds'], - metrics: [ - { label: 'Kp', value: 6, unit: '' }, - { label: 'Cloud', value: 20, unit: '%' }, - ], - series: [2, 3, 3, 4, 5, 6, 6], - age: 15, - }), - S({ - id: 'sfo-arrivals', - kind: 'airport', - family: 'sky', - title: 'SFO arrivals on 28L/R', - subtitle: 'Paired approaches over the bay', - place: 'San Francisco, California', - lon: -122.379, - lat: 37.6213, - span: 0.12, - th: 480, - height: 25e3, - layers: ['sky.commercial-flights', 'sky.airport-boards'], - metrics: [ - { label: 'Arrivals', value: 44, unit: '/h' }, - { label: 'Delay', value: 9, unit: 'min' }, - ], - age: 2, - }), - S({ - id: 'etna', - kind: 'volcano', - family: 'eyes', - title: 'Etna webcam vs ash advisory', - subtitle: 'Strombolian activity at the SE crater', - place: 'Sicily, Italy', - lon: 14.995, - lat: 37.751, - span: 0.2, - th: 420, - height: 30e3, - layers: ['hazards.volcanoes', 'eyes.public-cctv'], - metrics: [ - { label: 'Cameras', value: 4, unit: 'live' }, - { label: 'Ash FL', value: 120, unit: '' }, - ], - age: 13, - }), - S({ - id: 'dxb', - kind: 'airport', - family: 'sky', - title: 'Dubai International night bank', - subtitle: 'Long-haul departures to Europe', - place: 'Dubai, UAE', - lon: 55.3657, - lat: 25.2532, - span: 0.1, - th: 560, - height: 25e3, - layers: ['sky.commercial-flights'], - metrics: [{ label: 'Departures', value: 61, unit: '/h' }], - age: 1, - alwaysOn: true, - }), - ]; -} - /* ------------------------------------------------------------------ */ /* WebGL planet */ /* ------------------------------------------------------------------ */ @@ -985,6 +581,7 @@ export async function createGlobe(container, options = {}) { } layerById.get('ground.alpr-cameras').status = 'off'; let situations = null; + let viewer = null; /* ---------- GL ---------- */ let prog = null; @@ -1442,6 +1039,172 @@ export async function createGlobe(container, options = {}) { drawn.push({ s, x: p.x, y: p.y }); } } + drawWeather(ts); + drawPins(ts); + } + + /* ---------- weather (simulated look of the engine's weather family) ---------- */ + const wx = (() => { + const rr = rng(7); + const cells = Array.from({ length: 40 }, () => ({ + lon: -105 + rr() * 30, + lat: 25 + rr() * 22, + r: 40e3 + rr() * 160e3, + v: rr(), + })); + const strikes = Array.from({ length: 160 }, () => ({ + lon: -97 + rr() * 14, + lat: 23 + rr() * 9, + p: rr(), + })); + const streaks = Array.from({ length: 220 }, () => ({ + lon: -180 + rr() * 360, + lat: -60 + rr() * 130, + p: rr(), + })); + return { cells, strikes, streaks }; + })(); + function drawWeather(ts) { + const on = (id) => layerById.get(id)?.on; + const R = radiusPx(); + if (on('weather.radar')) { + for (const c of wx.cells) { + const p = project(c.lon, c.lat); + if (!p) continue; + const rad = (c.r / R_EARTH) * R; + for (const [f, col] of [ + [1, 'rgba(40,200,90,0.28)'], + [0.6, 'rgba(240,220,40,0.34)'], + [0.3, c.v > 0.6 ? 'rgba(255,70,40,0.5)' : 'rgba(240,220,40,0.2)'], + ]) { + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(p.x, p.y, Math.max(1, rad * f), 0, Math.PI * 2); + ctx.fill(); + } + } + } + if (on('weather.lightning')) { + for (const st of wx.strikes) { + const p = project(st.lon, st.lat); + if (!p) continue; + const flick = Math.sin(ts / 90 + st.p * 50) > 0.6; + ctx.fillStyle = flick ? '#fff' : 'rgba(200,180,255,0.6)'; + ctx.fillRect(p.x - 1, p.y - 1, flick ? 2.6 : 1.6, flick ? 2.6 : 1.6); + } + dirty = true; + } + if (on('weather.wind')) { + ctx.strokeStyle = 'rgba(230,240,255,0.35)'; + ctx.lineWidth = 1; + for (const st of wx.streaks) { + const ph = ((ts / 4000 + st.p) % 1) * 6; + const lon = st.lon + ph; + const lat = st.lat + Math.sin((st.lon + ph) / 12) * 2; + const a = project(lon, lat); + const b = project(lon + 2.2, lat + Math.cos(lon / 12) * 0.5); + if (!a || !b) continue; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + dirty = true; + } + if (on('weather.clouds')) { + ctx.fillStyle = 'rgba(235,240,250,0.07)'; + for (const st of wx.streaks.slice(0, 90)) { + const p = project(st.lon, st.lat); + if (!p) continue; + ctx.beginPath(); + ctx.ellipse(p.x, p.y, 26, 12, st.p * 3, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + /* ---------- pins (API.md: post / witness / eye / live / hologram) ---------- */ + const pins = new Map(); + const PIN_COLOR = { + post: '#f4f7fb', + witness: '#ebc777', + hologram: '#ebc777', + live: '#ebc777', + eye: '#ffb547', + }; + function drawPins(ts) { + for (const pin of pins.values()) { + const p = project(pin.lon, pin.lat, pin.altM || 0); + if (!p) continue; + const col = pin.color || PIN_COLOR[pin.kind] || '#fff'; + if (pin.kind === 'live' || pin.kind === 'eye') { + const hd = + (((pin.headingDeg ?? 0) - (cam.heading || 0)) * Math.PI) / 180; + const fov = ((pin.fovDeg ?? 60) * Math.PI) / 180; + const len = 46; + const g = ctx.createRadialGradient(p.x, p.y, 2, p.x, p.y, len); + g.addColorStop(0, `${col}aa`); + g.addColorStop(1, `${col}00`); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.arc( + p.x, + p.y, + len, + hd - Math.PI / 2 - fov / 2, + hd - Math.PI / 2 + fov / 2, + ); + ctx.closePath(); + ctx.fill(); + if (pin.kind === 'live') { + const ph = (ts / 1400) % 1; + ctx.strokeStyle = col; + ctx.globalAlpha = 1 - ph; + ctx.beginPath(); + ctx.arc(p.x, p.y, 5 + ph * 14, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + dirty = true; + } + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); + ctx.fill(); + } else if (pin.kind === 'post') { + ctx.fillStyle = 'rgba(10,11,13,0.9)'; + ctx.strokeStyle = col; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - 4, p.y - 6); + ctx.lineTo(p.x - 9, p.y - 6); + ctx.arcTo(p.x - 12, p.y - 6, p.x - 12, p.y - 9, 3); + ctx.lineTo(p.x - 12, p.y - 17); + ctx.arcTo(p.x - 12, p.y - 20, p.x - 9, p.y - 20, 3); + ctx.lineTo(p.x + 9, p.y - 20); + ctx.arcTo(p.x + 12, p.y - 20, p.x + 12, p.y - 17, 3); + ctx.lineTo(p.x + 12, p.y - 9); + ctx.arcTo(p.x + 12, p.y - 6, p.x + 9, p.y - 6, 3); + ctx.lineTo(p.x + 4, p.y - 6); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = col; + for (const dx of [-5, 0, 5]) ctx.fillRect(p.x + dx - 1, p.y - 14, 2, 2); + } else { + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(Math.PI / 4); + ctx.fillStyle = col; + ctx.fillRect(-4.5, -4.5, 9, 9); + ctx.strokeStyle = 'rgba(5,6,7,0.9)'; + ctx.lineWidth = 1.5; + ctx.strokeRect(-4.5, -4.5, 9, 9); + ctx.restore(); + } + drawn.push({ pin, x: p.x, y: p.y - (pin.kind === 'post' ? 12 : 0) }); + } } /* ---------- flights ---------- */ @@ -1606,6 +1369,20 @@ export async function createGlobe(container, options = {}) { emit('pick', null); return; } + if (best.pin) { + const pn = best.pin; + emit('pick', { + kind: 'pin', + pinKind: pn.kind, + id: pn.id, + title: pn.label || pn.kind, + subtitle: pn.kind, + lon: pn.lon, + lat: pn.lat, + meta: { headingDeg: pn.headingDeg, fovDeg: pn.fovDeg, label: pn.label }, + }); + return; + } if (best.s) { const s = best.s; emit('pick', { @@ -1810,7 +1587,7 @@ export async function createGlobe(container, options = {}) { return out; }, async situations() { - if (!situations) situations = buildSituations(Date.now()); + if (!situations) situations = buildStubSituations(Date.now()); kick(); return situations; }, @@ -1833,6 +1610,54 @@ export async function createGlobe(container, options = {}) { ]; }, camera: () => ({ ...cam }), + /* ---- round two (mobile/globe/API.md) ---- */ + setViewer(v) { + viewer = v && Number.isFinite(v.lat) ? { lon: v.lon, lat: v.lat } : null; + }, + viewer: () => viewer, + thumbnail: (s, o) => stubThumbnail(s, o), + addPin(p) { + const id = p.id || `pin-${Math.random().toString(36).slice(2, 9)}`; + pins.set(id, { fovDeg: 60, headingDeg: 0, ...p, id }); + dirty = true; + kick(); + return id; + }, + removePin(id) { + const ok = pins.delete(id); + dirty = true; + kick(); + return ok; + }, + pins: () => [...pins.values()].map((p) => ({ ...p })), + async nearestEye(lon, lat, km = 50) { + let best = null; + for (const c of STUB_CAMS) { + const d = haversine(lon, lat, c.lon, c.lat) / 1000; + if (d <= km && (!best || d < best.distanceKm)) + best = { ...c, distanceKm: d, imageUrl: camFrameUrl(c.id) }; + } + return best; + }, + eyeFrameUrl: (id) => camFrameUrl(id), + labels(max = 40) { + const out = []; + for (const pin of pins.values()) { + if (!pin.label) continue; + const p = project(pin.lon, pin.lat, pin.altM || 0); + if (!p) continue; + out.push({ + id: pin.id, + kind: pin.kind, + layer: 'pins', + title: pin.label, + x: p.x, + y: p.y, + }); + if (out.length >= max) break; + } + return out; + }, destroy() { api.pause(); ro.disconnect(); diff --git a/mobile/ui/icons.js b/mobile/ui/icons.js index fc46de443..8b703355e 100644 --- a/mobile/ui/icons.js +++ b/mobile/ui/icons.js @@ -79,6 +79,21 @@ const P = { waveform: ``, arrowUpRight: ``, scope: ``, + question: ``, + context: ``, + correction: ``, + prediction: ``, + witness: ``, + live: ``, + upload: ``, + reply: ``, + note: ``, + bridge: ``, + users: ``, + frustum: ``, + bolt: ``, + sendUp: ``, + dot: ``, hand: ``, }; diff --git a/mobile/ui/liveViewer.js b/mobile/ui/liveViewer.js new file mode 100644 index 000000000..59bb2a52d --- /dev/null +++ b/mobile/ui/liveViewer.js @@ -0,0 +1,93 @@ +/** + * Watch a live broadcast from another tab on this device: frames arrive over + * BroadcastChannel about once a second with the broadcaster's telemetry and + * sealed segment count. Watching reports back so the broadcaster's viewer + * count is real. + */ + +import { esc, haptic } from './dom.js'; +import { createSheet } from './sheet.js'; +import { frame } from './cards.js'; +import { llShort, cardinal } from './bands.js'; + +export function createLiveViewer({ rt, globe }) { + const sheet = createSheet({ + label: 'Live broadcast', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'eye-sheet opaque', + heights: { large: (H) => Math.round(Math.min(H * 0.86, 760)) }, + }); + let cur = null; + let tick = 0; + let pin = null; + + function paint(l) { + if (!cur || l.id !== cur.id) return; + cur = { ...cur, ...l }; + const img = sheet.body.querySelector('.ev-frame img'); + if (img && l.frame) img.src = l.frame; + const t = sheet.body.querySelector('[data-lv-tele]'); + if (t) + t.textContent = `INSIDE · ${llShort(l.lat, l.lon, 3)} · HDG ${Math.round(l.headingDeg || 0)}° ${cardinal(l.headingDeg)} · ${l.segments || 0} SEALED SEGMENTS`; + if (pin) globe.removePin(pin); + if (Number.isFinite(l.lat)) + pin = globe.addPin({ + id: `watch:${l.id}`, + kind: 'live', + lon: l.lon, + lat: l.lat, + headingDeg: l.headingDeg || 0, + fovDeg: 62, + label: `${l.handle} · live`, + }); + } + + function open(w) { + const l = rt.lives().find((x) => x.id === w.liveId) || { + id: w.liveId, + handle: w.handle, + startedAt: w.startedAt, + }; + cur = l; + sheet.head.innerHTML = `
Inside·Live from a person
+

${esc(l.handle || 'Someone')} is live

+
${esc(l.situationTitle ? `Near ${l.situationTitle}` : 'Broadcasting from this device’s other tab')}
`; + sheet.body.innerHTML = `
Latest broadcast frame${frame()} +
LIVE SEALED EVERY 10 S
+
+

Frames reach this tab over BroadcastChannel about once a second (a prototype stand-in for a streaming server). The broadcaster’s file is sealed as a hash chain every 10 seconds; the bundle appears in their You tab when they end.

`; + paint(l); + sheet.open('large'); + rt.watch(l.id); + haptic('light'); + clearInterval(tick); + tick = setInterval(() => { + const el = sheet.body.querySelector('[data-lv-since]'); + if (!el || !cur?.startedAt) return; + const s = Math.max(0, Math.round((Date.now() - cur.startedAt) / 1000)); + el.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; + }, 1000); + } + + rt.on('live', paint); + rt.on('live-end', ({ id }) => { + if (cur?.id !== id) return; + const t = sheet.body.querySelector('[data-lv-tele]'); + if (t) + t.textContent = + 'BROADCAST ENDED · BUNDLE SEALED ON THE BROADCASTER’S DEVICE'; + sheet.body.querySelector('.c-live')?.classList.add('ended'); + }); + sheet.on('close', () => { + clearInterval(tick); + rt.watch(null); + if (pin) globe.removePin(pin); + pin = null; + cur = null; + }); + + return { open }; +} diff --git a/mobile/ui/minimap.js b/mobile/ui/minimap.js new file mode 100644 index 000000000..c54b0ae3f --- /dev/null +++ b/mobile/ui/minimap.js @@ -0,0 +1,114 @@ +/** + * Mini-map for placing an upload: real imagery (Esri export) under a fixed + * centre pin. Drag the map to move the place; +/− to zoom. Apple Maps' + * "move the map, not the pin" pattern, with a live coordinate strip. + */ + +import { icon } from './icons.js'; +import { $, h, esc, esriThumb, clamp, haptic } from './dom.js'; +import { llShort, mgrs } from './bands.js'; + +export function createMiniMap( + host, + { lat, lon, spanDeg = 0.02, onChange, label = '' }, +) { + const el = h(`
+
+ + +
+
+ ${label ? `
${esc(label)}
` : ''} +
`); + host.appendChild(el); + const pane = $('.mm-pane', el); + const img = $('.mm-img', el); + const next = $('.mm-next', el); + const strip = $('.mm-strip', el); + let c = { lat, lon }; + let span = spanDeg; + let drag = null; + + const dims = () => { + const r = el.getBoundingClientRect(); + return { w: Math.max(1, r.width), h: Math.max(1, r.height) }; + }; + // Degrees per pixel (equirectangular near the centre; esriThumb uses the same box). + function degPerPx() { + const { w, h: hh } = dims(); + const dLon = span / Math.max(0.2, Math.cos((c.lat * Math.PI) / 180)); + const dLat = span * (hh / w); + return { x: dLon / w, y: dLat / hh }; + } + + function load() { + const { w, h: hh } = dims(); + const url = esriThumb( + c.lon, + c.lat, + span, + Math.round(Math.min(800, w * 2)), + Math.round(Math.min(800, hh * 2)), + ); + next.onload = () => { + img.src = next.src; + pane.style.transform = ''; + }; + next.src = url; + strip.textContent = `${llShort(c.lat, c.lon, 5)} · ${mgrs(c.lat, c.lon, 4)}`; + } + + el.addEventListener('pointerdown', (e) => { + if (e.target.closest('button')) return; + drag = { x: e.clientX, y: e.clientY, dx: 0, dy: 0 }; + el.setPointerCapture(e.pointerId); + el.classList.add('dragging'); + }); + el.addEventListener('pointermove', (e) => { + if (!drag) return; + drag.dx = e.clientX - drag.x; + drag.dy = e.clientY - drag.y; + pane.style.transform = `translate(${drag.dx}px, ${drag.dy}px)`; + const k = degPerPx(); + strip.textContent = `${llShort(c.lat + drag.dy * k.y, c.lon - drag.dx * k.x, 5)} · moving`; + }); + const end = () => { + if (!drag) return; + el.classList.remove('dragging'); + const k = degPerPx(); + if (Math.abs(drag.dx) + Math.abs(drag.dy) > 2) { + c = { + lat: clamp(c.lat + drag.dy * k.y, -85, 85), + lon: ((c.lon - drag.dx * k.x + 540) % 360) - 180, + }; + haptic('tick'); + load(); + onChange?.({ ...c }); + } + drag = null; + }; + el.addEventListener('pointerup', end); + el.addEventListener('pointercancel', end); + el.style.touchAction = 'none'; + el.addEventListener('click', (e) => { + const z = e.target.closest('[data-z]')?.dataset.z; + if (!z) return; + span = clamp(span * (z === 'in' ? 0.5 : 2), 0.0015, 40); + haptic('tick'); + load(); + }); + requestAnimationFrame(load); + + return { + el, + get center() { + return { ...c }; + }, + set(ll, s) { + c = { lat: ll.lat, lon: ll.lon }; + if (s) span = s; + load(); + onChange?.({ ...c }); + }, + }; +} diff --git a/mobile/ui/realtime.js b/mobile/ui/realtime.js new file mode 100644 index 000000000..5dc7a3da2 --- /dev/null +++ b/mobile/ui/realtime.js @@ -0,0 +1,182 @@ +/** + * Cross-tab realtime over BroadcastChannel: presence, posts, reactions and + * live broadcasts. Two tabs of the app on one device see each other live, + * which is the whole multiplayer loop, minus a server. + */ + +import { emitter } from './dom.js'; + +const CHANNEL = 'gev-mobile-live-v1'; +const ME_KEY = 'gev.mobile.me.v1'; +const HEARTBEAT = 4000; +const EXPIRE = 12000; + +function loadMe() { + try { + const v = JSON.parse(localStorage.getItem(ME_KEY) || 'null'); + if (v?.handle) return v; + } catch { + /* ignore */ + } + const id = Math.random().toString(36).slice(2, 6); + const me = { handle: `@you.${id}`, home: null, createdAt: Date.now() }; + try { + localStorage.setItem(ME_KEY, JSON.stringify(me)); + } catch { + /* ignore */ + } + return me; +} + +export function createRealtime() { + const ev = emitter(); + const tab = Math.random().toString(36).slice(2, 8); + const me = loadMe(); + const peers = new Map(); // tab → { handle, room, at, watching } + const lives = new Map(); // live id → { live, frame, at, viewers:Map } + let room = null; + let watching = null; // live id this tab is watching + let ch = null; + try { + ch = new BroadcastChannel(CHANNEL); + } catch { + ch = null; + } + + const send = (msg) => { + try { + ch?.postMessage({ ...msg, tab, handle: me.handle, at: Date.now() }); + } catch { + /* ignore (e.g. un-cloneable) */ + } + }; + + function hello() { + send({ t: 'here', room, watching, visible: !document.hidden }); + } + + function prune() { + const now = Date.now(); + let changed = false; + for (const [k, p] of peers) + if (now - p.at > EXPIRE) { + peers.delete(k); + changed = true; + } + for (const [id, l] of lives) + if (now - l.at > EXPIRE) { + lives.delete(id); + ev.emit('live-end', { id }); + changed = true; + } else + for (const [v, at] of l.viewers) + if (now - at > EXPIRE) l.viewers.delete(v); + if (changed) ev.emit('presence', presence(room)); + } + + ch?.addEventListener('message', (e) => { + const m = e.data; + if (!m || m.tab === tab) return; + if (m.t === 'here' || m.t === 'hello') { + const prev = peers.get(m.tab); + peers.set(m.tab, { + handle: m.handle, + room: m.room, + at: Date.now(), + watching: m.watching, + }); + if (m.watching) + for (const l of lives.values()) + if (l.live.id === m.watching) l.viewers.set(m.tab, Date.now()); + if (!prev || prev.room !== m.room) ev.emit('presence', presence(room)); + if (m.t === 'hello') hello(); + } else if (m.t === 'bye') { + peers.delete(m.tab); + ev.emit('presence', presence(room)); + } else if (m.t === 'post') ev.emit('post', m.post); + else if (m.t === 'react') + ev.emit('react', { room: m.room, kind: m.kind, remote: true }); + else if (m.t === 'rate') ev.emit('rate', m); + else if (m.t === 'live') { + const cur = lives.get(m.live.id); + lives.set(m.live.id, { + live: m.live, + frame: m.frame || cur?.frame || null, + at: Date.now(), + viewers: cur?.viewers || new Map(), + }); + ev.emit('live', { ...m.live, frame: m.frame || cur?.frame || null }); + } else if (m.t === 'live-end') { + lives.delete(m.id); + ev.emit('live-end', { id: m.id }); + } else if (m.t === 'watch') { + ev.emit('watch', { live: m.live, tab: m.tab }); + } + }); + + send({ t: 'hello', room, watching }); + setInterval(() => { + hello(); + prune(); + }, HEARTBEAT); + addEventListener('pagehide', () => send({ t: 'bye' })); + document.addEventListener('visibilitychange', hello); + + function presence(r) { + const list = [{ tab, handle: me.handle, self: true }]; + for (const [k, p] of peers) + if (!r || p.room === r) + list.push({ tab: k, handle: p.handle, self: false }); + return list; + } + + return { + tab, + me, + on: ev.on, + off: ev.off, + setHome(home) { + me.home = home; + try { + localStorage.setItem(ME_KEY, JSON.stringify(me)); + } catch { + /* ignore */ + } + }, + join(r) { + room = r; + hello(); + ev.emit('presence', presence(room)); + }, + leave() { + room = null; + hello(); + }, + get room() { + return room; + }, + presence, + /** Tabs on this device currently anywhere in the app. */ + tabs: () => peers.size + 1, + post: (post) => send({ t: 'post', post }), + react: (r, kind) => send({ t: 'react', room: r, kind }), + rate: (payload) => send({ t: 'rate', ...payload }), + /* live broadcasts */ + liveTick: (live, frame) => send({ t: 'live', live, frame }), + liveEnd: (id) => send({ t: 'live-end', id }), + watch(id) { + watching = id; + hello(); + }, + lives: () => + [...lives.values()].map((l) => ({ ...l.live, frame: l.frame })), + /** Viewers of my own broadcast (other tabs that report watching it). */ + viewersOf(id) { + let n = 0; + const now = Date.now(); + for (const p of peers.values()) + if (p.watching === id && now - p.at < EXPIRE) n += 1; + return n; + }, + }; +} diff --git a/mobile/ui/room.js b/mobile/ui/room.js new file mode 100644 index 000000000..a64d8e127 --- /dev/null +++ b/mobile/ui/room.js @@ -0,0 +1,366 @@ +/** + * The room view inside a situation's sheet: presence, a pinned Known / + * Disputed / Unknown synthesis, open questions, evidence-first posts with + * earned badges, community notes with the bridging rule drawn in the open, + * anchors that fly the globe and scrub the ribbon, floating reactions, and + * a reply bar that offers Capture before text. + */ + +import { icon } from './icons.js'; +import { $, $$, h, esc, ago, haptic, toast, reducedMotion } from './dom.js'; +import { KIND, badgesFor, noteStatus, BRIDGE } from './rooms.js'; +import { avatar } from './cards.js'; +import { llShort, utc, fmtKm } from './bands.js'; +import { mediaUrl } from './witness.js'; + +const REACTIONS = [ + { id: 'seen', icon: 'eyes', label: 'Seeing it too' }, + { id: 'concern', icon: 'hazards', label: 'Concerned' }, + { id: 'thanks', icon: 'hand', label: 'Thanks' }, + { id: 'q', icon: 'question', label: 'Need answers' }, +]; + +export function createRoomView({ + rooms, + rt, + globe, + getChrome, + onCapture, + onCompose, +}) { + let cur = null; // { s, r, el } + let pinIds = []; + + function clearPins() { + for (const id of pinIds) globe.removePin(id); + pinIds = []; + getChrome()?.setExtraPips([]); + } + + function pinPosts(r) { + clearPins(); + for (const p of r.posts) { + if (!p.anchor || !Number.isFinite(p.anchor.lat)) continue; + const kind = p.evidence?.live + ? 'live' + : p.kind === 'witness' + ? 'witness' + : 'post'; + const id = globe.addPin({ + id: `post:${p.id}`, + lon: p.anchor.lon, + lat: p.anchor.lat, + kind: p.hologram ? 'hologram' : kind, + label: `${r.numbering.get(p.id)} ${KIND[p.kind]?.name || ''}`, + headingDeg: p.evidence?.headingDeg, + }); + if (id) pinIds.push(id); + } + getChrome()?.setExtraPips( + r.posts + .filter((p) => p.anchor?.t) + .map((p) => ({ + at: p.anchor.t, + label: r.numbering.get(p.id), + color: p.kind === 'witness' ? '#ebc777' : '#f4f7fb', + })), + ); + } + + /* ---------------- rendering ---------------- */ + + function citeChip(c) { + if (!c) return ''; + const label = /^O\d/.test(c) + ? `OBS ${c.slice(1)}` + : c === 'O-cam' + ? 'CAM' + : c; + return ``; + } + + function synthHtml(r) { + const sy = r.synth; + const sec = (id, title, list) => + list.length + ? `
${title}${list.length}
    ${list + .map( + (l) => + `
  • ${esc(l.text)}${l.cites + .filter(Boolean) + .map((c) => citeChip(c)) + .join('')}
  • `, + ) + .join('')}
` + : ''; + return `
+
${icon('sparkles')}Local synthesis${sy.n} posts · ${sy.obs} observations · ${esc(sy.hash.slice(0, 8))}
+ ${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)} +

Written on this device by fixed rules from the posts and the sensor data, not by an AI model. Every line cites its sources; it is rebuilt whenever the room changes.

+
`; + } + + function noteHtml(n, p) { + const st = noteStatus(n, n.mine); + const bar = (c) => { + const x = st.by[c]; + const pct = x.n ? Math.round((x.h / x.n) * 100) : 0; + return `${c}${x.h}/${x.n}`; + }; + const why = st.shown + ? 'Raters who usually disagree both found it helpful' + : st.status === 'not-bridged' + ? `Not shown: one cluster rates it below ${Math.round(BRIDGE.minHelpful * 100)} %` + : `Not shown yet: needs ${BRIDGE.minPerCluster}+ raters from cluster ${st.status.replace('needs-', '').split('').join(' and ')}`; + return `
+
${icon('note')}${st.shown ? 'Community note' : 'Proposed note'}${p.example ? ' · example' : ''}
+ ${st.shown ? `

${esc(n.text)}

` : `
Show the proposed note

${esc(n.text)}

`} +
${icon('bridge')}Bridging${bar('A')}${bar('B')}${esc(why)}
+
${n.mine ? `You rated ${n.mine.helpful ? 'helpful' : 'not helpful'} as cluster ${esc(n.mine.cluster)}` : ``}
+
`; + } + + function postHtml(p, r, depth = 0) { + const s = r.situation; + const k = KIND[p.kind] || KIND.context; + const badges = badgesFor(p, s, rt.me); + const replies = r.repliesOf(p); + const num = r.numbering.get(p.id); + const anchor = + p.anchor && Number.isFinite(p.anchor.lat) + ? `` + : ''; + const media = + p.evidence?.captureId || p.evidence?.uploadId + ? `
${p.evidence.unverified ? 'Unverified upload' : `${icon('sealFill')}`}
` + : ''; + return `
+
+ ${icon(k.icon)} + ${esc(k.name)}${esc(p.author.handle)}${p.author.self ? ' (you)' : ''}${esc(ago(p.at))} · ${esc(num)} + ${p.example ? 'Example' : ''} +
+

${esc(p.text)}

+ ${media} + ${badges.length ? `
${badges.map((b) => `${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` ${esc(b.detail)}` : ''}`).join('')}
` : ''} +
+ ${anchor} + + + + +
+ ${(p.notes || []).map((n) => noteHtml(n, p)).join('')} + ${replies.length ? `
${replies.map((x) => postHtml(x, r, depth + 1)).join('')}
` : ''} +
`; + } + + function presenceHtml(r) { + const here = rt.presence(r.id); + const ex = r.participants.filter((a) => a.example).slice(0, 4); + return `
+ ${here.map((a, i) => avatar({ handle: a.handle, self: a.self }, i).replace('class="av', 'class="av live')).join('')} + ${here.length} watching${here.length > 1 ? ' · live across your tabs' : ''} + ${ex.map(avatar).join('')} +
`; + } + + async function render(s, el, { focusPost } = {}) { + const r = await rooms.room(s); + cur = { s, r, el }; + rt.join(r.id); + el.innerHTML = `
+ ${presenceHtml(r)} + ${synthHtml(r)} + ${r.hasExamples ? '
Example Fictional people (handles end in .example) show how a room works. Only the sensor numbers are live; your own posts are real and stay on this device.
' : ''} + ${r.openQ.length ? `
${icon('question')}Open questions float until answered
${r.openQ.map((p) => postHtml(p, r)).join('')}` : ''} +
${icon('sealFill')}Evidence first then recency, never followers
+ ${r.ordered + .filter((p) => !r.openQ.includes(p)) + .map((p) => postHtml(p, r)) + .join('')} +
${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}
+
`; + hydrateMedia(el); + pinPosts(r); + if (focusPost) { + const pe = $(`[data-post="${CSS.escape(focusPost)}"]`, el); + pe?.scrollIntoView({ block: 'center' }); + pe?.classList.add('flash'); + } + return r; + } + + async function hydrateMedia(el) { + for (const m of $$('.p-media[data-media]', el)) { + const u = await mediaUrl(m.dataset.media, m.dataset.store); + if (!u) continue; + m.insertAdjacentHTML( + 'afterbegin', + m.dataset.video + ? `` + : `Attached capture`, + ); + } + } + + /* ---------------- reactions ---------------- */ + + function reactionRail() { + return `
${REACTIONS.map((x) => ``).join('')}
`; + } + + function floatReaction(host, kind) { + if (!host || reducedMotion()) return; + const x = REACTIONS.find((r) => r.id === kind) || REACTIONS[0]; + const el = h(`${icon(x.icon)}`); + el.style.setProperty('--dx', `${Math.round(Math.random() * 24 - 12)}px`); + host.appendChild(el); + el.animate( + [ + { transform: 'translate(0, 0) scale(.6)', opacity: 0 }, + { + transform: 'translate(var(--dx), -40px) scale(1.1)', + opacity: 1, + offset: 0.2, + }, + { + transform: 'translate(calc(var(--dx) * -1), -190px) scale(.9)', + opacity: 0, + }, + ], + { duration: 2200, easing: 'cubic-bezier(.2,.7,.3,1)' }, + ).finished.then(() => el.remove()); + } + + /* ---------------- events ---------------- */ + + async function rerender(keepScroll) { + if (!cur) return; + const sc = keepScroll?.scrollTop; + await render(cur.s, cur.el); + if (keepScroll) keepScroll.scrollTop = sc; + } + + async function onClick(e, { scroller, flyTo, scrub }) { + if (!cur) return false; + const r = cur.r; + const postEl = e.target.closest('[data-post]'); + const post = postEl + ? r.posts.find((p) => p.id === postEl.dataset.post) + : null; + const anchor = e.target.closest('[data-anchor]'); + if (anchor && post?.anchor) { + haptic('light'); + flyTo(post.anchor, cur.s); + if (post.anchor.t) scrub(post.anchor.t); + return true; + } + const vote = e.target.closest('[data-vote]'); + if (vote && post) { + haptic('tick'); + await rooms.vote(post, vote.dataset.vote); + await rerender(scroller); + return true; + } + if (e.target.closest('[data-reply]') && post) { + onCompose(cur.s, { replyTo: post }); + return true; + } + const rate = e.target.closest('[data-rate]'); + if (rate && post) { + const nid = rate.closest('[data-note]').dataset.note; + const note = post.notes.find((n) => n.id === nid); + const mine = await rooms.rate(note, rate.dataset.rate === '1'); + const { why } = await rooms.myCluster(); + haptic('success'); + const st = noteStatus(note, mine); + toast( + `Rated as cluster ${mine.cluster} (${why})${st.shown ? ' · note now bridged' : ''}`, + { icon: icon('bridge') }, + ); + await rerender(scroller); + return true; + } + const cite = e.target.closest('[data-cite]'); + if (cite) { + const id = cite.dataset.cite; + const target = [...r.numbering.entries()].find(([, v]) => v === id)?.[0]; + const el = target + ? $(`[data-post="${CSS.escape(target)}"]`, cur.el) + : null; + if (el) { + el.scrollIntoView({ + block: 'center', + behavior: reducedMotion() ? 'auto' : 'smooth', + }); + el.classList.remove('flash'); + void el.offsetWidth; + el.classList.add('flash'); + } else + toast( + `${id}: ${id.startsWith('O') ? 'sensor observation from the brief' : 'cited source'}`, + { icon: icon('info') }, + ); + return true; + } + const react = e.target.closest('[data-react]'); + if (react) { + haptic('tick'); + rt.react(r.id, react.dataset.react); + floatReaction(react.closest('.sheet-dock'), react.dataset.react); + return true; + } + const tog = e.target.closest('[data-act="react-toggle"]'); + if (tog) { + const dock = tog.closest('.sheet-dock'); + const open = !dock.classList.contains('react-open'); + dock.classList.toggle('react-open', open); + tog.setAttribute('aria-expanded', String(open)); + haptic('tick'); + return true; + } + if (e.target.closest('[data-act="capture-reply"]')) { + onCapture(cur.s); + return true; + } + if (e.target.closest('[data-act="write"]')) { + onCompose(cur.s, {}); + return true; + } + return false; + } + + rt.on('react', ({ room, kind }) => { + if (cur && room === cur.r.id) + floatReaction(document.querySelector('.brief-sheet .sheet-dock'), kind); + }); + rt.on('presence', () => { + if (!cur) return; + const pr = $('.presence', cur.el); + if (pr) pr.outerHTML = presenceHtml(cur.r); + }); + rooms.on('post', (p) => { + if (cur && p.room === cur.r.id) { + const sc = cur.el.closest('.sheet-body'); + rerender(sc); + } + }); + + return { + render, + onClick, + reactionRail, + replyBar: () => + `
`, + close() { + clearPins(); + cur = null; + rt.leave(); + }, + get current() { + return cur; + }, + fmtKm, + }; +} diff --git a/mobile/ui/rooms.js b/mobile/ui/rooms.js new file mode 100644 index 000000000..94fc31ce1 --- /dev/null +++ b/mobile/ui/rooms.js @@ -0,0 +1,1207 @@ +/** + * Rooms (DESIGN.md §11.3): every situation has a room where the conversation + * sits on the evidence. Structured posts, earned badges, evidence-first + * ordering, community notes with a visible bridging rule, and a local, + * deterministic Known / Disputed / Unknown synthesis that cites every line. + * + * Seeded posts are EXAMPLES: fictional handles, written to show the format. + * They only quote numbers from the situation's live data. The person's own + * posts are real, persisted in IndexedDB and broadcast to other tabs. + */ + +import { emitter, fmtNum, ago } from './dom.js'; +import { all, put, get } from '../capture/db.js'; +import { canonical, sha256Hex } from '../capture/provenance.js'; +import { + distKm, + fmtKm, + presenceRadiusKm, + headlineOf, + cardinal, + utc, +} from './bands.js'; + +export const KINDS = [ + { + id: 'witness', + name: 'Witness', + icon: 'witness', + hint: 'I saw or recorded this', + }, + { + id: 'context', + name: 'Context', + icon: 'context', + hint: 'Background, a link, a map', + }, + { + id: 'question', + name: 'Question', + icon: 'question', + hint: 'What the sensors can’t tell', + }, + { + id: 'correction', + name: 'Correction', + icon: 'correction', + hint: 'Something here is wrong', + }, + { + id: 'prediction', + name: 'Prediction', + icon: 'prediction', + hint: 'What happens next, on the record', + }, +]; +export const KIND = Object.fromEntries(KINDS.map((k) => [k.id, k])); + +/** Fictional example participants. Clusters drive the bridging simulation. */ +const EX = [ + { handle: '@harborpilot.example', cluster: 'A', domain: 'Mariner' }, + { handle: '@metdesk.example', cluster: 'B', domain: 'Met' }, + { handle: '@firstofficer.example', cluster: 'A', domain: 'Pilot' }, + { handle: '@nightmedic.example', cluster: 'B', domain: 'First responder' }, + { handle: '@riverside.example', cluster: 'A', local: true }, + { handle: '@skeptic.example', cluster: 'B' }, + { handle: '@orbitwatch.example', cluster: 'A' }, + { handle: '@lena.example', cluster: 'B', local: true }, + { handle: '@datadesk.example', cluster: 'A' }, +]; +const [HARBOR, MET, PILOT, MEDIC, RIVER, SKEPTIC, ORBIT, LENA, DATA] = EX; + +/* ---------------- Example seeds, per genre ---------------- */ + +/** + * Each template: k kind, a author, m minutes before the situation's update, + * text(s, v) → string, w was-there, sl sealed, re reply-to index, + * note {text, r: 'AA+B+…'}, up/dn votes, an anchor offset {dLat,dLon}. + */ +function genre(s) { + const k = `${s.kind || ''} ${s.archetype || ''}`; + if (/cyclone|typhoon|hurricane/.test(k)) return 'cyclone'; + if (/quake/.test(k)) return 'quake'; + if (/fire/.test(k)) return 'fire'; + if (/lightning|rain|storm|radar/.test(k)) return 'storm'; + if (/squawk/.test(k)) return 'squawk'; + if (/gnss|jam/.test(k)) return 'gnss'; + if (/iss|launch|pass|aurora|orbit/.test(k)) return 'orbit'; + if (/camera|cctv|eye/.test(k)) return 'eye'; + if (/queue|strait|port|ship|vessel/.test(k)) return 'sea'; + if (/heli|airport|military|jet|flight/.test(k)) return 'air'; + if (/reconstruction/.test(k)) return 'recon'; + return 'default'; +} + +const T = { + cyclone: [ + { + k: 'witness', + a: LENA, + m: 52, + w: 1, + sl: 1, + up: 31, + dn: 2, + d: [0.9, -0.6], + text: () => + 'Outer rain bands reached the waterfront about an hour ago. Gusts are moving the plastic chairs on the pier. Sealed clip attached.', + note: { + text: 'The clip’s seal places it inside the outer bands, consistent with the post. The timestamp agrees with the satellite loop.', + r: 'A+A+A+B+B+B-', + }, + }, + { + k: 'question', + a: RIVER, + m: 40, + up: 12, + text: (s, v) => + `Is the eyewall replacement finished? The brief still shows ${v('Pressure') || v(1) || 'the same pressure'}.`, + }, + { + k: 'context', + a: MET, + m: 33, + re: 1, + up: 24, + text: () => + 'During an eyewall replacement the wind field broadens: the peak can dip while the area of strong wind grows. Watch the wind radii, not only the peak.', + }, + { + k: 'correction', + a: SKEPTIC, + m: 21, + up: 18, + dn: 6, + text: (s, v) => + `Posts elsewhere call this a Category 5. The sustained wind in this room’s sensor data is ${v('Wind') || v(0)}. Check the scale before resharing.`, + note: { + text: 'Category is set by 1-minute sustained wind; the figure quoted is the agency estimate in the brief.', + r: 'A+A+B+', + }, + }, + { + k: 'prediction', + a: DATA, + m: 9, + up: 7, + dn: 4, + text: () => + 'Landfall slips three to six hours later than the current forecast pip. The cone has widened two runs in a row.', + }, + ], + quake: [ + { + k: 'witness', + a: LENA, + m: 600, + w: 1, + sl: 1, + up: 44, + dn: 1, + d: [0.4, -0.5], + text: () => + 'A long rolling motion, maybe twenty seconds, sixth floor. Glasses rattled, nothing fell. Sealed video from the hallway.', + }, + { + k: 'question', + a: RIVER, + m: 590, + up: 30, + text: () => + 'Is there a tsunami advisory for the coast? Nothing on the local channel yet.', + }, + { + k: 'context', + a: DATA, + m: 560, + re: 1, + up: 21, + text: (s, v) => + `Advisories come from the national warning centre, not from this feed. The sensor data here shows depth ${v('Depth') || 'in the brief'}; shallow offshore events are the ones that get watched.`, + }, + { + k: 'correction', + a: SKEPTIC, + m: 400, + up: 15, + dn: 3, + text: () => + 'Early automatic solutions often use a placeholder depth of 10 km. Quote the reviewed depth, not the first one.', + note: { + text: 'Correct: many first solutions fix depth at 10 km until reviewed.', + r: 'A+A+B+B+', + }, + }, + { + k: 'prediction', + a: MET, + m: 120, + up: 9, + dn: 2, + text: (s, v) => + `The aftershock count (${v('Aftershocks') || 'in the brief'}) roughly halves each day for the next week.`, + }, + ], + fire: [ + { + k: 'witness', + a: RIVER, + m: 70, + w: 1, + sl: 1, + up: 38, + dn: 3, + d: [0.12, 0.25], + text: () => + 'Ash on the cars this morning and the sky is orange to the east. Sealed photo from the ridge road.', + note: { + text: 'The photo’s heading points east-north-east, toward the hotspot cluster, at the sealed time.', + r: 'A+A+B+B+B+', + }, + }, + { + k: 'question', + a: LENA, + m: 55, + up: 26, + text: () => + 'Which way is the smoke heading tonight? Deciding whether to close the windows at the school.', + }, + { + k: 'context', + a: DATA, + m: 47, + up: 33, + text: (s, v) => + `Each satellite hotspot is a pixel about 375 m across, so ${v('Hotspots') || v(0)} hotspots is not that many separate fires.`, + }, + { + k: 'correction', + a: SKEPTIC, + m: 30, + up: 11, + dn: 9, + text: () => + 'A post elsewhere says the fire jumped the river. The perimeter layer in this room does not show that yet.', + note: { + text: 'Perimeters update a few times a day; absence here is not proof either way.', + r: 'A+A-B+', + }, + }, + { + k: 'prediction', + a: MEDIC, + m: 12, + up: 8, + text: () => + 'Hotspot count peaks mid-afternoon as humidity drops, then falls after sunset.', + }, + ], + storm: [ + { + k: 'witness', + a: LENA, + m: 25, + w: 1, + sl: 1, + up: 27, + d: [0.15, -0.2], + text: () => + 'Constant flashes to the south and thunder every few seconds. Street gutters already overflowing. Sealed clip.', + }, + { + k: 'question', + a: RIVER, + m: 22, + up: 19, + text: () => + 'Is the line strengthening or weakening? Trying to decide whether to drive home now.', + }, + { + k: 'context', + a: MET, + m: 16, + re: 1, + up: 28, + text: (s, v) => + `Watch the core, not the count: ${v(0)} tells you how much is happening, the radar core tells you where it is going. This one is moving with the line.`, + }, + { + k: 'correction', + a: SKEPTIC, + m: 11, + up: 9, + dn: 2, + text: () => + 'The flood photo going around is from a different city. Reverse image search finds it years earlier.', + note: { + text: 'Matches an older photo; nobody here has posted a sealed capture of it.', + r: 'A+A+B+B+', + }, + }, + { + k: 'prediction', + a: DATA, + m: 6, + up: 5, + dn: 3, + text: () => 'The strongest cells clear the coast within ninety minutes.', + }, + ], + squawk: [ + { + k: 'context', + a: PILOT, + m: 14, + up: 41, + text: () => + '7700 is a general emergency code. Crews use it for medical diversions and technical problems too. It does not by itself mean the aircraft is in danger.', + note: { + text: 'Accurate summary of the transponder code meanings.', + r: 'A+A+A+B+B+', + }, + }, + { + k: 'question', + a: RIVER, + m: 12, + up: 22, + text: () => + 'Where are they diverting to? The track turned north a few minutes ago.', + }, + { + k: 'witness', + a: LENA, + m: 9, + w: 1, + sl: 1, + up: 15, + d: [0.08, 0.1], + text: () => + 'Heard it low over the harbour with gear down, sounded normal. Sealed clip, heading north-west.', + }, + { + k: 'correction', + a: SKEPTIC, + m: 6, + up: 13, + dn: 1, + text: () => + 'It’s not a hijack. That would be 7500. Please stop reposting the hijack claim.', + }, + { + k: 'prediction', + a: DATA, + m: 3, + up: 6, + dn: 1, + text: () => + 'They land within twenty-five minutes at the nearest suitable airport.', + }, + ], + gnss: [ + { + k: 'witness', + a: PILOT, + m: 80, + w: 1, + up: 36, + text: () => + 'Navigation integrity dropped over the area on the way in; we flew on inertial for about twenty minutes.', + }, + { + k: 'question', + a: LENA, + m: 60, + up: 14, + text: () => 'Does this affect phones on the ground, or only aircraft?', + }, + { + k: 'context', + a: DATA, + m: 50, + re: 1, + up: 25, + text: (s, v) => + `This layer is built from what aircraft broadcast about their own navigation accuracy. ${v(0)} affected means aircraft reporting it, not phones.`, + }, + { + k: 'correction', + a: SKEPTIC, + m: 30, + up: 10, + dn: 7, + text: () => + 'Calling every degraded report “jamming” overstates it. Some are equipment faults.', + note: { + text: 'Plausible, but the spatial clustering here argues against random faults.', + r: 'A+A-B+B-', + }, + }, + { + k: 'prediction', + a: HARBOR, + m: 20, + up: 4, + dn: 2, + text: () => + 'Ships transiting tonight report the same pattern near the coast.', + }, + ], + orbit: [ + { + k: 'witness', + a: ORBIT, + m: 90, + w: 1, + sl: 1, + up: 29, + d: [0.3, 0.2], + text: () => + 'Saw it from the roof last night: brighter than anything else up there, horizon to horizon in about four minutes. Sealed photo, 6 s exposure.', + }, + { + k: 'question', + a: LENA, + m: 50, + up: 17, + text: () => + 'Will the clouds clear in time? It’s overcast here right now.', + }, + { + k: 'context', + a: ORBIT, + m: 44, + re: 1, + up: 21, + text: () => + 'You only see a pass when the station is sunlit and you are in darkness, which is why good passes cluster after dusk and before dawn.', + }, + { + k: 'correction', + a: SKEPTIC, + m: 30, + up: 8, + dn: 2, + text: (s, v) => + `The viral clip of a “fireball” is a reentry from last year. This room’s data shows ${v(0)}.`, + note: { + text: 'Needs a source for the reentry date before it can show.', + r: 'A+B-', + }, + }, + { + k: 'prediction', + a: MET, + m: 15, + up: 6, + dn: 1, + text: () => + 'A clear gap opens about half an hour before the pass, per the satellite loop.', + }, + ], + eye: [ + { + k: 'witness', + a: RIVER, + m: 8, + w: 1, + sl: 1, + up: 19, + d: [0.0006, 0.0008], + text: () => + 'Walking past now. It’s busier than the frame suggests; there’s a line out the door at the corner. Sealed photo from across the street.', + note: { + text: 'Sealed position is about 80 m from the camera, facing it. Consistent.', + r: 'A+A+B+B+', + }, + }, + { + k: 'question', + a: LENA, + m: 6, + up: 11, + text: () => 'Is the lane closure still on? Can’t tell from this angle.', + }, + { + k: 'context', + a: DATA, + m: 5, + up: 14, + text: (s) => + `This camera refreshes every ${Math.round((s.media?.refreshMs || 15000) / 1000)} s and looks ${cardinal(s.media?.headingDeg) || 'along the street'}. The time burned into the frame is local.`, + }, + { + k: 'correction', + a: SKEPTIC, + m: 4, + up: 6, + dn: 1, + text: (s) => + `It faces ${cardinal(s.media?.headingDeg) || 'down the street'}, not north. The “crowd heading to the square” is walking away from it.`, + }, + { + k: 'prediction', + a: MEDIC, + m: 2, + up: 3, + dn: 1, + text: () => 'Busiest in about an hour, when the venue lets out.', + }, + ], + sea: [ + { + k: 'witness', + a: HARBOR, + m: 120, + w: 1, + sl: 1, + up: 33, + d: [0.05, -0.04], + text: () => + 'Anchored in the waiting area since this morning. Told to hold another twelve hours. Sealed photo from the bridge wing.', + }, + { + k: 'question', + a: LENA, + m: 90, + up: 12, + text: () => 'Is the queue caused by weather or by scheduling?', + }, + { + k: 'context', + a: HARBOR, + m: 70, + re: 1, + up: 27, + text: (s, v) => + `Mostly scheduling: convoys run at set times. ${v(0)} waiting is high but not unusual after a weather hold.`, + }, + { + k: 'correction', + a: SKEPTIC, + m: 40, + up: 9, + dn: 3, + text: () => + 'Some of the “waiting” ships are bunkering, not queuing. The count overstates the backlog.', + note: { + text: 'Correct for some vessels; the anchorage also serves bunkering.', + r: 'A+A+B+B+B-', + }, + }, + { + k: 'prediction', + a: DATA, + m: 20, + up: 5, + dn: 2, + text: () => 'The queue is back under thirty by tomorrow night.', + }, + ], + air: [ + { + k: 'witness', + a: RIVER, + m: 30, + w: 1, + sl: 1, + up: 18, + d: [0.02, 0.03], + text: () => + 'Watching from the fence: steady stream, one every couple of minutes. Sealed clip with the approach light row in frame.', + }, + { + k: 'question', + a: LENA, + m: 22, + up: 9, + text: () => 'Why are some of them circling instead of landing?', + }, + { + k: 'context', + a: PILOT, + m: 18, + re: 1, + up: 24, + text: () => + 'Those are holding patterns: controllers stack arrivals when the runway rate drops. A few holds at a busy hub is normal.', + note: { + text: 'Accurate. Holds appear as racetracks on the track layer.', + r: 'A+A+B+B+', + }, + }, + { + k: 'correction', + a: SKEPTIC, + m: 10, + up: 5, + dn: 2, + text: (s, v) => + `This isn’t a record day: ${v(0)} is high but within the normal peak.`, + }, + { + k: 'prediction', + a: DATA, + m: 5, + up: 4, + dn: 2, + text: () => 'The bank clears within forty minutes.', + }, + ], + recon: [ + { + k: 'context', + a: DATA, + m: 300, + up: 22, + text: () => + 'The corridor is a published centreline, not a modelled arrival time. Read the narrative as a schematic.', + }, + { + k: 'question', + a: LENA, + m: 200, + up: 14, + text: () => + 'Which of the witness clips can actually be placed on the north bank?', + }, + { + k: 'witness', + a: RIVER, + m: 150, + w: 1, + up: 9, + text: () => + 'Walked the road last month. The bridge abutment in the clip matches the upstream crossing.', + }, + { + k: 'correction', + a: SKEPTIC, + m: 90, + up: 7, + dn: 3, + text: () => + 'Five map placements in one cluster are not five independent timings.', + note: { + text: 'Agrees with the hypothesis ledger’s treatment of that cluster.', + r: 'A+A+B+B+', + }, + }, + ], + default: [ + { + k: 'witness', + a: RIVER, + m: 40, + w: 1, + sl: 1, + up: 15, + d: [0.05, 0.05], + text: () => + 'Here now. What I can see matches the sensor picture. Sealed photo attached.', + }, + { + k: 'question', + a: LENA, + m: 30, + up: 9, + text: () => 'Is this normal for the time of day, or unusual?', + }, + { + k: 'context', + a: DATA, + m: 20, + re: 1, + up: 12, + text: (s, v) => + `${v(0) ? `${v(0)} is` : 'This is'} within the usual range for this place at this hour.`, + }, + { + k: 'prediction', + a: MET, + m: 8, + up: 3, + dn: 1, + text: () => 'Back to baseline by tomorrow.', + }, + ], +}; + +function metricText(s) { + const ms = s.metrics || []; + const fmt = (m) => + m + ? `${typeof m.value === 'number' ? fmtNum(m.value, m.value % 1 ? 1 : 0) : m.value}${m.unit ? ` ${m.unit}` : ''}` + : ''; + return (key) => { + if (typeof key === 'number') return fmt(ms[key]); + return fmt( + ms.find((m) => + String(m.label).toLowerCase().startsWith(String(key).toLowerCase()), + ), + ); + }; +} + +function seedPosts(s) { + const tpl = T[genre(s)] || T.default; + const v = metricText(s); + const base = Number(new Date(s.updatedAt)) || Date.now(); + const posts = tpl.map((t, i) => { + const at = base - t.m * 60e3; + const p = { + id: `${s.id}~ex${i + 1}`, + room: String(s.id), + kind: t.k, + text: t.text(s, v), + at, + author: { ...t.a, example: true }, + example: true, + evidence: { + wasThere: !!t.w, + sealed: !!t.sl, + example: true, + }, + votes: { up: t.up || 0, dn: t.dn || 0 }, + replyTo: t.re != null ? `${s.id}~ex${t.re + 1}` : null, + anchor: t.d + ? { lat: s.lat + t.d[0], lon: s.lon + t.d[1], t: at } + : t.k === 'witness' + ? { lat: s.lat, lon: s.lon, t: at } + : null, + notes: t.note + ? [ + { + id: `${s.id}~ex${i + 1}~n1`, + text: t.note.text, + author: '@notewriter.example', + ratings: (t.note.r.match(/[AB][+-]/g) || []).map((x, j) => ({ + rater: `r${j}`, + cluster: x[0], + helpful: x[1] === '+', + })), + }, + ] + : [], + }; + return p; + }); + return posts; +} + +/* ---------------- Badges, bridging, ordering ---------------- */ + +export function badgesFor(p, s, me) { + const b = []; + const ex = p.example; + if (p.evidence?.wasThere) + b.push({ + id: 'there', + label: 'Was there', + ex, + detail: + p.evidence.distanceKm != null + ? `${fmtKm(p.evidence.distanceKm)} from it` + : '', + }); + if (p.evidence?.sealed) + b.push({ + id: 'sealed', + label: p.evidence.live ? 'Sealed live' : 'Sealed', + ex, + }); + if (p.evidence?.unverified) + b.push({ id: 'unverified', label: 'Unverified', ex }); + if ( + p.author?.local || + (p.author?.self && me?.home && distKm(me.home, s) < 50) + ) + b.push({ id: 'local', label: 'Local', ex }); + if (p.author?.domain) b.push({ id: 'domain', label: p.author.domain, ex }); + return b; +} + +function evidenceScore(p) { + const e = p.evidence || {}; + return ( + (e.wasThere ? 8 : 0) + + (e.sealed ? 4 : 0) + + (p.author?.domain ? 2 : 0) + + (p.author?.local ? 1 : 0) - + (e.unverified ? 1 : 0) + ); +} + +/** + * Bridging rule (after Community Notes): a note shows only when raters from + * both clusters, who usually disagree, rate it helpful. Thresholds are shown + * in the UI: ≥ 2 raters in each cluster and ≥ 60 % helpful in each. + */ +export const BRIDGE = { minPerCluster: 2, minHelpful: 0.6 }; +export function noteStatus(note, mine) { + const ratings = [...note.ratings, ...(mine ? [mine] : [])]; + const by = { A: { n: 0, h: 0 }, B: { n: 0, h: 0 } }; + for (const r of ratings) { + const c = by[r.cluster]; + if (!c) continue; + c.n += 1; + if (r.helpful) c.h += 1; + } + const ok = (c) => + c.n >= BRIDGE.minPerCluster && c.h / c.n >= BRIDGE.minHelpful; + let status = 'shown'; + if (!ok(by.A) || !ok(by.B)) { + const lacking = ['A', 'B'].filter((k) => by[k].n < BRIDGE.minPerCluster); + status = lacking.length ? `needs-${lacking.join('')}` : 'not-bridged'; + } + return { status, by, shown: status === 'shown' }; +} + +/* ---------------- Synthesis (local, deterministic) ---------------- */ + +export async function synthesize(s, posts, numbering) { + const known = []; + const disputed = []; + const unknown = []; + const P = (p) => numbering.get(p.id); + // Observations from the live data first. + const obs = (s.metrics || []).slice(0, 3).map((m, i) => ({ + id: `O${i + 1}`, + text: `${m.label}: ${typeof m.value === 'number' ? fmtNum(m.value, m.value % 1 ? 1 : 0) : m.value}${m.unit ? ` ${m.unit}` : ''}`, + })); + if (obs.length) + known.push({ + text: `${obs.map((o) => o.text).join(' · ')} (${s.source || 'live feed'}, ${ago(s.updatedAt) || 'now'})`, + cites: obs.map((o) => o.id), + }); + if (s.media?.cameraId) + known.push({ + text: `A public camera ${s.media.city ? `in ${s.media.city} ` : ''}is looking ${cardinal(s.media.headingDeg) || 'at the scene'}, refreshing every ${Math.round((s.media.refreshMs || 15000) / 1000)} s.`, + cites: ['O-cam'], + }); + const roots = posts.filter((p) => !p.replyTo); + const replies = (p) => posts.filter((r) => r.replyTo === p.id); + const sealedThere = posts.filter( + (p) => p.kind === 'witness' && p.evidence?.sealed && p.evidence?.wasThere, + ); + for (const p of roots) { + if (p.kind === 'question') { + const ans = replies(p).filter( + (r) => r.kind === 'context' || r.kind === 'witness', + ); + if (ans.length) + known.push({ + text: `Answered: “${clip(p.text, 60)}” — ${clip(ans[0].text, 90)}`, + cites: [P(p), ...ans.map(P)], + }); + else + unknown.push({ + text: `Open question: “${clip(p.text, 110)}”`, + cites: [P(p)], + }); + continue; + } + if (p.kind === 'prediction') { + unknown.push({ + text: `Unresolved prediction: “${clip(p.text, 100)}”`, + cites: [P(p)], + }); + continue; + } + const corr = replies(p).filter((r) => r.kind === 'correction'); + const noteShown = (p.notes || []).find((n) => noteStatus(n).shown); + const split = + (p.votes?.dn || 0) / + Math.max(1, (p.votes?.up || 0) + (p.votes?.dn || 0)) > + 0.35; + if (p.kind === 'correction') { + const n = (p.notes || []).find((x) => noteStatus(x).shown); + (n ? known : disputed).push({ + text: n + ? `Correction stands: “${clip(p.text, 100)}” (note bridged both clusters)` + : `Correction offered, not yet bridged: “${clip(p.text, 100)}”`, + cites: [P(p)], + }); + continue; + } + if (corr.length || split) + disputed.push({ + text: `“${clip(p.text, 90)}” ${corr.length ? `— challenged by ${corr.map(P).join(', ')}` : '— votes split'}`, + cites: [P(p), ...corr.map(P)], + }); + else if (p.kind === 'witness' && p.evidence?.sealed && p.evidence?.wasThere) + known.push({ + text: `Sealed witness from the scene: “${clip(p.text, 90)}”${noteShown ? ' (note bridged)' : ''}`, + cites: [P(p)], + }); + else if (p.kind === 'context' && noteShown) + known.push({ + text: `Context with a bridged note: “${clip(p.text, 90)}”`, + cites: [P(p)], + }); + else if (p.evidence?.unverified) + disputed.push({ + text: `Unverified upload: “${clip(p.text, 90)}” — no capture-time seal`, + cites: [P(p)], + }); + } + if (!sealedThere.length) + unknown.push({ + text: `No sealed capture from within ${fmtKm(presenceRadiusKm(s))} yet. The best answer here is a photo from the scene.`, + cites: obs.length ? [obs[0].id] : [], + }); + const inputs = { + s: s.id, + updatedAt: s.updatedAt, + posts: posts.map((p) => [p.id, p.kind, p.votes?.up || 0, p.votes?.dn || 0]), + }; + const hash = await sha256Hex(canonical(inputs)); + // Never state anything without a citation. + const cited = (l) => l.filter((x) => x.cites.filter(Boolean).length); + return { + known: cited(known), + disputed: cited(disputed), + unknown: cited(unknown), + hash, + n: posts.length, + obs: obs.length, + at: Date.now(), + observations: obs, + }; +} + +const clip = (t, n) => { + const s = String(t || '') + .replace(/\s+/g, ' ') + .trim(); + return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s; +}; + +/* ---------------- The store ---------------- */ + +export function createRooms({ rt }) { + const ev = emitter(); + let mine = null; // Promise + let kv = null; + + async function loadMine() { + const list = await all('posts').catch(() => []); + const m = new Map(); + for (const p of list) { + if (!m.has(p.room)) m.set(p.room, []); + m.get(p.room).push(p); + } + return m; + } + const mineMap = () => (mine = mine || loadMine()); + + async function loadKv() { + const rec = await get('kv', 'rooms').catch(() => null); + return rec || { id: 'rooms', votes: {}, ratings: {}, reactions: {} }; + } + const state = () => (kv = kv || loadKv()); + async function saveKv() { + const s = await state(); + await put('kv', s).catch(() => {}); + } + + rt.on('post', async (p) => { + const m = await mineMap(); + if (!m.has(p.room)) m.set(p.room, []); + if (!m.get(p.room).some((x) => x.id === p.id)) + m.get(p.room).push({ ...p, remote: true }); + ev.emit('post', p); + }); + + /** My cluster, derived transparently from what I agreed with. */ + async function myCluster() { + const st = await state(); + let a = 0; + let b = 0; + for (const [, v] of Object.entries(st.votes)) { + if (!v.cluster) continue; + const sgn = v.kind === 'up' ? 1 : -1; + if (v.cluster === 'A') a += sgn; + else b += sgn; + } + const hash = [...rt.me.handle].reduce((x, c) => x + c.charCodeAt(0), 0); + const cluster = a === b ? (hash % 2 ? 'A' : 'B') : a > b ? 'A' : 'B'; + const why = + a === b + ? 'assigned by default until you have voted on more posts' + : `you agreed more with posts from cluster ${cluster} writers`; + return { cluster, why, a, b }; + } + + async function room(s) { + const id = String(s.id); + const seeds = seedPosts(s); + const m = await mineMap(); + const st = await state(); + const own = (m.get(id) || []).map((p) => ({ ...p })); + const posts = [...seeds, ...own]; + for (const p of posts) { + const v = st.votes[p.id]; + p.votes = { + up: (p.votes?.up || 0) + (v?.kind === 'up' ? 1 : 0), + dn: (p.votes?.dn || 0) + (v?.kind === 'dn' ? 1 : 0), + }; + p.myVote = v?.kind || null; + for (const n of p.notes || []) n.mine = st.ratings[n.id] || null; + } + const chrono = [...posts].sort((a, b) => a.at - b.at); + const numbering = new Map(chrono.map((p, i) => [p.id, `P${i + 1}`])); + const answered = new Set( + posts.filter((p) => p.replyTo).map((p) => p.replyTo), + ); + const roots = posts.filter((p) => !p.replyTo); + const openQ = roots + .filter((p) => p.kind === 'question' && !answered.has(p.id)) + .sort((a, b) => b.at - a.at); + const rest = roots + .filter((p) => !openQ.includes(p)) + .sort((a, b) => evidenceScore(b) - evidenceScore(a) || b.at - a.at); + const ordered = [...openQ, ...rest]; + const repliesOf = (p) => + posts.filter((r) => r.replyTo === p.id).sort((a, b) => a.at - b.at); + const synth = await synthesize(s, posts, numbering); + const claims = rest.filter( + (p) => p.kind !== 'question' && p.kind !== 'prediction', + ); + const top = claims[0] || ordered[0] || null; + const sealedCount = posts.filter( + (p) => p.kind === 'witness' && p.evidence?.sealed && p.evidence?.wasThere, + ).length; + const topDisputed = + top && + (repliesOf(top).some((r) => r.kind === 'correction') || + (top.votes.dn || 0) / Math.max(1, top.votes.up + top.votes.dn) > 0.35); + const status = topDisputed + ? { id: 'disputed', label: 'Disputed' } + : sealedCount >= 2 + ? { + id: 'confirmed', + label: `Confirmed by ${sealedCount} signed captures`, + } + : sealedCount === 1 + ? { id: 'sealed', label: '1 signed capture' } + : { id: 'open', label: 'Unconfirmed' }; + const participants = [ + ...new Map(posts.map((p) => [p.author.handle, p.author])).values(), + ]; + const lastAt = Math.max(...posts.map((p) => p.at)); + const recent = posts.filter((p) => Date.now() - p.at < 3 * 3600e3).length; + return { + id, + situation: s, + posts, + ordered, + openQ, + repliesOf, + numbering, + synth, + top, + status, + participants, + lastAt, + heat: + recent * 2 + posts.length + (own.length ? 6 : 0) + (s.severity || 0), + hasExamples: seeds.length > 0, + ownCount: own.length, + }; + } + + async function post(s, draft) { + const m = await mineMap(); + const id = `${s.id}~${rt.tab}~${Date.now().toString(36)}`; + const p = { + id, + room: String(s.id), + kind: draft.kind || 'witness', + text: String(draft.text || '').slice(0, 2000), + at: Date.now(), + author: { + handle: rt.me.handle, + self: true, + local: !!(rt.me.home && distKm(rt.me.home, s) < 50), + }, + evidence: draft.evidence || {}, + votes: { up: 0, dn: 0 }, + replyTo: draft.replyTo || null, + anchor: draft.anchor || null, + hologram: !!draft.hologram, + notes: [], + }; + await put('posts', p).catch((err) => + console.warn('post not persisted', err), + ); + if (!m.has(p.room)) m.set(p.room, []); + m.get(p.room).push(p); + rt.post(p); + ev.emit('post', p); + return p; + } + + async function vote(p, kind, cluster) { + const st = await state(); + if (st.votes[p.id]?.kind === kind) delete st.votes[p.id]; + else + st.votes[p.id] = { + kind, + cluster: p.author?.cluster || null, + at: Date.now(), + }; + await saveKv(); + } + + async function rate(note, helpful) { + const st = await state(); + const { cluster } = await myCluster(); + st.ratings[note.id] = { rater: 'you', cluster, helpful, at: Date.now() }; + await saveKv(); + rt.rate({ note: note.id, cluster, helpful }); + return st.ratings[note.id]; + } + + /** Every post this device wrote (for Witness cards and You). */ + async function myPosts() { + const m = await mineMap(); + return [...m.values()].flat().filter((p) => p.author?.self && !p.remote); + } + async function allLocalPosts() { + const m = await mineMap(); + return [...m.values()].flat(); + } + + /** Rooms ranked by heat, for Room cards and the Threads hub. */ + async function hot(situations, n = 6) { + const rooms = await Promise.all(situations.map((s) => room(s))); + rooms.sort((a, b) => b.heat - a.heat); + // Diversity: one room per genre unless people on this device posted there. + const seen = new Set(); + const out = []; + for (const r of rooms) { + const g = genre(r.situation); + if (seen.has(g) && !r.ownCount) continue; + seen.add(g); + out.push(r); + if (out.length >= n) break; + } + return out; + } + + /** Places people created from the attach sheet ("New place", a camera). */ + async function places() { + const rec = await get('kv', 'places').catch(() => null); + return rec?.list || []; + } + async function addPlace(s) { + const list = await places(); + if (!list.some((x) => x.id === s.id)) list.unshift(s); + await put('kv', { id: 'places', list: list.slice(0, 50) }).catch(() => {}); + ev.emit('places', list); + return s; + } + + return { + on: ev.on, + off: ev.off, + room, + post, + vote, + rate, + myCluster, + myPosts, + allLocalPosts, + hot, + seedPosts, + places, + addPlace, + }; +} + +/** Did a capture at (lat, lon, t) count as "there" for situation s? */ +export function wasThere(s, fix, t) { + if (!fix || !s) return { there: false, km: NaN }; + const km = distKm(fix, s); + const r = presenceRadiusKm(s); + const start = s.time?.start + ? +new Date(s.time.start) + : +new Date(s.updatedAt) - 12 * 3600e3; + const end = s.time?.end ? +new Date(s.time.end) : Date.now() + 3600e3; + const inTime = t >= start - 6 * 3600e3 && t <= end + 6 * 3600e3; + return { there: km <= r && inTime, km, radius: r, inTime }; +} + +/** Headline for Room card status lines. */ +export function roomHeadline(r) { + const s = r.situation; + const h = headlineOf(s); + return h + ? `${h.value}${h.unit ? ` ${h.unit}` : ''} ${h.label || ''}`.trim() + : s.place || ''; +} + +export { utc }; diff --git a/mobile/ui/sheet.js b/mobile/ui/sheet.js index 1cd29a175..5d5427f3a 100644 --- a/mobile/ui/sheet.js +++ b/mobile/ui/sheet.js @@ -59,6 +59,8 @@ export function createSheet(o) { ? `transform ${reducedMotion() ? 160 : 520}ms ${spring ? 'var(--spring)' : 'var(--ease-out)'}` : 'none'; sheet.style.transform = `translate3d(0, ${y}px, 0)`; + // Lets docked controls sit at the bottom of the visible part. + sheet.style.setProperty('--off', `${Math.max(0, y)}px`); if (scrim) { const lowest = offsetFor(detents[0]); const k = clamp(1 - y / Math.max(1, lowest + 120), 0, 1); diff --git a/mobile/ui/stubCompositor.js b/mobile/ui/stubCompositor.js new file mode 100644 index 000000000..b919691db --- /dev/null +++ b/mobile/ui/stubCompositor.js @@ -0,0 +1,419 @@ +/** + * Offline mimic of `globe.thumbnail()` (mobile/globe/API.md): base imagery + * for the situation's box → simple data overlays for its kind → a per-band + * grade. No framing or telemetry: the shell draws those in HTML. Used only + * by the stub engine so the shell can be designed without the live engine. + */ + +import { esriThumb } from './dom.js'; + +const cache = new Map(); +let running = 0; +const queue = []; + +function loadImg(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`image ${url}`)); + img.src = url; + }); +} + +function rng(seed) { + let s = seed >>> 0 || 1; + return () => ( + (s = Math.imul(s ^ (s >>> 15), 2246822519) >>> 0), + (s % 1e6) / 1e6 + ); +} + +const GRADE = { + orbit: { tint: 'rgba(70,40,160,0.32)', mode: 'color', sat: 0.55, bri: 0.78 }, + sky: { + tint: 'rgba(40,110,170,0.26)', + mode: 'soft-light', + sat: 0.7, + bri: 0.86, + }, + ground: { + tint: 'rgba(255,150,40,0.20)', + mode: 'soft-light', + sat: 0.9, + bri: 0.9, + }, + inside: { + tint: 'rgba(235,190,100,0.22)', + mode: 'soft-light', + sat: 0.85, + bri: 0.9, + }, +}; + +function overlay(ctx, s, w, h) { + const k = `${s.kind}`; + const r = rng( + [...String(s.id)].reduce((a, c) => a * 33 + c.charCodeAt(0), 5), + ); + const cx = w / 2; + const cy = h / 2; + ctx.save(); + if (/cyclone/.test(k)) { + // Spiral cloud bands + forecast cone + track. + ctx.globalCompositeOperation = 'screen'; + for (let i = 0; i < 4; i += 1) { + ctx.strokeStyle = `rgba(235,240,255,${0.28 - i * 0.05})`; + ctx.lineWidth = w * (0.07 - i * 0.012); + ctx.beginPath(); + for (let a = 0; a < Math.PI * 3.2; a += 0.05) { + const rr = 6 + a * w * 0.05 + i * 9; + const x = cx + Math.cos(a + i * 1.6) * rr; + const y = cy + Math.sin(a + i * 1.6) * rr * 0.92; + if (a === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = 'rgba(10,10,14,0.8)'; + ctx.beginPath(); + ctx.arc(cx, cy, w * 0.025, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.12)'; + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.setLineDash([5, 5]); + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx - w * 0.62, cy - h * 0.05 - w * 0.2); + ctx.lineTo(cx - w * 0.62, cy - h * 0.05 + w * 0.2); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.setLineDash([]); + for (let i = 1; i < 6; i += 1) { + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(cx - i * w * 0.11, cy - i * h * 0.012, 2.6, 0, Math.PI * 2); + ctx.fill(); + } + } else if (/quake/.test(k)) { + for (let i = 5; i >= 1; i -= 1) { + ctx.strokeStyle = `rgba(255,68,54,${0.18 + (5 - i) * 0.14})`; + ctx.lineWidth = 1.3; + ctx.beginPath(); + ctx.arc(cx, cy, i * w * 0.09, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.fillStyle = '#ff4436'; + ctx.beginPath(); + ctx.arc(cx, cy, 5, 0, Math.PI * 2); + ctx.fill(); + for (let i = 0; i < 26; i += 1) { + const a = r() * Math.PI * 2; + const d = r() * w * 0.32; + ctx.fillStyle = 'rgba(255,170,120,0.85)'; + ctx.beginPath(); + ctx.arc( + cx + Math.cos(a) * d, + cy + Math.sin(a) * d * 0.7, + 1.5 + r() * 2.5, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + } else if (/fire/.test(k)) { + // Smoke plume east, hotspots. + const g = ctx.createLinearGradient(cx, cy, w, cy - h * 0.15); + g.addColorStop(0, 'rgba(200,190,180,0.55)'); + g.addColorStop(1, 'rgba(200,190,180,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.moveTo(cx - 10, cy); + ctx.quadraticCurveTo(w * 0.8, cy - h * 0.3, w * 1.1, cy - h * 0.25); + ctx.lineTo(w * 1.1, cy + h * 0.05); + ctx.quadraticCurveTo(w * 0.8, cy, cx + 10, cy + 20); + ctx.fill(); + for (let i = 0; i < 70; i += 1) { + const x = cx + (r() - 0.6) * w * 0.4; + const y = cy + (r() - 0.5) * h * 0.22; + ctx.fillStyle = + r() > 0.7 ? 'rgba(255,240,170,0.95)' : 'rgba(255,90,40,0.9)'; + ctx.beginPath(); + ctx.arc(x, y, 1 + r() * 2.2, 0, Math.PI * 2); + ctx.fill(); + } + } else if (/lightning|rain|storm/.test(k)) { + // Radar reflectivity blobs + strikes. + const cells = /rain/.test(k) ? 5 : 9; + for (let i = 0; i < cells; i += 1) { + const x = w * (0.15 + r() * 0.7); + const y = h * (0.2 + r() * 0.6); + const rad = w * (0.08 + r() * 0.16); + for (const [f, c] of [ + [1, 'rgba(40,200,90,0.35)'], + [0.66, 'rgba(240,220,40,0.45)'], + [0.36, 'rgba(255,80,40,0.55)'], + [0.16, 'rgba(230,60,200,0.6)'], + ]) { + ctx.fillStyle = c; + ctx.beginPath(); + ctx.ellipse(x, y, rad * f, rad * f * 0.7, r() * 3, 0, Math.PI * 2); + ctx.fill(); + } + } + if (/lightning/.test(k)) + for (let i = 0; i < 90; i += 1) { + const x = w * (0.1 + r() * 0.8); + const y = h * (0.15 + r() * 0.7); + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.fillRect(x, y, 1.6, 1.6); + ctx.strokeStyle = 'rgba(200,180,255,0.7)'; + ctx.beginPath(); + ctx.moveTo(x - 3, y); + ctx.lineTo(x + 3, y); + ctx.moveTo(x, y - 3); + ctx.lineTo(x, y + 3); + ctx.stroke(); + } + } else if (/squawk|heli|military|airport|jet/.test(k)) { + const n = /airport/.test(k) + ? 9 + : /jet/.test(k) + ? 14 + : /military/.test(k) + ? 4 + : 1; + for (let j = 0; j < n; j += 1) { + ctx.strokeStyle = /squawk/.test(k) + ? 'rgba(255,68,54,0.9)' + : 'rgba(163,216,255,0.75)'; + ctx.lineWidth = 1.6; + ctx.setLineDash(/squawk/.test(k) ? [] : [3, 4]); + ctx.beginPath(); + if (/heli/.test(k)) { + ctx.ellipse(cx, cy, w * 0.3, h * 0.18, -0.3, 0, Math.PI * 2); + } else if (/military/.test(k)) { + const x = w * (0.2 + r() * 0.6); + const y = h * (0.2 + r() * 0.6); + ctx.ellipse(x, y, w * 0.14, h * 0.05, r() * 3, 0, Math.PI * 2); + } else if (/jet/.test(k)) { + const y = h * (0.25 + j * 0.035); + ctx.moveTo(0, y + 30); + ctx.bezierCurveTo(w * 0.3, y - 20, w * 0.7, y + 10, w, y - 30); + } else if (/airport/.test(k)) { + const a = (j / n) * Math.PI * 2; + ctx.moveTo(cx + Math.cos(a) * w * 0.6, cy + Math.sin(a) * h * 0.6); + ctx.quadraticCurveTo( + cx + Math.cos(a + 0.6) * w * 0.2, + cy + Math.sin(a + 0.6) * h * 0.2, + cx, + cy, + ); + } else { + // Diverting track: in from the east, turning north-west, descending. + ctx.moveTo(w * 1.05, h * 0.82); + ctx.bezierCurveTo(w * 0.7, h * 0.78, w * 0.62, h * 0.62, cx, cy); + } + ctx.stroke(); + } + ctx.setLineDash([]); + if (/squawk/.test(k)) { + ctx.fillStyle = '#ff4436'; + ctx.beginPath(); + ctx.moveTo(cx, cy - 9); + ctx.lineTo(cx + 6, cy + 7); + ctx.lineTo(cx, cy + 3.5); + ctx.lineTo(cx - 6, cy + 7); + ctx.closePath(); + ctx.fill(); + } + } else if (/iss|launch|aurora/.test(k)) { + if (/aurora/.test(k)) { + ctx.globalCompositeOperation = 'screen'; + for (let i = 0; i < 3; i += 1) { + const g = ctx.createLinearGradient( + 0, + h * (0.2 + i * 0.08), + 0, + h * (0.45 + i * 0.08), + ); + g.addColorStop(0, 'rgba(60,255,150,0)'); + g.addColorStop(0.5, `rgba(60,255,150,${0.35 - i * 0.08})`); + g.addColorStop(1, 'rgba(60,255,150,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.moveTo(0, h * (0.32 + i * 0.07)); + ctx.bezierCurveTo( + w * 0.3, + h * (0.18 + i * 0.07), + w * 0.6, + h * (0.42 + i * 0.07), + w, + h * (0.26 + i * 0.07), + ); + ctx.lineTo(w, h * (0.4 + i * 0.07)); + ctx.bezierCurveTo( + w * 0.6, + h * (0.56 + i * 0.07), + w * 0.3, + h * (0.3 + i * 0.07), + 0, + h * (0.46 + i * 0.07), + ); + ctx.fill(); + } + } else { + ctx.strokeStyle = 'rgba(179,157,255,0.9)'; + ctx.lineWidth = 1.6; + ctx.setLineDash([6, 5]); + ctx.beginPath(); + if (/launch/.test(k)) { + ctx.moveTo(cx, cy); + ctx.quadraticCurveTo(w * 0.7, h * 0.1, w * 1.05, -10); + } else { + ctx.moveTo(-10, h * 0.9); + ctx.bezierCurveTo(w * 0.3, h * 0.4, w * 0.7, h * 0.3, w + 10, h * 0.1); + } + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc( + /launch/.test(k) ? cx : w * 0.5, + /launch/.test(k) ? cy : h * 0.38, + 3.5, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + } else if (/gnss/.test(k)) { + const sz = w / 9; + for (let y = 0; y < h; y += sz * 0.86) { + for (let x = 0; x < w; x += sz) { + const d = Math.hypot(x - cx, y - cy) / (w * 0.6); + const v = Math.max(0, 1 - d) * (0.6 + r() * 0.4); + if (v < 0.15) continue; + ctx.fillStyle = `rgba(255,${Math.round(200 - v * 150)},60,${v * 0.55})`; + ctx.beginPath(); + for (let i = 0; i < 6; i += 1) { + const a = (Math.PI / 3) * i; + const px = + x + ((y / (sz * 0.86)) % 2 ? sz / 2 : 0) + Math.cos(a) * sz * 0.48; + const py = y + Math.sin(a) * sz * 0.48; + if (i) ctx.lineTo(px, py); + else ctx.moveTo(px, py); + } + ctx.fill(); + } + } + } else if (/queue|strait|port/.test(k)) { + for (let i = 0; i < (/strait/.test(k) ? 80 : 41); i += 1) { + const x = w * (0.2 + r() * 0.6); + const y = h * (0.25 + r() * 0.5); + ctx.fillStyle = 'rgba(255,255,255,0.92)'; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(r() * 3); + ctx.fillRect(-3.5, -1.2, 7, 2.4); + ctx.restore(); + } + } else if (/transit/.test(k)) { + ctx.strokeStyle = 'rgba(155,225,93,0.85)'; + ctx.lineWidth = 2.4; + ctx.beginPath(); + ctx.ellipse(cx, cy, w * 0.34, h * 0.3, 0.2, 0, Math.PI * 2); + ctx.stroke(); + for (let i = 0; i < 18; i += 1) { + const a = r() * Math.PI * 2; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc( + cx + Math.cos(a) * w * 0.34, + cy + Math.sin(a) * h * 0.3, + 2.4, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + } + ctx.restore(); +} + +async function compose(s, { width, height, dpr }) { + const w = Math.round(width * dpr); + const h = Math.round(height * dpr); + if (s.media?.url) return s.media.url; + const span = + s.span ?? Math.max(0.02, (s.camera?.height || 300e3) / 111e3 / 1.6); + const base = await loadImg( + s.thumbnail?.startsWith('/') + ? s.thumbnail + : esriThumb(s.lon, s.lat, span, Math.min(720, w), Math.min(960, h)), + ); + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const ctx = c.getContext('2d'); + const g = GRADE[s.band] || GRADE.ground; + const sc = Math.max(w / base.naturalWidth, h / base.naturalHeight); + ctx.filter = `saturate(${g.sat}) brightness(${g.bri}) contrast(1.08)`; + ctx.drawImage( + base, + (w - base.naturalWidth * sc) / 2, + (h - base.naturalHeight * sc) / 2, + base.naturalWidth * sc, + base.naturalHeight * sc, + ); + ctx.filter = 'none'; + ctx.globalCompositeOperation = g.mode; + ctx.fillStyle = g.tint; + ctx.fillRect(0, 0, w, h); + ctx.globalCompositeOperation = 'source-over'; + ctx.save(); + ctx.scale(dpr, dpr); + overlay(ctx, s, width, height); + ctx.restore(); + // Vignette. + const v = ctx.createRadialGradient( + w / 2, + h / 2, + w * 0.3, + w / 2, + h / 2, + w * 0.85, + ); + v.addColorStop(0, 'rgba(0,0,0,0)'); + v.addColorStop(1, 'rgba(0,0,0,0.45)'); + ctx.fillStyle = v; + ctx.fillRect(0, 0, w, h); + const blob = await new Promise((r) => c.toBlob(r, 'image/jpeg', 0.86)); + if (!blob) throw new Error('encode failed'); + return URL.createObjectURL(blob); +} + +export function stubThumbnail(s, { width = 360, height = 480, dpr = 2 } = {}) { + const key = `${s.id}:${width}x${height}@${dpr}`; + if (cache.has(key)) return cache.get(key); + const p = new Promise((resolve, reject) => { + const run = () => { + running += 1; + compose(s, { width, height, dpr: Math.min(2, dpr) }) + .then(resolve, reject) + .finally(() => { + running -= 1; + queue.shift()?.(); + }); + }; + if (running < 3) run(); + else queue.push(run); + }); + p.catch(() => cache.delete(key)); + cache.set(key, p); + return p; +} diff --git a/mobile/ui/stubSituations.js b/mobile/ui/stubSituations.js new file mode 100644 index 000000000..e1caab695 --- /dev/null +++ b/mobile/ui/stubSituations.js @@ -0,0 +1,657 @@ +/** + * Offline stub situations, shaped like mobile/globe/API.md (band, archetype, + * telemetry, headline, media, series [{t,v}]). Everything here is labelled + * simulated; Eye cards point at the dev server's real public-camera frames + * (`/api/cctv/frame/:id`) and fall back to imagery when that is unreachable. + */ + +import { esriThumb } from './dom.js'; + +export const SIM = 'Simulated · offline stub engine'; + +/** Public traffic cameras the dev server proxies (ids from /api/cctv/sources). */ +export const STUB_CAMS = [ + { + id: '354', + title: '5th St / Congress Ave', + city: 'Austin', + provider: 'Austin Transportation & Public Works', + lat: 30.267119, + lon: -97.743172, + headingDeg: 67.5, + }, + { + id: 'tfl-00001.06502', + title: 'Trafalgar Square', + city: 'London', + provider: 'Transport for London', + lat: 51.5074, + lon: -0.12765, + headingDeg: 135, + }, + { + id: 'wsdot-9520', + title: 'I-5 at 5th Ave / Columbia', + city: 'Seattle', + provider: 'WSDOT', + lat: 47.605166, + lon: -122.330316, + headingDeg: 202.5, + }, + { + id: 'ca-d4-tv301', + title: 'US-101 at Octavia St', + city: 'San Francisco', + provider: 'Caltrans', + lat: 37.77146, + lon: -122.42323, + headingDeg: 180, + }, + { + id: 'no-vegvesen-3000051_1', + title: 'F862 Sentrumstangenten', + city: 'Tromsø', + provider: 'Statens vegvesen', + lat: 69.64469, + lon: 18.947695, + headingDeg: 270, + }, + { + id: 'ca-d11-x1248', + title: 'I-5 at First Avenue', + city: 'San Diego', + provider: 'Caltrans', + lat: 32.72285, + lon: -117.16356, + headingDeg: 180, + }, + { + id: 'fi-c1455101', + title: 'vt4 Rovaniemi Revontuli', + city: 'Rovaniemi', + provider: 'Fintraffic', + lat: 66.503858, + lon: 25.725196, + headingDeg: 202.5, + }, +]; + +export const camFrameUrl = (id) => + `/api/cctv/frame/${encodeURIComponent(id)}?ts=${Math.floor(Date.now() / 10000)}`; + +function series(values, stepMin, now) { + return values.map((v, i) => ({ + t: now - (values.length - 1 - i) * stepMin * 60e3, + v, + })); +} + +export function buildStubSituations(now) { + const min = 60e3; + const S = (o) => ({ + live: true, + source: SIM, + section: 'now', + severity: 0, + updatedAt: now - (o.age ?? 2) * min, + time: o.time ?? { start: now - 6 * 3600e3, end: now + 24 * 3600e3 }, + camera: o.camera ?? { + lon: o.lon, + lat: o.lat, + height: o.height ?? 400e3, + heading: 0, + pitch: -90, + }, + thumbnail: esriThumb(o.lon, o.lat, o.span ?? 1, 480, o.th ?? 600), + ...o, + }); + const eye = (cam, o) => + S({ + kind: 'camera', + family: 'eyes', + band: 'ground', + archetype: 'eye', + lon: cam.lon, + lat: cam.lat, + span: 0.01, + height: 1500, + camera: { + lon: cam.lon, + lat: cam.lat, + height: 900, + heading: cam.headingDeg, + pitch: -30, + }, + layers: ['eyes.public-cctv', 'ground.traffic-flow'], + media: { + kind: 'image', + url: camFrameUrl(cam.id), + refreshMs: 15000, + cameraId: cam.id, + headingDeg: cam.headingDeg, + provider: cam.provider, + city: cam.city, + }, + telemetry: `GROUND · CAM ${cam.id.slice(-6).toUpperCase()} · ${cam.title.toUpperCase()}`, + source: `${cam.provider} public camera`, + ...o, + }); + const C = Object.fromEntries(STUB_CAMS.map((c) => [c.id, c])); + return [ + S({ + id: 'cyclone-wpac', + kind: 'cyclone', + family: 'hazards', + band: 'orbit', + archetype: 'sensor', + severity: 5, + title: 'Typhoon tracking toward Luzon', + subtitle: 'Category 4 · eyewall replacement under way', + place: 'Philippine Sea', + lon: 126.5, + lat: 15.4, + span: 9, + th: 640, + height: 2200e3, + layers: ['hazards.tropical-cyclones', 'weather.clouds', 'weather.wind'], + metrics: [ + { label: 'Wind', value: 185, unit: 'km/h' }, + { label: 'Pressure', value: 935, unit: 'hPa' }, + { label: 'Moving', value: 'WNW 18', unit: 'km/h' }, + ], + headline: { value: 185, unit: 'km/h', label: 'sustained wind' }, + telemetry: 'ORBIT · GOES IR · 185 KM/H · 935 HPA · WNW 18', + series: series( + [120, 130, 140, 150, 165, 175, 185, 185, 180, 185], + 360, + now, + ), + seriesLabel: 'sustained wind, km/h', + age: 2, + time: { start: now - 72 * 3600e3, end: now + 120 * 3600e3 }, + events: [{ at: now + 30 * 3600e3, label: 'Landfall (fcst)' }], + }), + eye(C['354'], { + id: 'eye-austin', + title: 'Saturday night on Congress Ave', + subtitle: 'Downtown crowds after the game', + place: 'Downtown Austin, Texas', + severity: 2, + age: 0, + }), + S({ + id: 'squawk-7700', + kind: 'squawk', + family: 'sky', + band: 'sky', + archetype: 'track', + severity: 4, + title: 'Squawk 7700 · descending toward Halifax', + subtitle: 'Simulated emergency track, diverting', + place: 'Over Nova Scotia', + lon: -62.4, + lat: 45.1, + span: 2.4, + th: 520, + height: 350e3, + layers: ['sky.emergency-squawks', 'sky.commercial-flights'], + metrics: [ + { label: 'Altitude', value: 'FL100', unit: '' }, + { label: 'Speed', value: 312, unit: 'kt' }, + { label: 'Squawk', value: '7700', unit: '' }, + ], + headline: { value: '7700', unit: '', label: 'general emergency' }, + telemetry: 'SKY · FL100 ↓ · 312 KT · SQ 7700', + track: { headingDeg: 310 }, + age: 1, + }), + S({ + id: 'lightning-gulf', + kind: 'lightning', + family: 'weather', + band: 'sky', + archetype: 'number', + severity: 3, + title: 'Lightning over the Gulf of Mexico', + subtitle: 'A squall line pushing toward the Louisiana coast', + place: 'Gulf of Mexico', + lon: -90.2, + lat: 27.4, + span: 5, + th: 460, + height: 900e3, + layers: ['weather.lightning', 'weather.radar'], + metrics: [ + { label: 'Strikes / h', value: 4812, unit: '' }, + { label: 'Peak density', value: 38, unit: '/km²' }, + ], + headline: { value: 4812, unit: '', label: 'strikes in the last hour' }, + telemetry: 'SKY · GLM · 4,812 STR/H · 27.4N 90.2W', + series: series([620, 900, 1400, 2100, 2900, 3600, 4100, 4812], 15, now), + seriesLabel: 'strikes / 15 min', + age: 1, + }), + S({ + id: 'quake-24h', + kind: 'earthquake', + family: 'hazards', + band: 'ground', + archetype: 'sensor', + severity: 4, + title: 'M6.4 · largest quake in 24 h', + subtitle: 'Shallow thrust event, aftershocks continuing', + place: 'Off Hokkaido, Japan', + lon: 144.2, + lat: 42.1, + span: 3.5, + th: 480, + height: 900e3, + layers: ['hazards.earthquakes'], + metrics: [ + { label: 'Magnitude', value: 6.4, unit: 'Mw' }, + { label: 'Depth', value: 38, unit: 'km' }, + { label: 'Aftershocks', value: 27, unit: '' }, + ], + headline: { value: 6.4, unit: 'Mw', label: 'magnitude' }, + telemetry: 'GROUND · USGS · M6.4 · 38 KM DEEP', + age: 11, + events: [{ at: now - 14 * 3600e3, label: 'M6.4' }], + }), + S({ + id: 'iss-pass', + kind: 'iss', + family: 'orbit', + band: 'orbit', + archetype: 'track', + severity: 2, + title: 'ISS crosses your sky at 21:42', + subtitle: 'Bright pass · max elevation 71°', + place: 'Low Earth orbit', + lon: -40, + lat: 30, + span: 30, + th: 520, + height: 9000e3, + layers: ['orbit.iss-crewed', 'orbit.ground-tracks'], + metrics: [ + { label: 'Altitude', value: 418, unit: 'km' }, + { label: 'Speed', value: 7.66, unit: 'km/s' }, + { label: 'Next pass', value: '21:42', unit: '' }, + ], + headline: { value: '21:42', unit: '', label: 'next pass · 71° max' }, + telemetry: 'ORBIT · 418 KM · 7.66 KM/S · PASS 21:42', + track: { headingDeg: 52 }, + age: 1, + }), + S({ + id: 'fires-sierra', + kind: 'fires', + family: 'hazards', + band: 'ground', + archetype: 'sensor', + severity: 4, + title: 'Fire cluster in the Sierra foothills', + subtitle: 'Hotspots doubling since dawn, smoke drifting east', + place: 'Nevada County, California', + lon: -120.9, + lat: 39.05, + span: 1.6, + th: 560, + height: 260e3, + layers: [ + 'hazards.active-fires', + 'hazards.fire-perimeters', + 'weather.wind', + ], + metrics: [ + { label: 'Hotspots', value: 214, unit: '' }, + { label: 'FRP', value: 1840, unit: 'MW' }, + { label: 'Wind', value: 32, unit: 'km/h' }, + ], + headline: { value: 214, unit: '', label: 'hotspots' }, + telemetry: 'GROUND · FIRMS VIIRS · 214 HOTSPOTS · WIND W 32', + series: series([12, 18, 30, 44, 61, 88, 120, 160, 190, 214], 60, now), + seriesLabel: 'hotspots / h', + age: 4, + }), + S({ + id: 'gnss-baltic', + kind: 'gnss', + family: 'infra', + band: 'sky', + archetype: 'number', + severity: 3, + title: 'GNSS jamming over the Baltic', + subtitle: 'Aircraft reporting degraded navigation integrity', + place: 'Baltic Sea', + lon: 21.0, + lat: 56.5, + span: 6, + th: 480, + height: 1500e3, + layers: ['infra.gnss-interference', 'sky.commercial-flights'], + metrics: [ + { label: 'Affected', value: 312, unit: 'aircraft' }, + { label: 'Area', value: 84, unit: 'k km²' }, + ], + headline: { value: 312, unit: '', label: 'aircraft affected' }, + telemetry: 'SKY · ADS-B NIC<7 · 312 AC · 84K KM²', + series: series([120, 180, 240, 260, 300, 312], 60, now), + seriesLabel: 'aircraft with NIC < 7', + age: 9, + }), + eye(C['tfl-00001.06502'], { + id: 'eye-trafalgar', + title: 'Trafalgar Square, live', + subtitle: 'Looking south-east down Whitehall', + place: 'Westminster, London', + severity: 1, + age: 0, + }), + S({ + id: 'novosel', + kind: 'helicopters', + family: 'sky', + band: 'sky', + archetype: 'track', + title: 'Rotor school: 14th circuit of the day', + subtitle: 'A training helicopter flying the pattern', + place: 'Fort Novosel, Alabama', + lon: -85.72, + lat: 31.36, + span: 0.22, + th: 420, + height: 40e3, + layers: ['sky.helicopters'], + metrics: [ + { label: 'Airborne', value: 23, unit: 'rotorcraft' }, + { label: 'Pattern alt', value: 700, unit: 'ft' }, + ], + headline: { value: 14, unit: 'th', label: 'circuit today' }, + telemetry: 'SKY · 700 FT · 88 KT · CIRCUIT 14', + track: { headingDeg: 140, circuit: true }, + age: 3, + alwaysOn: true, + }), + S({ + id: 'bhote-koshi-2026', + kind: 'reconstruction', + family: 'hazards', + band: 'inside', + archetype: 'sensor', + title: 'Bhote Koshi outburst flood', + subtitle: '16 sourced observations · 3 open hypotheses', + place: 'Rasuwa, Nepal ↔ Gyirong, Tibet', + section: 'reconstructions', + lon: 85.33, + lat: 28.15, + span: 0.3, + th: 640, + height: 30e3, + camera: { + lon: 85.3548, + lat: 28.207, + height: 14e3, + heading: 344, + pitch: -39, + }, + layers: [ + 'hazards.floods', + 'infra.recent-imagery', + 'eyes.signed-captures', + ], + metrics: [ + { label: 'Observations', value: 16, unit: '' }, + { label: 'Corridor', value: 145, unit: 'pts' }, + { label: 'Hypotheses', value: 3, unit: '' }, + ], + telemetry: 'INSIDE · 4D · 16 OBS · 22 KM CORRIDOR', + live: false, + age: 60 * 24 * 7, + source: 'GeoPera reconstruction · Vantor Open Data (CC BY-NC 4.0)', + time: { start: Date.UTC(2026, 7, 26, 0), end: Date.UTC(2026, 7, 27, 12) }, + thread: 'bhote-koshi-2026', + thumbnail: '/events/bhote-koshi-2026/post.webp', + }), + S({ + id: 'rain-houston', + kind: 'rain', + family: 'weather', + band: 'sky', + archetype: 'sensor', + severity: 3, + title: 'Heavy rain cell over Houston', + subtitle: '60 dBZ core over the Ship Channel, moving east', + place: 'Houston, Texas', + lon: -95.25, + lat: 29.74, + span: 0.9, + th: 540, + height: 120e3, + layers: ['weather.radar', 'weather.lightning'], + metrics: [ + { label: 'Peak', value: 60, unit: 'dBZ' }, + { label: 'Rate', value: 76, unit: 'mm/h' }, + ], + headline: { value: 76, unit: 'mm/h', label: 'rain rate' }, + telemetry: 'SKY · MRMS · 60 DBZ · 76 MM/H · E 22 KM/H', + age: 2, + }), + S({ + id: 'starbase', + kind: 'launch', + family: 'orbit', + band: 'orbit', + archetype: 'track', + title: 'Launch window opens at Starbase', + subtitle: 'Trajectory east over the Gulf', + place: 'Boca Chica, Texas', + lon: -97.157, + lat: 25.997, + span: 0.06, + th: 380, + height: 12e3, + layers: ['orbit.launches', 'orbit.ground-tracks'], + metrics: [ + { label: 'T−', value: '06:12:00', unit: '' }, + { label: 'Weather go', value: 80, unit: '%' }, + ], + headline: { value: 'T−06:12', unit: '', label: 'to window' }, + telemetry: 'ORBIT · PAD B · AZ 097° · T−06:12:00', + track: { headingDeg: 97, launch: true }, + age: 6, + events: [{ at: now + 6.2 * 3600e3, label: 'T-0' }], + }), + eye(C['wsdot-9520'], { + id: 'eye-seattle', + title: 'I-5 through downtown Seattle', + subtitle: 'Southbound into the convention center tunnel', + place: 'Seattle, Washington', + age: 0, + }), + S({ + id: 'suez-queue', + kind: 'queue', + family: 'sea', + band: 'ground', + archetype: 'number', + title: '41 ships waiting at Suez', + subtitle: 'Southbound convoy holding in the anchorage', + place: 'Gulf of Suez, Egypt', + lon: 32.55, + lat: 29.88, + span: 0.35, + th: 560, + height: 60e3, + layers: ['sea.ais-vessels', 'sea.tankers-cargo'], + metrics: [ + { label: 'Waiting', value: 41, unit: 'vessels' }, + { label: 'Median wait', value: 19, unit: 'h' }, + ], + headline: { value: 41, unit: '', label: 'vessels at anchor' }, + telemetry: 'GROUND · AIS · 41 AT ANCHOR · WAIT 19 H', + series: series([28, 30, 33, 35, 39, 41], 120, now), + seriesLabel: 'vessels at anchor', + age: 5, + }), + S({ + id: 'north-sea-mil', + kind: 'military', + family: 'sky', + band: 'sky', + archetype: 'sensor', + title: 'Tanker orbits over the North Sea', + subtitle: 'Two refuelling tracks and a fighter CAP', + place: 'North Sea', + lon: 3.5, + lat: 55.5, + span: 5, + th: 520, + height: 900e3, + layers: ['sky.military-aircraft'], + metrics: [ + { label: 'Aircraft', value: 14, unit: '' }, + { label: 'Tankers', value: 3, unit: '' }, + ], + telemetry: 'SKY · FL260 · 3 TANKERS · 14 MIL', + age: 7, + }), + eye(C['no-vegvesen-3000051_1'], { + id: 'eye-tromso', + kind: 'camera', + title: 'Tromsø under a Kp 6 sky', + subtitle: 'Road camera on Sentrumstangenten, aurora nowcast high', + place: 'Tromsø, Norway', + age: 0, + }), + S({ + id: 'jet-stream', + kind: 'jet', + family: 'weather', + band: 'sky', + archetype: 'sensor', + title: 'Jet stream at 210 kt over the Atlantic', + subtitle: 'Eastbound flights riding it, westbound avoiding it', + place: 'North Atlantic', + lon: -38, + lat: 49, + span: 22, + th: 460, + height: 4500e3, + layers: ['weather.wind', 'sky.commercial-flights'], + metrics: [{ label: 'Core', value: 210, unit: 'kt' }], + headline: { value: 210, unit: 'kt', label: 'jet core at FL340' }, + telemetry: 'SKY · GFS 250 HPA · 210 KT · FL340', + age: 20, + }), + S({ + id: 'atl-rush', + kind: 'airport', + family: 'sky', + band: 'sky', + archetype: 'number', + title: 'Busiest airport right now', + subtitle: 'Hartsfield–Jackson arrival bank', + place: 'Atlanta, Georgia', + lon: -84.428, + lat: 33.6407, + span: 0.09, + th: 600, + height: 18e3, + layers: [ + 'sky.commercial-flights', + 'sky.holding-patterns', + 'sky.airport-boards', + ], + metrics: [ + { label: 'Movements', value: 182, unit: '/h' }, + { label: 'Holding', value: 6, unit: '' }, + ], + headline: { value: 182, unit: '/h', label: 'movements' }, + telemetry: 'SKY · KATL · 182 MVT/H · 6 HOLDING', + series: series([120, 140, 166, 170, 182, 176, 181, 182], 30, now), + seriesLabel: 'movements / h', + age: 1, + alwaysOn: true, + }), + eye(C['ca-d4-tv301'], { + id: 'eye-sf', + title: 'US-101 at Octavia, San Francisco', + subtitle: 'Central Freeway merging into Mission', + place: 'San Francisco, California', + age: 0, + alwaysOn: true, + }), + S({ + id: 'tokyo-rush', + kind: 'transit', + family: 'ground', + band: 'ground', + archetype: 'sensor', + title: 'Rush hour on the Yamanote line', + subtitle: 'Headways at 2 min 30 s through Shinjuku', + place: 'Shinjuku, Tokyo', + lon: 139.7, + lat: 35.69, + span: 0.05, + th: 600, + height: 9e3, + layers: ['ground.transit-vehicles', 'ground.rail'], + metrics: [ + { label: 'Trains moving', value: 1840, unit: '' }, + { label: 'On time', value: 98.6, unit: '%' }, + ], + telemetry: 'GROUND · GTFS-RT · 1,840 TRAINS · 98.6% OT', + age: 2, + alwaysOn: true, + }), + S({ + id: 'aurora', + kind: 'aurora', + family: 'weather', + band: 'orbit', + archetype: 'sensor', + title: 'Aurora oval reaching 60°N tonight', + subtitle: 'Kp 6 storm, clear skies after 22:00', + place: 'Northern Scandinavia', + lon: 18.95, + lat: 69.65, + span: 8, + th: 600, + height: 1200e3, + layers: ['weather.clouds'], + metrics: [ + { label: 'Kp', value: 6, unit: '' }, + { label: 'Cloud', value: 20, unit: '%' }, + ], + headline: { value: 'Kp 6', unit: '', label: 'geomagnetic storm' }, + telemetry: 'ORBIT · OVATION · KP 6 · OVAL 60N', + series: series([2, 3, 3, 4, 5, 6, 6], 180, now), + seriesLabel: 'Kp index', + age: 15, + }), + S({ + id: 'singapore', + kind: 'strait', + family: 'sea', + band: 'ground', + archetype: 'sensor', + title: 'Singapore Strait at capacity', + subtitle: 'Eastbound lane full, 318 ships at anchor', + place: 'Singapore Strait', + lon: 103.85, + lat: 1.22, + span: 0.4, + th: 420, + height: 70e3, + layers: ['sea.ais-vessels'], + metrics: [ + { label: 'Transits', value: 22, unit: '/h' }, + { label: 'At anchor', value: 318, unit: '' }, + ], + telemetry: 'GROUND · AIS · 22 TRANSITS/H · 318 ANCHORED', + age: 3, + alwaysOn: true, + }), + ]; +} diff --git a/mobile/ui/threads.js b/mobile/ui/threads.js index e634f18f8..5ce5cbb4b 100644 --- a/mobile/ui/threads.js +++ b/mobile/ui/threads.js @@ -18,6 +18,9 @@ import { } from './dom.js'; import { canonical, sha256Hex } from '../capture/provenance.js'; import { loadEventPack } from './world.js'; +import { KIND } from './rooms.js'; +import { avatar } from './cards.js'; +import { BAND_NAME, bandOf, fmtKm } from './bands.js'; const LOCAL_KEY = 'gev.mobile.threads.local.v1'; const DRAFTS_KEY = 'gev.mobile.threads.drafts.v1'; @@ -328,12 +331,16 @@ function simulateRefinement(thread, prompt) { /* View */ /* ------------------------------------------------------------------ */ -export function createThreads(view, { bindScroll, onPlay, onShowOnGlobe }) { +export function createThreads( + view, + { bindScroll, onPlay, onShowOnGlobe, rooms, rt, getSituations, onOpenRoom }, +) { view.innerHTML = `

Threads

-
Reconstructions built in the open: observations, hypotheses, a ledger
+
Rooms on the evidence · 4D reconstructions
+
`; @@ -350,7 +357,49 @@ export function createThreads(view, { bindScroll, onPlay, onShowOnGlobe }) { ); bindScroll(view); + function roomRow(r) { + const s = r.situation; + const band = bandOf(s); + const here = rt.presence(r.id).length; + const k = r.top ? KIND[r.top.kind] : null; + return ``; + } + + async function renderRooms() { + const host = $('.th-rooms', view); + if (!getSituations || !rooms) return; + const sits = (await getSituations().catch(() => [])).filter( + (x) => x.kind !== 'reconstruction', + ); + if (!sits.length) return; + const list = await rooms.hot(sits, 8); + host.innerHTML = `
Rooms hottest first · every situation has one
+
${list.map(roomRow).join('')}
+

Example posts in rooms are written by fictional people (handles end in .example) to show the format. Your own posts are real, stored on this device, and appear live in your other tabs.

+
Reconstructions observations · hypotheses · ledger
`; + } + $('.th-rooms', view).addEventListener('click', (e) => { + const b = e.target.closest('[data-room]'); + if (!b) return; + haptic('light'); + getSituations().then((sits) => { + const s = sits.find((x) => String(x.id) === b.dataset.room); + if (s) onOpenRoom?.(s); + }); + }); + rooms?.on('post', () => { + if (!view.hidden && !open) renderRooms(); + }); + async function renderList() { + renderRooms(); bhote = bhote || (await bhoteThread().catch((err) => (console.error(err), null))); const drafts = readLocal(DRAFTS_KEY, []); diff --git a/mobile/ui/timeRibbon.js b/mobile/ui/timeRibbon.js index 91d79bf55..65f7c6914 100644 --- a/mobile/ui/timeRibbon.js +++ b/mobile/ui/timeRibbon.js @@ -141,7 +141,7 @@ export function createTimeRibbon(parent, { getWindow, onTime }) { if (!custom) { const xn = x(n); if (xn > -10 && xn < W + 10) { - ctx.fillStyle = '#FF453A'; + ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.arc(xn, mid + 13, 2.4, 0, Math.PI * 2); ctx.fill(); @@ -392,6 +392,14 @@ export function createTimeRibbon(parent, { getWindow, onTime }) { } resize(); }, + /** Animate to a moment and tell the globe (post anchors). */ + scrubTo(ms) { + const lo = start(); + const hi = end(); + const target = Math.min(hi, Math.max(lo, ms)); + setLive(false); + animateTo(target, () => emit()); + }, /** Programmatic scrub (narrative playback). */ seek(ms) { cancelAnimationFrame(raf); diff --git a/mobile/ui/witness.js b/mobile/ui/witness.js new file mode 100644 index 000000000..c56bae180 --- /dev/null +++ b/mobile/ui/witness.js @@ -0,0 +1,94 @@ +/** + * The person's own contributions as feed items: sealed captures, sealed live + * bundles and (unverified) uploads, joined with the room posts they became. + */ + +import { all, get } from '../capture/db.js'; +import { distKm } from './bands.js'; + +const urlCache = new Map(); // id → object URL (kept for the session) + +export async function mediaUrl(id, store = 'media') { + if (urlCache.has(id)) return urlCache.get(id); + const rec = await get(store, id).catch(() => null); + const blob = rec?.blob; + if (!blob) return null; + const u = URL.createObjectURL(blob); + urlCache.set(id, u); + return u; +} + +export function lastFix(bundle) { + const g = bundle?.session?.gps || []; + const f = g[g.length - 1]; + return f && Number.isFinite(f.lat) ? f : null; +} + +/** All local witness items, newest first. */ +export async function localWitness({ rooms, situations = [] }) { + const [caps, ups, posts] = await Promise.all([ + all('captures').catch(() => []), + all('uploads').catch(() => []), + rooms.myPosts().catch(() => []), + ]); + const allPosts = await rooms.allLocalPosts().catch(() => []); + const byEvidence = new Map(); + for (const p of posts) { + const k = p.evidence?.captureId || p.evidence?.uploadId; + if (k) byEvidence.set(k, p); + } + const sitById = new Map(situations.map((s) => [String(s.id), s])); + const out = []; + for (const c of caps) { + const post = byEvidence.get(c.id) || null; + const s = post ? sitById.get(post.room) : null; + const f = lastFix(c); + const live = c.session?.mode === 'live'; + out.push({ + id: c.id, + type: live ? 'live' : c.session?.mode === 'video' ? 'video' : 'capture', + seq: c.seq, + sealed: true, + at: Date.parse(c.sealedAt), + fix: f, + acc: f?.acc, + isVideo: /video/.test(c.media?.type || ''), + url: await mediaUrl(c.id), + bundle: c, + post, + situation: s, + km: s && f ? distKm(f, s) : NaN, + replies: post ? allPosts.filter((r) => r.replyTo === post.id).length : 0, + title: post?.text + ? post.text + : live + ? `Live broadcast · ${Math.round((c.session?.segments?.length || 0) * 10)} s sealed` + : `Sealed ${/video/.test(c.media?.type || '') ? 'clip' : 'photo'} #${c.seq}`, + sub: s + ? `Posted to ${s.title}` + : 'Not posted yet · tap to verify or post', + }); + } + for (const u of ups) { + const post = byEvidence.get(u.id) || null; + const s = post ? sitById.get(post.room) : null; + out.push({ + id: u.id, + type: 'upload', + sealed: false, + unverified: true, + at: u.takenAt || u.addedAt, + fix: Number.isFinite(u.lat) ? { lat: u.lat, lon: u.lon } : null, + isVideo: /video/.test(u.type || ''), + url: await mediaUrl(u.id, 'uploads'), + upload: u, + post, + situation: s, + km: s && Number.isFinite(u.lat) ? distKm(u, s) : NaN, + replies: post ? allPosts.filter((r) => r.replyTo === post.id).length : 0, + title: post?.text || u.name || 'Upload', + sub: s ? `Posted to ${s.title} · Unverified` : 'Unverified upload', + }); + } + return out.sort((a, b) => b.at - a.at); +} diff --git a/mobile/ui/world.js b/mobile/ui/world.js index e36974709..89be9c58c 100644 --- a/mobile/ui/world.js +++ b/mobile/ui/world.js @@ -3,9 +3,10 @@ * brief sheet (peek / medium / large) and the fold back into the card. */ -import { icon, FAMILY_ICON, FAMILIES } from './icons.js'; +import { icon, FAMILY_ICON } from './icons.js'; import { $, + $$, h, esc, ago, @@ -25,8 +26,20 @@ import { } from './dom.js'; import { createSheet } from './sheet.js'; import { isReconstruction } from './feed.js'; +import { createRoomView } from './room.js'; +import { createComposer } from './composer.js'; +import { frame } from './cards.js'; +import { + BAND_NAME, + bandOf, + telemetryOf, + realSeries, + llShort, + mgrs, + fmtKm, + sensorId, +} from './bands.js'; -const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name])); let eventPack = null; export async function loadEventPack() { if (!eventPack) @@ -50,11 +63,37 @@ export function createWorld({ isTwoPane, onThread, onFollowChange, + rooms, + rt, + getChrome, + getLocation, + onCapture, + onWatchLive, }) { const ev = emitter(); let current = null; let busy = false; let originCard = null; + let tab = 'brief'; + + const roomView = createRoomView({ + rooms, + rt, + globe, + getChrome, + onCapture: (s, o) => onCapture(s, o), + onCompose: (s, o) => composer.open(s, o), + }); + const composer = createComposer({ + rooms, + getTime: () => getChrome()?.ribbon?.time || null, + getLocation, + getView: () => lastView, + onCapture: (s, o) => onCapture(s, o), + onPosted: () => {}, + }); + let lastView = null; + globe.on('camera', (c) => (lastView = c)); /* ---------- chrome: back button ---------- */ const back = h( @@ -84,6 +123,8 @@ export function createWorld({ app.dataset.sheet = d; stage.style.setProperty('--sheet-h', `${sheet.visibleHeight()}px`); }); + const dock = h('
'); + sheet.sheet.appendChild(dock); let closingFromCode = false; const safeBottom = () => safeInsets().bottom; @@ -134,13 +175,14 @@ export function createWorld({ } function sparkHtml(s) { - if (!Array.isArray(s.series) || s.series.length < 3) return ''; + const series = realSeries(s); + if (series.length < 3) return ''; const W = 220; const H = 42; - const d = sparkPath(s.series, W, H, 3); + const d = sparkPath(series, W, H, 3); const last = d.split(/[ML]/).filter(Boolean).pop().split(' ').map(Number); const m = s.metrics?.[0]; - return `
Trend${esc(m?.label || '')} · last ${s.series.length} samples
+ return `
Trend${esc(s.seriesLabel || m?.label || '')} · last ${series.length} samples
@@ -187,20 +229,32 @@ export function createWorld({ ); } - function render(s) { - const followed = store.isFollowed(s.id); + function headHtml(s) { const recon = isReconstruction(s); - sheet.sheet.dataset.family = s.family; - const m0 = s.metrics?.[0]; + const band = bandOf(s); const status = s.live ? `Live` : recon - ? 'Reconstruction' + ? '4D reconstruction' : 'Stale'; - sheet.head.innerHTML = ` -
${icon(FAMILY_ICON[s.family] || 'now')}${esc(FAM_NAME[s.family] || s.family)}${status}·${esc(ago(s.updatedAt))}
-

${esc(s.title)}

${m0 ? `${esc(typeof m0.value === 'number' ? fmtNum(m0.value, m0.value % 1 ? 1 : 0) : m0.value)}${esc(m0.unit || '')}` : ''}
-
${esc(s.place || '')}${s.subtitle ? ` — ${esc(s.subtitle)}` : ''}
`; + const dist = Number.isFinite(s.distanceKm) + ? `·${esc(fmtKm(s.distanceKm))} away` + : ''; + return ` +
${esc(BAND_NAME[band])}${status}·${esc(ago(s.updatedAt))}${dist}
+

${esc(s.title)}

+
${esc(s.place || '')}${s.subtitle ? ` — ${esc(s.subtitle)}` : ''}
+
+ + + +
`; + } + + function briefHtml(s) { + const followed = store.isFollowed(s.id); + const recon = isReconstruction(s); + const band = bandOf(s); const metrics = (s.metrics || []).slice(0, 3); const time = s.time ? `${new Date(s.time.start).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })} → ${new Date(s.time.end).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}` @@ -213,17 +267,19 @@ export function createWorld({ return `${icon(FAMILY_ICON[fam] || 'layers')}${esc(l?.name || id)}`; }) .join(''); - sheet.body.innerHTML = ` + return ` +
${frame()}${esc(sensorId(s))}${esc(telemetryOf(s))}
${metrics.length ? `
${metrics.map(metricHtml).join('')}
` : ''}
- + + ${recon ? `` : ''}
${sparkHtml(s)} -
Evidence${recon ? 'sourced observations · tap for original' : 'context imagery'}
+
Evidence${recon ? 'sourced observations · tap for original' : 'context imagery · Esri'}
${recon ? '
'.repeat(4) : contextEvidence(s)}
Every number, its source and age
${(s.metrics || []) @@ -235,12 +291,44 @@ export function createWorld({
Lenses this situation turns on
${layerChips || 'None'}
Details
Where
${esc(fmtCoord(s.lat, s.lon, 4))}
+
MGRS
${esc(mgrs(s.lat, s.lon, 4) || '—')}
Window
${esc(time)}
Kind
${esc(s.kind || '—')}
Source
${esc(s.source || '—')}
${s.caveat ? `
Caveat${esc(s.caveat)}
` : ''}`; + } + + async function updateRoomCounts(s) { + const r = await rooms.room(s); + if (current !== s) return; + const n = $('[data-room-n]', sheet.head); + if (n) n.textContent = String(r.posts.length); + const here = $('[data-room-here]', sheet.head); + const k = rt.presence(r.id).length; + if (here) + here.innerHTML = r.openQ.length + ? `${icon('question')}${r.openQ.length} open` + : ''; + void k; + } + + async function renderBody(s, opts = {}) { + sheet.sheet.dataset.tab = tab; + $$('.brief-tabs [data-tab]', sheet.head).forEach((b) => + b.setAttribute('aria-selected', String(b.dataset.tab === tab)), + ); + if (tab === 'room') { + sheet.body.innerHTML = + '
Opening the room…
'; + dock.innerHTML = roomView.replyBar() + roomView.reactionRail(); + await roomView.render(s, sheet.body, opts); + return; + } + roomView.close(); + dock.innerHTML = ''; + sheet.body.innerHTML = briefHtml(s); if ( - recon && + isReconstruction(s) && (s.thread === 'bhote-koshi-2026' || s.id === 'bhote-koshi-2026') ) { packEvidence() @@ -250,7 +338,8 @@ export function createWorld({ return loadEventPack(); }) .then((pack) => { - if (current !== s || $('.caveat', sheet.body)) return; + if (current !== s || tab !== 'brief' || $('.caveat', sheet.body)) + return; sheet.body.insertAdjacentHTML( 'beforeend', `
${esc(pack.reconstruction.label)}${esc(pack.reconstruction.caveat)}
`, @@ -260,6 +349,59 @@ export function createWorld({ } } + function render(s, opts = {}) { + sheet.sheet.dataset.family = s.family; + sheet.sheet.dataset.band = bandOf(s); + sheet.head.innerHTML = headHtml(s); + updateRoomCounts(s); + return renderBody(s, opts); + } + + async function setTab(t, opts = {}) { + if (!current || t === tab) return; + tab = t; + haptic('tick'); + await renderBody(current, opts); + if (t === 'room' && sheet.detent !== 'large') sheet.setDetent('large'); + } + + sheet.head.addEventListener('click', (e) => { + const b = e.target.closest('.brief-tabs [data-tab]'); + if (b) setTab(b.dataset.tab); + }); + + sheet.body.addEventListener('click', async (e) => { + if (tab !== 'room' || !current) return; + await roomView.onClick(e, { + scroller: sheet.body, + flyTo: (a, s) => { + sheet.setDetent('peek'); + const kmScale = Math.max( + 1200, + Math.min(400e3, (s.camera?.height || 200e3) * 0.15), + ); + globe.flyTo({ + lon: a.lon, + lat: a.lat, + height: kmScale, + pitch: -45, + duration: 1.8, + }); + }, + scrub: (t) => getChrome()?.ribbon?.scrubTo?.(t), + }); + }); + dock.addEventListener('click', async (e) => { + await roomView.onClick(e, { + scroller: sheet.body, + flyTo: () => {}, + scrub: () => {}, + }); + }); + rooms.on('post', (p) => { + if (current && String(current.id) === p.room) updateRoomCounts(current); + }); + sheet.root.addEventListener('click', async (e) => { const act = e.target.closest('[data-act]')?.dataset.act; if (!act || !current) return; @@ -286,14 +428,9 @@ export function createWorld({ duration: 2.4, }); } else if (act === 'eye') { - const cams = globe - .contactsNear(s.lon, s.lat, 80) - .filter((c) => /cam|cctv|eye/i.test(c.kind)); - if (cams.length) { - globe.select(cams[0].id); - globe.flyTo({ lon: cams[0].lon, lat: cams[0].lat, height: 3000 }); - toast(`Nearest eye: ${cams[0].title}`); - } else toast('No public camera within 80 km', { icon: icon('video') }); + await getChrome()?.nearestEye(s.lon, s.lat, s.title, 80); + } else if (act === 'reply') { + onCapture(s, {}); } else if (act === 'thread') { onThread?.(s); } else if (act === 'share') { @@ -364,15 +501,21 @@ export function createWorld({ `inset(${C.top - T.top}px ${T.right - C.right}px ${T.bottom - C.bottom}px ${C.left - T.left}px round ${r}px)`; /* ---------- open ---------- */ - async function open(s, card = null) { + async function open(s, card = null, opts = {}) { if (busy) return; - if (current && current.id === s.id) return; + if (current && current.id === s.id) { + if (opts.tab && opts.tab !== tab) { + await setTab(opts.tab, opts); + } + return; + } busy = true; const reduce = reducedMotion(); const wasOpen = !!current; current = s; + tab = opts.tab || 'brief'; ev.emit('open', s); - render(s); + render(s, opts); globe.resume(); const focusing = globe.focus(s); originCard?.classList.remove('hero-source'); @@ -380,10 +523,11 @@ export function createWorld({ const media = card?.querySelector('.card-media'); const T = targetRect(); + const detent = tab === 'room' ? 'large' : 'medium'; if (wasOpen || !media) { app.classList.add('is-world'); - sheet.open('medium'); - busy = false; + sheet.open(detent); + settle(); haptic('light'); return; } @@ -410,19 +554,56 @@ export function createWorld({ }); await wait(170); app.classList.add('is-world'); - sheet.open('medium'); + sheet.open(detent); await el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 260, fill: 'forwards', }).finished; el.remove(); - busy = false; + settle(); return; } const dur = 620; const scrim = h(''); document.body.insertBefore(scrim, el); + // Sensor framing travels with the picture and locks onto the stage. + const lock = h( + ``, + ); + Object.assign(lock.style, { + left: `${C.left}px`, + top: `${C.top}px`, + width: `${C.width}px`, + height: `${C.height}px`, + }); + document.body.appendChild(lock); + lock.animate( + [ + { + left: `${C.left}px`, + top: `${C.top}px`, + width: `${C.width}px`, + height: `${C.height}px`, + }, + { + left: `${T.left + 14}px`, + top: `${T.top + safeInsets().top + 64}px`, + width: `${T.width - 28}px`, + height: `${T.height * 0.5 - safeInsets().top - 64}px`, + }, + ], + { duration: dur, easing: SPRING_SOFT, fill: 'forwards' }, + ); + setTimeout(() => { + lock + .animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 700, + easing: EASE_OUT, + fill: 'forwards', + }) + .finished.then(() => lock.remove()); + }, dur + 250); scrim.animate([{ opacity: 0 }, { opacity: 1 }], { duration: dur * 0.7, easing: EASE_OUT, @@ -448,7 +629,7 @@ export function createWorld({ // The world is behind the photo now: reveal it. app.classList.add('is-world'); scrim.remove(); - sheet.open('medium'); + sheet.open(detent); await Promise.race([focusing, wait(250)]); const fade = el.animate( [ @@ -459,12 +640,26 @@ export function createWorld({ ); await fade.finished; el.remove(); - busy = false; + settle(); } + /** End a transition; run a back gesture that arrived during it. */ + function settle() { + busy = false; + if (pendingClose) { + pendingClose = false; + close(); + } + } + let pendingClose = false; + /* ---------- close ---------- */ async function close({ fromSheet = false } = {}) { - if (!current || busy) return; + if (!current) return; + if (busy) { + pendingClose = true; + return; + } busy = true; const s = current; const reduce = reducedMotion(); @@ -472,6 +667,8 @@ export function createWorld({ if (!fromSheet) sheet.close(); closingFromCode = false; globe.select(null); + roomView.close(); + dock.innerHTML = ''; let card = originCard && document.contains(originCard) ? originCard : getCard(s.id); const T = targetRect(); @@ -552,5 +749,14 @@ export function createWorld({ refresh() { if (current) render(current); }, + setTab, + composer, + watchLive: (w) => onWatchLive?.(w), + get tab() { + return tab; + }, + get busy() { + return busy; + }, }; } diff --git a/mobile/ui/you.js b/mobile/ui/you.js index 6c9dd6400..ea60ae68e 100644 --- a/mobile/ui/you.js +++ b/mobile/ui/you.js @@ -1,13 +1,28 @@ -/** You: captures and their seals, followed situations, holograms, settings. */ +/** + * You: your sealed captures and live bundles, unverified uploads, your room + * posts and holograms, followed situations, home area, and settings. + */ import { icon, FAMILY_ICON } from './icons.js'; import { $, $$, esc, ago, haptic, store, applyMotionPref } from './dom.js'; import { all, get } from '../capture/db.js'; import { deviceKey } from '../capture/provenance.js'; +import { KIND, badgesFor } from './rooms.js'; +import { llShort } from './bands.js'; export function createYou( view, - { bindScroll, getSituations, onOpenSituation, onVerify, onCredits }, + { + bindScroll, + getSituations, + onOpenSituation, + onVerify, + onCredits, + rooms, + rt, + onOpenRoom, + onLocate, + }, ) { let urls = []; view.addEventListener( @@ -20,11 +35,24 @@ export function createYou( async function render() { urls.forEach((u) => URL.revokeObjectURL(u)); urls = []; - const [captures, key, sits] = await Promise.all([ + const [captures, key, sits, uploads, posts] = await Promise.all([ all('captures').catch(() => []), deviceKey().catch(() => null), getSituations().catch(() => []), + all('uploads').catch(() => []), + rooms.myPosts().catch(() => []), ]); + uploads.sort((a, b) => b.addedAt - a.addedAt); + posts.sort((a, b) => b.at - a.at); + const sitOf = (id) => sits.find((x) => String(x.id) === id); + const upThumbs = uploads.slice(0, 12).map((u) => { + if (!u.blob) return ''; + const url = URL.createObjectURL(u.blob); + urls.push(url); + return url; + }); + const holos = posts.filter((p) => p.hologram); + const home = rt.me.home; captures.sort((a, b) => b.seq - a.seq); const followed = store.get('followed') || []; const fol = followed @@ -46,9 +74,9 @@ export function createYou( view.innerHTML = `

You

-
${icon('you')}
This device +
${icon('you')}
${esc(rt.me.handle)} · this device ${key ? `Signing key ${esc(key.keyId)} · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'} - ${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${followed.length} followed
+ ${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${uploads.length} upload${uploads.length === 1 ? '' : 's'} · ${posts.length} post${posts.length === 1 ? '' : 's'} · ${followed.length} followed
Captures tap to verify
${ @@ -57,12 +85,39 @@ export function createYou( .slice(0, 30) .map( (c, i) => - ``, + ``, ) .join('')}` : `
${icon('seal')}
No sealed captures yet.
Open Capture → Witness and tap the shutter.
` } + ${ + uploads.length + ? `
Uploads unverified · no capture-time seal
${uploads + .slice(0, 12) + .map( + (u, i) => + `
${upThumbs[i] ? (/video/.test(u.type) ? `` : ``) : ''}UNVERIFIED
`, + ) + .join('')}
` + : '' + } + +
Your posts ${posts.length ? 'tap to open the room' : ''}
+ ${ + posts.length + ? `
${posts + .slice(0, 20) + .map((p) => { + const st = sitOf(p.room); + const k = KIND[p.kind] || KIND.context; + const b = st ? badgesFor(p, st, rt.me) : []; + return ``; + }) + .join('')}
` + : '
Nothing posted yet. Every situation has a room: open one and reply with evidence.
' + } +
Following
${ fol.length || contacts.length @@ -77,8 +132,15 @@ export function createYou( : '
Follow a situation from its brief and it appears here and in the tab-bar accessory.
' } -
Holograms Concept
-
${icon('cube')}
Leave something in a placePin a 3D note to a spot on Earth for people who visit with Look. Concept placeholder in this prototype.
+
Holograms ${holos.length ? `${holos.length} left in places` : ''}
+ ${ + holos.length + ? `
${holos.map((p) => ``).join('')}
` + : `
${icon('cube')}
Leave something in a placeWhen you post a capture, turn on “Leave as a hologram here”: it stays pinned on the globe at that spot for people who visit with Look.
` + } + +
Home area
+
Settings
@@ -144,6 +206,25 @@ export function createYou( return; } if (e.target.closest('[data-act="credits"]')) onCredits(); + const pr = e.target.closest('[data-post-room]'); + if (pr) { + const s = (await getSituations()).find( + (x) => String(x.id) === pr.dataset.postRoom, + ); + if (s) onOpenRoom(s, pr.dataset.post); + return; + } + if (e.target.closest('[data-act="home"]')) { + const loc = await onLocate(); + if (loc) { + rt.setHome({ + lat: Math.round(loc.lat * 10) / 10, + lon: Math.round(loc.lon * 10) / 10, + }); + haptic('success'); + render(); + } + } }); view.addEventListener('input', (e) => { if (!e.target.matches('.slider')) return;