${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'; 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'; 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, rooms, rt, getSituations, onOpenRoom }, ) { view.innerHTML = `
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.
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