source.fact.ngo a coherence.ngo project

assets/emergence.js

raw ↗ · AGPL-3.0

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();