/** * 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 = `

Threads

Reconstructions built in the open: observations, hypotheses, a ledger
`; const list = $('.th-list', view); const detail = $('.th-detail', view); const cards = $('.th-cards', view); let bhote = null; let open = null; // { thread, ledger } view.addEventListener( 'scroll', () => view.classList.toggle('is-scrolled', view.scrollTop > 44), { passive: true }, ); bindScroll(view); async function renderList() { bhote = bhote || (await bhoteThread().catch((err) => (console.error(err), null))); const drafts = readLocal(DRAFTS_KEY, []); const local = readLocal(LOCAL_KEY, {})[bhote?.id] || {}; cards.innerHTML = ` ${ bhote ? `` : '

The bundled event pack could not be loaded.

' } ${drafts .map( ( d, ) => ``, ) .join('')}
More reconstructions Example
${[ { t: 'Stadium encore, rebuilt from crowd captures', p: '42 signed captures · 3 hypotheses', f: 'eyes', i: 'eyes', }, { t: '7700 squawk replay over the Channel', p: 'ADS-B track · 2 witness clips', f: 'sky', i: 'sky', }, { t: 'Ferry crossing in fog, harbour pilots', p: 'AIS tracks · radar · 1 hypothesis', f: 'sea', i: 'sea', }, ] .map( (x) => ``, ) .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.id)}${ai ? icon('sparkles') : ''}${esc(hy.author.handle)} ${hy.example ? 'Example' : ''}${hy.simulated ? 'Proposed · local simulation' : ''}${esc(ago(hy.at))}

${esc(hy.text)}

${hy.cites.map((c) => ``).join('')}
${hy.simulated ? `
prompt
${esc(hy.prompt)}
model
${esc(hy.model)}
inputs
${hy.inputs.map((i) => `${esc(i.id)}:${esc(i.recordHash.slice(0, 10))}`).join(' ')}
` : ''}
Confidence${hy.confidence == null ? 'unscored' : `${Math.round(hy.confidence * 100)}%`}
`; } function obsHtml(o) { return `
  • `; } async function renderDetail() { const { thread } = open; const local = readLocal(LOCAL_KEY, {})[thread.id] || {}; const votes = local.votes || []; const proposals = local.proposals || []; open.ledger = await buildLedger(thread, local); const L = open.ledger; detail.innerHTML = `
    ${thread.before ? `
    2021 reference imagery
    2021 · reference
    2026 post-event imagery
    27 Aug 2026 · post-event
    ` : ''}

    ${esc(thread.title)}

    ${esc(thread.place || '')}${thread.real ? ' · real event pack' : ' · your draft'}
    ${thread.label ? `
    ${esc(thread.label)}${esc(thread.caveat)}
    ` : ''}
    1

    Observations

    Immutable. Each has a source, a time and a hash. Nobody edits an observation.

      ${thread.observations.map(obsHtml).join('')}
    2

    Hypotheses

    Claims that connect observations. Cite, dispute or supersede — never edit.

    ${thread.hypotheses.some((x) => x.example) ? '
    Example content These hypotheses, authors and vote counts are fictional, written to show the format. The observations above are real.
    ' : ''}
    ${[...thread.hypotheses, ...proposals].map((x) => hypHtml(x, votes)).join('') || '

    No hypotheses yet.

    '}
    Creates a proposed hypothesis with its prompt, model and inputs in the ledger. Local simulation — no network model is called.
    3

    Narrative

    A camera path through space and time that cites the strata below it.

    ${thread.steps.length} stops${thread.elapsedSeconds ? ` · ${Math.round(thread.elapsedSeconds / 60)} min schematic` : ''}Flies the globe down the valley, scrubbing the time ribbon.
      ${thread.steps.map((s, i) => `
    1. ${String(i + 1).padStart(2, '0')}${esc(s.title)}${esc(s.phase || '')}
    2. `).join('')}
    #

    Ledger

    Every change, hash-chained with SHA-256 on this device. Anyone can export and verify it.

      ${L.map( ( e, ) => `
    1. `, ).join('')}

    Head ${esc(L.at(-1)?.hash.slice(0, 24) || '')}… · ${L.length} entries

    `; placeSeg(); } function placeSeg(id) { const seg = $('.strata-nav .segmented', detail); if (!seg) return; const btn = id ? $(`[data-jump="${id}"]`, seg) : $('[aria-pressed="true"]', seg); $$('[data-jump]', seg).forEach((b) => b.setAttribute('aria-pressed', String(b === btn)), ); const th = $('.seg-thumb', seg); th.style.width = `${btn.offsetWidth}px`; th.style.transform = `translateX(${btn.offsetLeft}px)`; } async function openThread(thread) { open = { thread }; await renderDetail(); detail.hidden = false; view.classList.add('in-detail'); view.scrollTop = 0; if (!reducedMotion()) { detail.animate( [{ transform: 'translateX(100%)' }, { transform: 'none' }], { duration: 480, easing: 'cubic-bezier(.32,1.1,.4,1)' }, ); list.animate( [ { transform: 'none', opacity: 1 }, { transform: 'translateX(-28%)', opacity: 0.4 }, ], { duration: 480, easing: 'cubic-bezier(.32,1.1,.4,1)' }, ); } list.hidden = true; requestAnimationFrame(() => placeSeg()); } function closeThread() { list.hidden = false; view.classList.remove('in-detail'); if (!reducedMotion()) { list.animate( [ { transform: 'translateX(-28%)', opacity: 0.4 }, { transform: 'none', opacity: 1 }, ], { duration: 420, easing: 'cubic-bezier(.32,1.1,.4,1)' }, ); detail .animate([{ transform: 'none' }, { transform: 'translateX(100%)' }], { duration: 380, easing: 'cubic-bezier(.4,0,.6,1)', }) .finished.then(() => (detail.hidden = true)); } else detail.hidden = true; open = null; renderList(); } function saveLocal(fn) { const all = readLocal(LOCAL_KEY, {}); const cur = all[open.thread.id] || { votes: [], proposals: [] }; fn(cur); all[open.thread.id] = cur; writeLocal(LOCAL_KEY, all); } detail.addEventListener('click', async (e) => { if (e.target.closest('.back-btn')) return closeThread(); const jump = e.target.closest('[data-jump]'); if (jump) { placeSeg(jump.dataset.jump); const sec = $(`#st-${jump.dataset.jump}`, detail); view.scrollTo({ top: sec.offsetTop - 110, behavior: reducedMotion() ? 'auto' : 'smooth', }); return; } const row = e.target.closest('.obs-row'); if (row) { const more = row.nextElementSibling; more.hidden = !more.hidden; row.setAttribute('aria-expanded', String(!more.hidden)); return; } const g = e.target.closest('[data-globe]'); if (g) { const o = open.thread.observations.find((x) => x.id === g.dataset.globe); if (o) onShowOnGlobe?.(open.thread, o); return; } const cite = e.target.closest('[data-cite]'); if (cite) { const li = $(`.obs[data-o="${cite.dataset.cite}"]`, detail); if (li) { const more = $('.obs-more', li); more.hidden = false; li.scrollIntoView({ block: 'center', behavior: reducedMotion() ? 'auto' : 'smooth', }); li.classList.remove('flash'); void li.offsetWidth; li.classList.add('flash'); } return; } const vote = e.target.closest('[data-vote]'); if (vote) { const id = vote.closest('[data-h]').dataset.h; haptic('tick'); saveLocal((cur) => cur.votes.push({ id, kind: vote.dataset.vote, at: new Date().toISOString(), }), ); await renderDetail(); placeSeg('hyp'); toast( `${vote.dataset.vote === 'agree' ? 'Agreed with' : 'Disputed'} ${id} · ledger +1`, { icon: icon('threads') }, ); return; } if (e.target.closest('.narr-play')) { onPlay?.(open.thread); return; } const led = e.target.closest('[data-led]')?.dataset.led; if (led === 'verify') { const t0 = performance.now(); const r = await verifyLedger(open.ledger); const st = $('.led-status', detail); st.textContent = r.ok ? `✓ ${r.n} entries linked · ${Math.round(performance.now() - t0)} ms` : `✕ broken at #${r.at}`; st.className = `led-status num ${r.ok ? 'ok' : 'bad'}`; haptic(r.ok ? 'success' : 'warn'); } else if (led === 'export') { const blob = new Blob( [ JSON.stringify( { thread: open.thread.id, title: open.thread.title, exportedAt: new Date().toISOString(), algorithm: 'sha256(canonical({seq,at,type,actor,payloadHash,prev}))', entries: open.ledger, }, null, 2, ), ], { type: 'application/json' }, ); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${open.thread.id}.ledger.json`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); toast('Ledger exported', { icon: icon('download') }); } const lr = e.target.closest('.led-row'); if (lr) { const pre = lr.nextElementSibling; pre.hidden = !pre.hidden; lr.setAttribute('aria-expanded', String(!pre.hidden)); } }); detail.addEventListener('submit', async (e) => { e.preventDefault(); const ta = $('#ai-prompt', detail); const prompt = ta.value.trim() || ta.placeholder; const sim = simulateRefinement(open.thread, prompt); const n = (readLocal(LOCAL_KEY, {})[open.thread.id]?.proposals?.length || 0) + open.thread.hypotheses.length + 1; saveLocal((cur) => cur.proposals.push({ id: `H${n}`, author: { kind: 'ai', handle: 'AI · local simulation' }, at: new Date().toISOString(), text: sim.text, confidence: null, cites: sim.cites, agree: 0, dispute: 0, simulated: true, prompt, model: 'local-simulation-v0 (no network)', inputs: sim.inputs, }), ); haptic('success'); await renderDetail(); placeSeg('hyp'); $(`[data-h="H${n}"]`, detail)?.scrollIntoView({ block: 'center', behavior: reducedMotion() ? 'auto' : 'smooth', }); toast(`Proposed H${n} · recorded in the ledger`, { icon: icon('sparkles'), }); }); new ResizeObserver(() => placeSeg()).observe(view); renderList(); return { refresh: renderList, async openById(id) { if (!bhote) bhote = await bhoteThread(); if (id === bhote.id) return openThread(bhote); const rec = readLocal(DRAFTS_KEY, []).find((x) => x.id === id); if (rec) return openThread(await draftThread(rec)); }, /** Start a local draft thread from a situation or contact. */ startDraft(s) { const drafts = readLocal(DRAFTS_KEY, []); const id = `draft-${String(s.id).replace(/[^a-z0-9-]/gi, '')}`; if (!drafts.some((d) => d.id === id)) drafts.unshift({ id, title: s.title, place: s.place, lat: s.lat, lon: s.lon, family: s.family, source: s.source, createdAt: new Date().toISOString(), snapshot: { id: s.id, title: s.title, metrics: s.metrics || null, source: s.source || null, updatedAt: s.updatedAt || null, lon: s.lon, lat: s.lat, }, }); writeLocal(DRAFTS_KEY, drafts.slice(0, 20)); return id; }, thread: () => open?.thread || null, bhote: async () => (bhote = bhote || (await bhoteThread())), }; }