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

Threads

Rooms on the evidence · 4D reconstructions
`; 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); function roomRow(r) { const s = r.situation; const band = bandOf(s); const here = rt.presence(r.id).length; const k = r.top ? KIND[r.top.kind] : null; return ``; } async function renderRooms() { const host = $('.th-rooms', view); if (!getSituations || !rooms) return; const sits = (await getSituations().catch(() => [])).filter( (x) => x.kind !== 'reconstruction', ); if (!sits.length) return; const list = await rooms.hot(sits, 8); host.innerHTML = `
Rooms hottest first · every situation has one
${list.map(roomRow).join('')}

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.

Reconstructions observations · hypotheses · ledger
`; } $('.th-rooms', view).addEventListener('click', (e) => { const b = e.target.closest('[data-room]'); if (!b) return; haptic('light'); getSituations().then((sits) => { const s = sits.find((x) => String(x.id) === b.dataset.room); if (s) onOpenRoom?.(s); }); }); rooms?.on('post', () => { if (!view.hidden && !open) renderRooms(); }); async function renderList() { renderRooms(); 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())), }; }