Files
gods-eye-view/mobile/ui/cards.js
T
Claude 8b7fcb09df 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
2026-10-19 02:49:03 +00:00

245 lines
11 KiB
JavaScript

/**
* 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 };