assets/emergence.js
import { squarify } from './emergence-layout.js';
import { enhanceMobileMap } from './emergence-map.js';
import { enhanceMobileShell } from './emergence-mobile-shell.js';
// Optional enhancement. The complete record list and map are already rendered.
const allRecords = document.getElementById('em-all-records');
const templates = new Map([...allRecords.querySelectorAll('.em-record')].map(el => [el.dataset.recordId, el]));
const box = document.getElementById('em-treemap');
const crumb = document.getElementById('em-crumb');
const index = document.getElementById('em-topic-index');
const detail = document.getElementById('em-detail');
const status = document.getElementById('em-drill-status');
const preview = document.getElementById('em-tile-preview');
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const route = (...parts) => '#t/' + parts.map(encodeURIComponent).join('/');
let snapshot;
function updateWorkspaceNav() {
// The app shell owns pane state and nav highlighting; without it (no JS) nothing runs.
if (window.appShellActivePane) return;
const hash = location.hash;
const active = /^#(?:map|country-)/.test(hash) ? '#map' : /^#(?:topics|topic-|record-)/.test(hash) ? '#topics' : hash === '#boundaries' || hash === '#compare' ? hash : hash === '#method' || hash === '#verified' ? '' : '#day';
document.querySelectorAll('.em-lens-nav a').forEach(a => {
if (a.getAttribute('href') === active) a.setAttribute('aria-current', 'location');
else a.removeAttribute('aria-current');
});
}
updateWorkspaceNav();
// Keep ordinary link history in this workspace; the shared shell remains unchanged.
const workspaceNav = document.querySelector('.em-lens-nav');
workspaceNav.addEventListener('click', event => {
const link = event.target.closest('a[href^="#"]');
if (!link) return;
if (link.hash === '#map') document.getElementById('map').scrollTop = 0;
event.stopImmediatePropagation();
}, true);
workspaceNav.addEventListener('keydown', event => {
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const links = [...workspaceNav.querySelectorAll('a')].filter(link => link.getClientRects().length), current = links.indexOf(document.activeElement);
if (current < 0) return;
event.preventDefault();
event.stopImmediatePropagation();
const next = links[(current + (event.key === 'ArrowRight' ? 1 : links.length - 1)) % links.length];
next.focus();
location.hash = next.hash;
}, true);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', enhanceMobileShell);
else enhanceMobileShell();
function openTarget() {
let id;
try { id = decodeURIComponent(location.hash.slice(1)); } catch { return; }
const target = document.getElementById(id);
if (!target) return;
if (target.matches('.em-record') && target.hidden) {
const input = document.getElementById('em-search');
input.value = '';
input.dispatchEvent(new Event('input'));
}
let parent = target;
while (parent) {
if (parent.tagName === 'DETAILS') parent.open = true;
parent = parent.parentElement;
}
// Hash links inside closed details need a second scroll after opening their ancestors.
requestAnimationFrame(() => target.scrollIntoView({ block: 'start' }));
}
openTarget();
function fitLabels() {
box.classList.add('em-enhanced');
preview.hidden = true;
box.querySelectorAll('.em-tile').forEach(tile => {
const copy = tile.querySelector('.em-tile-copy');
tile.toggleAttribute('data-compact', tile.clientWidth < 94 || copy.scrollHeight + 8 > tile.clientHeight || copy.scrollWidth > tile.clientWidth);
// The immediate, hoverable preview replaces the browser's delayed title tooltip.
tile.removeAttribute('title');
});
}
new ResizeObserver(fitLabels).observe(box);
function showPreview(tile) {
if (!tile?.hasAttribute('data-compact')) { preview.hidden = true; return; }
const name = document.createElement('b'), count = document.createElement('span');
name.textContent = tile.dataset.label;
count.textContent = `${tile.dataset.count} event${tile.dataset.count === '1' ? '' : 's'}`;
preview.replaceChildren(name, count);
preview.hidden = false;
const bounds = box.getBoundingClientRect(), rect = tile.getBoundingClientRect();
const left = rect.left - bounds.left + rect.width / 2 - preview.offsetWidth / 2;
const above = rect.top - bounds.top - preview.offsetHeight - 8;
preview.style.left = Math.max(8, Math.min(left, box.clientWidth - preview.offsetWidth - 8)) + 'px';
preview.style.top = Math.max(8, Math.min(above < 8 ? rect.bottom - bounds.top + 8 : above, box.clientHeight - preview.offsetHeight - 8)) + 'px';
}
box.addEventListener('pointerover', event => {
if (!event.target.closest('.em-tile-preview')) showPreview(event.target.closest('.em-tile'));
});
box.addEventListener('pointerleave', () => { if (!box.contains(document.activeElement)) preview.hidden = true; });
box.addEventListener('focusin', event => showPreview(event.target.closest('.em-tile')));
box.addEventListener('focusout', event => { if (!box.contains(event.relatedTarget)) preview.hidden = true; });
box.addEventListener('keydown', event => { if (event.key === 'Escape') preview.hidden = true; });
// World lens: hover/focus metadata for event circles, in the same bubble language.
const world = document.querySelector('.em-world');
if (world) {
const map = world.querySelector('.em-map');
enhanceMobileMap(world);
const mapPreview = document.createElement('div');
mapPreview.className = 'em-tile-preview';
mapPreview.hidden = true;
world.appendChild(mapPreview);
const showMapPreview = target => {
if (matchMedia('(max-width:900px)').matches) { mapPreview.hidden = true; return; }
if (!target) { mapPreview.hidden = true; return; }
const name = document.createElement('b');
name.textContent = target.dataset.mapName || '';
const line = document.createElement('span');
line.textContent = target.dataset.mapSummary || '';
mapPreview.replaceChildren(name, line);
const first = document.createElement('span');
first.className = 'em-preview-first';
first.textContent = target.dataset.mapFirst ? 'first reported ' + relAge(target.dataset.mapFirst) : '';
if (first.textContent) mapPreview.append(first);
if (target.dataset.mapSample) {
const identity = document.createElement('span');
identity.textContent = 'not fact-checked · summarized from news reports';
const line2 = document.createElement('span');
const source = document.createElement('a');
line2.textContent = `“${target.dataset.mapSample}” — ${target.dataset.mapSource}`;
source.textContent = `${target.dataset.mapSource} ↗`;
source.href = target.dataset.mapUrl;
source.target = '_blank';
source.rel = 'noopener noreferrer';
mapPreview.append(identity, line2, source);
}
mapPreview.hidden = false;
target.querySelector('title')?.remove(); // the native tooltip adds latency on top
const bounds = map.getBoundingClientRect(), dot = target.querySelector('.em-dot');
if (!dot) return;
const r = dot.getBoundingClientRect();
const left = r.left - bounds.left + r.width / 2 - mapPreview.offsetWidth / 2;
const above = r.top - bounds.top - mapPreview.offsetHeight - 8;
mapPreview.style.left = Math.max(8, Math.min(left, map.clientWidth - mapPreview.offsetWidth - 8)) + 'px';
mapPreview.style.top = Math.max(8, Math.min(above < 8 ? r.bottom - bounds.top + 8 : above, map.clientHeight - mapPreview.offsetHeight - 8)) + 'px';
};
map.addEventListener('pointerover', event => showMapPreview(event.target.closest('a[data-map-name]')));
world.addEventListener('pointerleave', () => { if (!world.contains(document.activeElement)) showMapPreview(null); });
map.addEventListener('focusin', event => showMapPreview(event.target.closest('a[data-map-name]')));
world.addEventListener('focusout', event => { if (!world.contains(event.relatedTarget)) showMapPreview(null); });
world.addEventListener('keydown', event => { if (event.key === 'Escape') showMapPreview(null); });
}
function cloneRecord(id, boundary = false) {
const original = templates.get(id);
if (!original) return null;
const clone = original.cloneNode(true);
clone.removeAttribute('id');
clone.hidden = false;
clone.classList.toggle('em-boundary-record', boundary);
clone.querySelectorAll('details').forEach(d => d.open = false);
if (boundary) clone.querySelector('.em-claim').before(clone.querySelector('.em-uncertainty'));
return clone;
}
function renderDrill(moveFocus = false) {
if (!snapshot || !(location.hash === '#day' || /^#t(?:\/|$)/.test(location.hash))) return;
let path;
try { path = location.hash === '#day' ? [] : location.hash.replace(/^#t\/?/, '').split('/').filter(Boolean).map(decodeURIComponent); } catch { path = []; }
const tree = snapshot.topics.tree.filter(t => t.count > 0);
const topic = tree.find(t => t.topic === path[0]);
const sub = topic?.subtopics.find(s => s.subtopic === path[1] && s.count > 0);
crumb.innerHTML = `<a href="#t/">All topics</a>${topic ? `<span aria-hidden="true">/</span>${sub ? `<a href="${route(topic.topic)}">${esc(topic.name)}</a><span aria-hidden="true">/</span><span aria-current="page">${esc(sub.name)}</span>` : `<span aria-current="page">${esc(topic.name)}</span>`}` : ''}`;
detail.hidden = !sub;
box.hidden = !!sub;
preview.hidden = true;
index.closest('aside').hidden = !!sub;
const value = n => n.score ?? n.count;
const summary = n => n.events != null ? `${n.events} event${n.events === 1 ? '' : 's'} · ${n.count} report${n.count === 1 ? '' : 's'}` : `${n.count} report${n.count === 1 ? '' : 's'}`;
if (sub) {
// The event layer groups records into deduplicated events; the leaf shows each
// event with its facets (distinct angles) and member reports.
const events = (sub.event_refs || []).slice().sort((a, b) => b.weight - a.weight);
const heading = document.createElement('h3');
heading.textContent = events.length
? `${sub.name} / ${events.length} event${events.length === 1 ? '' : 's'} · ${sub.count} record${sub.count === 1 ? '' : 's'}`
: `${sub.name} / ${sub.count} records`;
const wrap = document.createElement('div');
wrap.className = 'em-record-grid';
if (events.length) {
wrap.append(...events.flatMap(ev => {
const card = document.createElement('article');
card.className = 'em-event';
const facets = ev.facets.map(f => `<span class="em-facet-chip">${esc(f.label)}</span>`).join('');
const meta = `<p class="em-event-meta"><span class="em-label">Event</span> ${ev.reports} report${ev.reports === 1 ? '' : 's'} · ${ev.sources.length} source${ev.sources.length === 1 ? '' : 's'} · weight ${Number(ev.weight).toFixed(2)}${ev.thread_days > 1 ? ` · <span class="em-ongoing">ongoing · day ${ev.thread_days}</span>` : ''}</p>${facets ? `<p class="em-event-facets">${facets}</p>` : ''}`;
card.innerHTML = meta;
const members = document.createElement('div');
members.className = 'em-event-records';
members.append(...ev.essences.map(e => cloneRecord(e.id)).filter(Boolean));
card.append(members);
return [card];
}));
} else {
// No event layer in this snapshot: fall back to the raw record listing.
const records = snapshot.essences.filter(e => e.topics?.[0]?.topic === topic.topic && e.topics[0].subtopic === sub.subtopic);
wrap.append(...records.map(e => cloneRecord(e.id)).filter(Boolean));
}
detail.replaceChildren(heading, wrap);
observeUnread(wrap);
status.textContent = '';
status.hidden = true;
} else {
const nodes = topic ? topic.subtopics.filter(s => s.count > 0).map(s => ({ name: s.name, count: s.events ?? s.count, summary: summary(s), value: value(s), go: route(topic.topic, s.subtopic) }))
: tree.map(t => ({ name: t.name, count: t.events ?? t.count, summary: summary(t), value: value(t), go: route(t.topic) }));
box.innerHTML = squarify(nodes.map((n, i) => ({ value: n.value, n, i }))).map(t =>
`<a class="em-tile em-tone-${t.i % 4}" href="${t.n.go}" aria-label="${esc(t.n.name)}, ${esc(t.n.summary)}. Explore." data-label="${esc(t.n.name)}" data-count="${t.n.count}" style="left:${t.x * 100}%;top:${t.y * 100}%;width:${t.w * 100}%;height:${t.h * 100}%"><span class="em-tile-copy"><b>${esc(t.n.name)}</b><span>${esc(t.n.summary)} <span aria-hidden="true">↗</span></span></span><span class="em-tile-marker" aria-hidden="true">${t.n.count}</span></a>`).join('');
box.append(preview);
index.innerHTML = nodes.map((n, i) => `<li><a href="${n.go}"><span class="em-index-dot em-tone-${i % 4}" aria-hidden="true"></span><span>${esc(n.name)}</span><span>${n.count}</span></a></li>`).join('');
index.previousElementSibling.textContent = topic ? 'Primary subtopics' : 'Primary topics';
status.textContent = '';
status.hidden = true;
fitLabels();
}
if (moveFocus) { crumb.tabIndex = -1; crumb.focus({ preventScroll: true }); }
}
window.addEventListener('hashchange', () => {
updateWorkspaceNav();
if (location.hash === '#day' || /^#t(?:\/|$)/.test(location.hash)) renderDrill(!workspaceNav.contains(document.activeElement));
else openTarget();
});
// Local search needs no network and remains available if data.json cannot be fetched.
const groups = [...allRecords.querySelectorAll('.em-group')];
const search = document.getElementById('em-search');
document.getElementById('em-search-controls').hidden = false;
const textIndex = [...templates.values()].map(el => ({ el, text: el.textContent.toLowerCase() }));
const initialOpen = new Map(groups.map(group => [group, group.open]));
search.addEventListener('input', () => {
const query = search.value.toLowerCase().trim();
let count = 0;
for (const { el, text } of textIndex) { el.hidden = !!query && !text.includes(query); if (!el.hidden) count++; }
groups.forEach(group => {
const hits = [...group.querySelectorAll('.em-record')].filter(el => !el.hidden).length;
group.hidden = hits === 0;
group.open = query ? hits > 0 : initialOpen.get(group);
});
document.getElementById('em-empty').hidden = count !== 0;
document.getElementById('em-search-status').textContent = `${count} of ${templates.size} records${query ? ' match' : ''}`;
});
async function enhance() {
try {
const response = await fetch('data.json' + (window.EMERGENCE_ASSET_V ? '?v=' + window.EMERGENCE_ASSET_V : ''));
if (!response.ok) throw new Error('Snapshot unavailable');
snapshot = await response.json();
// Consume the contract unchanged. Static cards keep the same identity and full source metadata.
document.querySelectorAll('[data-go]').forEach(a => a.href = a.dataset.go);
if (/^#t(?:\/|$)/.test(location.hash)) renderDrill();
const select = document.getElementById('em-event-type');
document.getElementById('em-boundary-control').hidden = false;
select.addEventListener('change', () => {
const records = snapshot.essences.filter(e => (e.event_type || 'report') === select.value);
document.getElementById('em-boundary-records').replaceChildren(...records.slice(0, 2).map(e => cloneRecord(e.id, true)).filter(Boolean));
document.getElementById('em-boundary-status').textContent = `${Math.min(2, records.length)} of ${records.length} · snapshot order`;
});
select.dispatchEvent(new Event('change'));
initReadState();
applyLive();
} catch {
// All static links still open complete topic groups; no empty loading state.
status.textContent = 'Interactive drill unavailable. Topic links open the record list.';
status.hidden = false;
}
}
// Live layer: the cloud cycle (Worker + R2) serves a fresher snapshot than the
// deployed static page. Records missing from the static DOM render client-side
// into a "newer than this page build" group — the static page stays the floor.
function clientRecord(e) {
const a = e.article || e;
const card = document.createElement('article');
card.className = 'em-record';
card.dataset.recordId = e.id;
card.id = 'record-' + e.id;
const at = e.distillation?.distilled_at;
const t = at ? new Date(at) : null;
card.innerHTML = `<div class="em-record-identity"><span class="em-status">not fact-checked · summarized from news reports</span><span class="em-record-type">${esc(e.event_type || 'report')}</span>${at ? `<time class="em-added" datetime="${esc(at)}" data-added="${esc(at)}">added ${t ? String(t.getUTCHours()).padStart(2, '0') + ':' + String(t.getUTCMinutes()).padStart(2, '0') + ' UTC' : ''}</time>` : ''}</div>
<h3>${esc(a.title || 'Source report')}</h3>
<a class="em-source" href="${esc(a.url)}" target="_blank" rel="noopener noreferrer">${esc(a.source_name)} ↗</a>
<div class="em-uncertainty"><p class="em-label">Ambiguous</p><p>${esc(e.uncertainty || 'No uncertainty note supplied.')}</p></div>
<div class="em-claim"><p class="em-label">Report</p><p>${esc(e.essence)}</p></div>`;
templates.set(e.id, card);
return card;
}
async function applyLive() {
if (!window.EMERGENCE_LIVE_URL) return;
let live = null;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 6000);
const res = await fetch(window.EMERGENCE_LIVE_URL, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) throw new Error('live unavailable');
live = await res.json();
} catch { return; } // the static snapshot is the floor; live is best-effort
if (!live || !live.essences || live.date < snapshot.date) return;
snapshot = live;
// Rail meters read the live numbers.
const rail = document.querySelector('.em-rail-stats');
if (rail) {
const meters = { Events: live.events.length, Reports: live.essences.length, Countries: (live.gis.countries || []).length };
rail.querySelectorAll('div').forEach(div => {
const key = div.querySelector('dt')?.textContent;
if (meters[key]) div.querySelector('dd').textContent = meters[key];
});
}
// Records newer than the page build get their own group at the top of the list.
syncMapPlaces(live);
const base = window.EMERGENCE_LIVE_URL ? new URL(window.EMERGENCE_LIVE_URL).origin : null;
if (base) syncCycleHeartbeat(base);
const known = new Set([...templates.keys()]);
const fresh = live.essences.filter(e => !known.has(e.id));
const container = document.getElementById('em-all-records');
if (fresh.length && container && !document.getElementById('em-live-group')) {
const group = document.createElement('details');
group.className = 'em-group';
group.id = 'em-live-group';
group.open = true;
group.innerHTML = `<summary><span>Live since this page build</span><span>${fresh.length} records <span aria-hidden="true">+</span></span></summary>`;
const grid = document.createElement('div');
grid.className = 'em-record-grid';
fresh.forEach(e => clientRecord(e)); // register all for the drill; show the first 30
fresh.slice(0, 30).forEach(e => grid.append(templates.get(e.id)));
observeUnread(grid);
group.append(grid);
container.prepend(group);
}
if (/^#t(?:\/|$)/.test(location.hash)) renderDrill();
applyFreshness();
refreshUnreadCount();
applyMapAges();
}
/* Freshness: green on everything — brightness is age, never importance. */
const relAge = iso => {
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms) || ms < 0) return '';
const m = Math.round(ms / 60000);
if (m < 1) return 'just now';
if (m < 60) return m + 'm ago';
const h = Math.round(m / 60);
if (h < 48) return h + 'h ago';
return Math.round(h / 24) + 'd ago';
};
const ageBucket = iso => {
const h = (Date.now() - Date.parse(iso)) / 36e5;
if (Number.isFinite(h) && h >= 0) return h <= 2 ? 'em-age-now' : h <= 24 ? 'em-age-today' : '';
return '';
};
function applyFreshness(root = document) {
root.querySelectorAll('.em-record[data-record-id]').forEach(card => {
const tag = card.querySelector('.em-added');
const at = tag?.dataset.added;
card.classList.remove('em-age-now', 'em-age-today');
const b = at ? ageBucket(at) : '';
if (b) card.classList.add(b);
if (tag && at) tag.textContent = 'added ' + relAge(at);
});
}
function syncCycleHeartbeat(base) {
const slot = document.getElementById('em-cycle');
if (!slot) return;
fetch(base + '/status.json', { signal: AbortSignal.timeout(5000) })
.then(r => r.ok ? r.json() : Promise.reject())
.then(s => {
const age = relAge(s.finished || s.started) || 'unknown';
const lane = s.mode === 'fast' ? ' · fast lane' : '';
slot.textContent = `last cycle ${age}${lane} · ${s.essences ?? ''} records live`;
slot.hidden = false;
})
.catch(() => {});
}
function syncMapPlaces(live) {
const svg = document.querySelector('.em-world .em-map');
if (!svg || !live.gis?.places) return;
const existing = new Map([...svg.querySelectorAll('a[data-map-place]')].map(a => [a.dataset.mapName, a]));
for (const pl of live.gis.places) {
const a = existing.get(pl.name);
if (!a) continue; // new pins arrive with the next deploy; classes refresh live
if (pl.last_update_at) { a.dataset.added = pl.last_update_at; a.dataset.lastUpdate = pl.last_update_at; }
if (pl.first_seen_at) a.dataset.mapFirst = pl.first_seen_at;
a.classList.remove('em-pin-now', 'em-pin-today');
const b = pl.last_update_at ? ageBucket(pl.last_update_at) : '';
if (b) a.classList.add(b.replace('em-age-', 'em-pin-'));
}
}
/* Read marks: local-only memory of what you've seen. No accounts, no server,
no sync — clearing site data resets it. Seen records dim; an unread-only
filter and a live unread meter ride the same state. */
const SEEN_KEY = 'em-read-marks';
const seen = (() => {
try { return new Map(Object.entries(JSON.parse(localStorage.getItem(SEEN_KEY) || '{}'))); }
catch { return new Map(); }
})();
const pruneSeen = () => {
const cutoff = Date.now() - 7 * 864e5;
for (const [id, ts] of seen) if (ts < cutoff) seen.delete(id);
};
let seenSaveTimer = null;
const saveSeen = () => {
clearTimeout(seenSaveTimer);
seenSaveTimer = setTimeout(() => {
try { localStorage.setItem(SEEN_KEY, JSON.stringify(Object.fromEntries(seen))); } catch {}
}, 800);
};
addEventListener('pagehide', () => { try { localStorage.setItem(SEEN_KEY, JSON.stringify(Object.fromEntries(seen))); } catch {} });
const isSeen = id => seen.has(id);
const markSeen = id => { seen.set(id, Date.now()); saveSeen(); };
pruneSeen();
function applySeen(scope = document) {
scope.querySelectorAll('.em-record[data-record-id]').forEach(card => {
card.classList.toggle('em-seen', isSeen(card.dataset.recordId));
});
}
const readObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
readObserver.unobserve(entry.target);
markSeen(entry.target.dataset.recordId);
entry.target.classList.add('em-seen');
refreshUnreadCount();
}
}, { threshold: 0.35 });
function observeUnread(scope = document) {
scope.querySelectorAll('.em-record[data-record-id]').forEach(card => {
if (isSeen(card.dataset.recordId)) card.classList.add('em-seen');
else readObserver.observe(card);
});
}
const unreadOf = () => snapshot ? snapshot.essences.filter(e => !isSeen(e.id)).length : 0;
let unreadMeter = null, unreadToggle = null;
function refreshUnreadCount() {
if (unreadMeter) unreadMeter.querySelector('dd').textContent = unreadOf();
if (unreadToggle) {
const n = unreadOf();
unreadToggle.textContent = `Unread only (${n})`;
unreadToggle.setAttribute('aria-pressed', unreadToggle.dataset.on === '1');
}
}
function filterUnreadOnly() {
const on = unreadToggle?.dataset.on === '1';
const groups = [...document.querySelectorAll('#em-all-records .em-group')];
groups.forEach(group => {
let visible = 0;
group.querySelectorAll('.em-record').forEach(el => {
const show = !on || !el.classList.contains('em-seen');
el.hidden = !show;
if (show) visible++;
});
group.hidden = visible === 0;
if (on) group.open = visible > 0;
else group.open = false;
});
}
function initReadState() {
// Rail meter (client-only; the static page carries nothing)
const railStats = document.querySelector('.em-rail-stats');
if (railStats && !document.getElementById('em-unread-meter')) {
unreadMeter = document.createElement('div');
unreadMeter.id = 'em-unread-meter';
unreadMeter.innerHTML = '<dt>Unread</dt><dd>0</dd>';
railStats.append(unreadMeter);
}
// Records pane controls
const controls = document.getElementById('em-search-controls');
if (controls && !document.getElementById('em-unread-toggle')) {
unreadToggle = document.createElement('button');
unreadToggle.type = 'button';
unreadToggle.id = 'em-unread-toggle';
unreadToggle.className = 'em-unread-btn';
unreadToggle.dataset.on = '0';
unreadToggle.addEventListener('click', () => {
unreadToggle.dataset.on = unreadToggle.dataset.on === '1' ? '0' : '1';
filterUnreadOnly();
refreshUnreadCount();
});
controls.append(unreadToggle);
const markAll = document.createElement('button');
markAll.type = 'button';
markAll.className = 'em-unread-btn';
markAll.textContent = 'Mark all read';
markAll.addEventListener('click', () => {
for (const e of snapshot.essences) markSeen(e.id);
document.querySelectorAll('.em-record[data-record-id]').forEach(c => { readObserver.unobserve(c); c.classList.add('em-seen'); });
if (unreadToggle.dataset.on === '1') filterUnreadOnly();
refreshUnreadCount();
});
controls.append(markAll);
}
observeUnread();
refreshUnreadCount();
}
function applyMapAges() {
document.querySelectorAll('.em-map a[data-map-name]').forEach(a => {
const t = a.dataset.lastUpdate;
a.classList.remove('em-age-1', 'em-age-2', 'em-age-3', 'em-age-4');
if (!t) { a.classList.add('em-age-4'); return; }
const h = (Date.now() - Date.parse(t)) / 36e5;
a.classList.add(h <= 2 ? 'em-age-1' : h <= 6 ? 'em-age-2' : h <= 24 ? 'em-age-3' : 'em-age-4');
});
}
applyMapAges();
setInterval(applyMapAges, 60000);
applyFreshness();
enhance();