/** * Attach sheet (DESIGN.md §11.4): after any capture, suggest where it * belongs from distance and time ("Typhoon · 4.2 km from track", "Times * Square camera · 180 m"), or a new place; pick a post kind, add a note, * choose how precisely to share the location, and optionally leave it as a * hologram. Posting creates a room post, a Witness card and a globe pin. */ import { icon } from './icons.js'; import { $, $$, esc, haptic, toast } from './dom.js'; import { createSheet } from './sheet.js'; import { KINDS, KIND, wasThere } from './rooms.js'; import { BAND_NAME, bandOf, distKm, fmtKm, llShort, presenceRadiusKm, utc, } from './bands.js'; const PRECISION = [ { id: 'exact', label: 'Exact', round: null }, { id: '100m', label: '100 m', round: 3 }, { id: '1km', label: '1 km', round: 2 }, ]; function hash(s) { let x = 2166136261; for (let i = 0; i < s.length; i += 1) x = Math.imul(x ^ s.charCodeAt(i), 16777619); return (x >>> 0).toString(36); } export function createAttach({ rooms, globe, getSituations, onPosted }) { const sheet = createSheet({ label: 'Post this capture', detents: ['large'], modal: true, dismissible: true, closeButton: true, className: 'attach-sheet opaque', heights: { large: (H) => Math.round(Math.min(H * 0.92, 880)) }, }); let ctx = null; async function suggestions(item, preset) { const sits = (await getSituations()).filter((s) => Number.isFinite(s.lat)); const fix = item.fix; const scored = sits.map((s) => { const km = fix ? distKm(fix, s) : NaN; const there = item.sealed && fix ? wasThere(s, fix, item.at).there : false; return { s, km, there }; }); let list; if (fix) list = scored .filter( (x) => x.km < Math.max(presenceRadiusKm(x.s) * 4, 60) || x.s.id === preset?.id, ) .sort((a, b) => a.km - b.km) .slice(0, 4); else list = [...scored] .sort((a, b) => (b.s.severity || 0) - (a.s.severity || 0)) .slice(0, 4); if (preset && !list.some((x) => x.s.id === preset.id)) { const p = scored.find((x) => x.s.id === preset.id) || { s: preset, km: fix ? distKm(fix, preset) : NaN, there: false, }; list.unshift(p); } else if (preset) list.sort((a, b) => a.s.id === preset.id ? -1 : b.s.id === preset.id ? 1 : 0, ); // The nearest public camera is a place too. if (fix) { const eye = await globe.nearestEye(fix.lon, fix.lat, 3).catch(() => null); if (eye && !list.some((x) => x.s.media?.cameraId === eye.id)) { const s = { id: `eye-${eye.id}`, kind: 'camera', family: 'eyes', band: 'ground', archetype: 'eye', title: `${eye.title} camera`, place: [eye.city, eye.provider].filter(Boolean).join(' · '), lat: eye.lat, lon: eye.lon, live: true, updatedAt: Date.now(), source: eye.provider ? `${eye.provider} public camera` : 'Public camera', metrics: [], media: { kind: 'image', url: eye.imageUrl, cameraId: eye.id, headingDeg: eye.headingDeg, refreshMs: 15000, city: eye.city, provider: eye.provider, }, camera: { lon: eye.lon, lat: eye.lat, height: 900, heading: eye.headingDeg || 0, pitch: -30, }, layers: [], }; list.push({ s, km: distKm(fix, s), there: item.sealed && distKm(fix, s) <= 1.5, eye: true, }); } } return list; } function row(x, i) { const s = x.s; const band = bandOf(s); const near = Number.isFinite(x.km) ? `${fmtKm(x.km)} from ${/cyclone|typhoon|hurricane/.test(s.kind) ? 'track' : x.eye ? 'camera' : 'it'}` : 'no location on this capture'; return ``; } function render() { const it = ctx.item; const head = it.unverified ? 'UPLOAD · UNVERIFIED' : it.kind === 'live' ? `WITNESS · SEALED LIVE · ${it.segments || 0} SEGMENTS` : `WITNESS · SEALED${it.seq ? ` #${it.seq}` : ''}`; sheet.head.innerHTML = `
${it.unverified ? 'Uploads carry no capture-time seal: they are marked Unverified everywhere and can be cited, never treated as an observation.' : 'The seal still proves the exact position to verifiers without publishing it.'}
`; } async function open(item, { preset = null, replyTo = null } = {}) { ctx = { item, list: [], sel: 0, kind: replyTo ? replyTo.kind === 'question' ? 'witness' : 'correction' : 'witness', precision: item.unverified ? '100m' : 'exact', hologram: false, replyTo, preset, }; ctx.list = await suggestions(item, preset); if (!ctx.list.length) ctx.sel = 'new'; render(); sheet.open('large'); } sheet.root.addEventListener('click', async (e) => { if (!ctx) return; const r = e.target.closest('.att-row'); if (r) { ctx.sel = r.dataset.i === 'new' ? 'new' : Number(r.dataset.i); $$('.att-row', sheet.body).forEach((b) => b.setAttribute('aria-checked', String(b === r)), ); const name = $('.att-name', sheet.body); name.hidden = ctx.sel !== 'new'; if (!name.hidden) name.focus(); haptic('tick'); return; } const k = e.target.closest('[data-kind]'); if (k) { ctx.kind = k.dataset.kind; $$('[data-kind]', sheet.body).forEach((b) => b.setAttribute('aria-checked', String(b === k)), ); haptic('tick'); return; } const pr = e.target.closest('[data-prec] button'); if (pr) { ctx.precision = pr.dataset.v; $$('[data-prec] button', sheet.body).forEach((b) => b.setAttribute('aria-pressed', String(b === pr)), ); haptic('tick'); return; } const holo = e.target.closest('[data-holo]'); if (holo) { e.preventDefault(); ctx.hologram = !ctx.hologram; holo.setAttribute('aria-checked', String(ctx.hologram)); haptic('tick'); return; } if (e.target.closest('[data-act="post"]')) await post(); }); async function post() { const it = ctx.item; let s; if (ctx.sel === 'new') { const name = $('.att-name', sheet.body).value.trim(); const ll = it.fix || { lat: 0, lon: 0 }; s = await rooms.addPlace({ id: `place-${hash(`${ll.lat.toFixed(3)},${ll.lon.toFixed(3)},${name}`)}`, kind: 'place', family: 'eyes', band: 'inside', archetype: 'sensor', title: name || `Place near ${llShort(ll.lat, ll.lon, 2)}`, place: llShort(ll.lat, ll.lon, 3), subtitle: 'Started by a witness on this device', lat: ll.lat, lon: ll.lon, live: true, updatedAt: Date.now(), source: 'Your capture', metrics: [], layers: ['eyes.signed-captures'], camera: { lon: ll.lon, lat: ll.lat, height: 3000, heading: 0, pitch: -45, }, local: true, }); } else { s = ctx.list[ctx.sel].s; if (ctx.list[ctx.sel].eye) await rooms.addPlace(s); } const p = PRECISION.find((x) => x.id === ctx.precision); const round = (v) => (p.round == null ? v : Number(v.toFixed(p.round))); const fix = it.fix; const there = it.sealed && fix ? wasThere(s, fix, it.at) : { there: false, km: fix ? distKm(fix, s) : NaN }; const text = $('.att-note', sheet.body).value.trim() || defaultText(it, s); const postRec = await rooms.post(s, { kind: ctx.kind, text, replyTo: ctx.replyTo?.id || null, hologram: ctx.hologram, anchor: fix ? { lat: round(fix.lat), lon: round(fix.lon), t: it.at, precision: ctx.precision, } : { t: it.at }, evidence: { captureId: it.unverified ? undefined : it.id, uploadId: it.unverified ? it.id : undefined, sealed: !!it.sealed, live: it.kind === 'live', unverified: !!it.unverified, wasThere: !!there.there, distanceKm: Number.isFinite(there.km) ? Math.round(there.km * 100) / 100 : null, precision: ctx.precision, headingDeg: it.headingDeg ?? null, video: !!it.isVideo, }, }); if (fix) globe.addPin({ id: `post:${postRec.id}`, lon: round(fix.lon), lat: round(fix.lat), kind: ctx.hologram ? 'hologram' : 'witness', label: ctx.hologram ? 'Hologram' : `${KIND[ctx.kind].name} · you`, headingDeg: it.headingDeg ?? undefined, }); haptic('success'); sheet.close(); toast(`Posted to ${s.title}${there.there ? ' · Was there' : ''}`, { icon: icon(it.unverified ? 'upload' : 'sealFill'), }); onPosted?.(s, postRec); } function defaultText(it, s) { if (it.unverified) return `Uploaded a ${it.isVideo ? 'video' : 'photo'} for ${s.title}.`; if (it.kind === 'live') return `Broadcast live from here: ${it.segments || 0} sealed segments.`; return `Sealed ${it.isVideo ? 'clip' : 'photo'} from here.`; } return { open, close: () => sheet.close() }; }