${esc(hy.text)}
- prompt
- ${esc(hy.prompt)}
- model
- ${esc(hy.model)}
- inputs
- ${hy.inputs.map((i) => `${esc(i.id)}:${esc(i.recordHash.slice(0, 10))}`).join(' ')}
/** * Threads: reconstructions people build together, always in three strata — * Observations (immutable, sourced, hashed) → Hypotheses (person / AI, * confidence, citations, agree / dispute) → Narrative (a camera path through * space and time). Every change lands in a hash-chained ledger. */ import { icon } from './icons.js'; import { $, $$, esc, ago, haptic, toast, fmtCoord, reducedMotion, } from './dom.js'; import { canonical, sha256Hex } from '../capture/provenance.js'; import { loadEventPack } from './world.js'; const LOCAL_KEY = 'gev.mobile.threads.local.v1'; const DRAFTS_KEY = 'gev.mobile.threads.drafts.v1'; function readLocal(key, fallback) { try { return JSON.parse(localStorage.getItem(key) || 'null') ?? fallback; } catch { return fallback; } } function writeLocal(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ } } /* ------------------------------------------------------------------ */ /* Thread models */ /* ------------------------------------------------------------------ */ /** Example hypotheses: fictional authors, written to show the format. */ const EXAMPLE_HYPOTHESES = [ { id: 'H1', author: { kind: 'person', handle: '@rivergauge_example' }, at: '2026-08-28T09:12:00Z', text: 'The outburst began at the debris-dammed lake (O2) after the second landslide (O3) loaded the dam.', confidence: 0.7, cites: ['O2', 'O3'], agree: 12, dispute: 3, }, { id: 'H2', author: { kind: 'ai', handle: 'AI · example model' }, at: '2026-08-28T11:40:00Z', text: 'Treat the Timure cluster (O5) as one timing observation: it groups five map placements, not five independent timings.', confidence: 0.55, cites: ['O5'], agree: 8, dispute: 0, prompt: 'Which observations are not independent for timing?', }, { id: 'H3', author: { kind: 'person', handle: '@northbank_example' }, at: '2026-08-29T06:05:00Z', text: 'The Rasuwagadhi witness clip (W1) was filmed from the north bank, upstream of the border gate (O4).', confidence: 0.4, cites: ['W1', 'O4'], agree: 4, dispute: 5, }, ]; async function bhoteThread() { const pack = await loadEventPack(); const obs = []; obs.push({ id: 'B0', title: 'Historical reference image', phase: 'imagery', lat: (pack.imagery.rectangle.north + pack.imagery.rectangle.south) / 2, lon: (pack.imagery.rectangle.east + pack.imagery.rectangle.west) / 2, source: pack.imagery.before.source, via: 'Vantor Open Data Program', time: pack.imagery.before.observedAt, timeConf: 'acquisition date', locConf: 'orthorectified scene', url: '/events/bhote-koshi-2026/pre.webp', record: pack.imagery.before, }); obs.push({ id: 'A0', title: 'Post-event image', phase: 'imagery', lat: obs[0].lat, lon: obs[0].lon, source: pack.imagery.after.source, via: 'Vantor Open Data Program', time: pack.imagery.after.observedAt, timeConf: 'acquisition date', locConf: 'orthorectified scene', url: '/events/bhote-koshi-2026/post.webp', record: pack.imagery.after, }); for (const w of pack.witnessAnchors) { obs.push({ id: 'W1', title: 'Rasuwagadhi witness video', phase: 'witness', lat: w.lat, lon: w.lon, source: 'IndoWatchOSINT post (GeoConfirmed)', via: w.provenance, time: null, timeConf: 'capture time unverified', locConf: 'source geoconfirmed', url: w.sourceUrl, record: w, }); } for (const e of pack.evidenceSpine) { obs.push({ id: `O${e.sequence}`, seq: e.sequence, title: e.title, summary: e.summary, phase: e.phase, lat: e.lat, lon: e.lon, source: `${e.media?.platform || 'Source'} post`, via: e.provenance?.via, time: e.timing?.capturedAt, timeConf: e.timing?.note || e.confidence?.time, locConf: e.confidence?.location, url: e.media?.sourceUrl, poster: e.media?.posterUrl, progress: e.trigger?.storyProgress, rights: e.media?.rightsStatus, record: e, }); } obs.push({ id: 'C1', title: 'River centreline corridor', phase: 'geometry', lat: pack.reconstruction.corridor[0].lat, lon: pack.reconstruction.corridor[0].lon, source: 'GeoPera reconstruction (commit 59ee5bd)', via: `${pack.reconstruction.corridor.length} points, every fourth published point inside the image bounds`, time: null, timeConf: 'not a modelled arrival time', locConf: 'published centreline', record: { corridor: pack.reconstruction.corridor }, }); for (const o of obs) o.hash = await sha256Hex(canonical(o.record)); const steps = pack.evidenceSpine.map((e) => ({ id: `O${e.sequence}`, title: e.shortTitle || e.title, phase: e.phase, lat: e.lat, lon: e.lon, elev: e.fallbackElevationM, progress: e.trigger?.storyProgress ?? 0, })); return { id: pack.id, real: true, title: pack.title, place: 'Rasuwa, Nepal ↔ Gyirong, Tibet', observedDate: pack.observedDate, created: `${pack.observedDate}T00:00:00Z`, caveat: pack.reconstruction.caveat, label: pack.reconstruction.label, elapsedSeconds: pack.reconstruction.elapsedSeconds, license: 'CC BY-NC 4.0 · GeoPera · Vantor Open Data · GeoGeorgeShadrach map', before: '/events/bhote-koshi-2026/pre.webp', after: '/events/bhote-koshi-2026/post.webp', viewpoint: pack.viewpoints.corridor, corridor: pack.reconstruction.corridor, observations: obs, hypotheses: EXAMPLE_HYPOTHESES.map((x) => ({ ...x, example: true })), steps, }; } async function draftThread(d) { const o = { id: 'O1', title: d.title, phase: 'situation snapshot', lat: d.lat, lon: d.lon, source: d.source || 'Live feed', via: 'Snapshot when the thread was started', time: d.createdAt, timeConf: 'snapshot time', locConf: 'feed position', record: d.snapshot || d, }; o.hash = await sha256Hex(canonical(o.record)); return { id: d.id, real: false, draft: true, title: d.title, place: d.place, created: d.createdAt, observations: [o], hypotheses: [], steps: [{ id: 'O1', title: d.title, lat: d.lat, lon: d.lon, progress: 0 }], }; } /* ------------------------------------------------------------------ */ /* Ledger */ /* ------------------------------------------------------------------ */ async function buildLedger(thread, local) { const entries = []; const push = async (type, actor, at, payload) => { const prev = entries.length ? entries[entries.length - 1].hash : null; const payloadHash = await sha256Hex(canonical(payload)); const seq = entries.length; const hash = await sha256Hex( canonical({ seq, at, type, actor, payloadHash, prev }), ); entries.push({ seq, at, type, actor, payload, payloadHash, prev, hash }); }; await push( 'thread.create', thread.real ? 'event pack' : 'you (this device)', thread.created, { id: thread.id, title: thread.title, license: thread.license || null }, ); for (const o of thread.observations) await push('observation.add', o.source, o.time || thread.created, { id: o.id, title: o.title, recordHash: o.hash, }); for (const hy of [...thread.hypotheses, ...(local.proposals || [])]) { await push( hy.author.kind === 'ai' ? hy.simulated ? 'ai.proposal' : 'hypothesis.propose' : 'hypothesis.propose', hy.author.handle, hy.at, { id: hy.id, text: hy.text, cites: hy.cites, confidence: hy.confidence, example: hy.example || undefined, ...(hy.simulated ? { prompt: hy.prompt, model: hy.model, inputs: hy.inputs, simulated: true, } : {}), }, ); } for (const v of local.votes || []) await push(`vote.${v.kind}`, 'you (this device)', v.at, { hypothesis: v.id, }); return entries; } async function verifyLedger(entries) { let prev = null; for (const e of entries) { const ph = await sha256Hex(canonical(e.payload)); const hh = await sha256Hex( canonical({ seq: e.seq, at: e.at, type: e.type, actor: e.actor, payloadHash: ph, prev, }), ); if (ph !== e.payloadHash || hh !== e.hash || e.prev !== prev) return { ok: false, at: e.seq }; prev = e.hash; } return { ok: true, n: entries.length, head: prev }; } /* ------------------------------------------------------------------ */ /* Simulated AI refinement (no network) */ /* ------------------------------------------------------------------ */ function simulateRefinement(thread, prompt) { const words = prompt.toLowerCase().match(/[a-z]{4,}/g) || []; const scored = thread.observations .map((o) => { const hay = `${o.title} ${o.summary || ''} ${o.phase}`.toLowerCase(); return { o, s: words.reduce((a, w) => a + (hay.includes(w) ? 1 : 0), 0) }; }) .filter((x) => x.s > 0) .sort((a, b) => b.s - a.s) .slice(0, 3) .map((x) => x.o); const cites = scored.length ? scored : thread.observations.slice(0, 2); const anchors = cites .map((o) => `${o.id} ${o.title} (${o.lat.toFixed(4)}, ${o.lon.toFixed(4)})`) .join('; '); return { text: `Proposed refinement for “${prompt.trim()}”: candidate anchors ${anchors}. Needs a person to check the anchors against the imagery before it can be cited.`, cites: cites.map((o) => o.id), inputs: cites.map((o) => ({ id: o.id, recordHash: o.hash })), }; } /* ------------------------------------------------------------------ */ /* View */ /* ------------------------------------------------------------------ */ export function createThreads(view, { bindScroll, onPlay, onShowOnGlobe }) { view.innerHTML = `
The bundled event pack could not be loaded.
' } ${drafts .map( ( d, ) => ``, ) .join('')}Example threads are placeholders showing the genres the format supports; they contain no real records.
`; } cards.addEventListener('click', async (e) => { const t = e.target.closest('[data-thread]'); if (t && bhote) return openThread(bhote); const d = e.target.closest('[data-draft]'); if (d) { const rec = readLocal(DRAFTS_KEY, []).find( (x) => x.id === d.dataset.draft, ); if (rec) openThread(await draftThread(rec)); return; } if (e.target.closest('[data-example]')) toast('Example thread — concept only, no records', { icon: icon('threads'), }); }); /* ---------- detail ---------- */ function hypHtml(hy, votes) { const v = votes.filter((x) => x.id === hy.id); const agree = (hy.agree || 0) + v.filter((x) => x.kind === 'agree').length; const dispute = (hy.dispute || 0) + v.filter((x) => x.kind === 'dispute').length; const mine = v.at(-1)?.kind; const total = agree + dispute || 1; const ai = hy.author.kind === 'ai'; return `${esc(hy.text)}
${esc(o.summary)}
` : ''}Immutable. Each has a source, a time and a hash. Nobody edits an observation.
Claims that connect observations. Cite, dispute or supersede — never edit.
No hypotheses yet.
'}A camera path through space and time that cites the strata below it.
Every change, hash-chained with SHA-256 on this device. Anyone can export and verify it.
${esc(JSON.stringify({ ...e, payload: e.payload }, null, 2))}Head ${esc(L.at(-1)?.hash.slice(0, 24) || '')}… · ${L.length} entries