/**
* Now: the global equivalent of a neighbourhood safety feed (DESIGN.md §11.2).
* Mixed card archetypes across the four altitude bands, ordered so that
* neighbours differ, with Room and Witness cards woven in, an altitude
* filter, distance from you, and a "Near you" lens. Two-column masonry with
* full-width rows for live cameras and rooms; columns are balanced exactly
* before every wide row.
*/
import { icon } from './icons.js';
import { $, $$, esc, ago, clamp, haptic, reducedMotion } from './dom.js';
import { cardHtml } from './cards.js';
import { localWitness } from './witness.js';
import {
BANDS,
bandOf,
archetypeOf,
distKm,
fmtKm,
utc,
presenceRadiusKm,
} from './bands.js';
const BAND_RANGE = {
orbit: '36,000 km',
sky: '15 km',
ground: '0 m',
inside: 'you',
};
function hashStr(s) {
let x = 2166136261;
for (let i = 0; i < s.length; i += 1)
x = Math.imul(x ^ s.charCodeAt(i), 16777619);
return x >>> 0;
}
export function isReconstruction(s) {
return (
s.section === 'reconstructions' || s.kind === 'reconstruction' || !!s.thread
);
}
export function isAlwaysOn(s) {
return s.section === 'always' || !!s.alwaysOn;
}
const WIDE = new Set(['eye', 'room']);
const BODY_H = {
sensor: 84,
track: 110,
number: 62,
witness: 78,
prompt: 46,
};
const GAP = 8;
function aspect(item) {
const a = item.archetype;
if (a === 'number') return 1.06;
if (a === 'track') return 1;
if (a === 'witness') return item.w?.liveNow ? 1.3 : 1.22;
if (a === 'prompt') return 1.08;
const opts = [1.18, 1.32, 1.0, 1.42, 1.12];
return opts[hashStr(String(item.key)) % opts.length];
}
/** Diversity-aware ordering: keep priority, avoid same archetype/band neighbours. */
export function diversify(list) {
const pool = [...list];
const out = [];
let sinceWide = 9;
while (pool.length) {
let best = 0;
let bs = -Infinity;
for (let i = 0; i < Math.min(pool.length, 7); i += 1) {
const it = pool[i];
const p1 = out[out.length - 1];
const p2 = out[out.length - 2];
let sc = -i * 1.15;
if (p1?.archetype === it.archetype) sc -= 6;
if (p2?.archetype === it.archetype) sc -= 2.5;
if (p1?.band === it.band) sc -= 4;
if (p2?.band === it.band) sc -= 1.5;
if (WIDE.has(it.archetype) && sinceWide < 3) sc -= 5;
if (sc > bs) {
bs = sc;
best = i;
}
}
const it = pool.splice(best, 1)[0];
sinceWide = WIDE.has(it.archetype) ? 0 : sinceWide + 1;
out.push(it);
}
return out;
}
export function createFeed(
view,
{
getSituations,
onOpen,
onOpenRoom,
onWitness,
onCapture,
onLocate,
bindScroll,
globe,
rooms,
rt,
},
) {
view.innerHTML = `
${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ``).join('')}
Acquiring the planet…
Around the world
LIVE · EVERY BAND
${BANDS.map((b) => ``).join('')}
End of feed · the planet keeps turning
`;
const inner = $('.feed-inner', view);
const fbody = $('.feed-body', view);
const bandbar = $('.bandbar', view);
const count = $('.feed-count', view);
const note = $('.feed-note', view);
const ptr = $('.ptr', view);
let band = 'all';
let near = false;
let all = [];
let userLoc = null;
let hot = [];
let witness = [];
const cardEls = new Map();
let selectedId = null;
const itemsByKey = new Map();
/* ---------- clocks ---------- */
function tickClocks() {
const now = new Date();
const u = utc(now);
const hd = $('[data-utc]', view);
if (hd) hd.textContent = u;
const dt = $('[data-date]', view);
if (dt) {
// The kicker greets the hour: what the planet is doing for you now.
const hr = now.getHours();
const greet =
hr < 5
? 'Up late'
: hr < 12
? 'Good morning'
: hr < 17
? 'Good afternoon'
: hr < 22
? 'Good evening'
: 'Tonight';
const txt = `${now
.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
})
.toUpperCase()} · ${greet.toUpperCase()}`;
if (dt.textContent !== txt) dt.textContent = txt;
}
const lt = $('[data-localtime]', view);
if (lt) {
const txt = now.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
if (lt.textContent !== txt) lt.textContent = txt;
}
if (view.hidden) return;
$$('[data-clock]', fbody).forEach((el) => (el.textContent = u));
$$('[data-since]', fbody).forEach((el) => {
const s = Math.max(
0,
Math.round((Date.now() - Number(el.dataset.since)) / 1000),
);
el.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
});
}
setInterval(tickClocks, 1000);
tickClocks();
function skeleton() {
fbody.innerHTML = `${[0, 1]
.map(
(ci) =>
`
${[0, 1, 2]
.map(
(i) =>
`
`,
)
.join('')}
`,
)
.join('')}
`;
}
/* ---------- items ---------- */
function withDistance(list) {
return list.map((s) => {
if (!userLoc || !Number.isFinite(s.lat)) return s;
const d = Number.isFinite(s.distanceKm)
? s.distanceKm
: distKm(userLoc, s);
return { ...s, distanceKm: d };
});
}
function situationItems() {
const sits = withDistance(all);
const items = sits.map((s, i) => ({
key: `s:${s.id}`,
type: 'situation',
s,
archetype: archetypeOf(s),
band: bandOf(s),
prio:
(s.severity || 0) * 10 -
i * 0.6 +
(s.live ? 2 : 0) +
// Detections from real data (circling, emergency squawks) lead.
(/circling|squawk|quake-cluster/.test(s.kind || '') ? 60 : 0),
}));
// The street is the most Citizen-like card: one live eye right after
// the two most severe situations.
const ranked = [...items].sort((a, b) => b.prio - a.prio);
const eye = ranked.find((x) => x.archetype === 'eye');
if (eye && ranked[1] && ranked.indexOf(eye) > 2)
eye.prio = ranked[1].prio - 0.1;
// A detection is the first card: never shuffled away by diversity.
for (const it of items)
if (/circling|squawk|quake-cluster/.test(it.s.kind || '')) it.lead = true;
return items;
}
function liveWitness() {
return rt.lives().map((l) => ({
id: `live:${l.id}`,
liveId: l.id,
type: 'live',
liveNow: true,
sealed: true,
at: l.startedAt,
startedAt: l.startedAt,
handle: l.handle,
viewers: l.viewers || 0,
frame: l.frame,
title: `${l.handle} is live${l.situationTitle ? ` near ${l.situationTitle}` : ''}`,
sub: `${l.segments || 0} sealed segments · heading ${Math.round(l.headingDeg || 0)}°`,
fix: Number.isFinite(l.lat) ? { lat: l.lat, lon: l.lon } : null,
acc: l.acc,
}));
}
function buildItems() {
let items = situationItems();
const lives = liveWitness();
const wit = [...lives, ...witness.slice(0, 4)].map((w) => ({
key: `w:${w.id}`,
type: 'witness',
w,
archetype: 'witness',
band: 'inside',
prio: w.liveNow ? 100 : Date.now() - w.at < 6 * 3600e3 ? 30 : 0,
}));
const roomItems = hot.map((r, i) => ({
key: `r:${r.id}`,
type: 'room',
room: r,
watching: rt.presence(r.id).length,
archetype: 'room',
band: 'inside',
prio: 40 - i * 12,
}));
if (!witness.length && !lives.length) {
const nearest = userLoc
? [...withDistance(all)]
.filter((s) => Number.isFinite(s.distanceKm))
.sort((a, b) => a.distanceKm - b.distanceKm)[0]
: null;
wit.push({
key: 'prompt',
type: 'prompt',
archetype: 'prompt',
band: 'inside',
prio: 28,
p: nearest
? {
title: `You’re ${fmtKm(nearest.distanceKm)} from ${nearest.title}`,
text:
nearest.distanceKm <= presenceRadiusKm(nearest)
? 'A sealed photo from where you stand is the strongest post its room can get.'
: 'Your phone is a sensor too. Seal what you see and it joins the room for the nearest situation.',
tele: `INSIDE · YOU · ${fmtKm(nearest.distanceKm).toUpperCase()} · ${utc(new Date(), { seconds: false })}`,
}
: {
title: 'Be the sensor',
text: 'Seal a photo, go live or upload. Your phone becomes an eye for the God’s eye, with its motion and position signed in.',
tele: `INSIDE · YOU · ${utc(new Date(), { seconds: false })}`,
},
});
}
if (near) {
items = items
.filter((x) => Number.isFinite(x.s.distanceKm))
.sort((a, b) => a.s.distanceKm - b.s.distanceKm)
.slice(0, 14)
.map((x, i) => ({ ...x, prio: 100 - i * 5 }));
}
let list = [...items, ...roomItems, ...wit];
if (band !== 'all') list = list.filter((x) => x.band === band);
list.sort((a, b) => b.prio - a.prio);
if (near) return list;
const lead = list.filter((x) => x.lead);
return [...lead, ...diversify(list.filter((x) => !x.lead))];
}
/* ---------- layout ---------- */
function layout(list) {
const W = fbody.clientWidth || view.clientWidth || 400;
const colW = (W - 16 - GAP) / 2;
const blocks = [];
let cols = [[], []];
let h = [0, 0];
const pending = [];
const canFlush = () => {
const d = Math.abs(h[0] - h[1]);
if (d < 4) return true;
const short = h[0] < h[1] ? 0 : 1;
const last = cols[short].at(-1);
return !!last && d < last.ar * colW * 0.55;
};
const flush = () => {
if (!cols[0].length && !cols[1].length) return;
const d = Math.abs(h[0] - h[1]);
const short = h[0] < h[1] ? 0 : 1;
const last = cols[short].at(-1);
if (last && d >= 4) last.ar = (last.ar * colW + d) / colW;
blocks.push({ kind: 'mason', cols });
cols = [[], []];
h = [0, 0];
};
for (const it of list) {
if (WIDE.has(it.archetype)) {
if (canFlush()) {
flush();
blocks.push({ kind: 'wide', it });
} else pending.push(it);
continue;
}
const c = h[0] <= h[1] ? 0 : 1;
const ar = aspect(it);
const cell = { it, ar };
cols[c].push(cell);
h[c] += ar * colW + (BODY_H[it.archetype] || 84) + GAP;
while (pending.length && canFlush()) {
flush();
blocks.push({ kind: 'wide', it: pending.shift() });
}
}
// A lonely trailing card would leave a hole: let the last wide row go first.
if (pending.length) {
flush();
for (const it of pending) blocks.push({ kind: 'wide', it });
} else if (cols[0].length + cols[1].length)
blocks.push({ kind: 'mason', cols });
return blocks;
}
function render() {
const list = buildItems();
itemsByKey.clear();
list.forEach((it) => itemsByKey.set(it.key, it));
const blocks = layout(list);
const keyed = (it, ar) =>
cardHtml(it, ar).replace(
'
b.kind === 'wide'
? `${keyed(b.it, 0.5625)}
`
: `${b.cols.map((c) => `
${c.map((cell) => keyed(cell.it, cell.ar)).join('')}
`).join('')}
`,
)
.join('');
cardEls.clear();
$$('.card[data-id]', fbody).forEach((el) => {
cardEls.set(el.dataset.id, el);
el.classList.toggle('is-selected', el.dataset.id === selectedId);
});
fbody.setAttribute('aria-busy', 'false');
$('.feed-end', view).hidden = !list.length;
if (!list.length)
fbody.innerHTML = `Nothing in this band right now${near ? ' near you' : ''}.
`;
// counts
const sit = situationItems();
const counts = { all: sit.length + hot.length + witness.length };
for (const b of BANDS)
counts[b.id] = sit.filter((x) => x.band === b.id).length;
counts.inside += hot.length + witness.length;
for (const [k, v] of Object.entries(counts)) {
const el = $(`[data-count="${k}"]`, bandbar);
if (el) el.textContent = String(v);
}
const live = all.filter((s) => s.live).length;
const eyes = all.filter((s) => archetypeOf(s) === 'eye').length;
count.textContent = `${live} live · ${eyes} eyes open · ${hot.reduce((a, r) => a + r.posts.length, 0)} posts in ${hot.length} hot rooms`;
if (near && !userLoc) {
note.hidden = false;
note.innerHTML = `${icon('location')}
See what’s around youDistances and the Near lens need your location. It stays on this device.
`;
} else note.hidden = true;
hydrate();
}
/* ---------- images: composited thumbnails, live eyes ---------- */
const thumbIO = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
thumbIO.unobserve(e.target);
loadThumb(e.target);
}
},
{ root: view, rootMargin: '700px 0px' },
);
const visibleEyes = new Set();
const eyeIO = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) visibleEyes.add(e.target);
else visibleEyes.delete(e.target);
}
},
{ root: view, threshold: 0.15 },
);
function markLoaded(img) {
const done = () => img.classList.add('loaded');
if (img.complete && img.naturalWidth) done();
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', () => img.classList.add('failed'), {
once: true,
});
}
}
async function loadThumb(img) {
const card = img.closest('.card');
const it = itemsByKey.get(card?.dataset.key);
const s = it?.s;
if (!s) return;
const media = img.closest('.card-media');
const r = media.getBoundingClientRect();
const w = 360;
const hgt =
Math.round((w * (r.height / Math.max(1, r.width))) / 40) * 40 || 480;
// The plain still shows at once; the composited frame replaces it.
if (s.thumbnail && !img.getAttribute('src')) {
img.src = s.thumbnail;
img.classList.add('still');
markLoaded(img);
}
let url = '';
try {
url = await globe.thumbnail(s, { width: w, height: hgt, dpr: 2 });
} catch {
url = '';
}
if (!img.isConnected || !url || url === img.getAttribute('src')) return;
const pre = new Image();
pre.decoding = 'async';
pre.onload = () => {
if (!img.isConnected) return;
img.src = url;
img.classList.remove('still');
img.classList.add('loaded', 'composited');
};
pre.src = url;
}
function hydrate() {
$$('img[data-thumb]', fbody).forEach((img) => thumbIO.observe(img));
$$('.c-eye', fbody).forEach((card) => {
eyeIO.observe(card);
card._next = Date.now() + Number(card.dataset.refresh || 15000);
const img = $('img[data-eye]', card);
markLoaded(img);
img.addEventListener(
'error',
() => {
const it = itemsByKey.get(card.dataset.key);
if (it?.s?.thumbnail && !img.dataset.fellBack) {
img.dataset.fellBack = '1';
img.src = it.s.thumbnail;
card.classList.add('eye-offline');
}
},
{ once: true },
);
});
$$('.c-witness img, .c-witness video', fbody).forEach((m) => {
if (m.tagName === 'VIDEO') m.classList.add('loaded');
else markLoaded(m);
});
tickClocks();
}
function freshFrame(card) {
const id = card.dataset.cam;
const fromEngine = id ? globe.eyeFrameUrl(id) : null;
if (fromEngine) return fromEngine;
const cur = $('img[data-eye]', card).src;
try {
const u = new URL(cur, location.href);
u.searchParams.set('ts', String(Math.floor(Date.now() / 1000)));
return u.pathname + u.search;
} catch {
return cur;
}
}
// Auto-refresh camera frames while on screen; cross-fade between buffers.
setInterval(() => {
if (view.hidden || document.hidden) return;
const now = Date.now();
for (const card of visibleEyes) {
if (card.classList.contains('eye-offline') || now < (card._next || 0))
continue;
card._next = now + Number(card.dataset.refresh || 15000);
const a = $('img[data-eye]', card);
const b = $('img.eye-b', card);
b.onload = () => {
b.classList.add('loaded', 'front');
a.classList.remove('front');
// swap roles
a.removeAttribute('data-eye');
a.classList.add('eye-b');
b.classList.remove('eye-b');
b.setAttribute('data-eye', '');
card.classList.remove('flash');
void card.offsetWidth;
card.classList.add('flash');
};
b.src = freshFrame(card);
}
}, 1000);
/* ---------- load ---------- */
async function refreshSocial() {
const vis = all.filter((s) => s.kind !== 'reconstruction');
hot = vis.length
? (await rooms.hot(vis, 3).catch(() => [])).filter(Boolean)
: [];
witness = await localWitness({ rooms, situations: all }).catch(() => []);
}
async function load({ quiet = false } = {}) {
if (!quiet) skeleton();
try {
const prev = new Map(all.map((s) => [s.id, JSON.stringify(s.metrics)]));
all = await getSituations();
await refreshSocial();
render();
if (quiet)
for (const s of all)
if (prev.has(s.id) && prev.get(s.id) !== JSON.stringify(s.metrics))
cardEls.get(String(s.id))?.classList.add('pulse');
} catch (err) {
console.error(err);
count.textContent = 'Could not reach the live feeds';
}
}
async function toggleNear(nb) {
near = !near;
nb.setAttribute('aria-pressed', String(near));
view.classList.toggle('is-near', near);
haptic('tick');
if (near && !userLoc) {
const loc = await onLocate();
if (loc) setLocation(loc);
}
view.scrollTo({ top: 0 });
swap();
}
bandbar.addEventListener('click', async (e) => {
const b = e.target.closest('[data-band]');
if (!b || b.dataset.band === band) return;
band = b.dataset.band;
$$('[data-band]', bandbar).forEach((x) =>
x.setAttribute('aria-selected', String(x === b)),
);
bandbar.dataset.sel = band;
haptic('tick');
b.scrollIntoView({
inline: 'nearest',
block: 'nearest',
behavior: reducedMotion() ? 'auto' : 'smooth',
});
swap();
});
function swap() {
fbody.classList.remove('swap');
void fbody.offsetWidth;
fbody.classList.add('swap');
render();
}
view.addEventListener('click', async (e) => {
const hitEl = e.target.closest('.card-hit');
if (hitEl) {
const card = hitEl.closest('.card');
const it = itemsByKey.get(card.dataset.key);
if (!it) return;
haptic('light');
if (it.type === 'situation') onOpen(it.s, card);
else if (it.type === 'room') onOpenRoom(it.room.situation, card);
else if (it.type === 'witness') onWitness(it.w, card);
else if (it.type === 'prompt') onCapture();
return;
}
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'near') return toggleNear(e.target.closest('[data-act]'));
if (act === 'refresh') refresh();
if (act === 'locate') {
const loc = await onLocate();
if (loc) setLocation(loc);
render();
}
});
/* ---------- large title + nav bar ---------- */
const title = $('.large-title', view);
view.addEventListener(
'scroll',
() => {
const y = view.scrollTop;
view.classList.toggle('is-scrolled', y > 44);
if (y < 0)
title.style.transform = `scale(${1 + Math.min(0.08, -y / 900)})`;
else if (title.style.transform) title.style.transform = '';
},
{ passive: true },
);
bindScroll(view);
/* ---------- pull to refresh ---------- */
let pull = null;
let refreshing = false;
const THRESH = 78;
view.addEventListener(
'touchstart',
(e) => {
if (view.scrollTop > 0 || refreshing || e.touches.length !== 1) return;
pull = { y0: e.touches[0].clientY, d: 0, armed: false };
},
{ passive: true },
);
view.addEventListener(
'touchmove',
(e) => {
if (!pull) return;
const dy = e.touches[0].clientY - pull.y0;
if (dy <= 0) {
pull.d = 0;
setPull(0);
return;
}
if (view.scrollTop > 0) return;
e.preventDefault();
pull.d = dy;
const d = (1 - 1 / ((dy * 0.55) / 320 + 1)) * 320;
setPull(d);
if (!pull.armed && d > THRESH) {
pull.armed = true;
haptic('light');
} else if (pull.armed && d < THRESH) pull.armed = false;
},
{ passive: false },
);
view.addEventListener(
'touchend',
() => {
if (!pull) return;
const armed = pull.armed;
pull = null;
if (armed) refresh();
else setPull(0, true);
},
{ passive: true },
);
function setPull(d, animate = false) {
inner.style.transition = animate ? 'transform 420ms var(--spring)' : 'none';
inner.style.transform = d ? `translate3d(0, ${d}px, 0)` : '';
ptr.style.transition = animate ? 'opacity 200ms linear' : 'none';
ptr.style.opacity = String(clamp(d / THRESH, 0, 1));
ptr.style.setProperty('--p', clamp(d / THRESH, 0, 1).toFixed(3));
}
async function refresh() {
if (refreshing) return;
refreshing = true;
ptr.classList.add('spinning');
setPull(64, true);
const t = performance.now();
await load({ quiet: true });
const wait = Math.max(0, 700 - (performance.now() - t));
setTimeout(() => {
ptr.classList.remove('spinning');
setPull(0, true);
refreshing = false;
haptic('success');
}, wait);
}
// Keep relative times honest.
setInterval(() => {
if (!view.hidden && !document.hidden) {
$$('.ago[data-ts]', view).forEach((el) => {
if (el.dataset.ts)
el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts);
});
}
}, 30000);
// Social changes re-render quietly (posts from other tabs, live broadcasts).
let socialTimer = 0;
const socialChanged = () => {
clearTimeout(socialTimer);
socialTimer = setTimeout(async () => {
await refreshSocial();
if (!view.hidden) {
const y = view.scrollTop;
render();
view.scrollTop = y;
}
}, 250);
};
rooms.on('post', socialChanged);
let liveKey = '';
rt.on('live', (l) => {
const card = $(`[data-key="w:live:${CSS.escape(l.id)}"]`, fbody);
if (card) {
const img = $('img', card);
if (img && l.frame) img.src = l.frame;
const v = $('[data-viewers]', card);
if (v) v.textContent = String(l.viewers || 0);
if (!view.hidden) rt.watch(l.id);
}
const key = rt
.lives()
.map((x) => x.id)
.join(',');
if (key !== liveKey) {
liveKey = key;
socialChanged();
}
});
rt.on('live-end', () => {
liveKey = rt
.lives()
.map((x) => x.id)
.join(',');
socialChanged();
});
rt.on('presence', () => {
for (const r of hot) {
const el = $(`[data-room-watch="${CSS.escape(r.id)}"]`, fbody);
const n = rt.presence(r.id).length;
if (el) el.innerHTML = n > 1 ? `${n} here` : '';
}
});
function setLocation(loc) {
userLoc = loc;
if (loc) globe.setViewer?.({ lon: loc.lon, lat: loc.lat });
}
/** Instant first frame: paint cached situations before the live call. */
function prime(list) {
if (!Array.isArray(list) || !list.length || all.length) return false;
all = list;
render();
count.textContent = `${count.textContent} · last good snapshot`;
return true;
}
let freshTimer = 0;
function markUpdated() {
const chip = $('.fresh-chip', view);
chip.textContent = 'Updated just now';
chip.hidden = false;
chip.classList.remove('in');
void chip.offsetWidth;
chip.classList.add('in');
clearTimeout(freshTimer);
freshTimer = setTimeout(() => (chip.hidden = true), 4200);
}
return {
load,
prime,
markUpdated,
briefSlot: $('.brief-slot', view),
titleEl: $('.today-title', view),
refresh,
render,
refreshSocial: socialChanged,
cardFor: (id) => cardEls.get(String(id)) || null,
situations: () => all,
select(id) {
selectedId = id == null ? null : String(id);
cardEls.forEach((el, k) =>
el.classList.toggle('is-selected', k === selectedId),
);
},
setLocation(loc) {
setLocation(loc);
render();
},
get location() {
return userLoc;
},
};
}