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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-19 02:49:03 +00:00
1 parent ba1673a6db
commit 8b7fcb09df
38 files changed
+9837 -1081

No files matched your search

+31 -3
View File
@@ -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),
);
}
+145
View File
@@ -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<null | { lat?, lon?, altM?, headingDeg?, takenAt?, takenAtHasZone?, orientation?, make?, model?, raw }>}
*/
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;
}
+155
View File
@@ -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 };
}
+9 -1
View File
@@ -11,7 +11,7 @@
name="description"
content="The window through which you see the world, and the eye through which the world sees itself."
/>
<meta name="theme-color" content="#05070b" />
<meta name="theme-color" content="#050607" />
<meta name="color-scheme" content="dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
@@ -24,6 +24,12 @@
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
<link rel="preconnect" href="https://server.arcgisonline.com" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
/>
<link rel="stylesheet" href="./styles/tokens.css" />
<link rel="stylesheet" href="./styles/shell.css" />
<link rel="stylesheet" href="./styles/feed.css" />
@@ -31,6 +37,8 @@
<link rel="stylesheet" href="./styles/globe-ui.css" />
<link rel="stylesheet" href="./styles/capture.css" />
<link rel="stylesheet" href="./styles/threads.css" />
<link rel="stylesheet" href="./styles/cards.css" />
<link rel="stylesheet" href="./styles/room.css" />
<link rel="stylesheet" href="./styles/layout.css" />
<script type="module" src="./main.js"></script>
</head>
+166 -27
View File
@@ -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();
+730 -25
View File
@@ -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);
}
+853
View File
@@ -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;
}
}
+176 -310
View File
@@ -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';
}
+198
View File
@@ -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;
}
File diff suppressed because it is too large. Load diff
+69 -32
View File
@@ -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 {
+9 -4
View File
@@ -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 {
+19 -23
View File
@@ -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;
+44 -7
View File
@@ -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);
+336
View File
@@ -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 `<button type="button" role="radio" class="att-row" data-i="${i}" aria-checked="${i === ctx.sel}" data-band="${band}">
<span class="ar-dot"></span>
<span class="ar-main"><b>${esc(s.title)}</b><span class="tele">${esc(BAND_NAME[band])} · ${esc(near)}</span></span>
${x.there ? `<span class="bdg" data-b="there">${icon('location')}Was there</span>` : ''}
</button>`;
}
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 = `<div class="att-head">
<span class="att-thumb ${it.unverified ? 'un' : ''}">${it.previewUrl ? (it.isVideo ? `<video src="${esc(it.previewUrl)}#t=0.1" muted playsinline preload="metadata"></video>` : `<img src="${esc(it.previewUrl)}" alt=""/>`) : ''}<span>${icon(it.unverified ? 'upload' : 'sealFill')}</span></span>
<div><div class="tele att-tele">${esc(head)}</div>
<div class="tele att-sub">${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 }))}</div>
<h2 class="sheet-title">Where does this belong?</h2></div>
</div>`;
sheet.body.innerHTML = `
<div class="att-list" role="radiogroup" aria-label="Situation">
${ctx.list.map(row).join('')}
<button type="button" role="radio" class="att-row new" data-i="new" aria-checked="${ctx.sel === 'new'}" data-band="inside">
<span class="ar-dot"></span><span class="ar-main"><b>New place</b><span class="tele">Start a room here${it.fix ? ` · ${esc(llShort(it.fix.lat, it.fix.lon, 3))}` : ''}</span></span>${icon('plus')}
</button>
<input class="att-name" type="text" maxlength="80" placeholder="Name this place" ${ctx.sel === 'new' ? '' : 'hidden'} data-no-drag/>
</div>
<div class="att-h tele">Post as</div>
<div class="kind-row" role="radiogroup" aria-label="Kind of post">${KINDS.map((k) => `<button type="button" role="radio" class="kind-chip" data-kind="${k.id}" aria-checked="${k.id === ctx.kind}">${icon(k.icon)}<span>${k.name}</span></button>`).join('')}</div>
<textarea class="cmp-text att-note" rows="3" maxlength="2000" placeholder="What are we looking at? (optional)" data-no-drag aria-label="Note"></textarea>
<div class="att-h tele">Share location at</div>
<div class="seg-chips" data-prec>${PRECISION.map((p) => `<button type="button" data-v="${p.id}" aria-pressed="${p.id === ctx.precision}">${p.label}</button>`).join('')}</div>
<p class="att-fine">${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.'}</p>
<label class="att-holo"><span class="ah-ic">${icon('cube')}</span><span class="ah-t"><b>Leave as a hologram here</b><span>People who stand here with Look will find it, pinned in place.</span></span><button class="switch" type="button" role="switch" aria-checked="${ctx.hologram}" data-holo aria-label="Leave as hologram"></button></label>
<button class="pill-btn primary att-post" type="button" data-act="post">${icon('sendUp')}Post to the room</button>`;
}
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() };
}
+224
View File
@@ -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')}`;
}
+441 -25
View File
@@ -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 = `
<div class="cap" data-mode="look">
<video class="cap-video" playsinline muted autoplay></video>
@@ -72,14 +85,31 @@ export function createCapture(view, { globe, onSealed, onVerify }) {
</div>
</div>
<div class="cap-status glass-strong" hidden></div>
<button class="cap-target" type="button" hidden aria-label="Replying in this room. Tap to clear"></button>
<div class="live-hud" aria-live="polite">
<div class="lh-top"><span class="lh-pill tele"><i></i><b data-lh="state">OFF AIR</b><span data-lh="elapsed">tap ● to go live</span></span><span class="lh-view tele">${icon('users')}<span data-lh="viewers">0</span> watching</span></div>
<div class="lh-chain" aria-label="Sealed segments"></div>
<div class="lh-tele tele" data-lh="tele">STANDBY</div>
</div>
<div class="cap-upload" hidden>
<div class="up-pick">
<span class="up-ic">${icon('upload')}</span>
<h2>Add from your library</h2>
<p>A photo or video you already have. We read its EXIF position and time if present, you place it on the map, and it is marked <b>Unverified</b> everywhere: without a capture-time seal it can be cited, never treated as an observation.</p>
<label class="pill-btn primary up-btn">${icon('upload')}Choose photo or video<input type="file" accept="image/*,video/*" hidden/></label>
</div>
<div class="up-place" hidden></div>
</div>
<div class="cap-bottom">
<div class="cap-modes" role="tablist" aria-label="Capture mode">
<button role="tab" type="button" data-mode="look" aria-selected="true">Look</button>
<button role="tab" type="button" data-mode="witness" aria-selected="false">Witness</button>
<button role="tab" type="button" data-mode="live" aria-selected="false">Live</button>
<button role="tab" type="button" data-mode="upload" aria-selected="false">Upload</button>
</div>
<div class="cap-controls">
<button class="cap-thumb" type="button" aria-label="Last capture"><span>${icon('seal')}</span></button>
<button class="shutter" type="button" aria-label="Seal a photo. Hold for video"><span class="shutter-ring"></span><span class="shutter-core"></span></button>
<button class="shutter" type="button" aria-label="Seal a photo. Hold for video"><span class="shutter-ring"></span><span class="shutter-core"></span><span class="shutter-label tele"></span></button>
<button class="cap-side icon-btn glass-strong" type="button" data-act="recenter" aria-label="Sensors">${icon('scope')}</button>
</div>
<div class="cap-legend"></div>
@@ -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 = `<div class="seal-card glass-strong">
<div class="seal-media">${kind === 'video' ? `<video src="${url}" muted playsinline autoplay loop></video>` : `<img src="${url}" alt="Captured frame"/>`}${test ? '<span class="badge-example">Test frame</span>' : ''}</div>
<div class="seal-media">${kind === 'video' || kind === 'live' ? `<video src="${url}" muted playsinline autoplay loop></video>` : `<img src="${url}" alt="Captured frame"/>`}${test ? '<span class="badge-example">Test frame</span>' : ''}</div>
<div class="seal-glyph"><svg viewBox="0 0 100 100" aria-hidden="true"><path class="seal-ring" d="${SEAL_D}"/><path class="seal-check" d="M33 51 l11 11 l23 -24"/></svg></div>
<div class="seal-state">Sealing…</div>
<div class="seal-hash mono" aria-label="Bundle hash"></div>
@@ -906,7 +982,9 @@ export function createCapture(view, { globe, onSealed, onVerify }) {
card.classList.add('sealed');
haptic('seal');
$('.seal-state', sealEl).innerHTML =
`<b>Sealed</b> · capture #${bundle.seq}`;
bundle.session?.mode === 'live'
? `<b>Sealed live</b> · ${bundle.session.segments?.length || 0} segments · capture #${bundle.seq}`
: `<b>Sealed</b> · 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 }) {
<dt>Heading</dt><dd>${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}</dd>
<dt>Media</dt><dd>${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB</dd>
</dl>
<div class="seal-actions"><button class="pill-btn seal" type="button" data-seal="verify">${icon('seal')}Verify</button><button class="pill-btn plain" type="button" data-seal="done">Done</button></div>`,
<div class="seal-actions"><button class="pill-btn primary" type="button" data-seal="post">${icon('sendUp')}${target ? 'Post to the room' : 'Post to a room'}</button><button class="pill-btn seal" type="button" data-seal="verify">${icon('seal')}Verify</button><button class="pill-btn plain" type="button" data-seal="done">Keep private</button></div>`,
);
}
@@ -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 =
'<span class="seg pending tele"><i></i>sealing every 10 s</span>';
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(
`<span class="seg tele" title="sha256 ${esc(seg.hash)}"><i></i>#${seg.i + 1} ${esc(seg.hash.slice(0, 6))}${seg.prev ? ` ← ${esc(seg.prev.slice(0, 4))}` : ''}</span>`,
),
);
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 = `
<div class="up-head"><span class="up-thumb">${isVideo ? `<video src="${esc(url)}#t=0.1" muted playsinline preload="metadata"></video>` : `<img src="${esc(url)}" alt="Chosen file"/>`}</span>
<div class="up-name"><b>${esc(file.name || 'Untitled')}</b><span class="tele">${esc((file.type || 'file').toUpperCase())} · ${(file.size / 1024).toFixed(0)} KB</span></div>
<span class="bdg" data-b="unverified">${icon('hazards')}Unverified</span></div>
<dl class="up-exif">${rows.map(([k, v]) => `<dt class="tele">${k}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
<div class="up-h tele">${icon('pin')}Place it — drag the map under the pin</div>
<div class="up-map"></div>
<div class="up-h tele">${icon('clock')}When was it taken</div>
<input class="up-time" type="datetime-local" value="${toLocalInput(taken)}" aria-label="When it was taken"/>
<p class="up-why">${icon('info')}<span>Marked <b>Unverified</b> 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.</span></p>
<div class="up-actions"><button class="pill-btn plain" type="button" data-up="cancel">Cancel</button><button class="pill-btn primary" type="button" data-up="next">${icon('chevronRight')}Continue</button></div>`;
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')}<span>Answering in <b>${esc(s.title)}</b>${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''}</span>${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;
+244
View File
@@ -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 = () =>
'<span class="fr" aria-hidden="true"><i></i><i></i><i></i><i></i></span>';
const reticle = () => '<span class="reticle" aria-hidden="true"></span>';
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 `<div class="c-band tele" data-band="${band}"><i class="bd"></i>${parts.map(esc).join('<b>·</b>')}</div>`;
}
function distBits(s) {
return Number.isFinite(s.distanceKm) ? [`${fmtKm(s.distanceKm)} away`] : [];
}
function liveBit(s) {
return s.live
? ''
: `<span class="c-stale tele">${s.kind === 'reconstruction' ? '4D · archive' : 'stale'}</span>`;
}
function body(s, { cta = '' } = {}) {
return `<div class="card-body">
${bandLine(s, [...distBits(s), ago(s.updatedAt)])}
<h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
<div class="card-place"><span>${esc(s.place || '')}</span>${liveBit(s)}</div>
${cta}
</div>`;
}
/** The shared image frame for a situation card. */
function media(s, ar, { strip = true, inner = '', eye = false } = {}) {
return `<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
<img alt="" decoding="async" ${eye ? 'data-eye' : 'data-thumb'} />
${eye ? '<img alt="" decoding="async" class="eye-b" aria-hidden="true" />' : ''}
${frame()}${reticle()}
<div class="c-top"><span class="c-sid tele">${esc(sensorId(s))}</span>${s.live ? `<span class="c-live tele"><i></i>${eye ? `LIVE <span data-clock></span>` : 'LIVE'}</span>` : s.kind === 'reconstruction' ? '<span class="c-live tele recon">4D</span>' : ''}</div>
${inner}
${strip ? `<div class="c-strip tele">${esc(telemetryOf(s))}</div>` : ''}
</div>`;
}
const hit = (label) =>
`<button class="card-hit" type="button" aria-label="${esc(label)}"></button>`;
/* ---------------- Situation archetypes ---------------- */
function sensorCard(s, ar) {
return `<article class="card c-sensor" data-id="${esc(s.id)}" data-band="${bandOf(s)}" data-arch="sensor" aria-labelledby="ct-${esc(s.id)}">
${hit(`Open ${s.title}, ${s.place || ''}`)}
${media(s, ar)}
${body(s)}
</article>`;
}
function trackCard(s, ar) {
const hl = headlineOf(s);
const harm = /squawk/.test(s.kind) && /7[5-7]00/.test(String(hl?.value));
return `<article class="card c-track ${harm ? 'is-harm' : ''}" data-id="${esc(s.id)}" data-band="${bandOf(s)}" data-arch="track" aria-labelledby="ct-${esc(s.id)}">
${hit(`Open ${s.title}`)}
${media(s, ar, {
inner: hl
? `<div class="c-readout"><b class="num">${esc(fmtVal(hl.value))}</b>${hl.unit ? `<small>${esc(hl.unit)}</small>` : ''}<span class="tele">${esc(hl.label || '')}</span></div>`
: '',
})}
${body(s, { cta: `<span class="c-cta tele">${icon('ride')}Ride along</span>` })}
</article>`;
}
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 `<article class="card c-number" data-id="${esc(s.id)}" data-band="${bandOf(s)}" data-arch="number" aria-labelledby="ct-${esc(s.id)}">
${hit(`Open ${s.title}`)}
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
<img alt="" decoding="async" data-thumb class="c-num-bg" />
${frame()}
<div class="c-num">
${bandLine(s, [s.kind === 'gnss' ? 'ADS-B' : '', ...distBits(s)])}
<div class="c-big num">${esc(fmtVal(hl?.value))}<small>${esc(hl?.unit || '')}</small></div>
<div class="c-big-l">${esc(hl?.label || '')}</div>
${d ? `<svg class="c-spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><path class="a" d="${d}L${W - 3} ${H}L3 ${H}Z"/><path class="l" d="${d}"/></svg><div class="c-spark-l tele">${esc(s.seriesLabel || 'trend')} · ${ser.length} samples</div>` : ''}
</div>
<div class="c-strip tele">${esc(telemetryOf(s))}</div>
</div>
<div class="card-body">
<h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
<div class="card-place"><span>${esc(s.place || '')} · ${esc(ago(s.updatedAt))}</span></div>
</div>
</article>`;
}
function eyeCard(s) {
const hd = s.media?.headingDeg;
return `<article class="card c-eye wide" data-id="${esc(s.id)}" data-band="ground" data-arch="eye" data-cam="${esc(s.media?.cameraId || '')}" data-refresh="${Number(s.media?.refreshMs) || 15000}" aria-labelledby="ct-${esc(s.id)}">
${hit(`Open live camera, ${s.title}`)}
<div class="card-media" style="aspect-ratio:16/9">
<img alt="" decoding="async" data-eye src="${esc(s.media?.url || '')}" />
<img alt="" decoding="async" class="eye-b" aria-hidden="true" />
${frame()}${reticle()}
<div class="c-top"><span class="c-live tele"><i></i>LIVE <span data-clock></span></span><span class="c-sid tele">${esc(sensorId(s))}</span></div>
<div class="c-eye-cap">
${Number.isFinite(hd) ? `<span class="c-fov" style="--hd:${hd}deg" aria-label="Looking ${cardinal(hd)}"><i></i></span>` : ''}
<div><h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
<div class="card-place"><span>${esc(s.place || '')}</span></div></div>
</div>
<div class="c-strip tele">${esc(telemetryOf(s))}${Number.isFinite(hd) ? ` · LOOKING ${cardinal(hd)} ${Math.round(hd)}°` : ''}</div>
</div>
<div class="card-body row">
${bandLine(s, [...distBits(s), 'Public camera'])}
<span class="c-src tele">${esc(s.media?.provider || s.source || '')}</span>
</div>
</article>`;
}
/* ---------------- 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 `<article class="card c-witness ${w.unverified ? 'is-unverified' : ''} ${w.liveNow ? 'is-onair' : ''}" data-witness="${esc(w.id)}" data-band="inside" data-arch="witness">
${hit(w.liveNow ? `Watch ${w.handle}'s live broadcast` : `Open your ${w.type}`)}
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
${w.isVideo && !w.frame ? `<video muted playsinline preload="metadata" ${w.url ? `src="${esc(w.url)}#t=0.1"` : ''}></video>` : `<img alt="" decoding="async" ${w.url || w.frame ? `src="${esc(w.frame || w.url)}"` : ''} />`}
${frame()}
<div class="c-top">${w.liveNow ? `<span class="c-live tele onair"><i></i>LIVE <span data-since="${w.startedAt}"></span></span><span class="c-sid tele">${icon('users')}<span data-viewers>${w.viewers || 0}</span></span>` : `<span class="c-seal ${sealed ? '' : 'un'}">${icon(sealed ? 'sealFill' : 'upload')}</span>`}</div>
<div class="c-strip tele">${esc(tele)}</div>
</div>
<div class="card-body">
<div class="c-band tele" data-band="inside"><i class="bd"></i>${esc(w.liveNow ? 'Live from a person' : w.unverified ? 'Your upload' : 'Your witness')}<b>·</b>${esc(ago(w.at))}</div>
<h3 class="card-title">${esc(w.title)}</h3>
<div class="card-place"><span>${esc(w.sub || '')}</span></div>
</div>
</article>`;
}
function promptCard(p, ar) {
return `<article class="card c-prompt" data-prompt="capture" data-band="inside" data-arch="witness">
${hit('Open Capture')}
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
${frame()}
<div class="c-prompt-in">
<span class="c-prompt-ic">${icon('viewfinder')}</span>
<div class="c-band tele" data-band="inside"><i class="bd"></i>Inside · you</div>
<b>${esc(p.title)}</b>
<span>${esc(p.text)}</span>
</div>
<div class="c-strip tele">${esc(p.tele)}</div>
</div>
<div class="card-body"><span class="c-cta tele">${icon('viewfinder')}Open Capture</span></div>
</article>`;
}
/* ---------------- 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 `<i class="av ${a.self ? 'self' : ''}" style="--h:${hue};z-index:${10 - i}" title="${esc(a.handle)}">${esc((a.handle || '?').replace(/^@/, '')[0].toUpperCase())}</i>`;
}
function roomCard(r, watching = 0) {
const s = r.situation;
const top = r.top;
const k = top ? KIND[top.kind] : null;
return `<article class="card c-room wide" data-room="${esc(s.id)}" data-band="inside" data-arch="room" data-status="${esc(r.status.id)}">
${hit(`Join the room for ${s.title}`)}
<div class="c-room-in">
<div class="c-room-head">
<div class="c-band tele" data-band="inside"><i class="bd"></i>Room<b>·</b>${esc(s.title)}</div>
${top?.example ? '<span class="badge-example">Example conversation</span>' : `<span class="c-watch tele" data-room-watch="${esc(s.id)}">${watching > 1 ? `<i></i>${watching} here` : ''}</span>`}
</div>
${top ? `<blockquote class="c-claim ${top.example ? 'is-example' : ''}">${esc(top.text)}</blockquote>` : ''}
<div class="c-room-meta">
<span class="c-status tele" data-status="${esc(r.status.id)}">${icon(r.status.id === 'disputed' ? 'dispute' : r.status.id === 'open' ? 'question' : 'sealFill')}${esc(r.status.label)}</span>
${top ? `<span class="c-kind tele">${icon(k?.icon || 'note')}${esc(k?.name || '')} · ${esc(top.author.handle)}</span>` : ''}
</div>
<div class="c-room-foot">
<span class="avs">${r.participants.slice(0, 5).map(avatar).join('')}</span>
<span class="tele">${r.participants.length} people · ${r.posts.length} posts${r.openQ.length ? ` · ${r.openQ.length} open question${r.openQ.length > 1 ? 's' : ''}` : ''}</span>
<span class="c-join">${icon('chevronRight')}</span>
</div>
</div>
</article>`;
}
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 };
+174
View File
@@ -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 = `<div class="c-band tele" data-band="inside"><i class="bd"></i>Room<b>·</b>${esc(s.title)}</div>
<h2 class="sheet-title">${r ? 'Reply' : 'Post to the room'}</h2>
${r ? `<div class="cmp-quote"><b class="tele">${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)}</b>${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}</div>` : ''}`;
const places = placeOptions();
const t = getTime();
sheet.body.innerHTML = `
<button class="cmp-capture" type="button" data-act="capture">
<span class="cc-ic">${icon('viewfinder')}</span>
<span class="cc-t"><b>Answer with a sealed capture</b><span>The strongest reply is a photo or a live view from the scene, signed with your phone’s motion and position. It earns the Was there badge.</span></span>
${icon('chevronRight', { cls: 'chev' })}
</button>
<div class="cmp-or tele"><span>or write</span></div>
<div class="kind-row" role="radiogroup" aria-label="Kind of post">
${KINDS.map((k) => `<button type="button" role="radio" class="kind-chip" data-kind="${k.id}" aria-checked="${k.id === ctx.kind}">${icon(k.icon)}<span>${k.name}</span></button>`).join('')}
</div>
<p class="kind-hint tele">${esc(KIND[ctx.kind].hint)}</p>
<textarea class="cmp-text" rows="4" maxlength="2000" placeholder="${esc(placeholder(ctx.kind))}" data-no-drag aria-label="Post text"></textarea>
<div class="cmp-anchor">
<div class="ca-h tele">${icon('pin')}Anchor in place</div>
<div class="seg-chips" data-anchor="place">${places.map((p) => `<button type="button" data-v="${p.id}" aria-pressed="${p.id === ctx.place}">${esc(p.label)}${p.ll ? `<small>${esc(llShort(p.ll.lat, p.ll.lon, 2))}</small>` : ''}</button>`).join('')}</div>
<div class="ca-h tele">${icon('clock')}Anchor in time</div>
<div class="seg-chips" data-anchor="time"><button type="button" data-v="none" aria-pressed="${ctx.time === 'none'}">No time</button><button type="button" data-v="now" aria-pressed="${ctx.time === 'now'}">Now<small>${esc(utc(new Date(), { seconds: false }))}</small></button>${t ? `<button type="button" data-v="ribbon" aria-pressed="${ctx.time === 'ribbon'}">Ribbon time<small>${esc(utc(t, { seconds: false }))}</small></button>` : ''}</div>
</div>
<div class="cmp-foot"><span class="tele">Stored on this device · shared live with your other open tabs</span><button class="pill-btn primary" type="button" data-act="post">Post</button></div>`;
}
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() };
}
+92
View File
@@ -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 = `<div class="c-band tele" data-band="ground"><i class="bd"></i>Ground<b>·</b>Public camera${Number.isFinite(eye.distanceKm) ? `<b>·</b>${esc(fmtKm(eye.distanceKm))} from ${esc(from || 'here')}` : ''}</div>
<h2 class="sheet-title">${esc(eye.title || eye.id)}</h2>
<div class="sheet-sub">${esc([eye.city, eye.provider].filter(Boolean).join(' · '))}</div>`;
sheet.body.innerHTML = `<div class="ev-frame" data-band="ground">
<img class="front" alt="Live frame from ${esc(eye.title || 'camera')}" src="${esc(url())}"/><img alt="" aria-hidden="true"/>
${frame()}
<div class="c-top"><span class="c-live tele"><i></i>LIVE <span data-ev-clock>${utc()}</span></span><span class="c-sid tele">CAM ${esc(String(eye.id).slice(-8).toUpperCase())}</span></div>
<div class="c-strip tele">GROUND · ${esc(llShort(eye.lat, eye.lon, 4))}${Number.isFinite(hd) ? ` · LOOKING ${cardinal(hd)} ${Math.round(hd)}°` : ''}</div>
</div>
<div class="ev-facts tele"><span>Refreshed <b class="ev-age">just now</b></span><span>every 15 s while open</span></div>
<div class="ev-actions"><button class="pill-btn tinted" type="button" data-ev="fly">${icon('globe')}Show on globe</button></div>
<p class="footnote" style="padding:12px 2px 0">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.</p>`;
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() };
}
+580 -147
View File
@@ -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 = `
<header class="navbar"><span class="nav-title">Now</span>
<div class="nav-trailing"><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
<div class="nav-trailing"><button type="button" class="near-chip" data-act="near" aria-pressed="false">${icon('location')}<span>Near you</span></button><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
</header>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${FAMILIES.map((f, i) => `<i style="--c:${f.color};--i:${i}"></i>`).join('')}</div></div>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `<i style="--i:${i}"></i>`).join('')}</div></div>
<div class="feed-inner">
<h1 class="large-title">Now</h1>
<div class="title-sub"><span class="live-dot"></span><span class="feed-count num">Loading the planet…</span></div>
<div class="feed-tabs" role="tablist" aria-label="Feed sections" data-hscroll>
${SECTIONS.map((s, i) => `<button role="tab" type="button" data-sec="${s.id}" aria-selected="${i === 0}">${s.label}</button>`).join('')}
<span class="feed-tabs-ink" aria-hidden="true"></span>
<div class="feed-hero">
<h1 class="large-title">Now</h1>
<div class="feed-clock tele" aria-hidden="true"><b data-utc></b><span data-date></span></div>
</div>
<div class="title-sub tele"><span class="live-dot"></span><span class="feed-count">Acquiring the planet…</span></div>
<div class="bandbar" role="tablist" aria-label="Altitude" data-hscroll>
<button role="tab" type="button" data-band="all" aria-selected="true"><span class="bb-n">All</span><span class="bb-r tele" data-count="all">—</span></button>
${BANDS.map((b) => `<button role="tab" type="button" data-band="${b.id}" aria-selected="false"><span class="bb-n"><i></i>${b.name}</span><span class="bb-r tele"><span data-count="${b.id}">—</span> · ${BAND_RANGE[b.id]}</span></button>`).join('')}
</div>
<div class="feed-note" hidden></div>
<div class="masonry" role="feed" aria-busy="true"><div class="mcol"></div><div class="mcol"></div></div>
<p class="feed-end" hidden>You’re caught up with the planet.</p>
<div class="feed-body" role="feed" aria-busy="true"></div>
<p class="feed-end tele" hidden>End of feed · the planet keeps turning</p>
</div>`;
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) =>
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${ASPECTS[(i + ci * 2) % ASPECTS.length]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
)
.join('');
fbody.innerHTML = `<div class="masonry">${[0, 1]
.map(
(ci) =>
`<div class="mcol">${[0, 1, 2]
.map(
(i) =>
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${[1.2, 1, 1.35][(i + ci) % 3]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
)
.join('')}</div>`,
)
.join('')}</div>`;
}
/* ---------- 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 `<article class="card ${s.live ? 'is-live' : isReconstruction(s) ? 'is-recon' : s.stale || (!isAlwaysOn(s) && s.section !== 'always') ? 'is-stale' : 'is-evergreen'}" data-id="${esc(s.id)}" data-family="${esc(s.family)}" aria-labelledby="ct-${esc(s.id)}">
<button class="card-hit" type="button" aria-label="Open ${esc(s.title)}, ${esc(s.place || '')}"></button>
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
<img src="${esc(s.thumbnail || '')}" alt="" loading="lazy" decoding="async" fetchpriority="low" />
<div class="card-badges">
<span class="fam-tag glass-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(fam)}</span>
${s.live ? '<span class="live-pill"><span class="live-dot"></span>LIVE</span>' : isReconstruction(s) ? `<span class="live-pill recon">${icon('threads')}4D</span>` : ''}
</div>
${m ? `<div class="card-metric"><b class="num">${esc(fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0))}</b>${m.unit ? `<small>${esc(m.unit)}</small>` : ''}<span>${esc(m.label)}</span></div>` : ''}
</div>
<div class="card-body">
<h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
<div class="card-place">${icon('pin')}<span>${esc(s.place || '')}</span></div>
<div class="card-foot"><span class="src">${esc(shortSource(s.source))}</span><span class="ago num" data-ts="${esc(s.updatedAt || '')}">${esc(ago(s.updatedAt))}</span></div>
</div>
</article>`;
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(
'<article ',
`<article data-key="${esc(it.key)}" `,
);
fbody.innerHTML = blocks
.map((b) =>
b.kind === 'wide'
? `<div class="wide-row">${keyed(b.it, 0.5625)}</div>`
: `<div class="masonry">${b.cols.map((c) => `<div class="mcol">${c.map((cell) => keyed(cell.it, cell.ar)).join('')}</div>`).join('')}</div>`,
)
.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 = `<div class="feed-empty tele">Nothing in this band right now${near ? ' near you' : ''}.</div>`;
// 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 = `<div class="near-prompt glass">${icon('location')}<div><b>See what’s around you</b><span>Sorted from San Francisco until you share your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
note.innerHTML = `<div class="near-prompt">${icon('location')}<div><b>See what’s around you</b><span>Distances and the Near lens need your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
} 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 ? `<i></i>${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;
},
};
}
+122 -16
View File
@@ -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(`<div class="gc-top">
<div class="gc-readout glass-strong liquid"><span class="num gc-alt">—</span><span class="mono gc-ll">—</span></div>
<div class="gc-readout"><span class="tele gc-alt">—</span><span class="tele gc-ll">—</span></div>
<div class="gc-buttons">
<button class="icon-btn glass-strong liquid" type="button" data-act="lens" aria-label="Visual lens" aria-haspopup="true">${icon('lens')}</button>
<button class="icon-btn glass-strong liquid gc-compass" type="button" data-act="home" aria-label="Reset view to the whole planet"><span class="compass-n">${icon('north')}</span></button>
<button class="icon-btn glass-strong liquid" type="button" data-act="credits" aria-label="Map data attribution">${icon('info')}</button>
</div>
</div>`);
// The altimeter: orbit → sky → ground, tap a band to descend or climb.
const alt = h(`<div class="alt-rail" role="group" aria-label="Altitude band">
${[
['orbit', 'ORBIT', 9000e3, 1],
['sky', 'SKY', 60e3, 0.5],
['ground', 'GROUND', 1500, 0.2],
]
.map(
([b, l, hgt, top]) =>
`<button type="button" data-alt="${b}" data-h="${hgt}" data-band="${b}" style="--y:${(1 - altFrac(hgt)) * 100}%" aria-label="Go to ${BAND_NAME[b]} altitude"><span class="tele">${l}</span></button>`,
)
.join('')}
<span class="alt-ticks" aria-hidden="true">${[36000e3, 400e3, 15e3, 1e3]
.map((x) => `<i style="--y:${(1 - altFrac(x)) * 100}%"></i>`)
.join('')}</span>
<span class="alt-mark" aria-hidden="true"><i></i></span>
</div>`);
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(
'<button class="gc-attr" type="button" data-act="credits" aria-label="Attribution">Esri · Maxar · Earthstar · NASA</button>',
);
@@ -88,7 +169,7 @@ export function createGlobeChrome({
`<div class="pick-card glass-strong" role="dialog" aria-label="Selected contact" hidden></div>`,
);
const bottom = h('<div class="gc-bottom"></div>');
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 `<span data-family="${KIND_FAM_L[l.kind] || 'infra'}" style="transform:${tf}"><i></i>${esc(l.title)}</span>`;
const pin = l.layer === 'pins';
return `<span ${pin ? `class="lb-pin" data-band="${KIND_FAM_L[l.kind] || 'inside'}"` : ''} data-family="${KIND_FAM_L[l.kind] || 'infra'}" style="transform:${tf}"><i></i>${esc(l.title)}</span>`;
})
.join('');
if (labelLayer.innerHTML !== html) labelLayer.innerHTML = html;
@@ -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 =
`<i></i>${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,
};
}
+48 -2
View File
@@ -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');
}
+236 -411
View File
@@ -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();
+15
View File
@@ -79,6 +79,21 @@ const P = {
waveform: `<path d="M3 12h2M6.5 8v8M10 4.5v15M13.5 9v6M17 6.5v11M20.5 10.5v3"/>`,
arrowUpRight: `<path d="M7 17 17 7M8.5 7H17v8.5"/>`,
scope: `<circle cx="12" cy="12" r="7.6"/><path d="M12 2.4v4M12 17.6v4M2.4 12h4M17.6 12h4"/><circle cx="12" cy="12" r="1.2" ${F}/>`,
question: `<circle cx="12" cy="12" r="9.2"/><path d="M9.4 9.4a2.7 2.7 0 1 1 3.9 2.4c-.8.4-1.3 1-1.3 1.9v.6"/><circle cx="12" cy="17" r="1.1" ${F}/>`,
context: `<path d="M4.2 5.4c2.6-.9 5.2-.7 7.8.8v13.2c-2.6-1.5-5.2-1.7-7.8-.8ZM19.8 5.4c-2.6-.9-5.2-.7-7.8.8v13.2c2.6-1.5 5.2-1.7 7.8-.8Z"/>`,
correction: `<path d="M4 19.6h4.2L19 8.8a2.4 2.4 0 0 0-3.4-3.4L4.8 16.2Z"/><path d="m13.8 7.2 3.4 3.4"/>`,
prediction: `<path d="M3.6 17.4 9 12l3.4 3.4 7.6-7.6"/><path d="M15.2 7.8H20v4.8" /><path d="M3.6 20.4h16.8" stroke-dasharray="1.6 2.4"/>`,
witness: `<path d="M2.6 12c2.2-4.2 5.4-6.3 9.4-6.3s7.2 2.1 9.4 6.3c-2.2 4.2-5.4 6.3-9.4 6.3S4.8 16.2 2.6 12Z"/><circle cx="12" cy="12" r="3.1"/><path d="M12 2v1.6M12 20.4V22M2 12h.6M21.4 12h.6"/>`,
live: `<circle cx="12" cy="12" r="2.4" ${F}/><path d="M16.2 7.8a6 6 0 0 1 0 8.4M7.8 7.8a6 6 0 0 0 0 8.4M19.2 4.8a10.2 10.2 0 0 1 0 14.4M4.8 4.8a10.2 10.2 0 0 0 0 14.4"/>`,
upload: `<path d="M12 15.4V4.2M8.2 8 12 4.2 15.8 8"/><path d="M4.6 13.6v4.2c0 1.2 1 2.2 2.2 2.2h10.4c1.2 0 2.2-1 2.2-2.2v-4.2"/>`,
reply: `<path d="M9.6 6.2 4 11.6l5.6 5.4"/><path d="M4.4 11.6h9.4c3.4 0 6.2 2.8 6.2 6.2v.6"/>`,
note: `<path d="M5.2 3.8h9.6l4 4v12.4H5.2Z"/><path d="M14.6 3.8v4.2h4.2M8.4 12.4h7.2M8.4 15.8h5"/>`,
bridge: `<path d="M2.8 16.6h18.4M5 16.6v3.2M19 16.6v3.2M3.4 12.6c2.6-4.6 14.6-4.6 17.2 0"/><path d="M8 9.6v7M12 8.4v8.2M16 9.6v7"/>`,
users: `<circle cx="9" cy="8.6" r="3.2"/><path d="M3.2 19c.9-3 3.1-4.6 5.8-4.6s4.9 1.6 5.8 4.6"/><circle cx="16.6" cy="9.2" r="2.6"/><path d="M15.4 14.6c2.4-.3 4.4.9 5.4 3.6"/>`,
frustum: `<circle cx="12" cy="18.2" r="2" ${F}/><path d="M12 16.2 5.2 4.6M12 16.2l6.8-11.6M5.2 4.6c4.4-1.6 9.2-1.6 13.6 0"/>`,
bolt: `<path d="M13.4 2.6 5.8 13.2h5.6l-1.2 8.2 8-11.2h-5.8Z" ${F}/>`,
sendUp: `<path d="M12 19V5.6M6.6 11 12 5.6l5.4 5.4" stroke-width="2.2"/>`,
dot: `<circle cx="12" cy="12" r="4" ${F}/>`,
hand: `<path d="M8.4 12.6V5.8a1.4 1.4 0 0 1 2.8 0v5.4M11.2 10.8V4.4a1.4 1.4 0 0 1 2.8 0v6.4M14 10.8V5.6a1.4 1.4 0 0 1 2.8 0v7.6M8.4 12.6l-1.3-1.7a1.5 1.5 0 0 0-2.3 1.9l3.4 5.3c1.2 1.8 3 2.9 5.2 2.9h.4c2.6 0 4.9-2.1 4.9-4.8V8.5a1.4 1.4 0 0 0-2.8 0"/>`,
};
+93
View File
@@ -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 = `<div class="c-band tele" data-band="inside"><i class="bd"></i>Inside<b>·</b>Live from a person</div>
<h2 class="sheet-title">${esc(l.handle || 'Someone')} is live</h2>
<div class="sheet-sub">${esc(l.situationTitle ? `Near ${l.situationTitle}` : 'Broadcasting from this device’s other tab')}</div>`;
sheet.body.innerHTML = `<div class="ev-frame" data-band="inside"><img class="front" alt="Latest broadcast frame" ${l.frame ? `src="${esc(l.frame)}"` : ''}/>${frame()}
<div class="c-top"><span class="c-live tele onair"><i></i>LIVE <span data-lv-since></span></span><span class="c-sid tele">SEALED EVERY 10 S</span></div>
<div class="c-strip tele" data-lv-tele></div></div>
<p class="footnote" style="padding:12px 2px 0">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.</p>`;
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 };
}
+114
View File
@@ -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(`<div class="minimap" data-band="inside">
<div class="mm-pane"><img class="mm-img" alt="" draggable="false"/><img class="mm-next" alt="" draggable="false"/></div>
<span class="mm-pin" aria-hidden="true">${icon('pin')}</span>
<span class="mm-cross" aria-hidden="true"></span>
<div class="mm-zoom"><button type="button" data-z="in" aria-label="Zoom in">${icon('plus')}</button><button type="button" data-z="out" aria-label="Zoom out">${icon('minus')}</button></div>
<div class="mm-strip tele"></div>
${label ? `<div class="mm-label tele">${esc(label)}</div>` : ''}
</div>`);
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 });
},
};
}
+182
View File
@@ -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;
},
};
}
+366
View File
@@ -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 `<button type="button" class="cite" data-cite="${esc(c)}">${esc(label)}</button>`;
}
function synthHtml(r) {
const sy = r.synth;
const sec = (id, title, list) =>
list.length
? `<div class="sy-sec" data-sy="${id}"><div class="sy-h tele"><i></i>${title}<span>${list.length}</span></div><ul>${list
.map(
(l) =>
`<li><span>${esc(l.text)}</span><span class="sy-cites">${l.cites
.filter(Boolean)
.map((c) => citeChip(c))
.join('')}</span></li>`,
)
.join('')}</ul></div>`
: '';
return `<section class="synth" aria-label="Known, disputed, unknown">
<div class="sy-top"><span class="tele">${icon('sparkles')}Local synthesis</span><span class="tele sy-meta">${sy.n} posts · ${sy.obs} observations · ${esc(sy.hash.slice(0, 8))}</span></div>
${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)}
<p class="sy-foot">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.</p>
</section>`;
}
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 `<span class="br" data-c="${c}"><b>${c}</b><i style="--p:${pct}%"></i><em class="num">${x.h}/${x.n}</em></span>`;
};
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 `<div class="note ${st.shown ? 'shown' : 'pending'}" data-note="${esc(n.id)}">
<div class="note-h tele">${icon('note')}${st.shown ? 'Community note' : 'Proposed note'}${p.example ? ' · example' : ''}</div>
${st.shown ? `<p>${esc(n.text)}</p>` : `<details><summary>Show the proposed note</summary><p>${esc(n.text)}</p></details>`}
<div class="bridge"><span class="tele">${icon('bridge')}Bridging</span>${bar('A')}${bar('B')}<span class="why">${esc(why)}</span></div>
<div class="note-rate">${n.mine ? `<span class="tele">You rated ${n.mine.helpful ? 'helpful' : 'not helpful'} as cluster ${esc(n.mine.cluster)}</span>` : `<button type="button" class="pill-btn plain sm" data-rate="1">Helpful</button><button type="button" class="pill-btn plain sm" data-rate="0">Not helpful</button>`}</div>
</div>`;
}
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)
? `<button type="button" class="anchor tele" data-anchor="${esc(p.id)}">${icon('pin')}${esc(llShort(p.anchor.lat, p.anchor.lon, 3))}${p.anchor.t ? ` · ${esc(utc(new Date(p.anchor.t), { seconds: false }))}` : ''}</button>`
: '';
const media =
p.evidence?.captureId || p.evidence?.uploadId
? `<div class="p-media" data-media="${esc(p.evidence.captureId || p.evidence.uploadId)}" data-store="${p.evidence.uploadId ? 'uploads' : 'media'}" data-video="${p.evidence.video ? 1 : ''}">${p.evidence.unverified ? '<span class="p-unv tele">Unverified upload</span>' : `<span class="p-seal">${icon('sealFill')}</span>`}</div>`
: '';
return `<article class="post k-${p.kind} ${p.example ? 'is-example' : ''} ${p.author?.self ? 'is-mine' : ''}" data-post="${esc(p.id)}" style="--depth:${depth}">
<div class="p-head">
<span class="p-kind" title="${esc(k.name)}">${icon(k.icon)}</span>
<span class="p-meta"><b class="tele">${esc(k.name)}</b><span class="p-who">${esc(p.author.handle)}${p.author.self ? ' (you)' : ''}</span><span class="tele p-when">${esc(ago(p.at))} · ${esc(num)}</span></span>
${p.example ? '<span class="badge-example">Example</span>' : ''}
</div>
<p class="p-text">${esc(p.text)}</p>
${media}
${badges.length ? `<div class="badges">${badges.map((b) => `<span class="bdg ${b.ex ? 'ex' : ''}" data-b="${b.id}">${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` <em>${esc(b.detail)}</em>` : ''}</span>`).join('')}</div>` : ''}
<div class="p-actions">
${anchor}
<span class="p-sp"></span>
<button type="button" class="vote ${p.myVote === 'up' ? 'on' : ''}" data-vote="up" aria-label="Agree">${icon('agree')}<span class="num">${p.votes.up}</span></button>
<button type="button" class="vote dis ${p.myVote === 'dn' ? 'on' : ''}" data-vote="dn" aria-label="Dispute">${icon('dispute')}<span class="num">${p.votes.dn}</span></button>
<button type="button" class="vote" data-reply aria-label="Reply">${icon('reply')}</button>
</div>
${(p.notes || []).map((n) => noteHtml(n, p)).join('')}
${replies.length ? `<div class="replies">${replies.map((x) => postHtml(x, r, depth + 1)).join('')}</div>` : ''}
</article>`;
}
function presenceHtml(r) {
const here = rt.presence(r.id);
const ex = r.participants.filter((a) => a.example).slice(0, 4);
return `<div class="presence">
<span class="avs live">${here.map((a, i) => avatar({ handle: a.handle, self: a.self }, i).replace('class="av', 'class="av live')).join('')}</span>
<span class="pr-t tele"><b data-here>${here.length}</b> watching${here.length > 1 ? ' · live across your tabs' : ''}</span>
<span class="avs ex">${ex.map(avatar).join('')}</span>
</div>`;
}
async function render(s, el, { focusPost } = {}) {
const r = await rooms.room(s);
cur = { s, r, el };
rt.join(r.id);
el.innerHTML = `<div class="room" data-room="${esc(r.id)}">
${presenceHtml(r)}
${synthHtml(r)}
${r.hasExamples ? '<div class="example-note"><span class="badge-example">Example</span> 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.</div>' : ''}
${r.openQ.length ? `<div class="room-h tele">${icon('question')}Open questions <span>float until answered</span></div>${r.openQ.map((p) => postHtml(p, r)).join('')}` : ''}
<div class="room-h tele">${icon('sealFill')}Evidence first <span>then recency, never followers</span></div>
${r.ordered
.filter((p) => !r.openQ.includes(p))
.map((p) => postHtml(p, r))
.join('')}
<div class="room-end tele">${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}</div>
</div>`;
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
? `<video src="${esc(u)}#t=0.1" muted playsinline preload="metadata"></video>`
: `<img src="${esc(u)}" alt="Attached capture" loading="lazy"/>`,
);
}
}
/* ---------------- reactions ---------------- */
function reactionRail() {
return `<div class="react-rail" role="group" aria-label="React">${REACTIONS.map((x) => `<button type="button" data-react="${x.id}" aria-label="${x.label}">${icon(x.icon)}</button>`).join('')}</div>`;
}
function floatReaction(host, kind) {
if (!host || reducedMotion()) return;
const x = REACTIONS.find((r) => r.id === kind) || REACTIONS[0];
const el = h(`<span class="react-float">${icon(x.icon)}</span>`);
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: () =>
`<div class="reply-bar"><button type="button" class="rb-cap" data-act="capture-reply">${icon('viewfinder')}<span><b>Reply with evidence</b><small>Seal what you see</small></span></button><button type="button" class="rb-write" data-act="write">${icon('note')}Write</button><button type="button" class="rb-react" data-act="react-toggle" aria-expanded="false" aria-label="React">${icon('hand')}</button></div>`,
close() {
clearPins();
cur = null;
rt.leave();
},
get current() {
return cur;
},
fmtKm,
};
}
+1207
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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);
+419
View File
@@ -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;
}
+657
View File
@@ -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,
}),
];
}
+51 -2
View File
@@ -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 = `
<div class="th-list">
<header class="navbar"><span class="nav-title">Threads</span></header>
<h1 class="large-title">Threads</h1>
<div class="title-sub">Reconstructions built in the open: observations, hypotheses, a ledger</div>
<div class="title-sub tele">Rooms on the evidence · 4D reconstructions</div>
<div class="th-rooms"></div>
<div class="th-cards"></div>
</div>
<div class="th-detail" hidden></div>`;
@@ -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 `<button class="rm-row" type="button" data-room="${esc(s.id)}" data-band="${band}" data-status="${esc(r.status.id)}">
<div class="rm-top"><span class="c-band tele" data-band="${band}"><i class="bd"></i>${esc(BAND_NAME[band])}<b>·</b>${esc(ago(r.lastAt))}${Number.isFinite(s.distanceKm) ? `<b>·</b>${esc(fmtKm(s.distanceKm))}` : ''}</span>${here > 1 ? `<span class="c-watch tele"><i></i>${here} here</span>` : ''}</div>
<h3>${esc(s.title)}</h3>
${r.top ? `<p class="rm-claim">${k ? icon(k.icon) : ''}<span>${esc(r.top.text)}</span></p>` : ''}
<div class="rm-foot"><span class="c-status tele" data-status="${esc(r.status.id)}">${icon(r.status.id === 'disputed' ? 'dispute' : r.status.id === 'open' ? 'question' : 'sealFill')}${esc(r.status.label)}</span>
<span class="avs">${r.participants.slice(0, 4).map(avatar).join('')}</span>
<span class="tele rm-n">${r.posts.length} posts${r.openQ.length ? ` · ${r.openQ.length} open` : ''}${r.ownCount ? ` · ${r.ownCount} yours` : ''}</span></div>
</button>`;
}
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 = `<div class="section-h">Rooms <small>hottest first · every situation has one</small></div>
<div class="rm-list">${list.map(roomRow).join('')}</div>
<p class="footnote" style="padding:6px 2px 0">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.</p>
<div class="section-h">Reconstructions <small>observations · hypotheses · ledger</small></div>`;
}
$('.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, []);
+9 -1
View File
@@ -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);
+94
View File
@@ -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);
}
+243 -37
View File
@@ -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('<div class="sheet-dock"></div>');
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 `<div class="block"><div class="block-h">Trend<small>${esc(m?.label || '')} · last ${s.series.length} samples</small></div>
return `<div class="block"><div class="block-h">Trend<small>${esc(s.seriesLabel || m?.label || '')} · last ${series.length} samples</small></div>
<div class="spark-row"><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
<defs><linearGradient id="spark-grad" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0"/></linearGradient></defs>
<path class="spark-area" d="${d}L${W - 3} ${H}L3 ${H}Z"/><path class="spark-line" d="${d}"/><circle class="spark-end" cx="${last[0]}" cy="${last[1]}" r="3"/></svg>
@@ -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
? `<span class="live-tag"><span class="live-dot"></span>Live</span>`
: recon
? '<span>Reconstruction</span>'
? '<span>4D reconstruction</span>'
: '<span>Stale</span>';
sheet.head.innerHTML = `
<div class="brief-top"><span class="fam-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(FAM_NAME[s.family] || s.family)}</span>${status}<span>·</span><span class="num">${esc(ago(s.updatedAt))}</span></div>
<div class="brief-title-row"><h2 class="sheet-title">${esc(s.title)}</h2>${m0 ? `<span class="peek-metric num">${esc(typeof m0.value === 'number' ? fmtNum(m0.value, m0.value % 1 ? 1 : 0) : m0.value)}<small>${esc(m0.unit || '')}</small></span>` : ''}</div>
<div class="sheet-sub">${esc(s.place || '')}${s.subtitle ? ` — ${esc(s.subtitle)}` : ''}</div>`;
const dist = Number.isFinite(s.distanceKm)
? `<span>·</span><span>${esc(fmtKm(s.distanceKm))} away</span>`
: '';
return `
<div class="brief-top tele" data-band="${band}"><i class="bd"></i><span>${esc(BAND_NAME[band])}</span>${status}<span>·</span><span>${esc(ago(s.updatedAt))}</span>${dist}</div>
<div class="brief-title-row"><h2 class="sheet-title">${esc(s.title)}</h2></div>
<div class="sheet-sub">${esc(s.place || '')}${s.subtitle ? ` — ${esc(s.subtitle)}` : ''}</div>
<div class="brief-tabs" role="tablist" aria-label="Situation">
<button role="tab" type="button" data-tab="brief" aria-selected="${tab === 'brief'}">Brief</button>
<button role="tab" type="button" data-tab="room" aria-selected="${tab === 'room'}">Room<span class="bt-n tele" data-room-n></span></button>
<span class="bt-here tele" data-room-here></span>
</div>`;
}
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 `<span class="chip layer-chip" data-family="${esc(fam)}">${icon(FAMILY_ICON[fam] || 'layers')}${esc(l?.name || id)}</span>`;
})
.join('');
sheet.body.innerHTML = `
return `
<div class="brief-strip tele" data-band="${band}">${frame()}<span>${esc(sensorId(s))}</span><span>${esc(telemetryOf(s))}</span></div>
${metrics.length ? `<div class="metrics">${metrics.map(metricHtml).join('')}</div>` : ''}
<div class="actions" role="group" aria-label="Actions">
<button class="action" type="button" data-act="follow" aria-pressed="${followed}">${icon('star')}<span>${followed ? 'Following' : 'Follow'}</span></button>
<button class="action" type="button" data-act="ride">${icon('ride')}<span>Ride</span></button>
<button class="action" type="button" data-act="eye">${icon('video')}<span>Nearest eye</span></button>
<button class="action" type="button" data-act="thread">${icon('threads')}<span>Thread</span></button>
<button class="action" type="button" data-act="reply">${icon('viewfinder')}<span>Witness</span></button>
${recon ? `<button class="action" type="button" data-act="thread">${icon('threads')}<span>4D thread</span></button>` : ''}
<button class="action" type="button" data-act="share">${icon('share')}<span>Share</span></button>
</div>
${sparkHtml(s)}
<div class="block"><div class="block-h">Evidence<small>${recon ? 'sourced observations · tap for original' : 'context imagery'}</small></div>
<div class="block"><div class="block-h">Evidence<small>${recon ? 'sourced observations · tap for original' : 'context imagery · Esri'}</small></div>
<div class="evidence" data-hscroll>${recon ? '<div class="ev"><div class="ev-img"></div></div>'.repeat(4) : contextEvidence(s)}</div></div>
<div class="block"><div class="block-h">Every number, its source and age</div>
<table class="metric-table"><tbody>${(s.metrics || [])
@@ -235,12 +291,44 @@ export function createWorld({
<div class="block"><div class="block-h">Lenses this situation turns on</div><div class="layer-chips">${layerChips || '<span class="sheet-sub">None</span>'}</div></div>
<div class="block"><div class="block-h">Details</div><dl class="facts">
<dt>Where</dt><dd class="mono">${esc(fmtCoord(s.lat, s.lon, 4))}</dd>
<dt>MGRS</dt><dd class="mono">${esc(mgrs(s.lat, s.lon, 4) || '—')}</dd>
<dt>Window</dt><dd>${esc(time)}</dd>
<dt>Kind</dt><dd>${esc(s.kind || '—')}</dd>
<dt>Source</dt><dd>${esc(s.source || '—')}</dd></dl></div>
${s.caveat ? `<div class="caveat"><b>Caveat</b>${esc(s.caveat)}</div>` : ''}`;
}
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 =
'<div class="room-loading tele">Opening the room…</div>';
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',
`<div class="caveat"><b>${esc(pack.reconstruction.label)}</b>${esc(pack.reconstruction.caveat)}</div>`,
@@ -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('<div class="hero-scrim" aria-hidden="true"></div>');
document.body.insertBefore(scrim, el);
// Sensor framing travels with the picture and locks onto the stage.
const lock = h(
`<div class="hero-lock" data-band="${bandOf(s)}" aria-hidden="true">${frame()}<span class="hl-tele tele">ACQUIRING · ${esc(llShort(s.lat, s.lon, 2))} · ${esc(telemetryOf(s))}</span></div>`,
);
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;
},
};
}
+89 -8
View File
@@ -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 = `
<header class="navbar"><span class="nav-title">You</span></header>
<h1 class="large-title">You</h1>
<div class="you-id"><span class="avatar">${icon('you')}</span><div><b>This device</b>
<div class="you-id"><span class="avatar">${icon('you')}</span><div><b>${esc(rt.me.handle)} · this device</b>
<span>${key ? `Signing key <span class="mono">${esc(key.keyId)}</span> · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'}</span>
<span>${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${followed.length} followed</span></div></div>
<span>${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</span></div></div>
<div class="section-h">Captures <small>tap to verify</small></div>
${
@@ -57,12 +85,39 @@ export function createYou(
.slice(0, 30)
.map(
(c, i) =>
`<button class="capture-tile" type="button" data-cap="${esc(c.id)}" aria-label="Capture ${c.seq}, ${esc(ago(c.sealedAt))}">${thumbs[i] ? (/video/.test(c.media.type) ? `<video src="${thumbs[i]}" muted playsinline preload="metadata"></video>` : `<img src="${thumbs[i]}" alt="" loading="lazy" decoding="async"/>`) : ''}<span class="ct-n num">#${c.seq}</span><span class="ct-seal">${icon('sealFill')}</span></button>`,
`<button class="capture-tile" type="button" data-cap="${esc(c.id)}" aria-label="Capture ${c.seq}, ${esc(ago(c.sealedAt))}">${thumbs[i] ? (/video/.test(c.media.type) ? `<video src="${thumbs[i]}#t=0.1" muted playsinline preload="metadata"></video>` : `<img src="${thumbs[i]}" alt="" loading="lazy" decoding="async"/>`) : ''}<span class="ct-n num">#${c.seq}</span>${c.session?.mode === 'live' ? `<span class="ct-live tele">LIVE · ${c.session.segments?.length || 0} SEG</span>` : ''}<span class="ct-seal">${icon('sealFill')}</span></button>`,
)
.join('')}</div>`
: `<div class="empty">${icon('seal')}<div>No sealed captures yet.</div><div>Open Capture → Witness and tap the shutter.</div></div>`
}
${
uploads.length
? `<div class="section-h">Uploads <small>unverified · no capture-time seal</small></div><div class="captures">${uploads
.slice(0, 12)
.map(
(u, i) =>
`<div class="capture-tile is-unverified" aria-label="Upload ${esc(u.name)}">${upThumbs[i] ? (/video/.test(u.type) ? `<video src="${upThumbs[i]}#t=0.1" muted playsinline preload="metadata"></video>` : `<img src="${upThumbs[i]}" alt="" loading="lazy" decoding="async"/>`) : ''}<span class="ct-live tele">UNVERIFIED</span></div>`,
)
.join('')}</div>`
: ''
}
<div class="section-h">Your posts <small>${posts.length ? 'tap to open the room' : ''}</small></div>
${
posts.length
? `<div class="group">${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 `<button class="row" type="button" data-post-room="${esc(p.room)}" data-post="${esc(p.id)}"><span class="row-icon" style="--fam:var(--b-inside)">${icon(k.icon)}</span><span class="row-main"><span class="row-title">${esc(p.text)}</span><span class="row-sub">${esc(k.name)} · ${esc(st?.title || p.room)} · ${esc(ago(p.at))}${b.length ? ` · ${b.map((x) => esc(x.label)).join(' · ')}` : ''}</span></span>${icon('chevronRight', { cls: 'chev' })}</button>`;
})
.join('')}</div>`
: '<div class="empty">Nothing posted yet. Every situation has a room: open one and reply with evidence.</div>'
}
<div class="section-h">Following</div>
${
fol.length || contacts.length
@@ -77,8 +132,15 @@ export function createYou(
: '<div class="empty">Follow a situation from its brief and it appears here and in the tab-bar accessory.</div>'
}
<div class="section-h">Holograms <small><span class="badge-example">Concept</span></small></div>
<div class="holo">${icon('cube')}<div><b>Leave something in a place</b>Pin a 3D note to a spot on Earth for people who visit with Look. Concept placeholder in this prototype.</div></div>
<div class="section-h">Holograms <small>${holos.length ? `${holos.length} left in places` : ''}</small></div>
${
holos.length
? `<div class="group">${holos.map((p) => `<button class="row" type="button" data-post-room="${esc(p.room)}" data-post="${esc(p.id)}"><span class="row-icon" style="--fam:var(--b-inside)">${icon('cube')}</span><span class="row-main"><span class="row-title">${esc(p.text)}</span><span class="row-sub mono">${esc(llShort(p.anchor?.lat, p.anchor?.lon, 4))} · ${esc(ago(p.at))}</span></span>${icon('chevronRight', { cls: 'chev' })}</button>`).join('')}</div>`
: `<div class="holo">${icon('cube')}<div><b>Leave something in a place</b>When 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.</div></div>`
}
<div class="section-h">Home area</div>
<div class="group"><button class="row" type="button" data-act="home"><span class="row-icon" style="--fam:var(--b-ground)">${icon('home')}</span><span class="row-main"><span class="row-title">${home ? `Set near ${esc(llShort(home.lat, home.lon, 1))}` : 'Set your home area'}</span><span class="row-sub">Earns the Local badge on posts within 50 km. Stored on this device at 10 km precision.</span></span>${icon('chevronRight', { cls: 'chev' })}</button></div>
<div class="section-h">Settings</div>
<div class="group">
@@ -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;