mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat(mobile): rooms, live and upload capture, and the altitude-band identity
The Now feed mixes sensor, eye, track, number, witness and room cards across Orbit, Sky, Ground and Inside, ordered so neighbours differ, with composited thumbnails and live public-camera frames. Every situation has a room: typed, place- and time-anchored posts, evidence badges, bridging-rated notes and a cited Known / Disputed / Unknown local synthesis, with cross-tab presence over BroadcastChannel. Example posts are labelled as such. Capture adds Live (10 s segments sealed into a hash chain, shown on the globe as a view frustum) and Upload (EXIF placement, Unverified tier), and an attach sheet that files any capture into a nearby situation. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
38 files changed
+9837
-1081
No files matched your search
+580
-147
@@ -1,20 +1,32 @@
|
||||
/**
|
||||
* Now: a two-column, RedNote-style masonry of live situations with a large
|
||||
* collapsing title, section tabs, pull-to-refresh and skeleton loading.
|
||||
* 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, FAMILY_ICON, FAMILIES } from './icons.js';
|
||||
import { $, $$, esc, ago, fmtNum, clamp, haptic } from './dom.js';
|
||||
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 SECTIONS = [
|
||||
{ id: 'now', label: 'Happening now' },
|
||||
{ id: 'near', label: 'Near you' },
|
||||
{ id: 'recon', label: 'Reconstructions' },
|
||||
{ id: 'always', label: 'Always on' },
|
||||
];
|
||||
|
||||
const ASPECTS = [1.25, 1, 1.33, 0.82, 1.18, 0.95];
|
||||
const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name]));
|
||||
const BAND_RANGE = {
|
||||
orbit: '36,000 km',
|
||||
sky: '15 km',
|
||||
ground: '0 m',
|
||||
inside: 'you',
|
||||
};
|
||||
|
||||
function hashStr(s) {
|
||||
let x = 2166136261;
|
||||
@@ -23,14 +35,6 @@ function hashStr(s) {
|
||||
return x >>> 0;
|
||||
}
|
||||
|
||||
/** Height/width ratio for a card: from the thumbnail URL when known, else stable per id. */
|
||||
function aspectFor(s) {
|
||||
const m = /size=(\d+),(\d+)/.exec(s.thumbnail || '');
|
||||
if (m) return clamp(Number(m[2]) / Number(m[1]), 0.72, 1.4);
|
||||
if (s.kind === 'reconstruction') return 1.3;
|
||||
return ASPECTS[hashStr(String(s.id)) % ASPECTS.length];
|
||||
}
|
||||
|
||||
export function isReconstruction(s) {
|
||||
return (
|
||||
s.section === 'reconstructions' || s.kind === 'reconstruction' || !!s.thread
|
||||
@@ -40,150 +44,498 @@ export function isAlwaysOn(s) {
|
||||
return s.section === 'always' || !!s.alwaysOn;
|
||||
}
|
||||
|
||||
function distKm(a, b) {
|
||||
const R = 6371;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
|
||||
const x =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((a.lat * Math.PI) / 180) *
|
||||
Math.cos((b.lat * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
|
||||
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, onLocate, bindScroll },
|
||||
{
|
||||
getSituations,
|
||||
onOpen,
|
||||
onOpenRoom,
|
||||
onWitness,
|
||||
onCapture,
|
||||
onLocate,
|
||||
bindScroll,
|
||||
globe,
|
||||
rooms,
|
||||
rt,
|
||||
},
|
||||
) {
|
||||
view.innerHTML = `
|
||||
<header class="navbar"><span class="nav-title">Now</span>
|
||||
<div class="nav-trailing"><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
|
||||
<div class="nav-trailing"><button type="button" class="near-chip" data-act="near" aria-pressed="false">${icon('location')}<span>Near you</span></button><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
|
||||
</header>
|
||||
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${FAMILIES.map((f, i) => `<i style="--c:${f.color};--i:${i}"></i>`).join('')}</div></div>
|
||||
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `<i style="--i:${i}"></i>`).join('')}</div></div>
|
||||
<div class="feed-inner">
|
||||
<h1 class="large-title">Now</h1>
|
||||
<div class="title-sub"><span class="live-dot"></span><span class="feed-count num">Loading the planet…</span></div>
|
||||
<div class="feed-tabs" role="tablist" aria-label="Feed sections" data-hscroll>
|
||||
${SECTIONS.map((s, i) => `<button role="tab" type="button" data-sec="${s.id}" aria-selected="${i === 0}">${s.label}</button>`).join('')}
|
||||
<span class="feed-tabs-ink" aria-hidden="true"></span>
|
||||
<div class="feed-hero">
|
||||
<h1 class="large-title">Now</h1>
|
||||
<div class="feed-clock tele" aria-hidden="true"><b data-utc></b><span data-date></span></div>
|
||||
</div>
|
||||
<div class="title-sub tele"><span class="live-dot"></span><span class="feed-count">Acquiring the planet…</span></div>
|
||||
<div class="bandbar" role="tablist" aria-label="Altitude" data-hscroll>
|
||||
<button role="tab" type="button" data-band="all" aria-selected="true"><span class="bb-n">All</span><span class="bb-r tele" data-count="all">—</span></button>
|
||||
${BANDS.map((b) => `<button role="tab" type="button" data-band="${b.id}" aria-selected="false"><span class="bb-n"><i></i>${b.name}</span><span class="bb-r tele"><span data-count="${b.id}">—</span> · ${BAND_RANGE[b.id]}</span></button>`).join('')}
|
||||
</div>
|
||||
<div class="feed-note" hidden></div>
|
||||
<div class="masonry" role="feed" aria-busy="true"><div class="mcol"></div><div class="mcol"></div></div>
|
||||
<p class="feed-end" hidden>You’re caught up with the planet.</p>
|
||||
<div class="feed-body" role="feed" aria-busy="true"></div>
|
||||
<p class="feed-end tele" hidden>End of feed · the planet keeps turning</p>
|
||||
</div>`;
|
||||
|
||||
const inner = $('.feed-inner', view);
|
||||
const cols = $$('.mcol', view);
|
||||
const masonry = $('.masonry', view);
|
||||
const tabs = $('.feed-tabs', view);
|
||||
const ink = $('.feed-tabs-ink', 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 section = 'now';
|
||||
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} UTC`.replace('Z UTC', ' UTC');
|
||||
const dt = $('[data-date]', view);
|
||||
if (dt)
|
||||
dt.textContent = now
|
||||
.toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
.toUpperCase();
|
||||
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() {
|
||||
cols.forEach((c, ci) => {
|
||||
c.innerHTML = [0, 1, 2]
|
||||
.map(
|
||||
(i) =>
|
||||
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${ASPECTS[(i + ci * 2) % ASPECTS.length]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
|
||||
)
|
||||
.join('');
|
||||
fbody.innerHTML = `<div class="masonry">${[0, 1]
|
||||
.map(
|
||||
(ci) =>
|
||||
`<div class="mcol">${[0, 1, 2]
|
||||
.map(
|
||||
(i) =>
|
||||
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${[1.2, 1, 1.35][(i + ci) % 3]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
|
||||
)
|
||||
.join('')}</div>`,
|
||||
)
|
||||
.join('')}</div>`;
|
||||
}
|
||||
|
||||
/* ---------- 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 cardHtml(s) {
|
||||
const m = s.metrics?.[0];
|
||||
const ar = aspectFor(s);
|
||||
const fam = FAM_NAME[s.family] || s.family;
|
||||
return `<article class="card ${s.live ? 'is-live' : isReconstruction(s) ? 'is-recon' : s.stale || (!isAlwaysOn(s) && s.section !== 'always') ? 'is-stale' : 'is-evergreen'}" data-id="${esc(s.id)}" data-family="${esc(s.family)}" aria-labelledby="ct-${esc(s.id)}">
|
||||
<button class="card-hit" type="button" aria-label="Open ${esc(s.title)}, ${esc(s.place || '')}"></button>
|
||||
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
|
||||
<img src="${esc(s.thumbnail || '')}" alt="" loading="lazy" decoding="async" fetchpriority="low" />
|
||||
<div class="card-badges">
|
||||
<span class="fam-tag glass-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(fam)}</span>
|
||||
${s.live ? '<span class="live-pill"><span class="live-dot"></span>LIVE</span>' : isReconstruction(s) ? `<span class="live-pill recon">${icon('threads')}4D</span>` : ''}
|
||||
</div>
|
||||
${m ? `<div class="card-metric"><b class="num">${esc(fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0))}</b>${m.unit ? `<small>${esc(m.unit)}</small>` : ''}<span>${esc(m.label)}</span></div>` : ''}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
|
||||
<div class="card-place">${icon('pin')}<span>${esc(s.place || '')}</span></div>
|
||||
<div class="card-foot"><span class="src">${esc(shortSource(s.source))}</span><span class="ago num" data-ts="${esc(s.updatedAt || '')}">${esc(ago(s.updatedAt))}</span></div>
|
||||
</div>
|
||||
</article>`;
|
||||
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),
|
||||
}));
|
||||
// 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;
|
||||
return items;
|
||||
}
|
||||
|
||||
function shortSource(src = '') {
|
||||
return String(src).split(/[·(]/)[0].trim() || 'Live feed';
|
||||
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 filtered() {
|
||||
if (section === 'recon') return all.filter(isReconstruction);
|
||||
if (section === 'always') return all.filter(isAlwaysOn);
|
||||
if (section === 'near') {
|
||||
const at = userLoc || { lon: -122.42, lat: 37.77 };
|
||||
return [...all]
|
||||
.filter((s) => Number.isFinite(s.lon))
|
||||
.sort((a, b) => distKm(at, a) - distKm(at, b))
|
||||
.slice(0, 8);
|
||||
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 })}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return all
|
||||
.filter((s) => !isReconstruction(s) && !isAlwaysOn(s))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.severity ?? 0) - (a.severity ?? 0) ||
|
||||
new Date(b.updatedAt) - new Date(a.updatedAt),
|
||||
);
|
||||
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);
|
||||
return near ? list : diversify(list);
|
||||
}
|
||||
|
||||
/* ---------- 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 = filtered();
|
||||
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(
|
||||
'<article ',
|
||||
`<article data-key="${esc(it.key)}" `,
|
||||
);
|
||||
fbody.innerHTML = blocks
|
||||
.map((b) =>
|
||||
b.kind === 'wide'
|
||||
? `<div class="wide-row">${keyed(b.it, 0.5625)}</div>`
|
||||
: `<div class="masonry">${b.cols.map((c) => `<div class="mcol">${c.map((cell) => keyed(cell.it, cell.ar)).join('')}</div>`).join('')}</div>`,
|
||||
)
|
||||
.join('');
|
||||
cardEls.clear();
|
||||
const heights = [0, 0];
|
||||
const parts = [[], []];
|
||||
for (const s of list) {
|
||||
const col = heights[0] <= heights[1] ? 0 : 1;
|
||||
parts[col].push(cardHtml(s));
|
||||
heights[col] += aspectFor(s) + 0.42;
|
||||
}
|
||||
cols.forEach((c, i) => (c.innerHTML = parts[i].join('')));
|
||||
$$('.card[data-id]', masonry).forEach((el) => {
|
||||
$$('.card[data-id]', fbody).forEach((el) => {
|
||||
cardEls.set(el.dataset.id, el);
|
||||
el.classList.toggle('is-selected', el.dataset.id === selectedId);
|
||||
});
|
||||
masonry.setAttribute('aria-busy', 'false');
|
||||
fbody.setAttribute('aria-busy', 'false');
|
||||
$('.feed-end', view).hidden = !list.length;
|
||||
if (!list.length)
|
||||
fbody.innerHTML = `<div class="feed-empty tele">Nothing in this band right now${near ? ' near you' : ''}.</div>`;
|
||||
// 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;
|
||||
count.textContent = `${live} live · ${all.length} situations · ${new Date().toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })}`;
|
||||
if (section === 'near' && !userLoc) {
|
||||
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 = `<div class="near-prompt glass">${icon('location')}<div><b>See what’s around you</b><span>Sorted from San Francisco until you share your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
|
||||
note.innerHTML = `<div class="near-prompt">${icon('location')}<div><b>See what’s around you</b><span>Distances and the Near lens need your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
|
||||
} else note.hidden = true;
|
||||
// Images fade in when decoded.
|
||||
$$('img', masonry).forEach((img) => {
|
||||
if (img.complete) img.classList.add('loaded');
|
||||
else
|
||||
img.addEventListener('load', () => img.classList.add('loaded'), {
|
||||
once: 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function placeInk() {
|
||||
const btn = $(`[data-sec="${section}"]`, tabs);
|
||||
if (!btn) return;
|
||||
ink.style.width = `${btn.offsetWidth - 20}px`;
|
||||
ink.style.transform = `translateX(${btn.offsetLeft + 10}px)`;
|
||||
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 } = {}) {
|
||||
@@ -191,49 +543,74 @@ export function createFeed(
|
||||
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(s.id)?.classList.add('pulse');
|
||||
cardEls.get(String(s.id))?.classList.add('pulse');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
count.textContent = 'Could not reach the live feeds';
|
||||
}
|
||||
}
|
||||
|
||||
tabs.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-sec]');
|
||||
if (!b || b.dataset.sec === section) return;
|
||||
section = b.dataset.sec;
|
||||
$$('[data-sec]', tabs).forEach((x) =>
|
||||
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');
|
||||
placeInk();
|
||||
b.scrollIntoView({
|
||||
inline: 'nearest',
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
behavior: reducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
masonry.classList.remove('swap');
|
||||
void masonry.offsetWidth;
|
||||
masonry.classList.add('swap');
|
||||
render();
|
||||
swap();
|
||||
});
|
||||
|
||||
function swap() {
|
||||
fbody.classList.remove('swap');
|
||||
void fbody.offsetWidth;
|
||||
fbody.classList.add('swap');
|
||||
render();
|
||||
}
|
||||
|
||||
view.addEventListener('click', async (e) => {
|
||||
const hit = e.target.closest('.card-hit');
|
||||
if (hit) {
|
||||
const card = hit.closest('.card');
|
||||
const s = all.find((x) => String(x.id) === card.dataset.id);
|
||||
if (s) onOpen(s, card);
|
||||
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') {
|
||||
userLoc = await onLocate();
|
||||
const loc = await onLocate();
|
||||
if (loc) setLocation(loc);
|
||||
render();
|
||||
}
|
||||
});
|
||||
@@ -300,8 +677,7 @@ export function createFeed(
|
||||
);
|
||||
|
||||
function setPull(d, animate = false) {
|
||||
const t = animate ? 'transform 420ms var(--spring)' : 'none';
|
||||
inner.style.transition = t;
|
||||
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));
|
||||
@@ -324,20 +700,74 @@ export function createFeed(
|
||||
}, wait);
|
||||
}
|
||||
|
||||
// Keep "2 min ago" honest.
|
||||
// Keep relative times honest.
|
||||
setInterval(() => {
|
||||
$$('.ago[data-ts]', view).forEach((el) => {
|
||||
if (el.dataset.ts)
|
||||
el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts);
|
||||
});
|
||||
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);
|
||||
|
||||
new ResizeObserver(placeInk).observe(tabs);
|
||||
requestAnimationFrame(placeInk);
|
||||
// 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 ? `<i></i>${n} here` : '';
|
||||
}
|
||||
});
|
||||
|
||||
function setLocation(loc) {
|
||||
userLoc = loc;
|
||||
if (loc) globe.setViewer?.({ lon: loc.lon, lat: loc.lat });
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
refresh,
|
||||
render,
|
||||
refreshSocial: socialChanged,
|
||||
cardFor: (id) => cardEls.get(String(id)) || null,
|
||||
situations: () => all,
|
||||
select(id) {
|
||||
@@ -347,8 +777,11 @@ export function createFeed(
|
||||
);
|
||||
},
|
||||
setLocation(loc) {
|
||||
userLoc = loc;
|
||||
if (section === 'near') render();
|
||||
setLocation(loc);
|
||||
render();
|
||||
},
|
||||
get location() {
|
||||
return userLoc;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user