diff --git a/README.md b/README.md index 2c6be63..badca89 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,8 @@ If saving shows a warning, follow it before assuming your change is active. If c Open timeline settings with the **sliders icon**. Choose Followers or Paid subscribers, filter All/Gained/Lost, or switch between **Constellation** (one star per recorded action) and **Journal** (every recorded event). These viewing choices do not change what you record. +XFlog loads the newest activity first. Scroll toward the older edge or choose **Load older activity** to bring in another page. Both layouts show how many events and people are loaded alongside the totals for the selected filters and time range. + Each star represents one recorded action. Repeated actions from the same person stay as separate entries; new activity does not replace an earlier action. In Constellation, scroll up for older history and down for newer history. The person nearest the center becomes highlighted as you move, and their details appear in the readout. You can also click any visible star or name to select and center that person; they stay selected until you move through history again. Click the selected person or their readout for details. Interaction details include a linked post excerpt of up to 280 characters when text is already saved; no extra X request is made. The side arrows move one recorded action at a time. Use the bottom sliders to move through history or change zoom, which starts at **80%**. You can also hold the middle mouse button and scroll up to zoom in, or down to zoom out. Journal uses normal vertical scrolling and hides the sliders.
@@ -268,7 +270,7 @@ Use either **clock button** to change the shared time range for Constellation, J | **Verification** | Verified/unverified and blue, business or government badge types when supplied. | | **Profile location** | Similar location text, tidied for spacing, capitalization and clear aliases such as NYC → New York City and Bangalore → Bengaluru. | -Zoom out to see every available group in Constellation. On smaller screens, scroll sideways or use the history slider to reach groups that do not fit. Journal uses collapsible sections. A person can appear in several groups: a later like or reply does not hide their earlier follow or unfollow. Group badges and legend counts show total actions within the selected range. Hover over a group badge or legend count for the number of unique people; screen readers announce both counts. Each matching action remains a separate star. +Zoom out to see groups with loaded events in Constellation. The overview also reports how many groups have events still to load. On smaller screens, scroll sideways or use the history slider to reach groups that do not fit. Journal uses collapsible sections and shows a summary for groups whose events have yet to load. A person can appear in several groups. Group badges and legend counts show total actions within the selected range. Hover over a group badge or legend count for the number of unique people. Screen readers announce both counts. Each loaded action has its own star. Account size, age, verification and location use only details delivered with new events while enabled. Old missing details stay Unknown; turning a group off keeps saved history. Profile facts describe the recorded event, not a live profile check. When space allows, enabled Verification, Account size and Account age add facts to the selected-person readout. Known verified accounts get a checkmark beside their name in Constellation and the readout when Verification is enabled. diff --git a/src/xflog/app.py b/src/xflog/app.py index 82aedfd..48d4572 100644 --- a/src/xflog/app.py +++ b/src/xflog/app.py @@ -14,11 +14,12 @@ from . import audience_groups from .config import ASSETS, Config from .errors import SetupError -from .history.demo import demo_activity, demo_history +from .history.demo import demo_activity, demo_history, demo_timeline from .history.demo import encounter_context as demo_encounter_context from .history.filters import valid_filters from .history.summary import SUBJECT_TYPES, SUMMARY_PERIODS, activity_window, all_readings, readings -from .history.timeline import Timeline, decode_cursor +from .history.timeline import Timeline +from .history.timeline_cursor import InvalidTimelineRequest from .interactions import TYPES, catalog from .interactions.history import ThreadOptions from .profile_names import ProfileNames @@ -37,6 +38,19 @@ from .web.ui import mount_ui as mount_browser_ui +def _history_scope(kind, subject, since, limit, lens): + if not valid_filters(kind, subject, 0): + raise HTTPException(422, "Invalid timeline filter.") + if lens != "none" and lens not in audience_groups.MODULES: + raise HTTPException(422, "Invalid audience group.") + if not 1 <= limit <= 50: + raise HTTPException(422, "History page size must be between 1 and 50.") + try: + return timestamp(since) if since else "" + except (ValueError, OverflowError): + raise HTTPException(422, "Invalid history range.") from None + + def create_app(config: Config | None = None, *, transport=None, mount_ui=True): config = config or Config.from_env() runtime = Runtime(config, transport=transport) @@ -207,36 +221,33 @@ async def timeline_history( since: str = "", local_only: bool = False, limit: int = 25, + lens: str = "none", + after_revision: str = "", + selected: str = "", + encounter_revision: str = "", ): - try: - decode_cursor(before) - except ValueError: - raise HTTPException(422, "Invalid timeline cursor.") from None - if not valid_filters(kind, subject, 0): - raise HTTPException(422, "Invalid timeline filter.") - try: - since = timestamp(since) if since else "" - except (ValueError, OverflowError): - raise HTTPException(422, "Invalid history range.") from None - if not 1 <= limit <= 50: - raise HTTPException(422, "History page size must be between 1 and 50.") - account = await store.setting("account") - if not account: - return {"events": [], "next": None} + since = _history_scope(kind, subject, since, limit, lens) query = { "kind": kind, "subject": subject, "before": before, "since": since, "limit": limit, + "lens": lens, + "after_revision": after_revision, + "selected": selected, + "encounter_revision": encounter_revision, } - result = await timeline.history(account["id"], query) - if not local_only and await profile_names.ensure(result["events"]): - result = await timeline.history(account["id"], query) - groups = await store.setting("audience_groups", {}) - result["events"] = await audience_groups.decorate_history( - result["events"], groups, timeline.encounter_context - ) + try: + result = await timeline.current_history(query) + if before in {"", "0"} and not local_only and result["events"]: + fresh = [ + event for event in result["events"] if not event.get("encounter_correction") + ] + if fresh and await profile_names.ensure(fresh): + result = await timeline.current_history(query) + except InvalidTimelineRequest as error: + raise HTTPException(422, str(error)) from None return result @app.get("/api/events") @@ -256,11 +267,40 @@ async def history(kind: str = "all", subject: str = "followers", before: int = 0 return result @app.get("/api/demo") - async def demo(kind: str = "all", subject: str = "followers", before: int = 0): - if not valid_filters(kind, subject, before): - raise HTTPException(422, "Invalid demo filter.") - result = demo_history({"kind": kind, "subject": subject, "before": before}) + async def demo( + kind: str = "all", + subject: str = "followers", + before: str = "0", + since: str = "", + limit: int | None = None, + lens: str = "none", + ): + since = _history_scope(kind, subject, since, 25 if limit is None else limit, lens) groups = await store.setting("audience_groups", {}) + if ( + limit is not None + or since + or lens != "none" + or not before.isascii() + or not before.isdecimal() + ): + try: + return await demo_timeline( + { + "kind": kind, + "subject": subject, + "before": before, + "since": since, + "limit": 25 if limit is None else limit, + "lens": lens, + }, + groups, + ) + except InvalidTimelineRequest as error: + raise HTTPException(422, str(error)) from None + if len(before) > 19: + raise HTTPException(422, "Invalid demo cursor.") + result = demo_history({"kind": kind, "subject": subject, "before": int(before)}) result["events"] = await audience_groups.decorate_history( result["events"], groups, demo_encounter_context ) diff --git a/src/xflog/assets/app.js b/src/xflog/assets/app.js index b3b7334..eb7ee11 100644 --- a/src/xflog/assets/app.js +++ b/src/xflog/assets/app.js @@ -9,11 +9,10 @@ layout: "horizontal", events: [], next: null, + aggregate: null, selected: null, status: null, csrf: "", - generation: 0, - loading: false, groups: [], lens: "none", groupRange: "24h", @@ -94,6 +93,7 @@ label: eventLabel, initials, onOpen: (identity) => selectEvent(identity, true), + onNearEnd: () => requestOlder(false), }); const { createActivityHud } = await import("/static/xf/activity-hud.js"); const activityHud = createActivityHud({ @@ -139,17 +139,59 @@ const { createNotice, statusNotice } = await import("/static/xf/notice.js"); const notices = createNotice($("#notice")); const notice = (message, tone = "info") => notices.show(message, tone); - const { historyPages, groupSince, mergeHistory, createLiveHistory, updatedSelection } = await import("/static/xf/live-history.js"); + const { createLiveHistory, historyScope } = await import("/static/xf/live-history.js"); + const { historyCount } = await import("/static/xf/audience-groups.js"); + const historyMore = $("#history-more"); + const historyStatus = $("#history-page-status"); + let historyPhase; + let historyError; const liveHistory = createLiveHistory({ - api, - read: () => ({ ...state }), - apply: (events, next) => { - state.selected = updatedSelection(state.events, state.selected, events); + api: (path, signal) => api(path, undefined, signal), + read: () => state, + connected: () => root.isConnected, + clear: clearHistory, + apply: ({events, next, aggregate}, reason) => { + if (historyError && notices.message === historyError) notice(""); + historyError = null; + const olderControl = document.activeElement === $("#memory-next"); + if (!events.some(event => event.event_id === state.selected)) + state.selected = events[Math.min(reason === "initial" ? 2 : 0, events.length - 1)]?.event_id || null; state.events = events; state.next = next; + state.aggregate = aggregate; if (root.dataset.route === "timeline") { - renderEvents(scene.update); - if (state.selected) selectEvent(state.selected, false, followControls.update); + renderEvents(reason === "initial" ? scene.render : scene.update, reason === "older"); + if (state.selected) + selectEvent(state.selected, false, reason === "initial" ? followControls.select : followControls.update); + if (olderControl && $("#memory-next").disabled) $("#history").focus({preventScroll:true}); + } + }, + change: ({phase, catchingUp, olderError}) => { + historyPhase = phase; + $("#history").setAttribute("aria-busy", String(phase === "initial" || phase === "older")); + const hideOlder = !state.next || phase === "initial"; + if (hideOlder && document.activeElement === historyMore) + $("#history").focus({preventScroll:true}); + historyMore.hidden = hideOlder; + historyMore.setAttribute("aria-disabled", String(phase === "older")); + const buttonLabel = olderError ? "Retry older activity" : "Load older activity"; + if (historyMore.textContent !== buttonLabel) historyMore.textContent = buttonLabel; + const message = phase === "older" ? "Loading older activity…" + : olderError ? "Older activity couldn’t be loaded." + : catchingUp ? "Catching up on new activity…" + : !phase && !state.next && state.events.length ? "All matching activity loaded" : ""; + if (historyStatus.textContent !== message) historyStatus.textContent = message; + }, + failure: (error, phase) => { + historyError = error.message; + notice(error.message, "error"); + if (phase === "initial") { + $("#history").innerHTML = + '

History couldn’t be loaded.

Check your local database and connection, then refresh to retry.

'; + $("#history [data-retry]").addEventListener("click", () => { + $("#history").focus({preventScroll:true}); + void liveHistory.load(); + }); } }, refreshInteractions: () => interactionControls.refresh(), @@ -203,6 +245,10 @@ } } function view(name) { + if (name !== "timeline") { + liveHistory.cancel(); + journal.clear({remember: true}); + } for (const page of ["timeline", "profile", "about"]) $(`#${page}-view`).hidden = page !== name; $$("[data-nav]").forEach((button) => { @@ -225,9 +271,12 @@ : null, ); if (name === "timeline") { - renderEvents(scene.update); - if (state.selected) selectEvent(state.selected); - activityHud.refresh(); + if (liveHistory.needsLoad()) void liveHistory.load(activityHud.refresh); + else { + renderEvents(scene.update); + if (state.selected) selectEvent(state.selected); + void activityHud.refresh(); + } } $(".back-button").hidden = name === "timeline"; if ($("#explore-dialog").open) $("#explore-dialog").close(); @@ -236,6 +285,7 @@ } function renderStatus(status) { const previous = state.status; + const scope = historyScope(state); state.status = status; state.csrf = status.csrf; followControls.configure(status, state.demo); @@ -296,8 +346,16 @@ notice(status.message, "warning"); if (!$("#profile-view").hidden) profileControls.renderStatus(status); renderAudienceGroups(status.audience_groups || []); + if (previous && historyScope(state) !== scope) { + if (root.dataset.route === "timeline") void liveHistory.load(); + else { + liveHistory.cancel(); + clearHistory(); + } + return; + } if ( - previous && !state.events.length && + previous && !state.events.length && !historyPhase && (previous.state !== status.state || previous.connected !== status.connected || previous.selection.subscriptions !== status.selection.subscriptions) ) renderEvents(); @@ -311,7 +369,7 @@ enabled: { [group.key]: toggle.checked }, }); renderAudienceGroups(result.audience_groups); - await load(); + await liveHistory.load(); announce("Audience groups saved. No extra X requests are made."); } catch (error) { toggle.checked = group.enabled; @@ -321,17 +379,14 @@ onLens: (lens) => { state.lens = lens; groupControls.render(state.groups, lens, state.groupRange); - scene.configure(state.groups, lens); - if (state.layout === "vertical") renderEvents(); - void load(); + void liveHistory.load(); }, onRange: setTimeRange, }); async function setTimeRange(range) { state.groupRange = range; groupControls.render(state.groups, state.lens, range); - await activityHud.refresh(); - if (state.groupRange === range) await load(); + await liveHistory.load(activityHud.refresh); } function renderAudienceGroups(groups) { const signature = JSON.stringify(groups); @@ -340,17 +395,14 @@ state.lens = resolveLens(groups, state.lens); state.groups = groups; groupControls.render(groups, state.lens, state.groupRange); - scene.configure(groups, state.lens); - selectedSignal.render( - state.events.find((event) => event.event_id === state.selected), - groups, - ); - if (state.layout === "vertical") renderEvents(); } - function renderEvents(renderScene = scene.render) { + function renderEvents(renderScene = scene.render, append = false) { + if ($("#timeline-view").hidden) return; const list = $("#history"); $(".scene-context").hidden = !state.events.length && state.lens === "none"; $("#timeline-view").dataset.layout = state.layout; + if (state.layout === "horizontal" || !state.events.length) + journal.clear({remember: state.layout === "horizontal" && Boolean(state.events.length)}); list.className = `history ${state.layout}`; list.setAttribute( "aria-label", @@ -360,7 +412,7 @@ selectedSignal.render(null); followControls.select(null); scene.clear(); - $("#scene-people-count").textContent = state.lens === "none" ? "" : "0 people in saved history"; + $("#scene-people-count").textContent = state.lens === "none" ? "" : historyCount(0, 0, state.aggregate); list.innerHTML = state.lens === "none" ? emptyHistory(state) : '

No activity in this range.

'; list .querySelector("[data-empty-action]") @@ -371,16 +423,18 @@ else setDemo(true); }); } else if (state.layout === "horizontal") { - renderScene(state.events, state.selected); + renderScene(state.events, state.selected, state.aggregate); } else { scene.clear(); journal.render(state.events, { selected: state.selected, modules: state.groups, lens: state.lens, + aggregate: state.aggregate, + append, }); $("#scene-people-count").textContent = - `${state.events.length} ${state.events.length === 1 ? "event" : "events"} in ${state.lens === "none" ? "loaded" : "saved"} history`; + historyCount(state.events.length, new Set(state.events.map(event => event.actor_id)).size, state.aggregate); } if (state.layout === "vertical") { $("#memory-scrubber").max = Math.max(0, state.events.length - 1); @@ -405,11 +459,9 @@ if (!event) return; state.selected = identity; selectedSignal.render(event, state.groups); - $$("[data-event]").forEach((button) => - button.setAttribute("aria-pressed", button.dataset.event === identity), - ); - renderPersonDetail(root, event); if (state.layout === "horizontal") scene.select(identity); + else journal.select(identity); + renderPersonDetail(root, event); const index = state.events.findIndex((item) => item.event_id === identity); $("#journey-position").textContent = `Memory ${index + 1} of ${state.events.length} loaded`; @@ -423,69 +475,23 @@ updateFollow(event); interactionControls.select(event); } - function requestOlder() { - if ( - !state.next || - root.dataset.route !== "timeline" || - $("#history").getAttribute("aria-busy") === "true" - ) - return; - load(true); + function requestOlder(retry = true) { + if (root.dataset.route === "timeline") void liveHistory.older(retry); } - let historyLoad = null; - async function load(append = false) { - historyLoad?.abort(); - const controller = new AbortController(); - historyLoad = controller; - const generation = ++state.generation; - state.loading = true; - const demo = state.demo; - const snapshot = {...state, since: groupSince(state)}; - if (!append && !state.events.length) { - selectedSignal.render(null); - followControls.select(null); - scene.clear(); - $("#history").innerHTML = - '
Loading local history…
'; - } - $("#history").setAttribute("aria-busy", "true"); - try { - const rows = []; - let result; - const readPage = path => api(path, undefined, controller.signal); - for await (result of historyPages(readPage, snapshot, append ? state.next : "0")) { - if (generation !== state.generation) return; - rows.push(...result.events); - if (state.lens !== "none") $("#scene-people-count").textContent = `Loading saved history · ${rows.length} records`; - } - if (generation !== state.generation) return; - state.events = mergeHistory([...rows, ...(append ? state.events : [])]) - .filter(event => !snapshot.since || Date.parse(event.received_at) >= Date.parse(snapshot.since)); - state.next = result.next; - if (!state.events.some((event) => event.event_id === state.selected)) - state.selected = - state.events[Math.min(2, state.events.length - 1)]?.event_id || null; - renderEvents(); - if (state.selected) selectEvent(state.selected); - if (!append) void interactionControls.refresh(); - announce( - `${rows.length} ${state.subject} events ${append ? "added" : "loaded"}.`, - ); - } catch (error) { - if (generation !== state.generation) return; - notice(error.message, "error"); - if (!state.events.length) { - scene.clear(); - $("#history").innerHTML = - '

History couldn’t be loaded.

Check your local database and connection, then refresh to retry.

'; - $("#history [data-retry]").addEventListener("click", () => load()); - } - } finally { - if (generation === state.generation) { - state.loading = false; - $("#history").setAttribute("aria-busy", "false"); - } - } + historyMore.addEventListener("click", () => requestOlder()); + function clearHistory() { + state.events = []; + state.next = state.selected = state.aggregate = null; + scene.clear(); + scene.configure(state.groups, state.lens); + journal.clear(); + selectedSignal.render(null); + followControls.select(null); + interactionControls.select(null); + $("#scene-people-count").textContent = ""; + $("#journey-position").textContent = "Loading local history"; + $("#history").innerHTML = + '
Loading local history…
'; } async function setDemo(enabled) { const address = new URL(location.href); @@ -493,17 +499,13 @@ else address.searchParams.delete("demo"); history.replaceState(null, "", address); state.demo = enabled; - state.events = []; - state.selected = null; - state.next = null; $("#demo-toggle").textContent = enabled ? "Leave demo ↗" : "Explore demo ↗"; notice(""); if (state.status) renderStatus(state.status); - view("timeline"); - await activityHud.refresh(); - await load(); + if (root.dataset.route === "timeline") await liveHistory.load(activityHud.refresh); + else view("timeline"); } function dialog(html) { if ($("#explore-dialog").open) $("#explore-dialog").close(); @@ -554,21 +556,16 @@ $$("[data-filter]").forEach((item) => item.setAttribute("aria-pressed", item === button), ); - state.events = []; - state.selected = null; - load(); + void liveHistory.load(); }), ); $$("[data-subject]").forEach((button) => - button.addEventListener("click", async () => { + button.addEventListener("click", () => { state.subject = button.dataset.subject; $$("[data-subject]").forEach((item) => item.setAttribute("aria-pressed", item === button), ); - state.events = []; - state.selected = null; - await activityHud.refresh(); - load(); + void liveHistory.load(activityHud.refresh); }), ); $$("[data-layout]").forEach((button) => @@ -577,7 +574,7 @@ $$("[data-layout]").forEach((item) => item.setAttribute("aria-pressed", item === button), ); - renderEvents(); + if (historyPhase !== "initial") renderEvents(); announce(`${state.layout} timeline selected.`); }), ); @@ -601,7 +598,7 @@ : Promise.resolve(null); const result = await api("/api/preferences", selection); renderStatus(await api("/api/status")); - await liveHistory.refresh(); + if (root.dataset.route === "timeline") await liveHistory.refresh(); if (!result.saved) fillProfile(); notice( result.message, @@ -655,14 +652,6 @@ const end = state.layout === "horizontal" ? 1000 : state.events.length - 1; if (Number(event.target.value) >= end) requestOlder(); }); - $("#history").addEventListener("scroll", () => { - const history = $("#history"); - if ( - state.layout === "vertical" && - history.scrollTop + history.clientHeight >= history.scrollHeight - 80 - ) - requestOlder(); - }); $("#motion-toggle").addEventListener("click", (event) => { const enabled = root.dataset.motion !== "on"; @@ -672,40 +661,76 @@ ? "Ambient motion on" : "Ambient motion paused"; }); + let pollTimer; + let pageActive = true; + function stopHistory() { + pageActive = false; + clearTimeout(pollTimer); + liveHistory.cancel(); + } + const unmount = new MutationObserver(() => { + if (!root.isConnected) dispose(); + }); + // Gradio can remove the workspace node or replace its outer host. + for (let node = root; node; ) { + const parent = node.parentNode; + if (parent) { + unmount.observe(parent, {childList: true}); + node = parent; + } else node = node.host; + } + function dispose() { + stopHistory(); + unmount.disconnect(); + window.removeEventListener("pagehide", stopHistory); + window.removeEventListener("pageshow", resumeHistory); + scene.destroy(); + journal.destroy(); + groupControls.destroy(); + activityHud.destroy(); + selectedSignal.destroy(); + followControls.destroy(); + interactionControls.destroy(); + notices.destroy(); + } + window.addEventListener("pagehide", stopHistory); + window.addEventListener("pageshow", resumeHistory); + function resumeHistory(event) { + if (event.persisted) { + pageActive = true; + void poll(); + } + } async function poll() { if (!root.isConnected) { - historyLoad?.abort(); - scene.destroy(); - groupControls.destroy(); - activityHud.destroy(); - selectedSignal.destroy(); - followControls.destroy(); - interactionControls.destroy(); - notices.destroy(); + dispose(); return; } if (!document.hidden || state.status?.selection.notifications) { try { - renderStatus(await api("/api/status")); - if (root.dataset.route === "timeline") await activityHud.refresh(); - await liveHistory.refresh(); + const status = await api("/api/status"); + if (!pageActive) return; + renderStatus(status); + if (root.dataset.route === "timeline") { + await activityHud.refresh(); + if (pageActive && root.dataset.route === "timeline") await liveHistory.refresh(); + } } catch (error) { notices.update({ message: error.message, tone: "error" }); } } - setTimeout(poll, 10000); + if (pageActive) pollTimer = setTimeout(poll, 10000); } try { - renderStatus(await api("/api/status")); + const status = await api("/api/status"); + if (!pageActive) return; + renderStatus(status); if (new URLSearchParams(location.search).get("demo") === "1") await setDemo(true); - else { - await activityHud.refresh(); - await load(); - } + else await liveHistory.load(activityHud.refresh); const hash = location.hash.slice(1); if (["profile", "about"].includes(hash)) view(hash); - setTimeout(poll, 10000); + if (pageActive) pollTimer = setTimeout(poll, 10000); } catch (error) { notice(error.message, "error"); } diff --git a/src/xflog/assets/audience-groups.js b/src/xflog/assets/audience-groups.js index 6cc213a..f7c4e71 100644 --- a/src/xflog/assets/audience-groups.js +++ b/src/xflog/assets/audience-groups.js @@ -1,4 +1,14 @@ -export function groupEvents(events, modules, lens) { +export function historyCount(loadedEvents, loadedActors, aggregate) { + const people = count => `${count} ${count === 1 ? "person" : "people"}`; + const records = count => `${count} ${count === 1 ? "event" : "events"}`; + const loaded = `${records(loadedEvents)} · ${people(loadedActors)}`; + if (!aggregate) return `${loaded} loaded`; + const saved = `${records(aggregate.total_events)} · ${people(aggregate.distinct_actors)}`; + return loadedEvents === aggregate.total_events && loadedActors === aggregate.distinct_actors + ? `${saved} saved` : `${loaded} loaded · ${saved} saved`; +} + +export function groupEvents(events, modules, lens, {totals = [], summaries = false} = {}) { if (lens === "none") return null; const module = modules.find((item) => item.key === lens && item.enabled); if (!module) return null; @@ -27,14 +37,25 @@ export function groupEvents(events, modules, lens) { const group = buckets.get(key) || buckets.get("unknown"); group.events.push(event); } - const result = [...buckets.values()].filter((group) => group.events.length); + for (const total of totals) { + const loaded = buckets.get(total.key); + if (!summaries && !loaded?.events.length) continue; + const bucket = loaded ?? {key: total.key, tone: total.tone, events: []}; + Object.assign(bucket, { + label: total.label, + eventCount: total.event_count, + distinctActors: total.distinct_actors, + }); + buckets.set(total.key, bucket); + } + const groupsWithEvents = [...buckets.values()].filter(group => group.events.length || group.eventCount); return module.dynamic_buckets - ? result.sort((a, b) => + ? groupsWithEvents.sort((a, b) => a.key === "unknown" ? 1 : b.key === "unknown" ? -1 : a.label.localeCompare(b.label), ) - : result; + : groupsWithEvents; } diff --git a/src/xflog/assets/constellation-colors.js b/src/xflog/assets/constellation-colors.js index b44d43f..cb827c1 100644 --- a/src/xflog/assets/constellation-colors.js +++ b/src/xflog/assets/constellation-colors.js @@ -33,10 +33,13 @@ export function colorGroups(groups, module) { } export function groupDescription(group) { - const actions = group.people.length; - return `${actions} ${actions === 1 ? "action" : "actions"} · ${group.personCount} ${group.personCount === 1 ? "person" : "people"}`; + const actions = group.eventCount ?? group.people.length; + const people = group.distinctActors ?? group.personCount; + const total = `${actions} ${actions === 1 ? "action" : "actions"} · ${people} ${people === 1 ? "person" : "people"}`; + return actions !== group.people.length || people !== group.personCount + ? `${total} saved · ${group.people.length} ${group.people.length === 1 ? "action" : "actions"} loaded` : total; } export function groupKey(groups, escape) { - return groups.map(group => `${escape(group.label)}${group.people.length}`).join(""); + return groups.map(group => `${escape(group.label)}${group.eventCount ?? group.people.length}`).join(""); } diff --git a/src/xflog/assets/constellation-layout.js b/src/xflog/assets/constellation-layout.js index 605f09b..a2ff6dc 100644 --- a/src/xflog/assets/constellation-layout.js +++ b/src/xflog/assets/constellation-layout.js @@ -103,13 +103,17 @@ export function visibleLabels(candidates, selected) { return visible; } -export function constellationLayout({ +export function constellationLayout({zoom, ...options}) { + return prepareConstellation(options)(zoom); +} + +export function prepareConstellation({ events, width, height, - zoom, modules, lens, + aggregate, }) { const people = entriesFromEvents(events); const upper = Math.min(90, height * 0.14); @@ -119,80 +123,91 @@ export function constellationLayout({ 1, Math.floor((height - upper - Math.max(80, upper + 1)) / rowHeight), ); - let rawBuckets = groupEvents(events, modules, lens); + const totals = aggregate?.groups; + const rawBuckets = groupEvents(events, modules, lens, {totals}); const module = modules.find(module => module.key === lens && module.enabled); - if (rawBuckets) rawBuckets = colorGroups(rawBuckets, module); - const buckets = rawBuckets; - const groups = buckets - ? buckets.map(({ events, ...bucket }) => ({ + const groups = rawBuckets + ? colorGroups(rawBuckets, module).map(({ events, ...bucket }) => ({ ...bucket, people: entriesFromEvents(events), personCount: peopleFromEvents(events).length, })) : [{ key: "all", label: "Loaded audience", tone: "neutral", people }]; - const occupied = colorGroups( - groups.filter((group) => group.people.length), - module, - ); - const columns = zoom <= 0.25 && buckets - ? Math.min(occupied.length, Math.max(availableColumns, Math.ceil(occupied.length / availableRows))) - : Math.min(occupied.length, availableColumns); - const overviewWidth = buckets ? Math.max(width, columns * 160) : width; - const rows = zoom <= 0.25 ? Math.ceil(occupied.length / Math.max(1, columns)) : 1; - const lower = Math.max( - upper + rows * rowHeight, - height - Math.min(255, height * 0.36), - ); - const areaHeight = lower - upper; - occupied.forEach((group, index) => { - const row = Math.floor(index / Math.max(1, columns)); - const inRow = Math.min(columns, occupied.length - row * columns); - group.x = (zoom <= 0.25 ? overviewWidth : width) * (((index % columns) + 0.5) / inRow); - group.y = zoom <= 0.25 ? upper + areaHeight * ((row + 0.5) / rows) : upper + areaHeight / 2; - }); + const occupied = groups.filter(group => group.people.length); const positions = new Map(); occupied.forEach((group, groupIndex) => { - group.people.forEach((person, memberIndex) => { - positions.set(person, { group, memberIndex, groupIndex }); + group.people.forEach((person, member) => { + positions.set(person, { group, member, groupIndex }); }); }); - const blend = clamp((zoom - 0.25) / 0.75, 0, 1); - const spacing = (width < 680 ? 245 : 285) * zoom; - const orderedPeople = buckets ? [...occupied].reverse().flatMap(group => group.people) : people; - const groupGap = buckets ? width * 0.5 * blend : 0; - const worldWidth = zoom <= 0.25 ? overviewWidth : width + Math.max(0, orderedPeople.length - 1) * spacing * blend + - Math.max(0, occupied.length - 1) * groupGap; - const points = orderedPeople.map((person, index) => { - const { group, memberIndex: member, groupIndex } = positions.get(person); + const ordered = rawBuckets ? [...occupied].reverse().flatMap(group => group.people) : people; + const placements = ordered.map(person => { + const { group, member, groupIndex } = positions.get(person); const angle = member * 2.399963229728653; const radius = Math.min(width < 600 ? 44 : 76, 14 + Math.sqrt(member) * 13); - const fitX = clamp(group.x + Math.cos(angle) * radius, 32, (zoom <= 0.25 ? overviewWidth : width) - 32); - const fitY = clamp(group.y + Math.sin(angle) * radius * 0.6, upper, lower); - const timelineX = width / 2 + (orderedPeople.length - 1 - index) * spacing; - const groupOffset = groupIndex * groupGap; - const timelineY = clamp( - group.y + (seed(person.id) - 0.5) * Math.min(400, areaHeight * 0.9), - upper + 20, - lower - 20, - ); return { - ...person, - group: group.key, - tone: group.tone, - color: group.color, - x: zoom <= 0.25 ? fitX : width / 2 + (timelineX - width / 2) * blend + groupOffset, - y: fitY * (1 - Math.sqrt(blend)) + timelineY * Math.sqrt(blend), + group, groupIndex, + dx: Math.cos(angle) * radius, + dy: Math.sin(angle) * radius * 0.6, + drift: seed(person.id) - 0.5, }; }); - return { - people: points, + // Scene bindings keep these point identities while zoom changes their coordinates. + const geometry = { + people: ordered.map((person, index) => { + const {group} = placements[index]; + return {...person, group: group.key, tone: group.tone, color: group.color}; + }), groups: occupied, - grouped: buckets !== null, - width: worldWidth, + grouped: rawBuckets !== null, + unloadedGroups: rawBuckets && totals ? totals.length - rawBuckets.filter(bucket => bucket.eventCount !== undefined).length : 0, upper, - lower, - overview: buckets !== null && zoom <= 0.25, - canPan: worldWidth > width + 0.5, - center: (upper + lower) / 2, + }; + const grouped = geometry.grouped; + const count = geometry.people.length; + return (zoom) => { + const columns = zoom <= 0.25 && grouped + ? Math.min(occupied.length, Math.max(availableColumns, Math.ceil(occupied.length / availableRows))) + : Math.min(occupied.length, availableColumns); + const overviewWidth = grouped ? Math.max(width, columns * 160) : width; + const rows = zoom <= 0.25 ? Math.ceil(occupied.length / Math.max(1, columns)) : 1; + const lower = Math.max( + upper + rows * rowHeight, + height - Math.min(255, height * 0.36), + ); + const areaHeight = lower - upper; + occupied.forEach((group, index) => { + const row = Math.floor(index / Math.max(1, columns)); + const inRow = Math.min(columns, occupied.length - row * columns); + group.x = (zoom <= 0.25 ? overviewWidth : width) * (((index % columns) + 0.5) / inRow); + group.y = zoom <= 0.25 ? upper + areaHeight * ((row + 0.5) / rows) : upper + areaHeight / 2; + }); + const blend = clamp((zoom - 0.25) / 0.75, 0, 1); + const spacing = (width < 680 ? 245 : 285) * zoom; + const groupGap = grouped ? width * 0.5 * blend : 0; + const worldWidth = zoom <= 0.25 ? overviewWidth : width + Math.max(0, count - 1) * spacing * blend + + Math.max(0, occupied.length - 1) * groupGap; + const blendY = Math.sqrt(blend); + geometry.people.forEach((point, index) => { + const { group, groupIndex, dx, dy, drift } = placements[index]; + const fitX = clamp(group.x + dx, 32, (zoom <= 0.25 ? overviewWidth : width) - 32); + const fitY = clamp(group.y + dy, upper, lower); + const timelineX = width / 2 + (count - 1 - index) * spacing; + const groupOffset = groupIndex * groupGap; + const timelineY = clamp( + group.y + drift * Math.min(400, areaHeight * 0.9), + upper + 20, + lower - 20, + ); + point.x = zoom <= 0.25 ? fitX : width / 2 + (timelineX - width / 2) * blend + groupOffset; + point.y = fitY * (1 - blendY) + timelineY * blendY; + }); + return Object.assign(geometry, { + width: worldWidth, + lower, + overview: grouped && zoom <= 0.25, + canPan: worldWidth > width + 0.5, + center: (upper + lower) / 2, + }); }; } diff --git a/src/xflog/assets/constellation-viewport.js b/src/xflog/assets/constellation-viewport.js new file mode 100644 index 0000000..9a6f010 --- /dev/null +++ b/src/xflog/assets/constellation-viewport.js @@ -0,0 +1,58 @@ +// Cover the widest label's half-width plus the viewport and collision gaps. +export const overscan = 128; + +function bound(items, value, coordinate, afterEqual = false) { + let start = 0, end = items.length; + while (start < end) { + const middle = (start + end) >>> 1; + const x = coordinate(items[middle]); + if (x < value || (afterEqual && x === value)) start = middle + 1; + else end = middle; + } + return start; +} + +const personX = item => item.label.person.x; +const segmentStart = item => item.start; +const maxEnd = item => item.maxEnd; + +export function createViewportIndex(labels, sparks) { + const people = labels.map((label, index) => ({label, index})) + .sort((a, b) => personX(a) - personX(b) || a.index - b.index); + const segments = sparks.map(spark => ({ + spark, + start: Math.min(spark.a.x, spark.b.x), + end: Math.max(spark.a.x, spark.b.x), + })).sort((a, b) => a.start - b.start); + let end = -Infinity; + for (const segment of segments) { + end = Math.max(end, segment.end); + segment.maxEnd = end; + } + return { + range(left, right) { + const start = bound(people, left, personX); + const stop = bound(people, right, personX, true); + const connections = []; + // Same-group timeline links join adjacent points. Prefix bounds also + // retain any segment that spans the range with both endpoints outside it. + const first = bound(segments, left, maxEnd); + const last = bound(segments, right, segmentStart, true); + for (let index = first; index < last; index++) + if (segments[index].end >= left) connections.push(segments[index].spark); + return { + labels: people.slice(start, stop).sort((a, b) => a.index - b.index).map(item => item.label), + connections, + }; + }, + nearest(center) { + const split = bound(people, center, personX); + const right = people[split]; + const left = split && people[bound(people, personX(people[split - 1]), personX)]; + if (!left) return right?.label.person; + if (!right) return left.label.person; + const difference = (center - personX(left)) - (personX(right) - center); + return (difference < 0 || (difference === 0 && left.index < right.index) ? left : right).label.person; + }, + }; +} diff --git a/src/xflog/assets/constellation.css b/src/xflog/assets/constellation.css index aa5da98..cc9a12b 100644 --- a/src/xflog/assets/constellation.css +++ b/src/xflog/assets/constellation.css @@ -512,6 +512,8 @@ border: 0; background: transparent; } +/* Retained timeline positions must not extend the overview scroll area. */ +.is-group-overview .star-label, .is-group-overview .star-initials { display: none; } @@ -619,7 +621,8 @@ stroke-linejoin: round; } .star-post { display: none; } -.is-selected .star-post { +.is-selected .star-post, +.star-post:focus { display: block; margin-top: 8px; padding: 6px 9px; @@ -632,11 +635,27 @@ overflow-wrap: anywhere; text-decoration: none; } +.star-label:focus-within { z-index: 7; } +@media (max-width: 400px) { + .constellation-person:not(.is-selected) .star-label:has(.star-post:focus) { + width: 96px; + } + .constellation-person:not(.is-selected) .star-label:has(.star-post:focus) .star-identity { + display: none; + } +} +.constellation-person:not(.is-selected) .star-post:focus > span:first-child { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} .star-post:hover { text-decoration: underline; } .star-post > span:first-child::before { content: "“"; } .star-post > span:first-child::after { content: "”"; } -.is-selected .star-post { +.is-selected .star-post, +.star-post:focus { position: absolute; left: 4px; right: 4px; diff --git a/src/xflog/assets/constellation.js b/src/xflog/assets/constellation.js index 423c45d..bba2bde 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -5,13 +5,15 @@ import { flowStep, wheelDistance, normalizeMotionSpeed, - constellationLayout, + prepareConstellation, visibleLabels, seed, } from "./constellation-layout.js"; +import { createViewportIndex, overscan } from "./constellation-viewport.js"; import { createShootingStar } from "./shooting-star.js"; import { verificationBadge } from "./verification-badge.js"; import { groupKey, groupDescription } from "./constellation-colors.js"; +import { historyCount } from "./audience-groups.js"; export function createConstellation({ element, @@ -30,14 +32,20 @@ export function createConstellation({ zoomIn = $("#zoom-in"), zoomOut = $("#zoom-out"), history = $("#memory-scrubber"), peopleCount = $("#scene-people-count"), next = $("#memory-next"), previous = $("#memory-previous"), - key = $("#constellation-key"), context = $(".scene-context"); + key = $("#constellation-key"), context = $(".scene-context"), signal = $("#selected-signal"); const shootingStar = createShootingStar(); let events = [], selected = null, modules = [], lens = "none", - geometry = null; + aggregate = null, + geometry = null, + layoutAtZoom = null; + let selectionByEvent = null, selection = null, bindingsByEvent = null; + let viewportIndex = null, viewportLabels = [], activeSparks = [], + positionedLabels = new Set(), labelsByNode = null; let sceneVersion = 0; + let restoringFocus = false; // History is deduplicated by event ID before grouping, so only data changes alter membership. let personCount = 0, countDirty = true; let zoom = 0.8, @@ -54,8 +62,12 @@ export function createConstellation({ sparks = [], actorButtons = [], sky = null, meteor = null, meteorTail = null, - labelInset = 16, + canvas = null, linkLayer = null, groupLayer = null, + groupButtons = null, connections = null, + sceneWidth = 0, sceneHeight = 0, keyScope = null; + let labelInset = null, labelsDirty = true, viewportLeft = null, + animationDirty = true, lastMotion = null, browseLeft = null; const reduced = matchMedia("(prefers-reduced-motion: reduce)"); const active = () => geometry && @@ -64,13 +76,10 @@ export function createConstellation({ root.dataset.route === "timeline" && !document.hidden; const moving = () => !reduced.matches && root.dataset.motion !== "off"; - const current = () => events.find((event) => event.event_id === selected); const maximum = () => Math.max(0, element.scrollWidth - element.clientWidth); - const position = () => - geometry?.people.find((person) => person.events.some(event => event.event_id === selected)); - const selectedProfileIndex = () => - geometry?.people.indexOf(position()) ?? - -1; + const singletonCanLoad = () => Boolean(geometry?.people.length === 1 && hasOlder()); + const position = () => selection?.person; + const selectedProfileIndex = () => selection?.index ?? -1; function personLabel(person, event = person.event) { return `${postPreview(event, 140, "star-post")}`; } @@ -92,15 +101,20 @@ export function createConstellation({ labels = []; sparks = []; actorButtons = []; - sky = meteor = meteorTail = null; + sky = meteor = meteorTail = canvas = linkLayer = groupLayer = null; + groupButtons = connections = layoutAtZoom = null; + selectionByEvent = selection = keyScope = bindingsByEvent = labelsByNode = null; + viewportIndex = null; + viewportLabels = activeSparks = []; + positionedLabels.clear(); + viewportLeft = browseLeft = labelInset = null; + labelsDirty = animationDirty = true; + lastMotion = null; } function queue() { if (!frame && active()) frame = requestAnimationFrame(paint); } - function navigator() { - if (!geometry) return; - travelControls(); - groupHint(); + function zoomControls() { zoomValue.textContent = `${Math.round(zoom * 100)}%`; zoomControl.value = zoom; zoomControl.setAttribute( @@ -113,36 +127,37 @@ export function createConstellation({ control.disabled = geometry.people.length < 2; zoomOut.disabled ||= zoom === 0; zoomIn.disabled ||= zoom === 2; - history.max = 1000; history.disabled = !geometry.canPan; - history.value = maximum() - ? Math.round((1 - element.scrollLeft / maximum()) * 1000) - : 500; - peopleCount.textContent = - `${personCount} ${personCount === 1 ? "person" : "people"} in ${geometry.grouped ? "saved" : "loaded"} history`; + } + function scrubber() { + const max = maximum(); + const value = max ? Math.round((1 - element.scrollLeft / max) * 1000) : 500; + if (Number(history.value) !== value) history.value = value; } function groupHint() { - key.hidden = !geometry.grouped; - const group = geometry.groups.find(item => item.key === position()?.group); - const scope = geometry.overview ? "overview" : group?.key; + const group = geometry.grouped && !geometry.overview + ? geometry.groups.find(item => item.key === position()?.group) + : null; + const scope = geometry.overview ? "overview" : group; if (keyScope === scope) return; keyScope = scope; + const unloaded = geometry.unloadedGroups; + const overview = unloaded ? `Loaded constellations · ${unloaded} ${unloaded === 1 ? "group" : "groups"} with no events loaded` : "All constellations"; key.innerHTML = geometry.grouped - ? geometry.overview ? 'All constellations' + ? geometry.overview ? `${overview}` : groupKey(group ? [group] : [], esc) : ""; key.setAttribute("aria-label", geometry.overview ? "Constellation groups" : "Current constellation"); - labelInset = (context?.offsetHeight || 0) + 28; + labelInset = null; + labelsDirty = true; } - function selectVisibleProfile(notify = onSelect) { + function selectVisibleProfile(notify = onSelect, duringPaint = false) { const center = element.scrollLeft + element.clientWidth / 2; - const closest = geometry.people.reduce( - (best, person) => - Math.abs(person.x - center) < Math.abs(best.x - center) ? person : best, - geometry.people[0], - ); + // Pannable group overviews retain their existing browsing behavior. + const closest = viewportIndex.nearest(center); if (closest && closest !== position()) { - select(closest.event.event_id); + applySelection(closest.event.event_id); + if (!duringPaint) queue(); notify(closest.event.event_id); } } @@ -152,7 +167,8 @@ export function createConstellation({ for (const [button, available] of [ [ next, - canPan && (index < geometry.people.length - 1 || hasOlder()), + (canPan && (index < geometry.people.length - 1 || hasOlder())) || + singletonCanLoad(), ], [previous, canPan && index > 0], ]) { @@ -164,141 +180,205 @@ export function createConstellation({ : button === next ? "Older memory" : "Newer memory"); } } - function select(identity) { - if (!root.isConnected || !element.isConnected) return; + function applySelection(identity, initial = false) { + if (!initial && identity === selected) return false; + const previous = position(); selected = identity; + selection = selectionByEvent.get(identity); const chosenPerson = position(); - actorButtons.forEach(({button, personElement, person}) => { + const updateBinding = (item, index) => { + const {button, personElement, person} = actorButtons[index]; const chosen = button.dataset.event === chosenPerson?.event.event_id; - const event = chosen ? current() : person.event; + const event = chosen ? selection.event : person.event; button.setAttribute("aria-pressed", String(chosen)); button.setAttribute( "aria-label", `${event.name || event.handle || "X account"}, ${label(event)}, recorded event`, ); personElement.classList.toggle("is-selected", chosen); - }); - for (const item of labels) { - const event = item.person === chosenPerson ? current() : item.person.event; - item.label.setAttribute("aria-label", `${item.person === chosenPerson ? "Open recorded event for" : "Select"} ${event.name || event.handle || "X account"}`); - if (item.identity !== event.event_id) { - item.label.innerHTML = personLabel(item.person, event); - item.identity = event.event_id; - item.controls = item.label.querySelectorAll?.("button,a") || []; - item.preview = item.label.querySelector?.(".star-post"); + const chosenLabel = item.person === chosenPerson; + const labelEvent = chosenLabel ? selection.event : item.person.event; + item.label.setAttribute("aria-label", `${chosenLabel ? "Open recorded event for" : "Select"} ${labelEvent.name || labelEvent.handle || "X account"}`); + if (initial) { + item.label.innerHTML = personLabel(item.person, labelEvent); + item.controls = item.label.querySelectorAll("button,a"); + item.preview = item.label.querySelector(".star-post"); + showLabel(item, false); } - const box = item.label.getBoundingClientRect(); - item.width = box.width; - item.height = box.height; + item.width = undefined; + }; + if (initial) labels.forEach(updateBinding); + else { + for (const id of new Set([previous?.event.event_id, chosenPerson?.event.event_id])) + for (const index of bindingsByEvent.get(id) ?? []) updateBinding(labels[index], index); } - navigator(); - queue(); + if (initial || previous?.group !== chosenPerson?.group) groupHint(); + travelControls(); + labelsDirty = true; + return true; } - function paint(now) { - frame = 0; - if (!active()) { - last = 0; - return; - } - const elapsed = last ? clamp(now - last, 0, 64) : 0; - last = now; - if (moving()) - clock += elapsed * normalizeMotionSpeed(root.dataset.motionSpeed || 1); - if (target !== null) { - const previous = element.scrollLeft; - const next = moving() - ? flowStep(element.scrollLeft, target, elapsed || 16) - : target; - element.scrollLeft = next; - // Browsers can round away the final eased step; finish instead of stalling. - if (Math.abs(target - element.scrollLeft) < 2 || element.scrollLeft === previous) { - element.scrollLeft = target; - target = null; - seeking = false; + function select(identity) { + if (geometry && root.isConnected && element.isConnected && applySelection(identity)) + queue(); + } + function showLabel(item, visible) { + if (item.visible === visible) return; + item.visible = visible; + item.label.style.visibility = visible ? "visible" : "hidden"; + item.label.setAttribute("aria-hidden", String(!visible)); + item.label.tabIndex = -1; + item.controls?.forEach(control => { control.tabIndex = visible ? 0 : -1; }); + } + function resetViewport() { + viewportIndex = createViewportIndex(labels, geometry.overview ? [] : sparks); + viewportLeft = null; + } + function updateViewport(left) { + const range = viewportIndex.range(left - overscan, left + element.clientWidth + overscan); + viewportLabels = range.labels; + const next = range.connections; + if (next.length === activeSparks.length && next.every((spark, index) => spark === activeSparks[index])) + return false; + const retained = new Set(next); + for (const spark of activeSparks) { + if (!retained.has(spark)) { + spark.element.setAttribute("display", "none"); + spark.trail.setAttribute("display", "none"); + spark.hidden = true; } } - const browsing = geometry.canPan && !seeking && element.dataset.userScrolling === "true"; - if (browsing) selectVisibleProfile(onPreview); - const left = element.scrollLeft, - chosenPerson = position(); - sky.style.transform = `translateX(${left}px)`; - const shot = moving() - ? shootingStar(clock, element.clientWidth, element.clientHeight) - : null; - meteor.style.opacity = String(shot?.opacity || 0); - meteor.dataset.active = String(Boolean(shot && shot.opacity > 0)); - if (shot) - for (const key of ["x1", "y1", "x2", "y2"]) { - meteor.setAttribute(key, shot[key].toFixed(2)); - meteorTail.setAttribute(key, shot[key].toFixed(2)); + activeSparks = next; + return true; + } + function layoutLabels(left, focusedControl = document.activeElement) { + const chosenPerson = position(); + const chosenLabel = labels[selectedProfileIndex()]; + const focusedLabel = labelsByNode.get(focusedControl?.closest("[data-label]")); + const relevant = new Set(viewportLabels); + if (chosenLabel) relevant.add(chosenLabel); + if (focusedLabel) relevant.add(focusedLabel); + const viewportWidth = element.clientWidth; + const bottom = geometry.lower + 25; + // The signal can grow with the selected event; reserve its actual space only for a pinned label. + const focusBottom = focusedLabel && focusedLabel !== chosenLabel + ? Math.min(element.clientHeight - 80, (signal?.offsetHeight ? signal.offsetTop - element.offsetTop - 8 : Infinity)) + : 0; + labelInset ??= (context?.offsetHeight || 0) + 28; + // Read invalidated sizes together before positioning any label. + for (const item of relevant) { + const previewShown = item.preview && + (item === chosenLabel || item.preview === focusedControl); + if (item.width === undefined || item.previewShown !== previewShown) { + item.previewShown = previewShown; + const box = item.label.getBoundingClientRect(); + item.width = box.width; + item.height = box.height; + item.previewHeight = previewShown ? item.preview.offsetHeight : 0; } + } const candidates = []; - for (const item of labels) { + for (const item of [...relevant].sort((a, b) => a.index - b.index)) { const chosen = item.person === chosenPerson; const x = item.person.x - left; const width = item.width, height = item.height; - const bottom = geometry.lower + 25; let top = item.person.y + 27; - const previewHeight = chosen && item.preview ? item.preview.offsetHeight : 0; + const previewHeight = item.previewHeight; if (top + height + (previewHeight ? previewHeight + 8 : 0) > bottom) top = item.person.y - height - (previewHeight ? 40 : 25); top = clamp(top, labelInset, Math.max(labelInset, bottom - height)); - const previewTop = Math.max(item.person.y + 40, top < item.person.y ? 0 : top + height + 8); - if (chosen && item.preview) item.preview.style.top = `${previewTop - top}px`; - const previewBottom = chosen && item.preview ? previewTop + item.preview.offsetHeight : top + height; + const pinnedFocus = item === focusedLabel && !chosen; + const previewTop = pinnedFocus + ? top + height + 8 + : Math.max(item.person.y + 40, top < item.person.y ? 0 : top + height + 8); + if (item.previewShown) item.preview.style.top = `${previewTop - top}px`; + const previewBottom = item.previewShown ? previewTop + previewHeight : top + height; + const blockHeight = Math.max(height, previewBottom - top); + const limit = pinnedFocus ? Math.max(labelInset, focusBottom - blockHeight) : top; + top = Math.min(top, limit); const center = clamp( - x, width / 2 + 16, element.clientWidth - width / 2 - 16, + x, width / 2 + 16, viewportWidth - width / 2 - 16, ); item.label.style.left = `${center + left}px`; item.label.style.top = `${top}px`; if ( - !geometry.overview && - (chosen || (x >= 12 && x <= element.clientWidth - 12)) && + (chosen || item === focusedLabel || (x >= 12 && x <= viewportWidth - 12)) && center - width / 2 >= 12 && - center + width / 2 <= element.clientWidth - 12 + center + width / 2 <= viewportWidth - 12 ) { const candidate = { - id: item.person.event.event_id, + id: item.person, left: center - width / 2, right: center + width / 2, top, - bottom: Math.max(top + height, previewBottom), + bottom: top + blockHeight, minTop: labelInset, - maxBottom: Math.max(bottom, previewBottom), - distance: Math.abs(center - element.clientWidth / 2), + maxBottom: pinnedFocus ? focusBottom : Math.max(bottom, previewBottom), + distance: pinnedFocus ? -1 : Math.abs(center - viewportWidth / 2), }; const alternateTop = clamp( top < item.person.y ? item.person.y + 27 : item.person.y - height - 25, labelInset, - Math.max(labelInset, bottom - height), + Math.max(labelInset, pinnedFocus ? candidate.maxBottom - blockHeight : bottom - height), ); - if (!chosen) - candidate.alternates = [ - { ...candidate, top: alternateTop, bottom: alternateTop + height }, - ]; + if (!chosen) { + const extent = pinnedFocus ? blockHeight : height; + candidate.alternates = [{ ...candidate, top: alternateTop, bottom: alternateTop + extent }]; + if (pinnedFocus) { + // A pinned label can also use the opposite edge because it no longer follows its star. + const otherLeft = center > viewportWidth / 2 ? 16 : viewportWidth - width - 16; + const edge = Math.max(labelInset, candidate.maxBottom - extent); + for (const left of [candidate.left, otherLeft]) { + for (const alternate of [labelInset, edge]) { + candidate.alternates.push({ + ...candidate, left, right: left + width, top: alternate, bottom: alternate + extent, + }); + } + } + } + } candidates.push(candidate); } } - const shown = visibleLabels(candidates, chosenPerson?.event.event_id); - labels.forEach((item) => { - const placement = shown.get(item.person.event.event_id); - const visible = Boolean(placement); - if (placement) item.label.style.top = `${placement.top}px`; - item.label.style.visibility = visible ? "visible" : "hidden"; - item.label.setAttribute("aria-hidden", String(!visible)); - item.label.tabIndex = -1; - item.controls.forEach(control => { control.tabIndex = visible ? 0 : -1; }); - }); + const shown = visibleLabels(candidates, chosenPerson); + for (const item of positionedLabels) { + if (relevant.has(item)) continue; + showLabel(item, false); + // Old positions can otherwise extend the scroll area after zooming out. + item.label.style.left = ""; + } + for (const item of relevant) { + const placement = shown.get(item.person); + if (placement) { + item.label.style.top = `${placement.top}px`; + if (item === focusedLabel && item !== chosenLabel) + item.label.style.left = `${left + (placement.left + placement.right) / 2}px`; + } + showLabel(item, Boolean(placement) || item === focusedLabel); + } + positionedLabels = relevant; + } + function animate(motion) { + const shot = motion ? shootingStar(clock, sceneWidth, sceneHeight) : null; + meteor.style.opacity = String(shot?.opacity || 0); + meteor.dataset.active = String(Boolean(shot && shot.opacity > 0)); + if (shot) + for (const key of ["x1", "y1", "x2", "y2"]) { + meteor.setAttribute(key, shot[key].toFixed(2)); + meteorTail.setAttribute(key, shot[key].toFixed(2)); + } stars.forEach((star, index) => { star.style.opacity = String( - moving() + motion ? 0.1 + 0.12 * (1 + Math.sin(clock / 1900 + index * 2.4)) : 0.22, ); }); - sparks.forEach(({ element: spark, trail, a, b }, index) => { - const part = (clock / 10000 + seed(index)) % 1; + } + function animateConnections() { + for (const connection of activeSparks) { + const {element: spark, trail, a, b, phase} = connection; + const part = (clock / 10000 + phase) % 1; const tail = Math.max(0, part - 0.06); spark.setAttribute("cx", (a.x + (b.x - a.x) * part).toFixed(2)); spark.setAttribute("cy", (a.y + (b.y - a.y) * part).toFixed(2)); @@ -306,14 +386,67 @@ export function createConstellation({ trail.setAttribute("y1", (a.y + (b.y - a.y) * tail).toFixed(2)); trail.setAttribute("x2", (a.x + (b.x - a.x) * part).toFixed(2)); trail.setAttribute("y2", (a.y + (b.y - a.y) * part).toFixed(2)); - }); + if (connection.hidden) { + spark.removeAttribute("display"); + trail.removeAttribute("display"); + connection.hidden = false; + } + } + } + function paint(now) { + frame = 0; + if (!active()) { + last = 0; + return; + } + const elapsed = last ? clamp(now - last, 0, 64) : 0; + last = now; + const motion = moving(); + if (motion) + clock += elapsed * normalizeMotionSpeed(root.dataset.motionSpeed || 1); + if (target !== null) { + const previous = element.scrollLeft; + const next = motion + ? flowStep(element.scrollLeft, target, elapsed || 16) + : target; + element.scrollLeft = next; + // Browsers can round away the final eased step; finish instead of stalling. + if (Math.abs(target - element.scrollLeft) < 2 || element.scrollLeft === previous) { + element.scrollLeft = target; + target = null; + seeking = false; + } + } + const left = element.scrollLeft; + const scrolled = left !== viewportLeft; + const browsing = geometry.canPan && !seeking && element.dataset.userScrolling === "true"; + // A scroll event can mark browsing after paint has already laid out this offset. + if (browsing && left !== browseLeft) { + browseLeft = left; + selectVisibleProfile(onPreview, true); + } + if (!browsing) browseLeft = null; + if (scrolled) sky.style.transform = `translateX(${left}px)`; + const connectionsChanged = scrolled && !geometry.overview && updateViewport(left); + if (scrolled || labelsDirty) { + if (!geometry.overview) layoutLabels(left); + scrubber(); + viewportLeft = left; + labelsDirty = false; + } + const animating = String(motion); + if (motion || animationDirty || lastMotion !== motion) animate(motion); + if (!geometry.overview && (motion || animationDirty || connectionsChanged)) + animateConnections(); + animationDirty = false; + lastMotion = motion; if (browsing && target === null && !drag) { + browseLeft = null; delete element.dataset.userScrolling; onSelect(selected); } - navigator(); - root.dataset.animating = moving() ? "true" : "false"; - if (moving() || target !== null) queue(); + if (root.dataset.animating !== animating) root.dataset.animating = animating; + if (motion || target !== null) queue(); else last = 0; } function render(nextEvents, identity) { @@ -321,123 +454,179 @@ export function createConstellation({ stop(); wheelZoom = null; events = nextEvents; - selected = identity; - geometry = constellationLayout({ + sceneWidth = element.clientWidth; + sceneHeight = element.clientHeight; + layoutAtZoom = prepareConstellation({ events, - width: element.clientWidth, - height: element.clientHeight, - zoom, + width: sceneWidth, + height: sceneHeight, modules, lens, + aggregate, }); + geometry = layoutAtZoom(zoom); if (countDirty) { personCount = new Set(geometry.people.map(person => person.event.actor_id)).size; countDirty = false; } element.dataset.canPan = String(geometry.canPan); - element.classList.toggle("is-group-overview", geometry.overview); element.classList.toggle("is-grouped", geometry.grouped); - keyScope = null; - groupHint(); + // A fresh DOM scene has no valid measurements or last-painted viewport. + viewportLeft = browseLeft = labelInset = null; + labelsDirty = animationDirty = true; + keyScope = undefined; + key.hidden = !geometry.grouped; + history.max = 1000; + zoomControls(); + const countText = aggregate + ? historyCount(events.length, personCount, aggregate) + : `${personCount} ${personCount === 1 ? "person" : "people"} in loaded history`; + if (peopleCount.textContent !== countText) peopleCount.textContent = countText; const background = Array.from( { length: 76 }, (_, index) => ``, ).join(""); - const links = []; - if (!geometry.overview) { - if (geometry.groups.length === 1) { - const group = geometry.groups[0]; - let previous = null; - for (const person of geometry.people) { - if (person.group !== group.key) continue; - if (previous) links.push({ a: person, b: previous, color: group.color, dash: group.dash }); - previous = person; - } - } else if (geometry.groups.length > 1) { - const peopleByGroup = new Map(geometry.groups.map(group => [group.key, []])); - // Map matches NaN keys, but the previous strict group comparison did not. - for (const person of geometry.people) - if (!Number.isNaN(person.group)) peopleByGroup.get(person.group)?.push(person); - for (const group of geometry.groups) { - const people = peopleByGroup.get(group.key); - for (let index = 1; index < people.length; index++) - links.push({ a: people[index], b: people[index - 1], color: group.color, dash: group.dash }); - } - } - } - element.innerHTML = `
${geometry.people.map((person) => `
${personLabel(person)}
`).join("")}${geometry.overview ? geometry.groups.map((group) => ``).join("") : ""}
`; + element.innerHTML = `
${geometry.people.map((person) => `
${personLabel(person)}
`).join("")}
`; sky = element.querySelector(".constellation-sky"); meteor = element.querySelector(".shooting-star"); meteorTail = element.querySelector(".shooting-tail"); - const peopleByEvent = new Map(); - for (const person of geometry.people) - if (!peopleByEvent.has(person.event.event_id)) - peopleByEvent.set(person.event.event_id, person); + const firstEvents = new Map(); + for (const event of events) + if (!firstEvents.has(event.event_id)) firstEvents.set(event.event_id, event); + selectionByEvent = new Map(); + selection = null; + let index = 0; + for (const person of geometry.people) { + for (const event of person.events) + if (!selectionByEvent.has(event.event_id)) + selectionByEvent.set(event.event_id, {person, index, event: firstEvents.get(event.event_id)}); + index++; + } actorButtons = [...element.querySelectorAll("[data-actor]")].map(button => ({ button, personElement: button.closest(".constellation-person"), - person: peopleByEvent.get(button.dataset.event), + person: selectionByEvent.get(button.dataset.event)?.person, })); stars = [...element.querySelectorAll(".sky-star")]; - const labelNodes = new Map( - [...element.querySelectorAll("[data-label]")].map((item) => [ - item.dataset.label, - item, - ]), - ); - labels = geometry.people.map((person) => ({ - person, - label: labelNodes.get(person.event.event_id), - })); - const trails = element.querySelectorAll(".sequence-trail"); - sparks = [...element.querySelectorAll(".sequence-spark")].map( - (spark, index) => ({ - element: spark, - trail: trails[index], - ...links[index], - }), - ); + const labelNodes = element.querySelectorAll("[data-label]"); + labels = geometry.people.map((person, index) => ({person, index, label: labelNodes[index]})); + labelsByNode = new WeakMap(labels.map(item => [item.label, item])); + bindingsByEvent = new Map(); + actorButtons.forEach(({button}, index) => { + const id = button.dataset.event; + if (!bindingsByEvent.has(id)) bindingsByEvent.set(id, []); + bindingsByEvent.get(id).push(index); + }); + viewportLabels = activeSparks = []; + positionedLabels.clear(); + canvas = element.querySelector(".constellation-canvas"); + linkLayer = element.querySelector(".constellation-links"); + groupLayer = element.querySelector(".constellation-groups"); + groupButtons = connections = null; + sparks = []; actorButtons.forEach(({button, person}) => button.addEventListener("click", () => { - if (renderedVersion !== sceneVersion || !geometry || !root.isConnected || - !element.isConnected || !element.contains(button) || + if (!ownsScene(renderedVersion, button) || geometry.overview || !person || person.event.event_id !== button.dataset.event) return; clickPerson(person); }), ); - actorButtons.forEach(({button}) => { - button.tabIndex = geometry.overview ? -1 : 0; - button.setAttribute("aria-hidden", String(geometry.overview)); + labels.forEach(({person, label: button}) => { + const first = selectionByEvent.get(person.event.event_id)?.person; + button.addEventListener("click", event => { + if (ownsScene(renderedVersion, button) && !geometry.overview && + !event?.target?.closest?.("a")) clickPerson(first); + }); }); - labels.forEach(({ person, label: button }) => - button.addEventListener("click", (event) => { if (!event?.target?.closest?.("a")) clickPerson(person); }), + applySelection(identity, true); + updateMode(); + resetViewport(); + anchor(); + scrubber(); + queue(); + } + function ownsScene(version, node) { + return version === sceneVersion && geometry && root.isConnected && + element.isConnected && element.contains(node); + } + function prepareConnections() { + const links = []; + if (geometry.groups.length === 1) { + const group = geometry.groups[0]; + let previous = null; + for (const person of geometry.people) { + if (person.group !== group.key) continue; + if (previous) links.push({ a: person, b: previous, color: group.color, dash: group.dash }); + previous = person; + } + } else if (geometry.groups.length > 1) { + const peopleByGroup = new Map(geometry.groups.map(group => [group.key, []])); + // Map matches NaN keys, but the previous strict group comparison did not. + for (const person of geometry.people) + if (!Number.isNaN(person.group)) peopleByGroup.get(person.group)?.push(person); + for (const group of geometry.groups) { + const people = peopleByGroup.get(group.key); + for (let index = 1; index < people.length; index++) + links.push({ a: people[index], b: people[index - 1], color: group.color, dash: group.dash }); + } + } + linkLayer.innerHTML = links.map(({ a, b, color, dash }) => ``).join(""); + connections = linkLayer.querySelectorAll("path"); + const trails = linkLayer.querySelectorAll(".sequence-trail"); + sparks = [...linkLayer.querySelectorAll(".sequence-spark")].map( + (spark, index) => ({element: spark, trail: trails[index], ...links[index], phase: seed(index), hidden: true}), ); - element.querySelectorAll("[data-group]").forEach((button) => + } + function prepareGroupButtons() { + const renderedVersion = sceneVersion; + groupLayer.innerHTML = geometry.groups.map((group) => ``).join(""); + groupButtons = [...groupLayer.querySelectorAll("[data-group]")]; + groupButtons.forEach((button, index) => { button.addEventListener("click", () => { - const group = geometry.groups.find( - (item) => item.key === button.dataset.group, - ); - zoom = 1; - render(events, group.people[0].event.event_id); - seek( - events.findIndex( - (item) => item.event_id === group.people[0].event.event_id, - ), - "instant", - ); - }), - ); + if (!ownsScene(renderedVersion, button) || !geometry.overview) return; + const group = geometry.groups[index]; + const identity = group.people[0].event.event_id; + select(identity); + setZoom(1); + seek(events.findIndex(item => item.event_id === identity), "instant"); + }); + }); + } + function updateMode() { + const overview = geometry.overview; + const focused = document.activeElement; + if (focused && (overview + ? element.contains(focused) && focused.closest(".constellation-person") + : groupLayer.contains(focused))) { + (zoomControl.disabled ? element : zoomControl).focus({preventScroll: true}); + } + element.classList.toggle("is-group-overview", overview); + groupLayer.hidden = !overview; + linkLayer.style.visibility = overview ? "hidden" : ""; + if (overview && !groupButtons) prepareGroupButtons(); + if (!overview && !connections) prepareConnections(); + for (const {button} of actorButtons) { + button.tabIndex = overview ? -1 : 0; + button.setAttribute("aria-hidden", String(overview)); + } + if (overview) { + for (const item of positionedLabels) showLabel(item, false); + viewportLabels = []; + } + } + function anchor() { const point = position(); - element.scrollLeft = - geometry.canPan && point - ? clamp(point.x - element.clientWidth / 2, 0, maximum()) - : 0; + element.scrollLeft = geometry.canPan && point + ? clamp(point.x - element.clientWidth / 2, 0, maximum()) + : 0; target = null; seeking = false; scrollPosition = element.scrollLeft; delete element.dataset.userScrolling; - select(identity); - queue(); + if (drag) { + drag.x = drag.lastX; + drag.scroll = scrollPosition; + } } function clickPerson(person) { target = null; @@ -453,11 +642,8 @@ export function createConstellation({ if (!events.length || !geometry) return; delete element.dataset.userScrolling; const event = events[clamp(index, 0, events.length - 1)]; - selected = event.event_id; - if (geometry.overview) { - zoom = 0.8; - render(events, selected); - } + select(event.event_id); + if (geometry.overview) setZoom(0.8); const destination = position(); if (destination && geometry.canPan) { target = clamp(destination.x - element.clientWidth / 2, 0, maximum()); @@ -468,14 +654,50 @@ export function createConstellation({ seeking = false; } } - select(selected); + scrubber(); onSelect(selected); queue(); } function setZoom(value) { - if (!geometry || !events.length) return; - zoom = clamp(Math.round(value * 20) / 20, 0, 2); - render(events, selected); + if (!layoutAtZoom || !events.length || !ownsScene(sceneVersion, canvas)) return; + wheelZoom = null; + const nextZoom = clamp(Math.round(value * 20) / 20, 0, 2); + if (nextZoom === zoom || !Number.isFinite(nextZoom)) return; + const wasOverview = geometry.overview; + const coordinatesChanged = zoom > 0.25 || nextZoom > 0.25; + zoom = nextZoom; + layoutAtZoom(zoom); + element.dataset.canPan = String(geometry.canPan); + canvas.style.width = `${geometry.width}px`; + linkLayer.setAttribute("viewBox", `0 0 ${geometry.width} ${sceneHeight}`); + geometry.people.forEach((point, index) => { + const {button} = actorButtons[index]; + button.style.left = `${point.x}px`; + button.style.top = `${point.y}px`; + }); + if (wasOverview !== geometry.overview) { + updateMode(); + groupHint(); + } + if (geometry.overview) { + geometry.groups.forEach((group, index) => { + groupButtons[index].style.left = `${group.x}px`; + groupButtons[index].style.top = `${group.y}px`; + }); + } else { + connections.forEach((path, index) => { + const {a, b} = sparks[index]; + path.setAttribute("d", `M${a.x},${a.y}L${b.x},${b.y}`); + }); + } + if (coordinatesChanged) resetViewport(); + anchor(); + zoomControls(); + travelControls(); + scrubber(); + labelsDirty = !geometry.overview; + animationDirty = true; + queue(); } function scrub(progress) { if (!geometry?.canPan) return; @@ -484,17 +706,20 @@ export function createConstellation({ element.scrollLeft = (1 - clamp(progress, 0, 1)) * maximum(); delete element.dataset.userScrolling; selectVisibleProfile(); - navigator(); + scrubber(); queue(); } function travel(delta) { - if (!geometry?.canPan) return; + if (!geometry?.canPan) { + if (delta > 0 && singletonCanLoad()) onOlder(); + return; + } const index = selectedProfileIndex(); if (index < 0) return; const next = geometry.people[index + Math.sign(delta)]; if (next) seek(events.findIndex((event) => event.event_id === next.event.event_id)); - else if (delta > 0) onOlder(); + else if (delta > 0 && hasOlder()) onOlder(); } function onWheel(event) { if (!(event.buttons & 4) || event.ctrlKey || event.metaKey) wheelZoom = null; @@ -543,6 +768,7 @@ export function createConstellation({ drag = { id: event.pointerId, x: event.clientX, + lastX: event.clientX, scroll: element.scrollLeft, }; element.setPointerCapture(event.pointerId); @@ -550,6 +776,7 @@ export function createConstellation({ } function move(event) { if (!drag || drag.id !== event.pointerId) return; + drag.lastX = event.clientX; target = clamp(drag.scroll + drag.x - event.clientX, 0, maximum()); element.dataset.userScrolling = "true"; queue(); @@ -598,27 +825,47 @@ export function createConstellation({ } const refresh = () => { stop(); - if (active()) queue(); + queue(); }; const observer = new MutationObserver(refresh); observer.observe(root, { attributes: true, attributeFilter: ["data-route", "data-motion", "data-motion-speed"], }); - const resize = new ResizeObserver(() => { - if (geometry && element.clientWidth && element.clientHeight) + const invalidateLabelSizes = () => { + if (!geometry) return; + for (const item of labels) item.width = undefined; + labelInset = null; + labelsDirty = true; + queue(); + }; + const resize = new ResizeObserver((entries = []) => { + if (geometry && element.clientWidth && element.clientHeight && + (element.clientWidth !== sceneWidth || element.clientHeight !== sceneHeight)) { render(events, selected); + } else if (geometry && entries.some(entry => entry.target === context)) { + labelInset = null; + labelsDirty = true; + queue(); + } }); resize.observe(element); + if (context) resize.observe(context); const scroll = () => { if (!seeking && target === null && element.scrollLeft !== scrollPosition) element.dataset.userScrolling = "true"; scrollPosition = element.scrollLeft; queue(); }; + const labelFocus = (event) => { + if (!event.target.closest("[data-label]")) return; + labelsDirty = true; + queue(); + }; const focus = (event) => { + labelFocus(event); const button = event.target.closest("[data-actor]"); - if (button?.matches(":focus-visible")) + if (!restoringFocus && button?.matches(":focus-visible")) seek(events.findIndex((item) => item.event_id === button.dataset.event)); }; const listeners = [ @@ -628,6 +875,7 @@ export function createConstellation({ [element, "auxclick", middleButton], [element, "keydown", onKey], [element, "focusin", focus], + [element, "focusout", labelFocus], [element, "pointerdown", down], [element, "pointermove", move], [element, "pointerup", up], @@ -637,6 +885,11 @@ export function createConstellation({ [zoomOut, "click", () => setZoom(zoom - 0.25)], [zoomControl, "input", (event) => setZoom(Number(event.target.value))], ]; + if (document.fonts) { + listeners.push([document.fonts, "loadingdone", invalidateLabelSizes]); + listeners.push([document.fonts, "loadingerror", invalidateLabelSizes]); + } + if (document.defaultView) listeners.push([document.defaultView, "resize", invalidateLabelSizes]); listeners.forEach(([target, type, listener, options]) => target.addEventListener(type, listener, options), ); @@ -652,17 +905,52 @@ export function createConstellation({ reduced.addEventListener("change", preference); preference(); return { - render(nextEvents, identity) { + render(nextEvents, identity, nextAggregate = null) { + aggregate = nextAggregate; countDirty = true; render(nextEvents, identity); }, - update(nextEvents, identity) { - const offset = position() ? position().x - element.scrollLeft : null; + update(nextEvents, identity, nextAggregate = null) { + const activeElement = document.activeElement; + const focused = activeElement && element.contains(activeElement) ? activeElement : null; + const star = focused?.closest("[data-actor]"); + const label = labelsByNode?.get(focused?.closest("[data-label]")); + const group = focused?.closest("[data-group]"); + const left = element.scrollLeft, right = left + element.clientWidth; + const chosen = position(); + const point = !geometry?.overview && chosen?.x >= left && chosen.x <= right + ? chosen : viewportIndex?.nearest((left + right) / 2); + const offset = point ? point.x - left : null; + aggregate = nextAggregate; countDirty = true; render(nextEvents, identity); - if (offset !== null && position()) - element.scrollLeft = clamp(position().x - offset, 0, maximum()); + const anchor = point && selectionByEvent.get(point.event.event_id)?.person; + if (anchor) element.scrollLeft = clamp(anchor.x - offset, 0, maximum()); scrollPosition = element.scrollLeft; + scrubber(); + if (group || (geometry.overview && (star || label))) { + const button = geometry.overview && groupButtons.find(item => item.dataset.group === group?.dataset.group); + (button || element).focus({preventScroll: true}); + } else if (star || label) { + const replacement = selectionByEvent.get(star?.dataset.event ?? label.person.event.event_id); + const index = replacement?.index; + const nextLabel = labels[index]; + const wasPreview = label?.preview === focused; + const control = star ? actorButtons[index]?.button + : wasPreview && nextLabel === labels[selectedProfileIndex()] && nextLabel?.preview + ? nextLabel.preview : nextLabel?.controls[0]; + if (!star && control) { + // Position the control before restoring focus; the next frame pins it if needed. + updateViewport(element.scrollLeft); + layoutLabels(element.scrollLeft, control); + } + restoringFocus = true; + try { + (control ?? element).focus({preventScroll: true}); + } finally { + restoringFocus = false; + } + } }, select, seek, @@ -670,6 +958,7 @@ export function createConstellation({ setZoom, travel, configure: (nextModules, nextLens) => { + if (lens !== nextLens) aggregate = null; modules = nextModules; lens = nextLens; if (geometry) render(events, selected); @@ -677,6 +966,7 @@ export function createConstellation({ clear: () => { stop(); geometry = null; + aggregate = null; events = []; releaseScene(); personCount = 0; @@ -690,6 +980,8 @@ export function createConstellation({ destroy: () => { stop(); geometry = null; + aggregate = null; + events = []; releaseScene(); observer.disconnect(); resize.disconnect(); diff --git a/src/xflog/assets/format.js b/src/xflog/assets/format.js index d02972d..bfc20d9 100644 --- a/src/xflog/assets/format.js +++ b/src/xflog/assets/format.js @@ -52,7 +52,7 @@ export const displayDate = (event) => new Date(event.provider_at || event.received_at); export function compareEvents(a, b) { if (a.received_at && b.received_at && a.received_at !== b.received_at) - return b.received_at.localeCompare(a.received_at); + return a.received_at < b.received_at ? 1 : -1; return Number(b.sequence > 0) - Number(a.sequence > 0) || Math.abs(Number(b.sequence)) - Math.abs(Number(a.sequence)); } diff --git a/src/xflog/assets/journal-model.js b/src/xflog/assets/journal-model.js new file mode 100644 index 0000000..6346492 --- /dev/null +++ b/src/xflog/assets/journal-model.js @@ -0,0 +1,134 @@ +import { groupEvents } from "./audience-groups.js"; + +function createHeightIndex(estimates) { + let length = estimates.length; + let heights = Float64Array.from(estimates); + let tree = new Float64Array(length + 1); + // Each Fenwick entry owns a suffix of the prefix ending at its index. + for (let index = 1; index < tree.length; index++) { + tree[index] += heights[index - 1]; + const parent = index + (index & -index); + if (parent < tree.length) tree[parent] += tree[index]; + } + let searchBit = 1; + while (searchBit * 2 <= length) searchBit *= 2; + + function prefix(end) { + let offset = 0; + for (let index = end; index > 0; index -= index & -index) + offset += tree[index]; + return offset; + } + + return { + prefix, + get total() { return prefix(length); }, + height(index) { return heights[index]; }, + append(values) { + const size = length + values.length; + if (size > heights.length) { + const capacity = Math.max(size, heights.length * 2, 16); + const nextHeights = new Float64Array(capacity); + const nextTree = new Float64Array(capacity + 1); + nextHeights.set(heights); + nextTree.set(tree); + heights = nextHeights; + tree = nextTree; + } + for (const value of values) { + heights[length++] = value; + tree[length] = value + prefix(length - 1) - prefix(length - (length & -length)); + } + while (searchBit * 2 <= length) searchBit *= 2; + }, + at(offset) { + let index = 0; + for (let bit = searchBit; bit > 0; bit >>= 1) { + const next = index + bit; + if (next <= length && tree[next] <= offset) { + index = next; + offset -= tree[next]; + } + } + return Math.min(index, length - 1); + }, + update(index, height) { + const difference = height - heights[index]; + if (!difference) return; + heights[index] = height; + for (let parent = index + 1; parent <= length; parent += parent & -parent) + tree[parent] += difference; + }, + }; +} + +export function createJournalModel(events, {modules, lens, collapsed, day, aggregate}) { + const groups = groupEvents(events, modules, lens, {totals: aggregate?.groups, summaries: true}); + const rows = [], estimates = [], byEvent = new Map(), byKey = new Map(); + let position = 0; + + function add(row, estimate, visible = true) { + row.index = visible ? rows.length : -1; + byKey.set(row.key, row); + if (visible) { + rows.push(row); + estimates.push(estimate); + } + } + + function eventAppender(group, visible) { + const prefix = [group ? lens : "none", group?.key ?? null]; + const occurrences = new Map(); + function key(type, identity) { + const scopedIdentity = JSON.stringify([type, identity]); + const count = occurrences.get(scopedIdentity) ?? 0; + occurrences.set(scopedIdentity, count + 1); + return JSON.stringify([...prefix, type, identity, count]); + } + let prior = "", last = null; + return records => { + if (last && records.length) last.last = false; + records.forEach((event, index) => { + const text = day(event); + if (text !== prior) { + const first = last === null && index === 0; + add({type:"date", key:key("date", text), text, group, first}, group && first ? 42 : 58, visible); + } + prior = text; + const row = { + type: "event", key: key("event", event.event_id), event, group, + position: ++position, last: index === records.length - 1, + }; + if (row.last) last = row; + add(row, 102, visible); + if (!byEvent.has(event.event_id)) byEvent.set(event.event_id, row); + }); + }; + } + + let appendEvents = null; + if (groups) { + for (const group of groups) { + group.start = rows.length; + group.open = group.events.length > 0 && !collapsed.has(`${lens}:${group.key}`); + add({type:group.events.length ? "group" : "summary", key:JSON.stringify([lens, group.key, "group"]), group}, 57); + eventAppender(group, group.open)(group.events); + add({type:"gap", key:JSON.stringify([lens, group.key, "gap"]), group}, 20); + group.end = rows.length; + } + } else { + appendEvents = eventAppender(null, true); + appendEvents(events); + } + + const heights = createHeightIndex(estimates); + estimates.length = 0; + return { + groups, rows, byEvent, byKey, heights, + append: appendEvents && (records => { + appendEvents(records); + heights.append(estimates); + estimates.length = 0; + }), + }; +} diff --git a/src/xflog/assets/journal.js b/src/xflog/assets/journal.js index dcf3dda..e1e9aae 100644 --- a/src/xflog/assets/journal.js +++ b/src/xflog/assets/journal.js @@ -1,5 +1,5 @@ import { eventTone } from "./format.js"; -import { groupEvents } from "./audience-groups.js"; +import { createJournalModel } from "./journal-model.js"; export function createJournal({ element, @@ -10,61 +10,460 @@ export function createJournal({ label, initials, onOpen, + onNearEnd = () => {}, }) { const collapsed = new Set(); - let lens = "none"; - - function rows(events, selected) { - let prior = ""; - return events - .map((event) => { - const day = date.format(displayDate(event)); - const heading = - day !== prior ? `
${esc(day)}
` : ""; - prior = day; - const tone = eventTone(event); - const lost = tone === "negative"; - return `${heading}
`; - }) - .join(""); + let track, model, events, options, selected, frame = 0; + let savedAnchor = null, inset = 0, width = 0; + let revealRow = null, revealScrollTop = 0; + let renderedScrollTop = null; + let measured = new Map(); + const mounted = new Map(), groups = new Map(), observed = new Map(); + const observer = new ResizeObserver(measure); + + function eventMarkup(row) { + const event = row.event; + const tone = eventTone(event); + const lost = tone === "negative"; + return `
`; + } + + const eventPosition = row => `Event ${row.position} of ${events.length} loaded`; + + function groupCount(group) { + const total = group.eventCount ?? group.events.length; + const loaded = total === group.events.length ? "" : ` saved · ${group.events.length} loaded`; + const actors = group.distinctActors === undefined ? "" : + ` · ${group.distinctActors} ${group.distinctActors === 1 ? "person" : "people"}`; + return `${total} ${total === 1 ? "event" : "events"}${loaded}${actors}`; + } + + function mountGroup(group) { + const summary = !group.events.length; + const node = document.createElement(summary ? "div" : "details"); + node.className = `journal-group ${group.tone || ""}`; + node.dataset.group = group.key; + if (!summary) node.open = group.open; + const title = `${esc(group.label)} ${groupCount(group)}`; + node.innerHTML = summary + ? `
${title}
` + : `${title}
`; + groups.set(group, node); + return node; + } + + const isHeader = row => row.type === "group" || row.type === "summary"; + + function mountRow(row) { + let node; + if (isHeader(row)) { + node = groups.get(row.group).firstElementChild; + } else if (row.type === "date") { + node = document.createElement("div"); + node.className = row.first && row.group ? "date-group journal-first-date" : "date-group"; + node.textContent = row.text; + } else { + const template = document.createElement("template"); + template.innerHTML = eventMarkup(row); + node = template.content.firstElementChild; + } + node.dataset.journalRow = row.key; + mounted.set(row, node); + observed.set(node, row); + observer.observe(node, {box: "border-box"}); + return node; + } + + function unmountRow(row, node) { + observer.unobserve(node); + observed.delete(node); + mounted.delete(row); + if (!isHeader(row)) node.remove(); + } + + function locate(key) { + const node = key?.closest?.("[data-journal-row]"); + const row = model?.byKey.get(node?.dataset.journalRow); + return row && mounted.get(row) === node ? row : null; + } + + function anchor() { + if (!model?.rows.length) return null; + const offset = element.scrollTop - inset; + let index = model.heights.at(offset); + const row = model.rows[index]; + // If a date heading is at the edge, anchor its first event as well. + if (row.type === "date" && model.rows[index + 1]?.type === "event") index++; + const current = model.rows[index]; + return { + key: current.key, identity: current.event?.event_id, + offset: offset - model.heights.prefix(index), top: element.scrollTop, + }; + } + + function restoreAnchor(previous) { + if (!previous) return; + const row = model.byKey.get(previous.key) ?? + (previous.identity === undefined ? null : model.byEvent.get(previous.identity)); + element.scrollTop = row && row.index >= 0 + ? inset + model.heights.prefix(row.index) + previous.offset + : previous.top; + } + + function setTop(node, top) { + const value = `${top}px`; + if (node.style.top !== value) node.style.top = value; + } + + function placeMounted() { + track.style.height = `${model.heights.total}px`; + for (const [group, node] of groups) { + const top = model.heights.prefix(group.start); + setTop(node, top); + node.style.height = `${model.heights.prefix(group.end) - top - 20}px`; + } + for (const [row, node] of mounted) { + if (isHeader(row)) continue; + const base = row.group ? model.heights.prefix(row.group.start) + 1 : 0; + setTop(node, model.heights.prefix(row.index) - base); + } + } + + function renderWindow(pinned = locate(document.activeElement)) { + if (!track?.isConnected || !element.clientHeight) return; + const overscan = Math.min(400, element.clientHeight); + const top = element.scrollTop - inset; + const from = model.heights.at(top - overscan); + const to = model.heights.at(top + element.clientHeight + overscan); + const wanted = new Set(); + const wantedGroups = new Set(); + function include(row) { + if (!row) return; + if (row.group && !wantedGroups.has(row.group)) { + wantedGroups.add(row.group); + wanted.add(model.rows[row.group.start]); + } + if (row.type !== "gap") wanted.add(row); + } + for (let index = Math.max(0, from); index <= to; index++) include(model.rows[index]); + if (pinned?.index >= 0) include(pinned); + + for (const [row, node] of mounted) + if (!wanted.has(row)) unmountRow(row, node); + for (const [group, node] of groups) { + if (!wantedGroups.has(group)) { + node.remove(); + groups.delete(group); + } + } + + // Insert new rows in logical DOM order without moving already mounted focused nodes. + const tails = new Map(); + const ordered = [...wanted].sort((a, b) => b.index - a.index); + for (const row of ordered) { + let groupNode; + if (row.group) groupNode = groups.get(row.group) ?? mountGroup(row.group); + const node = mounted.get(row) ?? mountRow(row); + const parent = row.group && !isHeader(row) ? groupNode.lastElementChild : track; + const child = isHeader(row) ? groupNode : node; + if (child.parentNode !== parent) parent.insertBefore(child, tails.get(parent) ?? null); + tails.set(parent, child); + } + placeMounted(); + } + + function paintJournal() { + frame = 0; + if (revealRow && element.scrollTop !== revealScrollTop) revealRow = null; + renderWindow(); + } + + function queue() { + if (track?.isConnected && !frame) frame = requestAnimationFrame(paintJournal); + } + + function measure(entries) { + if (!track?.isConnected) return; + const previous = anchor(); + let changed = false; + for (const entry of entries) { + if (entry.target === element) { + const nextWidth = track.clientWidth; + if (width !== nextWidth) { + width = nextWidth; + // Keep last-known heights as estimates until those rows are visited again. + measured.clear(); + } + inset = track.offsetTop; + queue(); + continue; + } + const row = observed.get(entry.target); + if (!row || !track.contains(entry.target)) continue; + const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.target.offsetHeight; + if (!height) continue; + const occupied = height + (row.type === "event" ? 12 : isHeader(row) ? 1 : 0); + measured.set(row.key, {row, height: occupied}); + if (model.heights.height(row.index) !== occupied) { + model.heights.update(row.index, occupied); + changed = true; + } + } + if (changed) { + const keepRevealed = revealRow && element.scrollTop === revealScrollTop; + placeMounted(); + if (keepRevealed) alignRow(revealRow); + else restoreAnchor(previous); + renderedScrollTop = element.scrollTop; + queue(); + } + } + + function release() { + cancelAnimationFrame(frame); + frame = 0; + observer.disconnect(); + mounted.clear(); + groups.clear(); + observed.clear(); + track?.remove(); + track = model = events = options = revealRow = null; + renderedScrollTop = null; + measured.clear(); + } + + function restoreMeasurements(previous) { + for (const [key, {row, height}] of previous) { + const next = model.byKey.get(key); + if (!next || !sameHeightInputs(row, next)) continue; + measured.set(key, {row: next, height}); + if (next.index >= 0) model.heights.update(next.index, height); + } + } + + function sameHeightInputs(row, next) { + if (row.type === "event") { + const fields = ["name", "handle", "actor_id", "kind", "received_at", "provider_at"]; + return row.event === next.event || fields.every(field => row.event[field] === next.event[field]); + } + if (isHeader(row)) + return row.type === next.type && row.group.label === next.group.label && groupCount(row.group) === groupCount(next.group); + return row.first === next.first; + } + + function buildModel() { + return createJournalModel(events, {...options, collapsed, day: event => date.format(displayDate(event))}); + } + + function updateMountedEvent(row, node) { + node.setAttribute("aria-label", eventPosition(row)); + node.classList.toggle("journal-last-event", Boolean(row.group && row.last)); + node.querySelector("button").setAttribute("aria-pressed", String(row.event.event_id === selected)); + } + + function append(nextEvents, nextOptions) { + const previous = anchor(); + const focused = locate(document.activeElement); + const priorLength = events.length; + events = nextEvents; + options = nextOptions; + selected = options.selected; + if (model.append) { + model.append(events.slice(priorLength)); + for (const [row, node] of mounted) + if (row.type === "event") updateMountedEvent(row, node); + } else { + const previousHeights = measured; + measured = new Map(); + model = buildModel(); + restoreMeasurements(previousHeights); + for (const [group, node] of [...groups]) { + const header = node.firstElementChild; + const row = observed.get(header); + const replacement = model.byKey.get(row.key); + groups.delete(group); + if (replacement?.type !== row.type) { + unmountRow(row, header); + node.remove(); + continue; + } + groups.set(replacement.group, node); + if (replacement.group.label !== group.label) + header.querySelector(".journal-group-name").textContent = replacement.group.label; + const count = groupCount(replacement.group); + if (count !== groupCount(group)) header.querySelector(".journal-group-count").innerHTML = count; + } + for (const [row, node] of [...mounted]) { + const replacement = model.byKey.get(row.key); + if (replacement?.type !== row.type || replacement.index < 0 || + (row.type === "event" && !sameHeightInputs(row, replacement)) || + (row.type === "date" && row.first !== replacement.first)) { + unmountRow(row, node); + continue; + } + mounted.delete(row); + mounted.set(replacement, node); + observed.set(node, replacement); + if (row.type === "event") updateMountedEvent(replacement, node); + } + } + revealRow = null; + track.style.height = `${model.heights.total}px`; + restoreAnchor(previous); + renderedScrollTop = element.scrollTop; + const pinned = focused && model.byKey.get(focused.key); + renderWindow(pinned); + if (focused && !locate(document.activeElement)) (control(pinned) ?? element).focus({preventScroll: true}); + } + + function render(nextEvents, nextOptions) { + if (nextOptions.append && track?.isConnected && nextOptions.lens === options.lens && + nextEvents.length >= events.length && nextEvents[0] === events[0] && + nextEvents[events.length - 1] === events.at(-1)) { + append(nextEvents, nextOptions); + return; + } + const previous = anchor() ?? savedAnchor; + const focused = locate(document.activeElement); + const previousWidth = width; + const previousHeights = measured; + measured = new Map(); + savedAnchor = null; + release(); + events = nextEvents; + options = nextOptions; + selected = options.selected; + model = buildModel(); + track = document.createElement("div"); + track.className = "journal-window"; + element.replaceChildren(track); + inset = track.offsetTop; + width = track.clientWidth; + if (width === previousWidth) restoreMeasurements(previousHeights); + track.style.height = `${model.heights.total}px`; + restoreAnchor(previous); + if (!previous) element.scrollTop = 0; + renderedScrollTop = element.scrollTop; + observer.observe(element); + const row = focused && (model.byKey.get(focused.key) ?? model.byEvent.get(focused.event?.event_id)); + renderWindow(row); + if (focused) (control(row) ?? element).focus({preventScroll: true}); + } + + function alignRow(row) { + const {heights} = model; + const top = inset + heights.prefix(row.index); + const bottom = top + heights.height(row.index); + const sticky = row.group && row.type !== "group" ? heights.height(row.group.start) : 0; + const visibleTop = element.scrollTop + (sticky ? inset + sticky : 0); + const visibleBottom = element.scrollTop + element.clientHeight; + const earliest = top - (sticky ? inset + sticky : 0); + if (top < visibleTop) element.scrollTop = earliest; + else if (bottom > visibleBottom) + element.scrollTop = Math.min(earliest, bottom - element.clientHeight); + revealScrollTop = element.scrollTop; + } + + function scrollToRow(row) { + revealRow = row; + alignRow(row); + renderWindow(); + } + + function reveal(identity) { + let row = model?.byEvent.get(identity); + if (!row) return; + if (row.index < 0) { + collapsed.delete(`${options.lens}:${row.group.key}`); + render(events, {...options, selected, append:false}); + row = model.byEvent.get(identity); + } + scrollToRow(row); + } + + function setGroupOpen(row, open) { + const key = `${options.lens}:${row.group.key}`; + if (open) collapsed.delete(key); + else collapsed.add(key); + const inGroup = model.byKey.get(anchor()?.key)?.group === row.group; + render(events, {...options, selected, append:false}); + if (!open && inGroup) scrollToRow(model.byKey.get(row.key)); + } + + function click(event) { + const target = event.target.closest("button[data-event], summary"); + const row = locate(target); + if (row?.type === "event") onOpen(row.event.event_id); + else if (row?.type === "group" && !event.defaultPrevented) { + // Commit disclosure before a scroll frame can unmount the summary. + event.preventDefault(); + setGroupOpen(row, !row.group.open); + } + } + + function toggle(event) { + const node = event.target; + if (!node.matches("details[data-group]")) return; + const row = locate(node.firstElementChild); + if (row && node.open !== row.group.open) setGroupOpen(row, node.open); } + function control(row) { + const node = mounted.get(row); + return row?.type === "event" ? node?.querySelector("button") : row?.type === "group" ? node : null; + } + + function keydown(event) { + if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey) return; + const current = locate(event.target); + if (!current) return; + const direction = event.shiftKey ? -1 : 1; + for (let index = current.index + direction; model.rows[index]; index += direction) { + const row = model.rows[index]; + if (row.type !== "group" && row.type !== "event") continue; + event.preventDefault(); + scrollToRow(row); + control(row)?.focus({preventScroll: true}); + return; + } + } + + function scroll() { + if (!track?.isConnected) return; + queue(); + if (renderedScrollTop === element.scrollTop) return; + renderedScrollTop = null; + if (element.scrollTop + element.clientHeight >= element.scrollHeight - 80) onNearEnd(); + } + + const listeners = [ + ["scroll", scroll, {passive: true}], + ["click", click], + ["toggle", toggle, true], + ["keydown", keydown], + ["focusout", queue], + ]; + listeners.forEach(([type, handler, settings]) => element.addEventListener(type, handler, settings)); + return { - render(events, options) { - lens = options.lens; - const groups = groupEvents(events, options.modules, lens); - element.innerHTML = groups - ? groups - .map( - (group) => - `
${esc(group.label)} ${group.events.length} ${group.events.length === 1 ? "event" : "events"}
${rows(group.events, options.selected)}
`, - ) - .join("") - : rows(events, options.selected); - element.querySelectorAll("details[data-group]").forEach((group) => { - const key = `${lens}:${group.dataset.group}`; - group.addEventListener("toggle", () => { - if (group.open) collapsed.delete(key); - else collapsed.add(key); - }); - }); - element - .querySelectorAll("[data-event]") - .forEach((button) => - button.addEventListener("click", () => onOpen(button.dataset.event)), - ); + render, + reveal, + select(identity) { + if (selected === identity) return; + selected = identity; + for (const [row, node] of mounted) + if (row.type === "event") + node.querySelector("button").setAttribute("aria-pressed", String(row.event.event_id === selected)); }, - reveal(identity) { - const button = [...element.querySelectorAll("[data-event]")].find( - (item) => item.dataset.event === identity, - ); - if (!button) return; - const group = button.closest("details[data-group]"); - if (group) { - collapsed.delete(`${lens}:${group.dataset.group}`); - group.open = true; - } - button.scrollIntoView({ block: "nearest" }); + clear({remember = false} = {}) { + savedAnchor = remember ? anchor() ?? savedAnchor : null; + release(); + }, + destroy() { + savedAnchor = null; + collapsed.clear(); + release(); + listeners.forEach(([type, handler, settings]) => element.removeEventListener(type, handler, settings)); }, }; } diff --git a/src/xflog/assets/live-history.js b/src/xflog/assets/live-history.js index 8e6ba65..200018e 100644 --- a/src/xflog/assets/live-history.js +++ b/src/xflog/assets/live-history.js @@ -10,18 +10,17 @@ export function groupSince(state, now = Date.now()) { return amount ? new Date(Math.floor(now / 60000) * 60000 - Number(amount) * (unit === "h" ? 3600000 : 86400000)).toISOString() : ""; } -export function historyPath(state, before = "0") { +export function historyPath(state, before = "0", options = {}) { const params = new URLSearchParams({ kind: state.filter, subject: state.subject, before: String(before || "0"), + lens: state.lens || "none", limit: "50", }); - if (state.lens && state.lens !== "none") { + if (before !== "0" || options.localOnly || state.lens && state.lens !== "none") params.set("local_only", "true"); - params.set("limit", "50"); - } - if (state.since) { - params.set("since", state.since); - } - if (before !== "0") params.set("local_only", "true"); + if (state.since) params.set("since", state.since); + if (options.afterRevision) params.set("after_revision", options.afterRevision); + if (options.selected && before === "0") params.set("selected", options.selected); + if (options.encounterRevision) params.set("encounter_revision", options.encounterRevision); return `/api/${state.demo ? "demo" : "timeline"}?${params}`; } @@ -32,64 +31,216 @@ export function mergeHistory(events) { return [...unique.values()].sort(compareEvents); } -export async function* historyPages(api, state, before = "0") { - const seen = new Set([String(before)]); - while (before) { - const page = await api(historyPath(state, before)); - yield page; - before = page.next; - if (before && seen.has(String(before))) throw new Error("Saved history pagination did not advance."); - seen.add(String(before)); - } +export function historyScope(state) { + const selection = state.status?.selection; + return JSON.stringify([ + state.demo, state.demo ? null : state.status?.account?.id, state.subject, + state.filter, state.lens, state.groupRange, state.groupSignature, + state.demo ? null : selection?.interactions, state.demo ? null : selection?.profile_names, + ]); } -export function updatedSelection(previous, selected, events) { - return events.some(event => event.event_id === selected) ? selected : events[0]?.event_id || null; +function inRange(events, since) { + if (!since) return events; + const cutoff = Date.parse(since); + return events.filter(event => Date.parse(event.received_at) >= cutoff); } -const historyScope = state => JSON.stringify([state.status?.account?.id, - state.status?.selection.interactions, state.status?.selection.profile_names, state.subject, state.filter, state.lens, state.groupRange]); - -export function createLiveHistory({ api, read, apply, refreshInteractions }) { - let busy = false; - let applied = ""; - let appliedScope = ""; - return { - async refresh() { +const catchupPages = 3; +export function createLiveHistory({ api, read, clear, apply, change, failure, refreshInteractions, connected }) { + let active = null, accepted = null, catchup = null, olderError = false, liveError = false; + const report = () => change({ + phase: active?.phase, catchingUp: Boolean(catchup) || active?.phase === "live" || liveError, olderError, + }); + function cancel() { + active?.controller.abort(); + active = null; + report(); + } + function start(phase) { + active?.controller.abort(); + const ticket = { + controller: new AbortController(), phase, scope: historyScope(read()), + }; + active = ticket; + report(); + return ticket; + } + const owns = ticket => ticket === active && connected() && historyScope(read()) === ticket.scope; + async function page(ticket, state, before, seen, options) { + const result = await api(historyPath(state, before, options), ticket.controller.signal); + if (!owns(ticket)) return null; + const next = result.next == null ? null : String(result.next); + if (next !== null && (!next || next === "0" || next === before || seen.has(next) || !result.events.length)) + throw new Error("Saved history pagination did not advance. Try again."); + return { ...result, next }; + } + function syncProfiles(events, sources) { + if (!sources.length) return events; + const profiles = new Map(); + for (const event of sources) + if (event.name && event.handle && !profiles.has(event.actor_id)) + profiles.set(event.actor_id, event); + return events.map(event => { + const profile = profiles.get(event.actor_id); + if (!profile || profile.name === event.name && profile.handle === event.handle) return event; + return {...event, name: profile.name, handle: profile.handle, profile_url: profile.profile_url}; + }); + } + async function run(ticket, work) { + try { + await work(); + } catch (error) { + if (owns(ticket)) { + if (ticket.phase === "older") olderError = true; + if (ticket.phase === "live") liveError = true; + failure(error, ticket.phase); + } + } finally { + if (active === ticket) { + active = null; + report(); + } + } + } + async function load(prepare) { + accepted = catchup = null; + olderError = liveError = false; + const ticket = start("initial"); + clear(); + await run(ticket, async () => { + if (prepare) await prepare(); + if (!owns(ticket)) return; const state = read(); - state.since = groupSince(state); - if (busy || state.loading || state.demo || !state.status) return; - const scope = historyScope(state); - const version = `${scope}/${state.status.timeline_revision}/${state.since}`; - if (version === applied) return; - const generation = state.generation; - const existing = appliedScope && appliedScope !== scope ? [] : state.events; - const known = new Set(existing.map(event => event.event_id)); + const since = groupSince(state); + const result = await page(ticket, {...state, since}, "0", new Set()); + if (!result) return; + const revision = result.aggregate?.revision || state.status?.timeline_revision; + const events = inRange(mergeHistory(result.events), since); + apply({...result, events}, "initial"); + accepted = { + scope: ticket.scope, since, revision, initialRevision: revision, lastSince: since, lastVersion: revision, + cursors: new Set(result.next ? [result.next] : []), + }; + }); + } + async function older(retry = true) { + const state = read(); + if (!state.next || accepted?.scope !== historyScope(state) || + active?.phase === "initial" || active?.phase === "older" || (olderError && !retry)) return; + const ticket = start("older"); + await run(ticket, async () => { + const revision = state.aggregate?.revision; + const encounters = state.lens === "previous-encounters" && + state.groups?.some(group => group.key === state.lens && group.enabled); + const encounterRevision = !state.demo && encounters && + revision !== accepted.initialRevision ? revision : undefined; + const result = await page(ticket, {...state, since: accepted.since}, state.next, accepted.cursors, { + encounterRevision, + }); + if (!result) return; + const current = read(); + const rows = inRange(result.events, accepted.lastSince); + // Once the ordered continuation passes the rolling cutoff, all subsequent pages are older. + const next = rows.length === result.events.length ? result.next : null; + // Incoming older pages normally extend the ordered tail. Live backfills can interleave. + const known = new Set(current.events.map(event => event.event_id)); + const olderRows = mergeHistory(rows.filter(event => !known.has(event.event_id))); + const tail = !current.events.length || !olderRows.length || + compareEvents(current.events.at(-1), olderRows[0]) <= 0; + const events = tail ? current.events.concat(olderRows) : mergeHistory([...current.events, ...olderRows]); + apply({events, next, aggregate: current.aggregate}, tail ? "older" : "update"); + if (next) accepted.cursors.add(next); + olderError = false; + }); + } + async function refresh() { + const state = read(); + if (active || !connected() || state.demo || !state.status) return; + if (accepted?.scope !== historyScope(state)) return load(); + const since = groupSince(state); + const version = state.status.timeline_revision; + const oldest = state.aggregate?.oldest_received_at; + const empty = state.aggregate?.total_events === 0; + const stillInRange = since === accepted.lastSince || ( + since && accepted.lastSince && Date.parse(since) >= Date.parse(accepted.lastSince) && + (empty || oldest && Date.parse(since) <= Date.parse(oldest)) + ); + if (!catchup && !liveError && (version === accepted.lastVersion || version === accepted.revision) && stillInRange) + return; + // A clock correction that widens a rolling range needs a new older snapshot. + if (!catchup && since && accepted.lastSince && + Date.parse(since) < Date.parse(accepted.lastSince)) return load(); + const ticket = start("live"); + await run(ticket, async () => { + const batch = catchup || { + before: "0", seen: new Set(), since, version, afterRevision: accepted.revision, + }; + const seen = new Set(); + let before = batch.before; + let aggregate = batch.aggregate; const incoming = []; - busy = true; - try { - let page; - for await (page of historyPages(api, state)) { - const current = read(); - if (current.generation !== generation || current.demo !== state.demo || - historyScope(current) !== scope) return; - incoming.push(...page.events); - const selectionLoaded = !state.selected || !known.has(state.selected) || - incoming.some(event => event.event_id === state.selected); - if (appliedScope === scope && selectionLoaded && page.events.some(event => known.has(event.event_id))) break; + const corrections = new Map(); + let selectedRecord; + const rows = {...state, since: batch.since}; + for (let count = 0; before && count < catchupPages; count++) { + const result = await page(ticket, rows, before, seen, { + afterRevision: batch.afterRevision, selected: state.selected, + }); + if (!result) return; + if (result.next && batch.seen.has(result.next)) + throw new Error("Saved history pagination did not advance. Try again."); + aggregate = result.aggregate || aggregate; + for (const event of result.events) { + if (event.encounter_correction) { + const {encounter_correction, ...record} = event; + corrections.set(record.event_id, record); + } else incoming.push(event); } - const profiles = new Map(incoming.filter(event => event.name && event.handle) - .map(event => [event.actor_id, {name:event.name,handle:event.handle,profile_url:event.profile_url}])); - const events = mergeHistory([...incoming, ...existing]) - .filter(event => !state.since || Date.parse(event.received_at) >= Date.parse(state.since)) - .map(event => profiles.has(event.actor_id) ? {...event,...profiles.get(event.actor_id)} : event); - apply(events, existing.length ? state.next : page.next); - await refreshInteractions(); - applied = version; - appliedScope = scope; - } finally { - busy = false; + if (result.selected_event) { + selectedRecord = result.selected_event; + corrections.set(selectedRecord.event_id, selectedRecord); + } + before = result.next; + if (before) seen.add(before); + } + // A profile-only revision can change cached names on loaded rows without a new event. + let profileRows = []; + if (batch.before === "0" && !before && !incoming.length && + aggregate?.profile_revision !== state.aggregate?.profile_revision) { + const result = await page(ticket, rows, "0", new Set(), {localOnly: true}); + if (!result) return; + profileRows = result.events; } - }, - }; + const current = read(); + // Corrections update loaded rows. They never expand the loaded window. + const prior = corrections.size + ? current.events.map(event => corrections.get(event.event_id) ?? event) + : current.events; + const events = syncProfiles( + inRange(mergeHistory([...incoming, ...prior]), batch.since), + [...profileRows, ...incoming, ...(selectedRecord ? [selectedRecord] : [])], + ); + const metadata = aggregate || current.aggregate; + const next = metadata?.total_events === events.length ? null : current.next; + if (events.length !== current.events.length || events.some((event, index) => event !== current.events[index]) || + metadata?.revision !== current.aggregate?.revision || batch.since !== accepted.lastSince || next !== current.next) + apply({events, next, aggregate: metadata}, "update"); + if (before) { + for (const cursor of seen) batch.seen.add(cursor); + catchup = {...batch, before, aggregate}; + } else catchup = null; + accepted.lastSince = batch.since; + if (!before) { + accepted.revision = aggregate?.revision || batch.version; + accepted.lastVersion = batch.version; + } + liveError = false; + await refreshInteractions(); + }); + } + return { load, older, refresh, cancel, needsLoad: () => { + const scope = historyScope(read()); + return accepted?.scope !== scope && !(active?.phase === "initial" && active.scope === scope); + }}; } diff --git a/src/xflog/assets/shell.html b/src/xflog/assets/shell.html index a06f095..8109346 100644 --- a/src/xflog/assets/shell.html +++ b/src/xflog/assets/shell.html @@ -229,6 +229,10 @@

Every connection
has a chapter.

> No memories yet +
+ + +