From 1f78bbe7576e34d50bdff36df8c99a4e2ac195df Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 02:22:37 +0530 Subject: [PATCH 1/9] Update Constellation geometry in place on zoom --- src/xflog/assets/constellation-layout.js | 117 ++++++----- src/xflog/assets/constellation.js | 251 ++++++++++++++--------- tests/constellation_check.mjs | 20 +- tests/constellation_selection_check.mjs | 171 +++++++++++---- tests/test_browser_scene_lifecycle.py | 188 ++++++++++++++++- 5 files changed, 554 insertions(+), 193 deletions(-) diff --git a/src/xflog/assets/constellation-layout.js b/src/xflog/assets/constellation-layout.js index 605f09b..a83e8da 100644 --- a/src/xflog/assets/constellation-layout.js +++ b/src/xflog/assets/constellation-layout.js @@ -103,11 +103,14 @@ 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, }) { @@ -134,65 +137,79 @@ export function constellationLayout({ 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 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 = buckets ? [...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, 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.js b/src/xflog/assets/constellation.js index 423c45d..b6b0aee 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -5,7 +5,7 @@ import { flowStep, wheelDistance, normalizeMotionSpeed, - constellationLayout, + prepareConstellation, visibleLabels, seed, } from "./constellation-layout.js"; @@ -36,7 +36,8 @@ export function createConstellation({ selected = null, modules = [], lens = "none", - geometry = null; + geometry = null, + layoutAtZoom = null; let sceneVersion = 0; // History is deduplicated by event ID before grouping, so only data changes alter membership. let personCount = 0, countDirty = true; @@ -54,6 +55,9 @@ export function createConstellation({ sparks = [], actorButtons = [], sky = null, meteor = null, meteorTail = null, + canvas = null, linkLayer = null, groupLayer = null, + groupButtons = null, connections = null, + sceneWidth = 0, sceneHeight = 0, labelInset = 16, keyScope = null; const reduced = matchMedia("(prefers-reduced-motion: reduce)"); @@ -92,7 +96,8 @@ export function createConstellation({ labels = []; sparks = []; actorButtons = []; - sky = meteor = meteorTail = null; + sky = meteor = meteorTail = canvas = linkLayer = groupLayer = null; + groupButtons = connections = layoutAtZoom = null; } function queue() { if (!frame && active()) frame = requestAnimationFrame(paint); @@ -123,8 +128,9 @@ export function createConstellation({ } function groupHint() { key.hidden = !geometry.grouped; - const group = geometry.groups.find(item => item.key === position()?.group); - const scope = geometry.overview ? "overview" : group?.key; + const groupId = position()?.group; + const group = geometry.groups.find(item => item.key === groupId); + const scope = geometry.overview ? "overview" : group; if (keyScope === scope) return; keyScope = scope; key.innerHTML = geometry.grouped @@ -194,6 +200,12 @@ export function createConstellation({ navigator(); queue(); } + function showLabel(item, 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 paint(now) { frame = 0; if (!active()) { @@ -259,7 +271,7 @@ export function createConstellation({ center + width / 2 <= element.clientWidth - 12 ) { const candidate = { - id: item.person.event.event_id, + id: item.person, left: center - width / 2, right: center + width / 2, top, @@ -280,15 +292,11 @@ export function createConstellation({ candidates.push(candidate); } } - const shown = visibleLabels(candidates, chosenPerson?.event.event_id); + const shown = visibleLabels(candidates, chosenPerson); labels.forEach((item) => { - const placement = shown.get(item.person.event.event_id); - const visible = Boolean(placement); + const placement = shown.get(item.person); 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; }); + showLabel(item, Boolean(placement)); }); stars.forEach((star, index) => { star.style.opacity = String( @@ -297,7 +305,7 @@ export function createConstellation({ : 0.22, ); }); - sparks.forEach(({ element: spark, trail, a, b }, index) => { + if (!geometry.overview) sparks.forEach(({ element: spark, trail, a, b }, index) => { const part = (clock / 10000 + seed(index)) % 1; const tail = Math.max(0, part - 0.06); spark.setAttribute("cx", (a.x + (b.x - a.x) * part).toFixed(2)); @@ -322,20 +330,21 @@ export function createConstellation({ 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, }); + 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(); @@ -344,29 +353,7 @@ export function createConstellation({ (_, 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"); @@ -379,65 +366,109 @@ export function createConstellation({ person: peopleByEvent.get(button.dataset.event), })); 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, label: labelNodes[index]})); + 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 }) => - button.addEventListener("click", (event) => { if (!event?.target?.closest?.("a")) clickPerson(person); }), - ); - element.querySelectorAll("[data-group]").forEach((button) => - 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", - ); + labels.forEach(({person, label: button}) => + button.addEventListener("click", event => { + if (ownsScene(renderedVersion, button) && !geometry.overview && + !event?.target?.closest?.("a")) clickPerson(person); }), ); + updateMode(); + anchor(); + select(identity); + } + 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]}), + ); + } + function prepareGroupButtons() { + const renderedVersion = sceneVersion; + groupLayer.innerHTML = geometry.groups.map((group) => ``).join(""); + groupButtons = [...groupLayer.querySelectorAll("[data-group]")]; + groupButtons.forEach((button, index) => { + button.addEventListener("click", () => { + if (!ownsScene(renderedVersion, button) || !geometry.overview) return; + const group = geometry.groups[index]; + const identity = group.people[0].event.event_id; + selected = 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 labels) showLabel(item, false); + } + 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; @@ -454,10 +485,7 @@ export function createConstellation({ 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); - } + if (geometry.overview) setZoom(0.8); const destination = position(); if (destination && geometry.canPan) { target = clamp(destination.x - element.clientWidth / 2, 0, maximum()); @@ -473,9 +501,36 @@ export function createConstellation({ 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; + 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(); + 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}`); + }); + } + anchor(); + navigator(); + queue(); } function scrub(progress) { if (!geometry?.canPan) return; @@ -543,6 +598,7 @@ export function createConstellation({ drag = { id: event.pointerId, x: event.clientX, + lastX: event.clientX, scroll: element.scrollLeft, }; element.setPointerCapture(event.pointerId); @@ -550,6 +606,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(); @@ -606,7 +663,8 @@ export function createConstellation({ attributeFilter: ["data-route", "data-motion", "data-motion-speed"], }); const resize = new ResizeObserver(() => { - if (geometry && element.clientWidth && element.clientHeight) + if (geometry && element.clientWidth && element.clientHeight && + (element.clientWidth !== sceneWidth || element.clientHeight !== sceneHeight)) render(events, selected); }); resize.observe(element); @@ -690,6 +748,7 @@ export function createConstellation({ destroy: () => { stop(); geometry = null; + events = []; releaseScene(); observer.disconnect(); resize.disconnect(); diff --git a/tests/constellation_check.mjs b/tests/constellation_check.mjs index 767ae3b..75b5030 100644 --- a/tests/constellation_check.mjs +++ b/tests/constellation_check.mjs @@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url"; const base = pathToFileURL(process.argv[2] + "/"); const { constellationLayout, + prepareConstellation, peopleFromEvents, entriesFromEvents, visibleLabels, @@ -372,6 +373,7 @@ function exercise(refreshRate, speed = 1, duration = 1000) { return controls.get(selector); }, }; + const linkLayer = {style: {}, querySelectorAll: () => []}; const element = { isConnected: true, dataset: {}, @@ -380,7 +382,8 @@ function exercise(refreshRate, speed = 1, duration = 1000) { scrollWidth: 800, scrollLeft: 0, classList: { contains: () => true, toggle() {} }, - querySelector: (selector) => (selector === ".shooting-star" ? meteor : sky), + querySelector: (selector) => selector === ".shooting-star" ? meteor + : selector === ".constellation-links" ? linkLayer : sky, querySelectorAll: (selector) => (selector === ".sky-star" ? [star] : []), addEventListener() {}, removeEventListener() {}, @@ -545,6 +548,21 @@ function seedReference(value) { const geometryDigest = createHash("sha256").update(JSON.stringify(baselineGeometry)).digest("hex"); assert.equal(geometryDigest, "140fd1844b9614ca831c59b28ff1912b55f2789983d6c3638827166f71041ebb", "Group ordering, colors and geometry match the original layout at e283194"); +for (const records of lookupFixtures) + for (const lens of ["none", "activity", "profile-location"]) + for (const width of [390, 1024]) { + const options = {events: records, width, height: 772, modules, lens}; + const atZoom = prepareConstellation(options); + const original = atZoom(0.8), points = [...original.people], groups = [...original.groups]; + for (const zoom of [0, 0.05, 0.2, 0.25, 0.250001, 0.3, 0.55, 0.8, 1, 1.5, 2, 0]) { + const layout = atZoom(zoom); + assert.equal(layout, original, "Zoom keeps the geometry bindings live"); + assert.ok(layout.people.every((point, i) => point === points[i])); + assert.ok(layout.groups.every((group, i) => group === groups[i])); + assert.deepEqual(layout, constellationLayout({...options, zoom}), + "Repeated in-place layout matches a fresh calculation"); + } + } // Count only searches involving layout entries/groups; unrelated color lookups remain untouched. const originalFind = Array.prototype.find; diff --git a/tests/constellation_selection_check.mjs b/tests/constellation_selection_check.mjs index 33d16b2..8dd0a74 100644 --- a/tests/constellation_selection_check.mjs +++ b/tests/constellation_selection_check.mjs @@ -76,6 +76,7 @@ function button(dataset) { this.attributes[k] = v; }, addEventListener(k, v) { + listenerWrites++; this.handlers[k] = v; }, removeEventListener() {}, @@ -113,7 +114,8 @@ const root = { let nodes = [], labels = [], paths = []; -let captureLinks = false, renderedLinks = [], renderedTrails = [], renderedSparks = []; +let renderedLinks = [], renderedTrails = [], renderedSparks = [], groupNodes = []; +let historyWrites = 0, linkWrites = 0, groupWrites = 0, listenerWrites = 0; let beforeMarkup = false, measureRenderWork = false; let sceneParts = {}; const handlers = {}; @@ -155,15 +157,45 @@ const element = { : key === ".sequence-spark" ? renderedSparks : []; }, - contains(node) { return nodes.includes(node); }, + contains(node) { return nodes.includes(node) || labels.includes(node) || groupNodes.includes(node) || Object.values(sceneParts).includes(node); }, set innerHTML(value) { + historyWrites++; beforeMarkup = false; - sceneParts = Object.fromEntries([".constellation-sky", ".shooting-star", ".shooting-tail"].map(key => [key, { style: {}, dataset: {}, attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }])); - paths = [...value.matchAll(//g)] - .map(([, group, style, d, dash]) => ({group, style, d, dash})) : []; - renderedTrails = renderedLinks.map(() => button({})); - renderedSparks = renderedLinks.map(() => button({})); + sceneParts = Object.fromEntries([".constellation-sky", ".shooting-star", ".shooting-tail", ".constellation-canvas"] + .map(key => [key, button({})])); + sceneParts[".shooting-star"].dataset = {}; + groupNodes = []; + renderedLinks = []; + renderedSparks = []; + renderedTrails = []; + paths = []; + sceneParts[".constellation-links"] = { + ...button({}), + set innerHTML(markup) { + linkWrites++; + renderedLinks = [...markup.matchAll(//g)] + .map(([, group, style, d, dash]) => ({group, style, d, dash})); + paths = [...markup.matchAll(/ button({})); + renderedSparks = renderedLinks.map(() => button({})); + this.pathNodes = renderedLinks.map(link => ({ + setAttribute(key, value) { if (key === "d") link.d = value; }, + })); + }, + querySelectorAll(key) { + if (key === "path") return this.pathNodes; + return key === ".sequence-trail" ? renderedTrails : renderedSparks; + }, + }; + sceneParts[".constellation-groups"] = { + hidden: true, + contains(node) { return groupNodes.includes(node); }, + set innerHTML(markup) { + groupWrites++; + groupNodes = [...markup.matchAll(/data-group="([^"]*)"/g)].map(([, group]) => button({group})); + }, + querySelectorAll() { return groupNodes; }, + }; const buttons = [ ...value.matchAll(/<(?:button|div(?= class="star-label"))\b([^>]*)>([\s\S]*?)<\/(?:button|div)>/g), ].map((m) => { @@ -174,7 +206,7 @@ const element = { dataset[key] = v; return Object.assign(button(dataset), { innerHTML: m[2], - x: Number(m[1].match(/left:([\d.]+)px/)?.[1]), + style: {left: m[1].match(/left:([\d.]+px)/)?.[1]}, }); }); nodes = buttons.filter((n) => n.dataset.actor); @@ -359,7 +391,7 @@ assert.equal(element.scrollLeft, manualStart, "Click travel starts without an im frame(2000); assert.ok(element.scrollLeft < manualStart, "A manual click smoothly centers the chosen star"); for (let time = 2016; time <= 3024; time += 16) frame(time); -assert.ok(Math.abs(nodes.find(node=>node.dataset.actor === "B").x - element.scrollLeft - element.clientWidth / 2) < 1, +assert.ok(Math.abs(parseFloat(nodes.find(node=>node.dataset.actor === "B").style.left) - element.scrollLeft - element.clientWidth / 2) < 1, "The clicked star lands at the viewport center"); assert.equal(nodes.find(node=>node.dataset.actor === "B").attributes["aria-pressed"], "true", "A stationary off-center selection survives queued scroll events"); @@ -367,7 +399,7 @@ const openedBeforeLabel = opened.length; labels.find(node=>node.dataset.label === "old-C").handlers.click(); frame(3040); for (let time = 3056; time <= 4064; time += 16) frame(time); -assert.ok(Math.abs(nodes.find(node=>node.dataset.actor === "C").x - element.scrollLeft - element.clientWidth / 2) < 1, +assert.ok(Math.abs(parseFloat(nodes.find(node=>node.dataset.actor === "C").style.left) - element.scrollLeft - element.clientWidth / 2) < 1, "Clicking a name also centers its star"); assert.equal(nodes.find(node=>node.dataset.actor === "C").attributes["aria-pressed"], "true", "Clicking another person's label selects that person too"); @@ -401,7 +433,7 @@ frame(5120); assert.deepEqual(nodes.filter(node=>node.attributes["aria-pressed"] === "true").map(node=>node.dataset.event), ["old-A"], "Manual grouped selection also survives refresh with ambient motion disabled"); const groupedStar = nodes.find(node=>node.dataset.event === "old-A"); -assert.equal(element.scrollLeft, Math.round(Math.max(0, Math.min(500, groupedStar.x - element.clientWidth / 2))), +assert.equal(element.scrollLeft, Math.round(Math.max(0, Math.min(500, parseFloat(groupedStar.style.left) - element.clientWidth / 2))), "With motion off, grouped click centering is immediate and stays within the scroll bounds"); let prevented = 0; const zoomWheel = (deltaY, buttons = 4, extra = {}) => handlers.wheel({ @@ -535,7 +567,7 @@ try { }); otherScene.render([record("x", "X", 1, "follow.follow"), record("y", "Y", 0, "follow.follow")], "x"); expectCount("2 people in loaded history"); - scene.setZoom(0.8); + scene.render([record("solo", "Z", 0, "follow.follow")], "solo"); expectCount("1 person in loaded history"); otherScene.destroy(); } finally { @@ -565,7 +597,10 @@ assert.equal(actorQueries, beforeQueries.actorQueries, "Selection reuses the cur assert.equal(descendantQueries, beforeQueries.descendantQueries, "Unchanged label markup reuses its descendants"); const beforeRedrawParents = parentQueries; scene.setZoom(1); -assert.equal(parentQueries - beforeRedrawParents, events.length, "Redraw resolves one fresh parent per actor"); +assert.equal(parentQueries, beforeRedrawParents, "Zoom reuses actor parents"); +assert.equal(sceneParts[".constellation-sky"], previousSky, "Zoom reuses the sky"); +scene.render(events, "other-B"); +assert.equal(parentQueries - beforeRedrawParents, events.length, "Structural redraw resolves one fresh parent per actor"); const oldParentWrites = previousParent.writes; assert.notEqual(nodes[0].parent, previousParent, "A redraw creates fresh actor parents"); assert.notEqual(sceneParts[".constellation-sky"], previousSky, "A redraw creates new sky nodes"); @@ -575,7 +610,9 @@ assert.equal(previousParent.writes, oldParentWrites, "Detached actor parents are assert.equal(previousSky.style.transform, "old scene", "Frames never update the detached sky after redraw"); assert.notEqual(sceneParts[".constellation-sky"].style.transform, undefined, "Frames update the new sky"); const beforeResizeSky = sceneParts[".constellation-sky"]; +element.clientWidth++; resizeFirstScene(); +element.clientWidth--; beforeResizeSky.style.transform = "detached after resize"; frame(7040); assert.equal(beforeResizeSky.style.transform, "detached after resize", "Resize never paints the previous sky"); @@ -650,7 +687,9 @@ try { meteorScene.render(events, "old-A"); for (let i = 0; i < 96; i++) frame(10000 + i * 64); const oldMeteor = sceneParts[".shooting-star"], oldTail = sceneParts[".shooting-tail"]; + element.clientWidth++; resizeScene(); + element.clientWidth--; oldMeteor.style.opacity = "detached"; oldTail.attributes = {}; frame(10000 + 96 * 64); @@ -846,7 +885,6 @@ try { } // Reconstruct the original per-group filter oracle from the actual layout output. -captureLinks = true; const connectionScene = createConstellation({ element, root, escape: String, label: item => item.kind, initials: item => item.actor_id, onSelect() {}, onOpen() {}, @@ -898,11 +936,14 @@ function verifyConnections(records, modules, lens, zoom) { if (records.length) connectionScene.setZoom(zoom); const expected = expectedConnections(records, modules, lens, zoom); const visible = expected.layout.overview ? [] : expected.links; - assert.deepEqual(renderedLinks, visible.map(({a,b,color,dash}) => ({ + assert.deepEqual(expected.layout.overview ? [] : renderedLinks, visible.map(({a,b,color,dash}) => ({ group:a.group, style:color ? `color:${color}` : "", d:`M${a.x},${a.y}L${b.x},${b.y}`, dash:dash || "", })), "Rendered paths preserve group order, endpoints, direction, colors and dashes"); - assert.equal(renderedSparks.length, visible.length, "Each visible connection has one spark"); - assert.equal(renderedTrails.length, visible.length, "Each visible connection has one trail"); + assert.equal(sceneParts[".constellation-links"].style.visibility, expected.layout.overview ? "hidden" : ""); + if (!expected.layout.overview) { + assert.equal(renderedSparks.length, visible.length, "Each visible connection has one spark"); + assert.equal(renderedTrails.length, visible.length, "Each visible connection has one trail"); + } return expected; } try { @@ -942,9 +983,9 @@ try { const oldTrailWrites = oldTrails.map(trail => trail.attributeWrites); const oldSparkWrites = oldSparks.map(spark => spark.attributeWrites); connectionScene.setZoom(0); - assert.equal(renderedLinks.length, 0, "Overview renders no links, trails or sparks"); - assert.equal(renderedSparks.length, 0); - assert.equal(renderedTrails.length, 0); + assert.equal(sceneParts[".constellation-links"].style.visibility, "hidden", "Overview hides the connection layer"); + assert.equal(renderedSparks, oldSparks, "Overview retains the spark nodes"); + assert.equal(renderedTrails, oldTrails, "Overview retains the trail nodes"); frame(30016); assert.deepEqual(oldTrails.map(trail => trail.attributes), oldTrailAttributes, "Overview cannot update old trails"); assert.deepEqual(oldSparks.map(spark => spark.attributes), oldSparkAttributes, "Overview cannot update old sparks"); @@ -958,7 +999,7 @@ try { frame(30032); assert.ok(renderedTrails.every(trail => trail.attributes.x1 !== undefined), "Recreated trails animate"); expanded.links.forEach(({a,b}, index) => { - const part = seed(index) % 1; + const part = (seed(index) + 32 / 10000) % 1; assert.equal(renderedSparks[index].attributes.cx, (a.x + (b.x - a.x) * part).toFixed(2)); assert.equal(renderedSparks[index].attributes.cy, (a.y + (b.y - a.y) * part).toFixed(2)); }); @@ -974,7 +1015,6 @@ try { assert.equal(pending.size, 0, "Clear cancels connection animation"); } finally { connectionScene.destroy(); - captureLinks = false; } // Count positioned-point for-of/filter passes and link-shaped outputs around markup replacement. @@ -986,11 +1026,11 @@ function measurePreparation(action) { const counts = {groupIndexes:0, preMarkupForOf:0, postMarkupForOf:0, preMarkupFilters:0, linkObjects:0}; const isLink = item => item && typeof item === "object" && item.a?.event && item.b?.event && "group" in item.a && "group" in item.b && - "color" in item && "dash" in item; + "color" in item && "dash" in item && !("trail" in item); globalThis.Map = class extends NativeMap { constructor(entries) { super(entries); - if (beforeMarkup && Array.isArray(entries) && entries.length && + if (measureRenderWork && Array.isArray(entries) && entries.length && entries.every(entry => Array.isArray(entry) && Array.isArray(entry[1]) && !entry[1].length)) counts.groupIndexes++; } @@ -1002,12 +1042,12 @@ function measurePreparation(action) { return nativeIterator.call(this); }; Array.prototype.push = function(...items) { - if (beforeMarkup) counts.linkObjects += nativeFilter.call(items, isLink).length; + if (measureRenderWork) counts.linkObjects += nativeFilter.call(items, isLink).length; return nativePush.apply(this, items); }; Array.prototype.map = function(...args) { const result = nativeMap.apply(this, args); - if (beforeMarkup) counts.linkObjects += nativeFilter.call(result, isLink).length; + if (measureRenderWork) counts.linkObjects += nativeFilter.call(result, isLink).length; return result; }; Array.prototype.filter = function(...args) { @@ -1042,24 +1082,73 @@ const preparationScene = createConstellation({ onSelect() {}, onOpen() {}, }); try { + let membershipReads = 0; + const observedEvents = connectionEvents.map(event => ({ + ...event, + get audience_groups() { membershipReads++; return event.audience_groups; }, + })); preparationScene.configure([activity], "activity"); - preparationScene.render(connectionEvents, connectionEvents[0].event_id); - assert.deepEqual(measurePreparation(() => preparationScene.setZoom(0)), - {groupIndexes:0,preMarkupForOf:0,postMarkupForOf:1,preMarkupFilters:0,linkObjects:0}, - "Overview skips connection preparation but still builds the selection lookup"); - assert.deepEqual(measurePreparation(() => preparationScene.setZoom(1)), - {groupIndexes:1,preMarkupForOf:1,postMarkupForOf:1,preMarkupFilters:0,linkObjects:14}, "Multiple groups rebuild their indexed links"); - preparationScene.update(oneGroup, oneGroup[0].event_id); - assert.deepEqual(measurePreparation(() => preparationScene.setZoom(0.8)), - {groupIndexes:0,preMarkupForOf:1,postMarkupForOf:1,preMarkupFilters:0,linkObjects:17}, "One group streams connections without an index"); + assert.deepEqual(measurePreparation(() => preparationScene.render(observedEvents, observedEvents[0].event_id)), + {groupIndexes:1,preMarkupForOf:0,postMarkupForOf:2,preMarkupFilters:0,linkObjects:14}, + "Structural render builds the selection lookup and indexed connections"); + const originalNodes = nodes, originalLabels = labels, originalParts = sceneParts; + const originalPaths = sceneParts[".constellation-links"].pathNodes; + const before = [historyWrites, linkWrites, groupWrites, listenerWrites]; + assert.ok(membershipReads > 0, "Structural rendering reads group membership"); + membershipReads = 0; + for (const zoom of [1, 1.5, 2, 0.3]) { + assert.deepEqual(measurePreparation(() => preparationScene.setZoom(zoom)), + {groupIndexes:0,preMarkupForOf:0,postMarkupForOf:0,preMarkupFilters:0,linkObjects:0}, + "Ordinary zoom does not prepare connection topology or rebuild the selection lookup"); + const expected = expectedConnections(connectionEvents, [activity], "activity", zoom); + assert.deepEqual(nodes.map(node => parseFloat(node.style.left)), expected.layout.people.map(point => point.x)); + assert.deepEqual(renderedLinks.map(link => link.d), expected.links.map(({a,b}) => `M${a.x},${a.y}L${b.x},${b.y}`)); + } + assert.deepEqual([historyWrites, linkWrites, groupWrites, listenerWrites], before); + preparationScene.setZoom(0); + const firstGroupNodes = groupNodes; + const afterFirstOverview = [historyWrites, linkWrites, groupWrites, listenerWrites]; + assert.equal(groupWrites, before[2] + 1, "Only the overview layer is created on its first visit"); + for (const zoom of [0.25, 0.3, 0.05, 0.8]) preparationScene.setZoom(zoom); + assert.deepEqual([historyWrites, linkWrites, groupWrites, listenerWrites], afterFirstOverview); + assert.equal(membershipReads, 0, "Repeated zoom does not regroup the events"); + assert.equal(nodes, originalNodes); + assert.equal(labels, originalLabels); + assert.equal(sceneParts, originalParts); + assert.equal(sceneParts[".constellation-links"].pathNodes, originalPaths); + assert.equal(groupNodes, firstGroupNodes); + preparationScene.setZoom(0); + groupNodes[0].handlers.click(); + assert.equal(Number(controls.get("#scene-zoom").value), 1, "Overview cluster opens without rebuilding"); + assert.equal(nodes, originalNodes); + preparationScene.setZoom(0); + const beforeOverviewRebuild = linkWrites; + preparationScene.render(observedEvents, observedEvents[0].event_id); + assert.equal(linkWrites, beforeOverviewRebuild, "An overview rebuild defers connection preparation"); + preparationScene.setZoom(1); + assert.equal(linkWrites, beforeOverviewRebuild + 1, "First expansion builds only the missing links"); + + const priorNodes = nodes; + assert.deepEqual(measurePreparation(() => preparationScene.update(oneGroup, oneGroup[0].event_id)), + {groupIndexes:0,preMarkupForOf:0,postMarkupForOf:2,preMarkupFilters:0,linkObjects:17}, + "Replacing data rebuilds single-group connections without an index"); + assert.notEqual(nodes, priorNodes); preparationScene.configure([], "none"); - assert.deepEqual(measurePreparation(() => preparationScene.setZoom(0.8)), - {groupIndexes:0,preMarkupForOf:1,postMarkupForOf:1,preMarkupFilters:0,linkObjects:17}, "Ungrouped connections also stream without an index"); - preparationScene.update([oneGroup[0]], oneGroup[0].event_id); - assert.deepEqual(measurePreparation(() => preparationScene.setZoom(0.8)), - {groupIndexes:0,preMarkupForOf:1,postMarkupForOf:1,preMarkupFilters:0,linkObjects:0}, "A singleton produces no link or index"); assert.deepEqual(measurePreparation(() => preparationScene.render([], null)), {groupIndexes:0,preMarkupForOf:0,postMarkupForOf:0,preMarkupFilters:0,linkObjects:0}, "Empty history produces no connection work"); + const region = {...location, buckets:[{key:"unknown",label:"Unknown"}]}; + const overviewEvents = connectionEvents.slice(0, 2).map(item => ({ + ...item, + audience_groups:{"profile-location":"overview"}, + audience_group_buckets:{"profile-location":{key:"overview",label:"Overview City"}}, + })); + preparationScene.configure([region], "profile-location"); + preparationScene.render(overviewEvents, overviewEvents[0].event_id); + const groupKey = controls.get("#constellation-key"); + preparationScene.setZoom(0.25); + assert.match(groupKey.innerHTML, /All constellations/); + preparationScene.setZoom(0.3); + assert.match(groupKey.innerHTML, /Overview City/, "A group key cannot collide with the overview mode"); } finally { preparationScene.destroy(); } diff --git a/tests/test_browser_scene_lifecycle.py b/tests/test_browser_scene_lifecycle.py index 8c09998..c7fba59 100644 --- a/tests/test_browser_scene_lifecycle.py +++ b/tests/test_browser_scene_lifecycle.py @@ -1,4 +1,4 @@ -"""Exercise parent-owned scene replacement against the real offline UI.""" +"""Exercise Constellation zoom and scene replacement against the real offline UI.""" import importlib.util import os @@ -6,6 +6,7 @@ import subprocess import sys import time +from datetime import datetime, timedelta from pathlib import Path from urllib.error import URLError from urllib.request import urlopen @@ -78,7 +79,8 @@ def offline_browser_app(tmp_path_factory): }; window.cancelAnimationFrame = id => id < 0 ? frames.delete(id) : nativeCancel(id); window.enableFrames = () => { controlled = true; }; - window.paintCount = () => [...frames.values()].filter(cb => cb.name === 'paint').length; + window.paintIds = () => [...frames].filter(([, cb]) => cb.name === 'paint').map(([id]) => id); + window.paintCount = () => window.paintIds().length; window.stepFrames = () => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(cb => cb(performance.now())); @@ -225,11 +227,14 @@ def test_error_fallback_clears_scene_when_initial_load_kept_existing_history(off def test_detached_actor_click_cannot_act_on_a_reconstructed_scene(offline_page): + offline_page.evaluate( + "window.oldButton = document.querySelector('.constellation-star[aria-pressed=true]')" + ) + offline_page.set_viewport_size({"width": 1100, "height": 810}) + offline_page.wait_for_function("!window.oldButton.isConnected", polling=50) result = offline_page.evaluate("""() => { - const oldButton = document.querySelector('.constellation-star[aria-pressed="true"]'); + const oldButton = window.oldButton; const id = oldButton.dataset.event; - const zoom = document.querySelector('#scene-zoom'); - zoom.value = '1.25'; zoom.dispatchEvent(new Event('input', {bubbles:true})); const current = [...document.querySelectorAll('.constellation-star')] .find(button => button.dataset.event === id); const history = document.querySelector('#history'); @@ -251,3 +256,176 @@ def test_detached_actor_click_cannot_act_on_a_reconstructed_scene(offline_page): }, "liveDialog": True, } + + +@pytest.mark.parametrize("motion", ["no-preference", "reduce"]) +def test_zoom_reuses_grouped_scene_and_rebinds_animation(offline_page, motion): + page = offline_page + page.emulate_media(reduced_motion=motion) + + def dense_demo(route): + response = route.fetch() + payload = response.json() + events = payload["events"] + templates = ( + events[0], + next(event for event in events if event["kind"] != events[0]["kind"]), + ) + anchor = datetime.fromisoformat(events[0]["received_at"]) + payload["events"] = [ + { + **templates[index % 2], + "sequence": 240 - index, + "event_id": f"zoom-{index}", + "received_at": (anchor - timedelta(seconds=index)).isoformat(), + } + for index in range(240) + ] + payload["next"] = None + route.fulfill(response=response, json=payload) + + page.route("**/api/demo?*", dense_demo) + page.locator("#lens-trigger").click() + page.get_by_role("menuitemradio", name="Activity", exact=True).click() + page.wait_for_function( + """() => document.querySelectorAll('.constellation-star').length === 240 + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + slider = page.locator("#scene-zoom") + page.evaluate("""() => { + const history = document.querySelector('#history'); + const selector = '.constellation-canvas, .constellation-sky, .constellation-person, ' + + '.constellation-star, .star-label, .star-identity'; + const nodes = [...history.querySelectorAll(selector)]; + const selected = history.querySelector('.constellation-star[aria-pressed=true]'); + const path = history.querySelector('.constellation-links path'); + let listenerCount = 0, historyWrites = 0; + const nativeAdd = EventTarget.prototype.addEventListener; + EventTarget.prototype.addEventListener = function(type, ...args) { + if (this instanceof Element && this.matches(selector)) listenerCount++; + return nativeAdd.call(this, type, ...args); + }; + const observer = new MutationObserver(records => { historyWrites += records.length; }); + observer.observe(history, {childList:true}); + window.zoomProbe = { + history, selected, path, initialPath: path.getAttribute('d'), + unchanged: () => { + const current = [...history.querySelectorAll(selector)]; + return history === document.querySelector('#history') && current.length === nodes.length + && nodes.every((node, index) => node === current[index]); + }, + work: () => ({historyWrites:historyWrites + observer.takeRecords().length, + listeners:listenerCount}), + finish: () => { observer.disconnect(); EventTarget.prototype.addEventListener = nativeAdd; }, + }; + window.setZoomInput = value => { + const input = document.querySelector('#scene-zoom'); + input.value = String(value); + input.dispatchEvent(new Event('input', {bubbles:true})); + }; + selected.focus({preventScroll:true}); + zoomProbe.paintBefore = paintIds()[0]; + setZoomInput(1.25); + zoomProbe.paintAfter = paintIds()[0]; + }""") + assert page.evaluate("zoomProbe.unchanged() && document.activeElement === zoomProbe.selected") + if motion == "no-preference": + assert page.evaluate( + "zoomProbe.paintBefore < 0 && zoomProbe.paintBefore === zoomProbe.paintAfter" + ) + + slider.press("ArrowRight") + assert float(slider.input_value()) == 1.3 + bounds = slider.bounding_box() + assert bounds is not None + x, y = bounds["x"], bounds["y"] + bounds["height"] / 2 + page.mouse.move(x + bounds["width"] * 0.65, y) + page.mouse.down() + page.mouse.move(x + bounds["width"] * 0.90, y, steps=12) + page.mouse.up() + assert float(slider.input_value()) > 1.5 + page.locator("#zoom-out").click() + assert page.evaluate( + "zoomProbe.unchanged() && zoomProbe.path.getAttribute('d') !== zoomProbe.initialPath" + ) + + before_wheel = float(slider.input_value()) + history_bounds = page.locator("#history").bounding_box() + assert history_bounds is not None + x = history_bounds["x"] + history_bounds["width"] / 2 + y = history_bounds["y"] + history_bounds["height"] / 2 + page.mouse.move(x, y) + page.mouse.down(button="middle") + # Playwright's wheel helper omits the pressed-button mask. + session = page.context.new_cdp_session(page) + session.send( + "Input.dispatchMouseEvent", + {"type": "mouseWheel", "x": x, "y": y, "deltaX": 0, "deltaY": 100, "buttons": 4}, + ) + session.detach() + page.mouse.up(button="middle") + page.wait_for_function( + "previous => Number(document.querySelector('#scene-zoom').value) < previous", + arg=before_wheel, + polling=50, + ) + assert before_wheel - float(slider.input_value()) == pytest.approx(0.225, abs=0.026) + page.evaluate("setZoomInput(1); stepFrames(); setZoomInput(0.25); stepFrames()") + assert page.locator(".constellation-group:visible").count() == 2 + assert page.evaluate("""() => zoomProbe.unchanged() && zoomProbe.selected.tabIndex === -1 + && zoomProbe.selected.getAttribute('aria-hidden') === 'true' + && zoomProbe.history.scrollLeft >= 0 + && zoomProbe.history.scrollLeft <= zoomProbe.history.scrollWidth - zoomProbe.history.clientWidth""") + assert page.locator(".constellation-links path:visible").count() == 0 + assert page.evaluate("""() => { + const link = zoomProbe.path.parentNode; + const observer = new MutationObserver(() => {}); + observer.observe(link, {attributes:true, subtree:true}); + stepFrames(); + const writes = observer.takeRecords().length; + observer.disconnect(); + return writes === 0; + }""") + + page.evaluate("setZoomInput(0.30); stepFrames()") + assert page.locator(".constellation-group:visible").count() == 0 + assert page.evaluate("""() => { + const {history, selected, path} = zoomProbe; + const expected = Math.max(0, Math.min(parseFloat(selected.style.left) - history.clientWidth / 2, + history.scrollWidth - history.clientWidth)); + const spark = path.parentNode.querySelector('.sequence-spark'); + const trail = path.parentNode.querySelector('.sequence-trail'); + const [ax, ay, bx, by] = path.getAttribute('d').slice(1).split(/[,L]/).map(Number); + const cx = Number(spark.getAttribute('cx')), cy = Number(spark.getAttribute('cy')); + const progress = (cx - ax) / (bx - ax); + return zoomProbe.unchanged() && path.isConnected && selected.tabIndex === 0 + && selected.getAttribute('aria-hidden') === 'false' + && selected.getAttribute('aria-pressed') === 'true' + && Math.abs(history.scrollLeft - expected) < 2 + && spark.hasAttribute('cx') && spark.getAttribute('cx') === trail.getAttribute('x2') + && spark.getAttribute('cy') === trail.getAttribute('y2') + && progress >= 0 && progress <= 1 && Math.abs(cy - (ay + (by - ay) * progress)) < 0.1; + }""") + assert page.evaluate("zoomProbe.work()") == {"historyWrites": 0, "listeners": 0} + assert page.evaluate("() => { stepFrames(); return paintCount(); }") == ( + 0 if motion == "reduce" else 1 + ) + + page.evaluate("setZoomInput(0.2)") + page.locator(".constellation-group:visible").first.click() + assert float(slider.input_value()) == 1 + assert page.evaluate("zoomProbe.unchanged()") + assert page.evaluate("""() => { + const {history} = zoomProbe; + const other = history.querySelector('.constellation-star[aria-pressed=false]'); + other.click(); + setZoomInput(1.5); + const expected = Math.max(0, Math.min(parseFloat(other.style.left) - history.clientWidth / 2, + history.scrollWidth - history.clientWidth)); + return zoomProbe.unchanged() && other.getAttribute('aria-pressed') === 'true' + && Math.abs(history.scrollLeft - expected) < 2; + }""") + page.evaluate("zoomProbe.finish()") + page.set_viewport_size({"width": 1050, "height": 800}) + page.wait_for_function("!zoomProbe.unchanged()", polling=50) From dbca0c081d9b0089345ee985e81c722860df2dec Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 02:25:31 +0530 Subject: [PATCH 2/9] Keep first-match behavior for duplicate event labels --- src/xflog/assets/constellation.js | 9 +++++---- tests/constellation_selection_check.mjs | 7 +++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/xflog/assets/constellation.js b/src/xflog/assets/constellation.js index b6b0aee..6cf2111 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -380,12 +380,13 @@ export function createConstellation({ clickPerson(person); }), ); - labels.forEach(({person, label: button}) => + labels.forEach(({person, label: button}) => { + const first = peopleByEvent.get(person.event.event_id); button.addEventListener("click", event => { if (ownsScene(renderedVersion, button) && !geometry.overview && - !event?.target?.closest?.("a")) clickPerson(person); - }), - ); + !event?.target?.closest?.("a")) clickPerson(first); + }); + }); updateMode(); anchor(); select(identity); diff --git a/tests/constellation_selection_check.mjs b/tests/constellation_selection_check.mjs index 8dd0a74..32c7b94 100644 --- a/tests/constellation_selection_check.mjs +++ b/tests/constellation_selection_check.mjs @@ -863,6 +863,13 @@ try { assert.equal(lookupOpened.length, opensBeforeDuplicateClick + 1, "Either duplicate button acts on the first matching geometry person"); assert.equal(lookupOpened.at(-1), "duplicate"); + const duplicateLabels = labels.filter(node => node.dataset.label === "duplicate"); + lookupScene.setZoom(1.5); + assert.notEqual(duplicates[0].style.left, duplicates[1].style.left, "Duplicate entries keep separate coordinates"); + const opensBeforeLabels = lookupOpened.length; + duplicateLabels.forEach(node => node.handlers.click()); + assert.deepEqual(lookupOpened.slice(opensBeforeLabels), ["duplicate", "duplicate"], + "Either retained label opens the selected duplicate event using first-match identity"); const destroyedParent = duplicates[0].parent, destroyedWrites = destroyedParent.writes; lookupScene.destroy(); assert.doesNotThrow(() => duplicates[0].handlers.click(), "A destroyed scene rejects old clicks"); From f1f5118e48846ea5cb3351b117aa0aedf91864f1 Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 02:27:26 +0530 Subject: [PATCH 3/9] Release the cached legend group with the scene --- src/xflog/assets/constellation.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/xflog/assets/constellation.js b/src/xflog/assets/constellation.js index 6cf2111..f458563 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -98,6 +98,7 @@ export function createConstellation({ actorButtons = []; sky = meteor = meteorTail = canvas = linkLayer = groupLayer = null; groupButtons = connections = layoutAtZoom = null; + keyScope = null; } function queue() { if (!frame && active()) frame = requestAnimationFrame(paint); From a6209fa55f4115acf47acb3753cc80c185c7f5a5 Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 03:51:07 +0530 Subject: [PATCH 4/9] Skip unchanged Constellation layout during ambient animation --- src/xflog/assets/app.js | 4 +- src/xflog/assets/constellation.js | 281 +++++++++++++++--------- tests/constellation_check.mjs | 10 + tests/constellation_selection_check.mjs | 105 ++++++++- tests/test_browser_scene_lifecycle.py | 140 +++++++++++- 5 files changed, 427 insertions(+), 113 deletions(-) diff --git a/src/xflog/assets/app.js b/src/xflog/assets/app.js index b3b7334..26ba24b 100644 --- a/src/xflog/assets/app.js +++ b/src/xflog/assets/app.js @@ -405,11 +405,11 @@ if (!event) return; state.selected = identity; selectedSignal.render(event, state.groups); - $$("[data-event]").forEach((button) => + if (state.layout === "horizontal") scene.select(identity); + else $$("[data-event]").forEach((button) => button.setAttribute("aria-pressed", button.dataset.event === identity), ); renderPersonDetail(root, event); - if (state.layout === "horizontal") scene.select(identity); const index = state.events.findIndex((item) => item.event_id === identity); $("#journey-position").textContent = `Memory ${index + 1} of ${state.events.length} loaded`; diff --git a/src/xflog/assets/constellation.js b/src/xflog/assets/constellation.js index f458563..1c0127d 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -38,6 +38,7 @@ export function createConstellation({ lens = "none", geometry = null, layoutAtZoom = null; + let selectionByEvent = null, selection = null; let sceneVersion = 0; // History is deduplicated by event ID before grouping, so only data changes alter membership. let personCount = 0, countDirty = true; @@ -58,8 +59,9 @@ export function createConstellation({ canvas = null, linkLayer = null, groupLayer = null, groupButtons = null, connections = null, sceneWidth = 0, sceneHeight = 0, - labelInset = 16, 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 && @@ -68,13 +70,9 @@ 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 position = () => selection?.person; + const selectedProfileIndex = () => selection?.index ?? -1; function personLabel(person, event = person.event) { return `${postPreview(event, 140, "star-post")}`; } @@ -98,15 +96,15 @@ export function createConstellation({ actorButtons = []; sky = meteor = meteorTail = canvas = linkLayer = groupLayer = null; groupButtons = connections = layoutAtZoom = null; - keyScope = null; + selectionByEvent = selection = keyScope = null; + 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( @@ -119,18 +117,17 @@ 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 groupId = position()?.group; - const group = geometry.groups.find(item => item.key === groupId); + 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; @@ -139,9 +136,10 @@ export function createConstellation({ : 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) => @@ -149,7 +147,8 @@ export function createConstellation({ geometry.people[0], ); if (closest && closest !== position()) { - select(closest.event.event_id); + applySelection(closest.event.event_id); + if (!duringPaint) queue(); notify(closest.event.event_id); } } @@ -171,13 +170,16 @@ 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(); + const current = events.find(event => event.event_id === identity); actorButtons.forEach(({button, personElement, person}) => { const chosen = button.dataset.event === chosenPerson?.event.event_id; - const event = chosen ? current() : person.event; + const event = chosen ? current : person.event; button.setAttribute("aria-pressed", String(chosen)); button.setAttribute( "aria-label", @@ -186,20 +188,27 @@ export function createConstellation({ personElement.classList.toggle("is-selected", chosen); }); for (const item of labels) { - const event = item.person === chosenPerson ? current() : item.person.event; + 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"); + item.width = undefined; } - const box = item.label.getBoundingClientRect(); - item.width = box.width; - item.height = box.height; + const id = item.person.event.event_id; + if (id === previous?.event.event_id || id === chosenPerson?.event.event_id) + item.width = undefined; } - navigator(); - queue(); + if (initial || previous?.group !== chosenPerson?.group) groupHint(); + travelControls(); + labelsDirty = true; + return true; + } + function select(identity) { + if (geometry && root.isConnected && element.isConnected && applySelection(identity)) + queue(); } function showLabel(item, visible) { item.label.style.visibility = visible ? "visible" : "hidden"; @@ -207,69 +216,44 @@ export function createConstellation({ item.label.tabIndex = -1; item.controls?.forEach(control => { control.tabIndex = visible ? 0 : -1; }); } - 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 layoutLabels(left) { + const chosenPerson = position(); + const viewportWidth = element.clientWidth; + const bottom = geometry.lower + 25; + labelInset ??= (context?.offsetHeight || 0) + 28; + // Read invalidated sizes together before positioning any label. + for (const item of labels) { + if (item.width === undefined) { + const box = item.label.getBoundingClientRect(); + item.width = box.width; + item.height = box.height; + item.previewHeight = item.person === chosenPerson && item.preview ? item.preview.offsetHeight : 0; } } - 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)); - } const candidates = []; for (const item of labels) { 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 previewBottom = chosen && item.preview ? previewTop + previewHeight : top + height; 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 || (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, @@ -279,7 +263,7 @@ export function createConstellation({ bottom: Math.max(top + height, previewBottom), minTop: labelInset, maxBottom: Math.max(bottom, previewBottom), - distance: Math.abs(center - element.clientWidth / 2), + distance: Math.abs(center - viewportWidth / 2), }; const alternateTop = clamp( top < item.person.y ? item.person.y + 27 : item.person.y - height - 25, @@ -299,9 +283,19 @@ export function createConstellation({ if (placement) item.label.style.top = `${placement.top}px`; showLabel(item, Boolean(placement)); }); + } + 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, ); @@ -316,13 +310,60 @@ export function createConstellation({ trail.setAttribute("x2", (a.x + (b.x - a.x) * part).toFixed(2)); trail.setAttribute("y2", (a.y + (b.y - a.y) * part).toFixed(2)); }); + } + 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)`; + if (scrolled || labelsDirty) { + layoutLabels(left); + scrubber(); + viewportLeft = left; + labelsDirty = false; + } + const animating = String(motion); + if (motion || animationDirty || lastMotion !== motion) { + animate(motion); + 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) { @@ -330,7 +371,6 @@ export function createConstellation({ stop(); wheelZoom = null; events = nextEvents; - selected = identity; sceneWidth = element.clientWidth; sceneHeight = element.clientHeight; layoutAtZoom = prepareConstellation({ @@ -347,8 +387,16 @@ export function createConstellation({ } element.dataset.canPan = String(geometry.canPan); 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 = + `${personCount} ${personCount === 1 ? "person" : "people"} in ${geometry.grouped ? "saved" : "loaded"} history`; + if (peopleCount.textContent !== countText) peopleCount.textContent = countText; const background = Array.from( { length: 76 }, (_, index) => @@ -358,13 +406,18 @@ export function createConstellation({ 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); + 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}); + 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 = element.querySelectorAll("[data-label]"); @@ -382,15 +435,17 @@ export function createConstellation({ }), ); labels.forEach(({person, label: button}) => { - const first = peopleByEvent.get(person.event.event_id); + 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); }); }); updateMode(); + applySelection(identity, true); anchor(); - select(identity); + scrubber(); + queue(); } function ownsScene(version, node) { return version === sceneVersion && geometry && root.isConnected && @@ -433,7 +488,7 @@ export function createConstellation({ if (!ownsScene(renderedVersion, button) || !geometry.overview) return; const group = geometry.groups[index]; const identity = group.people[0].event.event_id; - selected = identity; + select(identity); setZoom(1); seek(events.findIndex(item => item.event_id === identity), "instant"); }); @@ -486,7 +541,7 @@ 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; + select(event.event_id); if (geometry.overview) setZoom(0.8); const destination = position(); if (destination && geometry.canPan) { @@ -498,7 +553,7 @@ export function createConstellation({ seeking = false; } } - select(selected); + scrubber(); onSelect(selected); queue(); } @@ -518,7 +573,10 @@ export function createConstellation({ button.style.left = `${point.x}px`; button.style.top = `${point.y}px`; }); - if (wasOverview !== geometry.overview) updateMode(); + if (wasOverview !== geometry.overview) { + updateMode(); + groupHint(); + } if (geometry.overview) { geometry.groups.forEach((group, index) => { groupButtons[index].style.left = `${group.x}px`; @@ -531,7 +589,10 @@ export function createConstellation({ }); } anchor(); - navigator(); + zoomControls(); + travelControls(); + scrubber(); + labelsDirty = animationDirty = true; queue(); } function scrub(progress) { @@ -541,7 +602,7 @@ export function createConstellation({ element.scrollLeft = (1 - clamp(progress, 0, 1)) * maximum(); delete element.dataset.userScrolling; selectVisibleProfile(); - navigator(); + scrubber(); queue(); } function travel(delta) { @@ -657,19 +718,32 @@ 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(() => { + 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)) + (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"; @@ -697,6 +771,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), ); @@ -717,12 +796,14 @@ export function createConstellation({ render(nextEvents, identity); }, update(nextEvents, identity) { - const offset = position() ? position().x - element.scrollLeft : null; + const point = position(); + const offset = point ? point.x - element.scrollLeft : null; countDirty = true; render(nextEvents, identity); if (offset !== null && position()) element.scrollLeft = clamp(position().x - offset, 0, maximum()); scrollPosition = element.scrollLeft; + scrubber(); }, select, seek, diff --git a/tests/constellation_check.mjs b/tests/constellation_check.mjs index 75b5030..03a7e50 100644 --- a/tests/constellation_check.mjs +++ b/tests/constellation_check.mjs @@ -274,6 +274,16 @@ assert.ok(visibleLabels([{...selectedBox,maxBottom:220}],"selected").has("select for(const neighbor of neighbors) assert.equal(staggered.get(neighbor.id).top,140); assert.equal(visibleLabels([selectedBox,{...selectedBox,id:"crowded"}],"selected").size,1, "Labels still hide when no non-overlapping placement is available"); +const scrolledBoxes = [...neighbors, selectedBox].map(box => ({ + ...box, left: box.left - 35, right: box.right - 35, + alternates: box.alternates?.map(alternate => ({ + ...alternate, left: alternate.left - 35, right: alternate.right - 35, + })), +})); +const scrolledLabels = visibleLabels(scrolledBoxes, "selected"); +assert.deepEqual([...scrolledLabels].map(([id, box]) => [id, box.top]), + [...staggered].map(([id, box]) => [id, box.top]), + "Collision placement preserves priority and vertical positions when only the viewport origin shifts"); const tight = {...neighbors[0],alternates:[{left:165,right:291,top:190,bottom:232}]}; assert.equal(visibleLabels([selectedBox,tight],"selected").get("left").bottom,190, "An alternate can move slightly further above a neighboring label to preserve the safety gap"); diff --git a/tests/constellation_selection_check.mjs b/tests/constellation_selection_check.mjs index 32c7b94..afd9ec8 100644 --- a/tests/constellation_selection_check.mjs +++ b/tests/constellation_selection_check.mjs @@ -52,6 +52,17 @@ const controls = new Map(), let olderLoads = 0; let shellQueries = 0, sceneQueries = 0, actorQueries = 0, descendantQueries = 0; let parentQueries = 0; +let labelReads = 0, previewReads = 0, navigationWrites = 0; +let collisionPasses = 0; +function countNavigation(control) { + const fields = new Set(["disabled", "value", "textContent", "max", "hidden", "innerHTML"]); + return new Proxy(control, { + set(target, key, value) { + if (fields.has(key)) navigationWrites++; + return Reflect.set(target, key, value); + }, + }); +} const selectedSteps = []; const previewSteps = []; function button(dataset) { @@ -68,7 +79,7 @@ function button(dataset) { this.markup = value; this.identityControl = { tabIndex: 0 }; this.previewControl = value.includes('class="star-post"') - ? { tabIndex: 0, offsetHeight: 24, style: {} } : null; + ? { tabIndex: 0, get offsetHeight() { previewReads++; return 24; }, style: {} } : null; }, classList: { toggle() {} }, setAttribute(k, v) { @@ -85,6 +96,7 @@ function button(dataset) { return parent; }, getBoundingClientRect() { + labelReads++; return { width: 190, height: 140 }; }, querySelectorAll() { descendantQueries++; return [this.identityControl, this.previewControl].filter(Boolean); }, @@ -98,16 +110,16 @@ const root = { shellQueries++; if (key.startsWith("#") && !controlIds.has(key.slice(1))) return null; if (!controls.has(key)) - controls.set(key, { + controls.set(key, countNavigation({ handlers: {}, style: { setProperty() {} }, classList: { toggle() {} }, - setAttribute() {}, + setAttribute() { navigationWrites++; }, addEventListener(type, handler) { this.handlers[type] = handler; }, removeEventListener() {}, - }); + })); return controls.get(key); }, }; @@ -732,7 +744,7 @@ try { for (const identity of ["lookup-4", "lookup-17", "lookup-0"]) { const before = geometryFinds; lookupScene.select(identity); - assert.equal(geometryFinds - before, 3, "Only position and navigation search geometry during selection"); + assert.equal(geometryFinds - before, 0, "Manual selection reuses its indexed geometry entry"); const selectedNodes = nodes.filter(node => node.attributes["aria-pressed"] === "true"); assert.deepEqual(selectedNodes.map(node => node.dataset.event), [identity]); assert.ok(selectedNodes[0].attributes["aria-label"].includes(`Person ${identity.slice(7)}`)); @@ -743,7 +755,7 @@ try { lookupHandlers.scroll(); frame(20000); assert.ok(previews.length > beforePreview, "The frame actually changes the highlighted event"); - assert.equal(geometryFinds - beforeFrameSearches, 7, "Animation uses only its existing geometry searches"); + assert.equal(geometryFinds - beforeFrameSearches, 0, "Animation-driven selection does not scan for the selected event"); const large = Array.from({length: 500}, (_, index) => ({ ...event(`lookup-${index}`, "same-actor", 500 - index, "follow.follow"), name: `Person ${index}`, @@ -751,7 +763,7 @@ try { lookupScene.render(large, "lookup-0"); geometryFinds = 0; lookupScene.select("lookup-4"); - assert.equal(geometryFinds, 3, "Manual search count stays constant for 500 actor buttons"); + assert.equal(geometryFinds, 0, "Manual search count stays constant for 500 actor buttons"); lookupScene.select("lookup-0"); const beforeLargePreview = previews.length; geometryFinds = 0; @@ -759,14 +771,14 @@ try { lookupHandlers.scroll(); frame(20016); assert.ok(previews.length > beforeLargePreview, "The large-scene frame changes the highlighted event"); - assert.equal(geometryFinds, 7, "Animation search count stays constant for 500 actor buttons"); + assert.equal(geometryFinds, 0, "Animation search count stays constant for 500 actor buttons"); lookupScene.render(many, "lookup-0"); lookupScene.select("lookup-0"); const currentButton = nodes.find(node => node.dataset.event === "lookup-0"); geometryFinds = 0; currentButton.handlers.click(); - assert.equal(geometryFinds, 1, "A selected actor click only searches for the selected position"); + assert.equal(geometryFinds, 0, "A selected actor click reuses the selected position"); assert.equal(lookupOpened.at(-1), "lookup-0", "A current selected actor still opens"); const nonselectedButton = nodes.find(node => node.dataset.event === "lookup-4"); nonselectedButton.handlers.click(); @@ -1183,6 +1195,81 @@ try { } finally { Array.prototype.filter = nativeFilter; } +// Collision candidate sorting and label DOM writes identify actual viewport work. +const nativeSort = Array.prototype.sort; +const near = {id: Symbol("test"), top: 0, distance: 0}; +const far = {...near, distance: 1}; +Array.prototype.sort = function(compare) { + // The initial candidate sort uses distance. Neighbor sorts use vertical position. + if (this[0]?.id?.event && "minTop" in this[0] && compare(near, far) === -1) + collisionPasses++; + return nativeSort.call(this, compare); +}; +const viewportScene = createConstellation({ + element, root, escape:String, label:item => item.kind, initials:item => item.actor_id, + onSelect() {}, onOpen() {}, +}); +try { + root.dataset.motion = "on"; + viewportScene.render([withPreview, ...events], "preview"); + frame(40000); + assert.ok(collisionPasses > 0 && labelReads > 0 && previewReads > 0 && navigationWrites > 0, + "The work counters see initial layout, preview measurement and navigation"); + const labelWrites = () => labels.reduce((sum, item) => sum + item.attributeWrites, 0); + const work = () => ({collisionPasses, labelReads, previewReads, navigationWrites, labelWrites:labelWrites()}); + const spark = renderedSparks[0]; + const sparkBefore = spark.attributes.cx; + const idle = work(); + frame(40016); + frame(40032); + frame(40048); + assert.deepEqual(work(), idle, "Ambient-only frames skip all state-driven work"); + assert.notEqual(spark.attributes.cx, sparkBefore, "The ambient clock and spark keep advancing"); + + element.scrollLeft = 0; + frame(40050); + const beforeDelayedScroll = collisionPasses; + handlers.scroll(); + frame(40052); + const leftmost = nodes.reduce((a, b) => parseFloat(a.style.left) < parseFloat(b.style.left) ? a : b); + assert.equal(leftmost.attributes["aria-pressed"], "true", + "A scroll event arriving after paint still centers the selection"); + assert.equal(collisionPasses, beforeDelayedScroll + 1, "The new selection invalidates layout at the same offset"); + viewportScene.seek(0, "instant"); + frame(40056); + + const previousLeft = element.scrollLeft; + viewportScene.seek(1); + const afterSelect = work(); + frame(40064); + assert.notEqual(element.scrollLeft, previousLeft, "Smooth navigation advances the viewport"); + assert.equal(collisionPasses, afterSelect.collisionPasses + 1, "One collision pass for a changed viewport"); + assert.equal(labelReads - afterSelect.labelReads, 2, "Only the previous and new selection need measurement"); + const afterScroll = work(); + frame(40080); + assert.equal(collisionPasses, afterScroll.collisionPasses + 1, "Easing keeps layout current"); + assert.equal(labelReads, afterScroll.labelReads, "Scrolling reuses dimensions"); + assert.equal(Number(controls.get("#memory-scrubber").value), + Math.round((1 - element.scrollLeft / 500) * 1000), "Scrubber tracks the actual eased position"); + + root.dataset.motion = "off"; + frame(40096); + assert.equal(pending.size, 0, "Paused motion settles after the final scroll step"); + const frozen = spark.attributeWrites; + const stopped = work(); + element.scrollLeft = element.scrollLeft ? 0 : 500; + handlers.scroll(); + frame(40112); + assert.equal(collisionPasses, stopped.collisionPasses + 1, "A native scroll still updates paused labels"); + assert.equal(spark.attributeWrites, frozen, "A scroll-only paused frame does not rewrite frozen sparks"); + assert.equal(pending.size, 0); + viewportScene.setZoom(0.9); + frame(40128); + assert.ok(spark.attributeWrites > frozen, "Paused sparks still rebind to changed geometry"); +} finally { + viewportScene.destroy(); + Array.prototype.sort = nativeSort; +} console.log( JSON.stringify({ script: new URL(import.meta.url).pathname, diff --git a/tests/test_browser_scene_lifecycle.py b/tests/test_browser_scene_lifecycle.py index c7fba59..cc1252d 100644 --- a/tests/test_browser_scene_lifecycle.py +++ b/tests/test_browser_scene_lifecycle.py @@ -81,9 +81,9 @@ def offline_browser_app(tmp_path_factory): window.enableFrames = () => { controlled = true; }; window.paintIds = () => [...frames].filter(([, cb]) => cb.name === 'paint').map(([id]) => id); window.paintCount = () => window.paintIds().length; - window.stepFrames = () => { + window.stepFrames = (now = performance.now()) => { const callbacks = [...frames.values()]; frames.clear(); - callbacks.forEach(cb => cb(performance.now())); + callbacks.forEach(cb => cb(now)); }; window.fetch = (url, options) => { const path = String(url); @@ -258,6 +258,142 @@ def test_detached_actor_click_cannot_act_on_a_reconstructed_scene(offline_page): } +def test_ambient_frames_reuse_label_layout_and_navigation(offline_page): + page = offline_page + + def preview_demo(route): + response = route.fetch() + payload = response.json() + for index, event in enumerate(payload["events"]): + event.update( + kind="interaction.replies", + post_id=str(10000 + index), + post_text=f"Synthetic preview {index}" + " long text " * (16 if index else 0), + ) + route.fulfill(response=response, json=payload) + + page.route("**/api/demo?*", preview_demo) + page.evaluate("document.querySelector('[data-filter=lost]').click()") + page.wait_for_function( + """() => document.querySelector('.is-selected .star-post') + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + result = page.evaluate("""() => { + const history = document.querySelector('#history'); + const scrubber = document.querySelector('#memory-scrubber'); + const nav = ['#zoom-value', '#scene-zoom', '#zoom-in', '#zoom-out', '#memory-next', + '#memory-previous', '#scene-people-count', '#constellation-key']; + const label = () => history.querySelector('.is-selected .star-label'); + const preview = () => label().querySelector('.star-post'); + const spark = history.querySelector('.sequence-spark'); + const observer = new MutationObserver(() => {}); + for (const selector of nav) + observer.observe(document.querySelector(selector), {attributes: true, childList: true, subtree: true}); + observer.observe(scrubber, {attributes: true}); + const labels = new MutationObserver(() => {}); + for (const node of history.querySelectorAll('.star-label')) + labels.observe(node, {attributes: true, childList: true, subtree: true}); + const reads = {boxes: 0, previews: 0}; + const box = Element.prototype.getBoundingClientRect; + const height = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); + Element.prototype.getBoundingClientRect = function() { + if (this.matches('.star-label')) reads.boxes++; + return box.call(this); + }; + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {...height, get() { + if (this.matches('.star-post')) reads.previews++; + return height.get.call(this); + }}); + let now = 1000; + const advance = () => stepFrames(now += 16); + const count = () => ({...reads, navigation: observer.takeRecords().length, + labels: labels.takeRecords().length}); + try { + advance(); + count(); + reads.boxes = reads.previews = 0; + const initialSpark = spark.getAttribute('cx'); + for (let index = 0; index < 6; index++) advance(); + const idle = {...count(), moving: initialSpark !== spark.getAttribute('cx')}; + + const previousLeft = history.scrollLeft; + history.scrollLeft += previousLeft > 40 ? -40 : 40; + history.dispatchEvent(new Event('scroll')); + advance(); + const expected = Math.round((1 - history.scrollLeft / (history.scrollWidth - history.clientWidth)) * 1000); + const scroll = history.scrollLeft !== previousLeft && labels.takeRecords().length > 0 + && Number(scrubber.value) === expected; + + const originalPreview = preview().textContent, originalHeight = preview().offsetHeight; + reads.boxes = reads.previews = 0; + const stars = [...history.querySelectorAll('.constellation-star')]; + const next = stars[0].getAttribute('aria-pressed') === 'true' ? stars[1] : stars[0]; + next.click(); + for (let index = 0; index < 64; index++) advance(); + const selection = preview().textContent !== originalPreview + && label().getAttribute('aria-hidden') === 'false' + && preview().tabIndex === 0 && preview().style.top !== '' + && reads.boxes > 0 && reads.previews > 0 && preview().offsetHeight !== originalHeight; + return {idle, scroll, selection}; + } finally { + observer.disconnect(); + labels.disconnect(); + Element.prototype.getBoundingClientRect = box; + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', height); + } + }""") + assert result == { + "idle": {"boxes": 0, "previews": 0, "navigation": 0, "labels": 0, "moving": True}, + "scroll": True, + "selection": True, + } + + +def test_reduced_motion_remeasures_labels_after_fonts_and_resize(offline_page): + page = offline_page + page.emulate_media(reduced_motion="reduce") + page.wait_for_function("matchMedia('(prefers-reduced-motion: reduce)').matches", polling=50) + page.evaluate("""() => { + const nativeBox = Element.prototype.getBoundingClientRect; + window.sceneLabelReads = 0; + Element.prototype.getBoundingClientRect = function() { + if (this.matches('.star-label')) window.sceneLabelReads++; + return nativeBox.call(this); + }; + stepFrames(); + window.sceneLabelReads = 0; + window.selectedLabel = document.querySelector('.is-selected .star-label'); + window.initialLabelHeight = window.selectedLabel.offsetHeight; + const style = document.createElement('style'); + style.textContent = `@font-face { + font-family: "XFlog test face"; + src: url(/static/xf/plex-sans-variable.ttf); + size-adjust: 220%; + }`; + document.head.append(style); + window.selectedLabel.style.fontFamily = '"XFlog test face", sans-serif'; + window.loadedTestFace = false; + document.fonts.addEventListener('loadingdone', () => { window.loadedTestFace = true; }, {once:true}); + void document.fonts.load('20px "XFlog test face"'); + }""") + page.wait_for_function("window.loadedTestFace && paintCount() > 0", polling=50) + assert page.evaluate("""() => { + stepFrames(); + return window.selectedLabel.isConnected && window.sceneLabelReads > 0 && paintCount() === 0 + && window.selectedLabel.offsetHeight !== window.initialLabelHeight; + }""") + + page.set_viewport_size({"width": 1000, "height": 800}) + page.wait_for_function("!window.selectedLabel.isConnected && paintCount() > 0", polling=50) + assert page.evaluate("""() => { + window.sceneLabelReads = 0; + stepFrames(); + return window.sceneLabelReads > 0 && paintCount() === 0 + && document.querySelector('.is-selected .star-label').getAttribute('aria-hidden') === 'false'; + }""") + + @pytest.mark.parametrize("motion", ["no-preference", "reduce"]) def test_zoom_reuses_grouped_scene_and_rebinds_animation(offline_page, motion): page = offline_page From dd333316cf7dabcda1a7601ce9a560732a6eb4f6 Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 04:51:42 +0530 Subject: [PATCH 5/9] Skip timeline label layout in Constellation overview --- src/xflog/assets/constellation.css | 2 + src/xflog/assets/constellation.js | 8 +-- tests/constellation_selection_check.mjs | 72 ++++++++++++++++++++++++- tests/test_browser_scene_lifecycle.py | 24 +++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/xflog/assets/constellation.css b/src/xflog/assets/constellation.css index aa5da98..0d47c64 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; } diff --git a/src/xflog/assets/constellation.js b/src/xflog/assets/constellation.js index 1c0127d..745b342 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -250,7 +250,6 @@ export function createConstellation({ item.label.style.left = `${center + left}px`; item.label.style.top = `${top}px`; if ( - !geometry.overview && (chosen || (x >= 12 && x <= viewportWidth - 12)) && center - width / 2 >= 12 && center + width / 2 <= viewportWidth - 12 @@ -346,7 +345,7 @@ export function createConstellation({ if (!browsing) browseLeft = null; if (scrolled) sky.style.transform = `translateX(${left}px)`; if (scrolled || labelsDirty) { - layoutLabels(left); + if (!geometry.overview) layoutLabels(left); scrubber(); viewportLeft = left; labelsDirty = false; @@ -441,8 +440,8 @@ export function createConstellation({ !event?.target?.closest?.("a")) clickPerson(first); }); }); - updateMode(); applySelection(identity, true); + updateMode(); anchor(); scrubber(); queue(); @@ -592,7 +591,8 @@ export function createConstellation({ zoomControls(); travelControls(); scrubber(); - labelsDirty = animationDirty = true; + labelsDirty = !geometry.overview; + animationDirty = true; queue(); } function scrub(progress) { diff --git a/tests/constellation_selection_check.mjs b/tests/constellation_selection_check.mjs index afd9ec8..58c4db8 100644 --- a/tests/constellation_selection_check.mjs +++ b/tests/constellation_selection_check.mjs @@ -1201,7 +1201,7 @@ const near = {id: Symbol("test"), top: 0, distance: 0}; const far = {...near, distance: 1}; Array.prototype.sort = function(compare) { // The initial candidate sort uses distance. Neighbor sorts use vertical position. - if (this[0]?.id?.event && "minTop" in this[0] && compare(near, far) === -1) + if ((!this.length || (this[0]?.id?.event && "minTop" in this[0])) && compare?.(near, far) === -1) collisionPasses++; return nativeSort.call(this, compare); }; @@ -1266,6 +1266,76 @@ try { viewportScene.setZoom(0.9); frame(40128); assert.ok(spark.attributeWrites > frozen, "Paused sparks still rebind to changed geometry"); + + const groupedEvents = [withPreview, ...events].map(item => ({ + ...item, audience_groups: {activity: item.kind}, + })); + viewportScene.configure([activity], "activity"); + viewportScene.setZoom(0); + viewportScene.render(groupedEvents, "preview"); + const overviewLabels = labels; + const labelWork = () => [collisionPasses, labelReads, previewReads, labelWrites()]; + const beforeOverview = labelWork(); + frame(40144); + assert.deepEqual(labelWork(), beforeOverview, "A fresh overview frame skips label layout"); + const assertOverviewHidden = () => { + assert.ok(labels.every(item => item.style.visibility === "hidden" && + item.attributes["aria-hidden"] === "true" && item.identityControl.tabIndex === -1 && + (!item.previewControl || item.previewControl.tabIndex === -1))); + }; + assertOverviewHidden(); + for (const [index, zoom] of [0.05, 0.25, 0].entries()) { + viewportScene.setZoom(zoom); + frame(40160 + index * 16); + } + assert.deepEqual(labelWork(), beforeOverview, "Overview zoom does no label reads, collision passes or visibility writes"); + assert.ok(labels.every(item => item.style.left === undefined && item.style.top === undefined), + "Overview zoom leaves fresh labels unpositioned"); + + const scrollWidth = element.scrollWidth; + element.scrollWidth = element.clientWidth; + viewportScene.setZoom(0.3); + assertOverviewHidden(); + frame(40208); + element.scrollWidth = scrollWidth; + assert.equal(collisionPasses, beforeOverview[0] + 1, "Expansion runs collision layout"); + assert.equal(labelReads - beforeOverview[1], labels.length, "First expansion measures cold labels"); + assert.equal(previewReads - beforeOverview[2], 1, "First expansion measures the selected preview"); + const selectedLabel = labels.find(item => item.dataset.label === "preview"); + assert.equal(selectedLabel.style.visibility, "visible"); + assert.equal(selectedLabel.attributes["aria-hidden"], "false"); + assert.equal(selectedLabel.identityControl.tabIndex, 0); + assert.equal(selectedLabel.previewControl.tabIndex, 0); + + const measured = [labelReads, previewReads]; + viewportScene.setZoom(0.25); + frame(40224); + assertOverviewHidden(); + viewportScene.setZoom(0.4); + frame(40240); + assert.equal(labels, overviewLabels, "Repeated transitions preserve the label nodes"); + assert.deepEqual([labelReads, previewReads], measured, "Expansion reuses still-valid dimensions"); + assert.equal(selectedLabel.attributes["aria-hidden"], "false"); + assert.equal(pending.size, 0, "Paused expansion settles after one frame"); + + viewportScene.setZoom(0.25); + viewportScene.select("new-A"); + frame(40256); + assertOverviewHidden(); + assert.deepEqual([labelReads, previewReads], measured, "Selection in overview defers invalidated sizes"); + element.scrollLeft = 100; + handlers.scroll(); + const beforeScroll = labelWork(); + frame(40272); + assert.deepEqual(labelWork(), beforeScroll, "Overview scrolling still skips timeline label work"); + assert.equal(Number(controls.get("#memory-scrubber").value), 800, "The scrubber tracks the overview offset"); + + viewportScene.setZoom(0.4); + frame(40288); + assert.equal(labelReads - measured[0], 2, "Expansion measures only labels affected by the selection change"); + const newlySelectedLabel = labels.find(item => item.dataset.label === "new-A"); + assert.equal(newlySelectedLabel.attributes["aria-hidden"], "false"); + assert.equal(newlySelectedLabel.identityControl.tabIndex, 0); } finally { viewportScene.destroy(); Array.prototype.sort = nativeSort; diff --git a/tests/test_browser_scene_lifecycle.py b/tests/test_browser_scene_lifecycle.py index cc1252d..1eb1bfb 100644 --- a/tests/test_browser_scene_lifecycle.py +++ b/tests/test_browser_scene_lifecycle.py @@ -524,6 +524,27 @@ def dense_demo(route): return writes === 0; }""") + assert page.evaluate("""() => { + const {history} = zoomProbe; + const labels = [...history.querySelectorAll('.star-label')]; + const hidden = () => labels.every(label => getComputedStyle(label).visibility === 'hidden' + && label.getAttribute('aria-hidden') === 'true' + && [...label.querySelectorAll('button, a')].every(control => control.tabIndex === -1)); + const observer = new MutationObserver(() => {}); + for (const label of labels) observer.observe(label, {attributes: true, subtree: true}); + const width = () => Math.max(history.clientWidth, history.querySelector('.constellation-canvas').offsetWidth); + try { + for (const zoom of [0, 0.1, 0.25]) { + setZoomInput(zoom); + stepFrames(); + if (!hidden() || history.scrollWidth !== width()) return false; + } + return zoomProbe.unchanged() && observer.takeRecords().length === 0; + } finally { + observer.disconnect(); + } + }""") + page.evaluate("setZoomInput(0.30); stepFrames()") assert page.locator(".constellation-group:visible").count() == 0 assert page.evaluate("""() => { @@ -535,7 +556,10 @@ def dense_demo(route): const [ax, ay, bx, by] = path.getAttribute('d').slice(1).split(/[,L]/).map(Number); const cx = Number(spark.getAttribute('cx')), cy = Number(spark.getAttribute('cy')); const progress = (cx - ax) / (bx - ax); + const label = selected.parentElement.querySelector('.star-label'); return zoomProbe.unchanged() && path.isConnected && selected.tabIndex === 0 + && getComputedStyle(label).visibility === 'visible' + && label.getAttribute('aria-hidden') === 'false' && label.querySelector('button').tabIndex === 0 && selected.getAttribute('aria-hidden') === 'false' && selected.getAttribute('aria-pressed') === 'true' && Math.abs(history.scrollLeft - expected) < 2 From 844d674895c4e0c6c4fcefd11bbb044b128794f3 Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 11:55:47 +0530 Subject: [PATCH 6/9] Limit Constellation layout and animation to the viewport --- src/xflog/assets/constellation-viewport.js | 58 +++++ src/xflog/assets/constellation.css | 21 +- src/xflog/assets/constellation.js | 216 +++++++++++++----- tests/constellation_check.mjs | 28 +++ tests/constellation_selection_check.mjs | 11 +- tests/test_browser_scene_lifecycle.py | 249 ++++++++++++++++++++- 6 files changed, 518 insertions(+), 65 deletions(-) create mode 100644 src/xflog/assets/constellation-viewport.js 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 0d47c64..cc9a12b 100644 --- a/src/xflog/assets/constellation.css +++ b/src/xflog/assets/constellation.css @@ -621,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; @@ -634,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 745b342..efdeb10 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -9,6 +9,7 @@ import { 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"; @@ -30,7 +31,7 @@ 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, @@ -38,7 +39,9 @@ export function createConstellation({ lens = "none", geometry = null, layoutAtZoom = null; - let selectionByEvent = null, selection = null; + let selectionByEvent = null, selection = null, bindingsByEvent = null; + let viewportIndex = null, viewportLabels = [], activeSparks = [], + positionedLabels = new Set(), labelsByNode = null; let sceneVersion = 0; // History is deduplicated by event ID before grouping, so only data changes alter membership. let personCount = 0, countDirty = true; @@ -96,7 +99,10 @@ export function createConstellation({ actorButtons = []; sky = meteor = meteorTail = canvas = linkLayer = groupLayer = null; groupButtons = connections = layoutAtZoom = null; - selectionByEvent = selection = keyScope = null; + selectionByEvent = selection = keyScope = bindingsByEvent = labelsByNode = null; + viewportIndex = null; + viewportLabels = activeSparks = []; + positionedLabels.clear(); viewportLeft = browseLeft = labelInset = null; labelsDirty = animationDirty = true; lastMotion = null; @@ -141,11 +147,8 @@ export function createConstellation({ } 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()) { applySelection(closest.event.event_id); if (!duringPaint) queue(); @@ -176,30 +179,31 @@ export function createConstellation({ selected = identity; selection = selectionByEvent.get(identity); const chosenPerson = position(); - const current = events.find(event => event.event_id === identity); - 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"); - item.width = undefined; + 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 id = item.person.event.event_id; - if (id === previous?.event.event_id || id === chosenPerson?.event.event_id) - item.width = undefined; + 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); } if (initial || previous?.group !== chosenPerson?.group) groupHint(); travelControls(); @@ -211,27 +215,62 @@ export function createConstellation({ 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; + } + } + activeSparks = next; + return true; + } function layoutLabels(left) { const chosenPerson = position(); + const chosenLabel = labels[selectedProfileIndex()]; + const focusedLabel = labelsByNode.get(document.activeElement?.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 labels) { - if (item.width === undefined) { + for (const item of relevant) { + const previewShown = item.preview && + (item === chosenLabel || item.preview === document.activeElement); + 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 = item.person === chosenPerson && item.preview ? item.preview.offsetHeight : 0; + 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, @@ -241,16 +280,22 @@ export function createConstellation({ 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 + previewHeight : 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, viewportWidth - width / 2 - 16, ); item.label.style.left = `${center + left}px`; item.label.style.top = `${top}px`; if ( - (chosen || (x >= 12 && x <= viewportWidth - 12)) && + (chosen || item === focusedLabel || (x >= 12 && x <= viewportWidth - 12)) && center - width / 2 >= 12 && center + width / 2 <= viewportWidth - 12 ) { @@ -259,29 +304,52 @@ export function createConstellation({ 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 - viewportWidth / 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); - labels.forEach((item) => { + 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`; - showLabel(item, Boolean(placement)); - }); + 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; @@ -299,8 +367,11 @@ export function createConstellation({ : 0.22, ); }); - if (!geometry.overview) 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)); @@ -308,7 +379,12 @@ 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; @@ -344,6 +420,7 @@ export function createConstellation({ } 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(); @@ -351,11 +428,11 @@ export function createConstellation({ labelsDirty = false; } const animating = String(motion); - if (motion || animationDirty || lastMotion !== motion) { - animate(motion); - animationDirty = false; - lastMotion = 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; @@ -405,13 +482,16 @@ export function createConstellation({ sky = element.querySelector(".constellation-sky"); meteor = element.querySelector(".shooting-star"); meteorTail = element.querySelector(".shooting-tail"); + 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}); + selectionByEvent.set(event.event_id, {person, index, event: firstEvents.get(event.event_id)}); index++; } actorButtons = [...element.querySelectorAll("[data-actor]")].map(button => ({ @@ -420,7 +500,16 @@ export function createConstellation({ })); stars = [...element.querySelectorAll(".sky-star")]; const labelNodes = element.querySelectorAll("[data-label]"); - labels = geometry.people.map((person, index) => ({person, label: labelNodes[index]})); + 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"); @@ -442,6 +531,7 @@ export function createConstellation({ }); applySelection(identity, true); updateMode(); + resetViewport(); anchor(); scrubber(); queue(); @@ -471,11 +561,11 @@ export function createConstellation({ links.push({ a: people[index], b: people[index - 1], color: group.color, dash: group.dash }); } } - linkLayer.innerHTML = links.map(({ a, b, color, dash }) => ``).join(""); + 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]}), + (spark, index) => ({element: spark, trail: trails[index], ...links[index], phase: seed(index), hidden: true}), ); } function prepareGroupButtons() { @@ -510,7 +600,10 @@ export function createConstellation({ button.tabIndex = overview ? -1 : 0; button.setAttribute("aria-hidden", String(overview)); } - if (overview) for (const item of labels) showLabel(item, false); + if (overview) { + for (const item of positionedLabels) showLabel(item, false); + viewportLabels = []; + } } function anchor() { const point = position(); @@ -562,6 +655,7 @@ export function createConstellation({ 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); @@ -587,6 +681,7 @@ export function createConstellation({ path.setAttribute("d", `M${a.x},${a.y}L${b.x},${b.y}`); }); } + if (coordinatesChanged) resetViewport(); anchor(); zoomControls(); travelControls(); @@ -750,7 +845,13 @@ export function createConstellation({ 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")) seek(events.findIndex((item) => item.event_id === button.dataset.event)); @@ -762,6 +863,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], diff --git a/tests/constellation_check.mjs b/tests/constellation_check.mjs index 03a7e50..5c1b58e 100644 --- a/tests/constellation_check.mjs +++ b/tests/constellation_check.mjs @@ -14,6 +14,7 @@ const { normalizeMotionSpeed, } = await import(new URL("constellation-layout.js", base)); const { createConstellation } = await import(new URL("constellation.js", base)); +const { createViewportIndex, overscan } = await import(new URL("constellation-viewport.js", base)); const { resolveLens } = await import(new URL("audience-ui.js", base)); const { groupKey, groupDescription } = await import(new URL("constellation-colors.js", base)); const { groupEvents } = await import(new URL("audience-groups.js", base)); @@ -57,6 +58,33 @@ for (const width of [320, 390, 798, 1440]) ); assert.ok(layout.center < height / 2); } +const points = [160, 0, 160, 320, 80, 640].map((x, index) => ({ + person: {x, event: {event_id: index === 2 ? "0" : String(index)}}, +})); +const segments = [ + {a:{x:-500}, b:{x:700}}, + {a:{x:480}, b:{x:320}}, + {a:{x:80}, b:{x:80}}, + {a:{x:1200}, b:{x:1400}}, +]; +const indexed = createViewportIndex(points, segments); +assert.equal(createViewportIndex([], []).nearest(40), undefined); +assert.equal(createViewportIndex(points.slice(0, 1), []).nearest(-100), points[0].person); +for (const center of [-100, 0, 40, 80, 120, 160, 240, 480, 640, 1000]) { + const expected = points.reduce((best, item) => + Math.abs(item.person.x - center) < Math.abs(best.person.x - center) ? item : best); + assert.equal(indexed.nearest(center), expected.person, "Nearest selection keeps original tie priority"); +} +for (const [left, width] of [[0, 64], [100, 128], [320, 390], [900, 1200], [2500, 1600]]) { + const start = left - overscan, end = left + width + overscan; + const {labels, connections} = indexed.range(start, end); + assert.deepEqual(labels, points.filter(({person}) => start <= person.x && person.x <= end)); + assert.deepEqual(new Set(connections), new Set(segments.filter(({a,b}) => + Math.min(a.x, b.x) <= end && Math.max(a.x, b.x) >= start))); +} +assert.ok(indexed.range(200, 220).connections.includes(segments[0]), + "A connection spanning the viewport is active with neither endpoint in range"); + const inactive = modules.map((module) => ({ ...module, enabled: false })); assert.equal( resolveLens(modules, "none"), diff --git a/tests/constellation_selection_check.mjs b/tests/constellation_selection_check.mjs index 58c4db8..cfd16f4 100644 --- a/tests/constellation_selection_check.mjs +++ b/tests/constellation_selection_check.mjs @@ -86,6 +86,7 @@ function button(dataset) { this.attributeWrites = (this.attributeWrites || 0) + 1; this.attributes[k] = v; }, + removeAttribute(k) { delete this.attributes[k]; }, addEventListener(k, v) { listenerWrites++; this.handlers[k] = v; @@ -988,7 +989,14 @@ try { const {links: animatedLinks} = verifyConnections(connectionEvents, [activity], "activity", 1); assert.ok(animatedLinks.length > 2, "The animation fixture has multiple indexed links"); frame(30000); + const inRange = ({a,b}) => Math.max(a.x, b.x) >= element.scrollLeft - 128 && + Math.min(a.x, b.x) <= element.scrollLeft + element.clientWidth + 128; + assert.ok(animatedLinks.some(inRange) && animatedLinks.some(link => !inRange(link))); animatedLinks.forEach(({a,b}, index) => { + if (!inRange({a,b})) { + assert.equal(renderedSparks[index].attributes.cx, undefined); + return; + } const part = seed(index) % 1, tail = Math.max(0, part - 0.06); assert.equal(renderedSparks[index].attributes.cx, (a.x + (b.x - a.x) * part).toFixed(2)); assert.equal(renderedSparks[index].attributes.cy, (a.y + (b.y - a.y) * part).toFixed(2)); @@ -1016,8 +1024,9 @@ try { group:a.group, style:color ? `color:${color}` : "", d:`M${a.x},${a.y}L${b.x},${b.y}`, dash:dash || "", })), "Leaving overview restores the exact ordered connections and styles"); frame(30032); - assert.ok(renderedTrails.every(trail => trail.attributes.x1 !== undefined), "Recreated trails animate"); expanded.links.forEach(({a,b}, index) => { + if (!inRange({a,b})) return; + assert.notEqual(renderedTrails[index].attributes.x1, undefined, "Active retained trails animate"); const part = (seed(index) + 32 / 10000) % 1; assert.equal(renderedSparks[index].attributes.cx, (a.x + (b.x - a.x) * part).toFixed(2)); assert.equal(renderedSparks[index].attributes.cy, (a.y + (b.y - a.y) * part).toFixed(2)); diff --git a/tests/test_browser_scene_lifecycle.py b/tests/test_browser_scene_lifecycle.py index 1eb1bfb..70b23bb 100644 --- a/tests/test_browser_scene_lifecycle.py +++ b/tests/test_browser_scene_lifecycle.py @@ -322,8 +322,11 @@ def preview_demo(route): history.dispatchEvent(new Event('scroll')); advance(); const expected = Math.round((1 - history.scrollLeft / (history.scrollWidth - history.clientWidth)) * 1000); - const scroll = history.scrollLeft !== previousLeft && labels.takeRecords().length > 0 - && Number(scrubber.value) === expected; + const labelBox = label().getBoundingClientRect(); + const viewport = history.getBoundingClientRect(); + const scroll = history.scrollLeft !== previousLeft && Number(scrubber.value) === expected + && label().getAttribute('aria-hidden') === 'false' + && labelBox.left >= viewport.left && labelBox.right <= viewport.right; const originalPreview = preview().textContent, originalHeight = preview().offsetHeight; reads.boxes = reads.previews = 0; @@ -394,6 +397,237 @@ def test_reduced_motion_remeasures_labels_after_fonts_and_resize(offline_page): }""") +@pytest.fixture +def viewport_page(offline_page): + page = offline_page + + def long_demo(route): + response = route.fetch() + payload = response.json() + template = payload["events"][0] + anchor = datetime.fromisoformat(template["received_at"]) + payload["events"] = [ + { + **template, + "event_id": f"viewport-{index}", + "name": f"Dummy actor {index}", + "sequence": 120 - index, + "received_at": (anchor - timedelta(seconds=index)).isoformat(), + "kind": "interaction.replies", + "post_id": str(10000 + index), + "post_text": f"Synthetic reply {index}", + } + for index in range(120) + ] + payload["next"] = None + route.fulfill(response=response, json=payload) + + page.route("**/api/demo?*", long_demo) + page.set_viewport_size({"width": 390, "height": 844}) + page.evaluate("document.querySelector('[data-filter=lost]').click()") + page.wait_for_function( + """() => document.querySelectorAll('.constellation-star').length === 120 + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + page.evaluate("""() => { + window.viewportTime = 1000; + window.viewportStep = () => stepFrames(viewportTime += 16); + const zoom = document.querySelector('#scene-zoom'); + zoom.value = '2'; + zoom.dispatchEvent(new Event('input', {bubbles: true})); + viewportStep(); + }""") + return page + + +def test_viewport_limits_label_and_spark_work_across_scroll_jumps(viewport_page): + page = viewport_page + # At 2× zoom a segment can cross this entire viewport and its overscan. + page.set_viewport_size({"width": 230, "height": 844}) + page.wait_for_function( + """() => { + const history = document.querySelector('#history'); + return history.clientWidth < 234 + && history.querySelector('.constellation-sky').viewBox.baseVal.width === history.clientWidth; + }""", + polling=50, + ) + result = page.evaluate("""async () => { + const {seed} = await import('/static/xf/constellation-layout.js'); + const history = document.querySelector('#history'); + const canvas = history.querySelector('.constellation-canvas'); + const actors = [...history.querySelectorAll('[data-actor]')]; + const labels = [...history.querySelectorAll('.star-label')]; + const linkLayer = history.querySelector('.constellation-links'); + const links = [...linkLayer.querySelectorAll('[data-link-group]')].map((group, index) => { + const [x1, y1, x2, y2] = group.querySelector('path').getAttribute('d').slice(1).split(/[,L]/).map(Number); + return {index, x1, y1, x2, y2, spark: group.querySelector('.sequence-spark'), + trail: group.querySelector('.sequence-trail')}; + }); + const jump = left => { + history.scrollLeft = left; + history.dispatchEvent(new Event('scroll')); + viewportStep(); + }; + const cross = links[Math.floor(links.length / 2)]; + const center = (cross.x1 + cross.x2) / 2; + const old = links[0]; + jump((old.x1 + old.x2 - history.clientWidth) / 2); + const phase = link => (Number(link.spark.getAttribute('cx')) - link.x1) / (link.x2 - link.x1); + const oldLeft = history.scrollLeft; + const oldPhase = phase(old); + const oldLabel = history.querySelector('.is-selected .star-label'); + const oldSparkPosition = old.spark.getAttribute('cx'); + const labelObserver = new MutationObserver(() => {}); + const sparkObserver = new MutationObserver(() => {}); + const childObserver = new MutationObserver(() => {}); + for (const label of labels) + labelObserver.observe(label, {attributes: true, attributeFilter: ['style']}); + sparkObserver.observe(linkLayer, {subtree: true, attributes: true, attributeFilter: ['cx']}); + childObserver.observe(canvas, {subtree: true, childList: true}); + const nativeAdd = EventTarget.prototype.addEventListener; + let listenerWrites = 0; + EventTarget.prototype.addEventListener = function(type, ...args) { + if (this instanceof Node && canvas.contains(this)) listenerWrites++; + return nativeAdd.call(this, type, ...args); + }; + try { + jump(center - history.clientWidth / 2); + const left = history.scrollLeft, right = left + history.clientWidth; + const margin = 128; + const expectedSparks = links.filter(link => Math.min(link.x1, link.x2) <= right + margin + && Math.max(link.x1, link.x2) >= left - margin).map(link => link.spark); + const sparkWrites = sparkObserver.takeRecords().map(record => record.target); + const labelWrites = new Set(labelObserver.takeRecords().map(record => record.target)); + const selected = actors.find(actor => actor.getAttribute('aria-pressed') === 'true'); + const nearest = actors.reduce((best, actor) => + Math.abs(parseFloat(actor.style.left) - (left + history.clientWidth / 2)) + < Math.abs(parseFloat(best.style.left) - (left + history.clientWidth / 2)) ? actor : best); + const selectedLabel = selected.parentElement.querySelector('.star-label'); + const eligibleWrites = actors.filter(actor => { + const x = parseFloat(actor.style.left); + return (x >= left - margin && x <= right + margin) + || (x >= oldLeft - margin && x <= oldLeft + history.clientWidth + margin); + }).map(actor => actor.parentElement.querySelector('.star-label')); + const labelBudget = new Set([...eligibleWrites, oldLabel, selectedLabel]); + const expectedPhase = ((oldPhase - seed(old.index) + seed(cross.index) + 0.0016) % 1 + 1) % 1; + const crossWrites = sparkWrites.includes(cross.spark); + const initialPhase = phase(cross); + const worldY = cross.y1 + (cross.y2 - cross.y1) * initialPhase; + const trailMatches = cross.spark.getAttribute('cx') === cross.trail.getAttribute('x2') + && cross.spark.getAttribute('cy') === cross.trail.getAttribute('y2') + && Math.abs(worldY - Number(cross.spark.getAttribute('cy'))) < 0.05; + const newActors = canvas.querySelectorAll('[data-actor]'); + const newLabels = canvas.querySelectorAll('.star-label'); + viewportStep(); + const nextSparkWrites = sparkObserver.takeRecords().map(record => record.target); + const withinRange = (writes) => writes.length === expectedSparks.length + && expectedSparks.every(spark => writes.includes(spark)); + return { + cross: Math.min(cross.x1, cross.x2) < left - margin && Math.max(cross.x1, cross.x2) > right + margin && crossWrites, + phase: Math.abs(initialPhase - expectedPhase) < 0.0001 + && Math.abs(phase(cross) - ((expectedPhase + 0.0016) % 1)) < 0.0001, + alignment: trailMatches, + sparkRange: withinRange(sparkWrites) && withinRange(nextSparkWrites), + offscreenUnchanged: old.spark.getAttribute('cx') === oldSparkPosition, + labels: labelWrites.has(selectedLabel) && [...labelWrites].every(label => labelBudget.has(label)), + leavingHidden: oldLabel.getAttribute('aria-hidden') === 'true' + && oldLabel.querySelector('button').tabIndex === -1, + selected: selected === nearest && selectedLabel.getAttribute('aria-hidden') === 'false', + nodes: childObserver.takeRecords().length === 0 && listenerWrites === 0 + && actors.every((actor, index) => actor === newActors[index]) + && labels.every((label, index) => label === newLabels[index]) + && canvas === history.querySelector('.constellation-canvas') + && linkLayer === canvas.querySelector('.constellation-links'), + }; + } finally { + EventTarget.prototype.addEventListener = nativeAdd; + labelObserver.disconnect(); + sparkObserver.disconnect(); + childObserver.disconnect(); + } + }""") + assert all(result.values()), result + + page.emulate_media(reduced_motion="reduce") + page.wait_for_function("matchMedia('(prefers-reduced-motion: reduce)').matches") + result = page.evaluate("""() => { + const history = document.querySelector('#history'); + viewportStep(); + const group = history.querySelector('[data-link-group]'); + const path = group.querySelector('path'); + const spark = group.querySelector('.sequence-spark'); + const trail = group.querySelector('.sequence-trail'); + const stale = spark.getAttribute('cx'); + const [x1, y1, x2, y2] = path.getAttribute('d').slice(1).split(/[,L]/).map(Number); + history.scrollLeft = (x1 + x2 - history.clientWidth) / 2; + history.dispatchEvent(new Event('scroll')); + viewportStep(); + const x = Number(spark.getAttribute('cx')); + const y = Number(spark.getAttribute('cy')); + const progress = (x - x1) / (x2 - x1); + return { + reentered: getComputedStyle(spark).visibility === 'visible' + && spark.getAttribute('cx') !== stale && progress >= 0 && progress <= 1 + && Math.abs(y - (y1 + (y2 - y1) * progress)) < 0.05 + && spark.getAttribute('cx') === trail.getAttribute('x2'), + paused: paintCount() === 0, + }; + }""") + assert result == {"reentered": True, "paused": True} + + +def test_viewport_keeps_offscreen_selection_and_focused_preview_usable(viewport_page): + page = viewport_page + result = page.evaluate("""() => { + const history = document.querySelector('#history'); + const actors = [...history.querySelectorAll('[data-actor]')]; + const label = actor => actor.parentElement.querySelector('.star-label'); + const first = actors[0], last = actors.at(-1); + const selected = () => history.querySelector('[aria-pressed=true][data-actor]'); + const isUsable = (item, control) => getComputedStyle(control).visibility === 'visible' + && getComputedStyle(control).display !== 'none' + && item.getAttribute('aria-hidden') === 'false' && control.tabIndex === 0; + const outside = actor => { + const x = parseFloat(actor.style.left); + return x < history.scrollLeft - 128 || x > history.scrollLeft + history.clientWidth + 128; + }; + + last.click(); + viewportStep(); + const pinnedSelection = selected() === last && outside(last) + && isUsable(label(last), label(last).querySelector('button')); + for (let i = 0; i < 160; i++) viewportStep(); + const preview = label(last).querySelector('.star-post'); + preview.focus({preventScroll: true}); + const focusedInitially = document.activeElement === preview; + history.scrollLeft = history.scrollWidth - history.clientWidth; + history.dispatchEvent(new Event('scroll')); + viewportStep(); + const pinnedFocus = focusedInitially && selected() === first && outside(last) + && document.activeElement === preview && isUsable(label(last), preview); + + preview.blur(); + viewportStep(); + const removedFromTabOrder = label(last).getAttribute('aria-hidden') === 'true' + && preview.tabIndex === -1; + const actorAccessible = last.tabIndex === 0 && last.getAttribute('aria-hidden') === 'false'; + return {pinnedSelection, pinnedFocus, removedFromTabOrder, actorAccessible}; + }""") + assert all(result.values()), result + + # A real key action enables :focus-visible before focusing the far actor without native scrolling. + page.keyboard.press("Tab") + assert page.evaluate("""() => { + const actor = [...document.querySelectorAll('#history [data-actor]')].at(-1); + actor.focus({preventScroll: true}); + viewportStep(); + return document.activeElement === actor && actor.matches(':focus-visible') + && actor.getAttribute('aria-pressed') === 'true'; + }""") + + @pytest.mark.parametrize("motion", ["no-preference", "reduce"]) def test_zoom_reuses_grouped_scene_and_rebinds_animation(offline_page, motion): page = offline_page @@ -551,9 +785,14 @@ def dense_demo(route): const {history, selected, path} = zoomProbe; const expected = Math.max(0, Math.min(parseFloat(selected.style.left) - history.clientWidth / 2, history.scrollWidth - history.clientWidth)); - const spark = path.parentNode.querySelector('.sequence-spark'); - const trail = path.parentNode.querySelector('.sequence-trail'); - const [ax, ay, bx, by] = path.getAttribute('d').slice(1).split(/[,L]/).map(Number); + const center = history.scrollLeft + history.clientWidth / 2; + const visiblePath = [...history.querySelectorAll('.constellation-links path')].find(path => { + const [x1, , x2] = path.getAttribute('d').slice(1).split(/[,L]/).map(Number); + return Math.min(x1, x2) <= center && Math.max(x1, x2) >= center; + }); + const spark = visiblePath.parentNode.querySelector('.sequence-spark'); + const trail = visiblePath.parentNode.querySelector('.sequence-trail'); + const [ax, ay, bx, by] = visiblePath.getAttribute('d').slice(1).split(/[,L]/).map(Number); const cx = Number(spark.getAttribute('cx')), cy = Number(spark.getAttribute('cy')); const progress = (cx - ax) / (bx - ax); const label = selected.parentElement.querySelector('.star-label'); From 92cd599bf1b11f9ec3a6bc81a58ec15625944c70 Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 12:28:16 +0530 Subject: [PATCH 7/9] Virtualize the Journal event and group rows --- src/xflog/assets/app.js | 20 +- src/xflog/assets/journal-model.js | 101 +++++++ src/xflog/assets/journal.js | 406 ++++++++++++++++++++++---- src/xflog/assets/timeline.css | 15 +- tests/journal_check.mjs | 264 +++++++---------- tests/test_assets.py | 2 +- tests/test_browser_scene_lifecycle.py | 392 ++++++++++++++++++++++++- 7 files changed, 981 insertions(+), 219 deletions(-) create mode 100644 src/xflog/assets/journal-model.js diff --git a/src/xflog/assets/app.js b/src/xflog/assets/app.js index 26ba24b..9b1384e 100644 --- a/src/xflog/assets/app.js +++ b/src/xflog/assets/app.js @@ -94,6 +94,7 @@ label: eventLabel, initials, onOpen: (identity) => selectEvent(identity, true), + onNearEnd: requestOlder, }); const { createActivityHud } = await import("/static/xf/activity-hud.js"); const activityHud = createActivityHud({ @@ -203,6 +204,7 @@ } } function view(name) { + if (name !== "timeline") journal.clear({remember: true}); for (const page of ["timeline", "profile", "about"]) $(`#${page}-view`).hidden = page !== name; $$("[data-nav]").forEach((button) => { @@ -348,9 +350,12 @@ if (state.layout === "vertical") renderEvents(); } function renderEvents(renderScene = scene.render) { + 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", @@ -406,9 +411,7 @@ state.selected = identity; selectedSignal.render(event, state.groups); if (state.layout === "horizontal") scene.select(identity); - else $$("[data-event]").forEach((button) => - button.setAttribute("aria-pressed", button.dataset.event === identity), - ); + else journal.select(identity); renderPersonDetail(root, event); const index = state.events.findIndex((item) => item.event_id === identity); $("#journey-position").textContent = @@ -445,6 +448,7 @@ selectedSignal.render(null); followControls.select(null); scene.clear(); + journal.clear(); $("#history").innerHTML = '
Loading local history…
'; } @@ -476,6 +480,7 @@ notice(error.message, "error"); if (!state.events.length) { scene.clear(); + journal.clear(); $("#history").innerHTML = '

History couldn’t be loaded.

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

'; $("#history [data-retry]").addEventListener("click", () => load()); @@ -655,14 +660,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"; @@ -676,6 +673,7 @@ if (!root.isConnected) { historyLoad?.abort(); scene.destroy(); + journal.destroy(); groupControls.destroy(); activityHud.destroy(); selectedSignal.destroy(); diff --git a/src/xflog/assets/journal-model.js b/src/xflog/assets/journal-model.js new file mode 100644 index 0000000..40bb063 --- /dev/null +++ b/src/xflog/assets/journal-model.js @@ -0,0 +1,101 @@ +import { groupEvents } from "./audience-groups.js"; + +function createHeightIndex(estimates) { + const heights = Float64Array.from(estimates); + const tree = new Float64Array(heights.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 <= heights.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(heights.length); }, + height(index) { return heights[index]; }, + at(offset) { + let index = 0; + for (let bit = searchBit; bit > 0; bit >>= 1) { + const next = index + bit; + if (next < tree.length && tree[next] <= offset) { + index = next; + offset -= tree[next]; + } + } + return Math.min(index, heights.length - 1); + }, + update(index, height) { + const difference = height - heights[index]; + if (!difference) return; + heights[index] = height; + for (let parent = index + 1; parent < tree.length; parent += parent & -parent) + tree[parent] += difference; + }, + }; +} + +export function createJournalModel(events, {modules, lens, collapsed, day}) { + const groups = groupEvents(events, modules, lens); + 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 addEvents(records, 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 = ""; + records.forEach((event, index) => { + const text = day(event); + if (text !== prior) { + const first = 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, + }; + add(row, 102, visible); + if (!byEvent.has(event.event_id)) byEvent.set(event.event_id, row); + }); + } + + if (groups) { + for (const group of groups) { + group.start = rows.length; + group.open = !collapsed.has(`${lens}:${group.key}`); + add({type:"group", key:JSON.stringify([lens, group.key, "group"]), group}, 57); + addEvents(group.events, group, group.open); + add({type:"gap", key:JSON.stringify([lens, group.key, "gap"]), group}, 20); + group.end = rows.length; + } + } else { + addEvents(events, null, true); + } + + return {groups, rows, byEvent, byKey, heights: createHeightIndex(estimates)}; +} diff --git a/src/xflog/assets/journal.js b/src/xflog/assets/journal.js index dcf3dda..b748c29 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,367 @@ 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 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 `
`; + } + + function mountGroup(group) { + const node = document.createElement("details"); + node.className = `journal-group ${group.tone || ""}`; + node.dataset.group = group.key; + node.open = group.open; + node.innerHTML = `${esc(group.label)} ${group.events.length} ${group.events.length === 1 ? "event" : "events"}
`; + groups.set(group, node); + return node; + } + + function mountRow(row) { + let node; + if (row.type === "group") { + 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 (row.type !== "group") 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 (row.type === "group") 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 && row.type !== "group" ? groupNode.lastElementChild : track; + const child = row.type === "group" ? 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 : row.type === "group" ? 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); + queue(); + } + } + + function release() { + cancelAnimationFrame(frame); + frame = 0; + observer.disconnect(); + mounted.clear(); + groups.clear(); + observed.clear(); + track?.remove(); + track = model = events = options = revealRow = 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 (row.type === "group") + return row.group.label === next.group.label && row.group.events.length === next.group.events.length; + return row.first === next.first; + } + + function render(nextEvents, nextOptions) { + 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 = createJournalModel(events, {...options, collapsed, day: event => date.format(displayDate(event))}); + 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; + 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}); + 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}); + 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") : node; + } + + 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 (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/timeline.css b/src/xflog/assets/timeline.css index fec741b..9221b32 100644 --- a/src/xflog/assets/timeline.css +++ b/src/xflog/assets/timeline.css @@ -153,9 +153,18 @@ padding: 16px 0 12px; flex-shrink: 0; } +.journal-window { + position: relative; + overflow-anchor: none; +} +.journal-window > .journal-group, +.journal-window .date-group, +.journal-window .event { + position: absolute; + inset-inline: 0; +} .journal-group { border-top: 1px solid var(--border); - margin-bottom: 20px; } .journal-group summary { display: flex; @@ -205,10 +214,10 @@ outline: 2px solid var(--accent); outline-offset: 4px; } -.journal-group .date-group:first-child { +.journal-group .journal-first-date { padding-top: 8px; } -.journal-group .event:last-child:before { +.journal-group .journal-last-event:before { bottom: 20px; } .date-group { diff --git a/tests/journal_check.mjs b/tests/journal_check.mjs index affe0ae..3e1a113 100644 --- a/tests/journal_check.mjs +++ b/tests/journal_check.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { pathToFileURL } from "node:url"; const base = pathToFileURL(process.argv[2] + "/"); const { groupEvents } = await import(new URL("audience-groups.js", base)); -const { createJournal } = await import(new URL("journal.js", base)); +const { createJournalModel } = await import(new URL("journal-model.js", base)); const modules = JSON.parse(process.argv[3]); const stamp = "2026-10-26T12:00:00Z"; const event = (event_id, actor_id, sequence, kind, size) => ({ @@ -56,132 +56,83 @@ assert.equal( "New catalog modules require no journal-specific logic.", ); -const esc = (value) => - String(value ?? "").replace( - /[&<>"']/g, - (character) => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ - character - ], - ); -const opened = []; -let scrolled = null; -const element = { - html: "", - groups: [], - buttons: [], - set innerHTML(html) { - this.html = html; - this.groups = []; - this.buttons = []; - const buttons = (markup, parent) => { - for (const [, id] of markup.matchAll(/data-event="([^"]+)"/g)) { - this.buttons.push({ - dataset: { event: id }, - handlers: {}, - addEventListener(type, callback) { - this.handlers[type] = callback; - }, - closest() { - return parent; - }, - scrollIntoView() { - scrolled = id; - }, - }); - } - }; - for (const [, attributes, key, body] of html.matchAll( - /]*data-group="([^"]+)"[^>]*)>([\s\S]*?)<\/details>/g, - )) { - const group = { - dataset: { group: key }, - open: /\sopen(?:\s|$)/.test(attributes), - handlers: {}, - addEventListener(type, callback) { - this.handlers[type] = callback; - }, - }; - this.groups.push(group); - buttons(body, group); - } - if (!this.groups.length) buttons(html, null); - }, - querySelectorAll(selector) { - return selector === "details[data-group]" ? this.groups : this.buttons; - }, -}; -const journal = createJournal({ - element, - escape: esc, - date: new Intl.DateTimeFormat("en"), - time: new Intl.DateTimeFormat("en"), - displayDate: (event) => new Date(event.provider_at || event.received_at), - gained: (event) => event.kind === "follow.follow", - label: (event) => event.kind, - initials: () => "P", - onOpen: (id) => opened.push(id), -}); -const options = { modules, lens: "activity", selected: "new-lost" }; -journal.render(events, options); -assert.equal(element.groups.length, 2); -assert.ok( - element.html.includes("3 events") && element.html.includes("1 event"), -); -assert.ok(element.html.includes('data-event="new-lost" aria-pressed="true"')); -element.buttons - .find((button) => button.dataset.event === "new-lost") - .handlers.click(); -assert.deepEqual(opened, ["new-lost"]); -element.groups[0].open = false; -element.groups[0].handlers.toggle(); -const appended = [ - ...events, - event("older", "4", 0, "follow.follow", "under-100"), -]; -journal.render(appended, options); -assert.equal( - element.groups[0].open, - false, - "Pagination preserves collapsed groups.", -); -assert.equal(element.buttons.length, 5); -journal.reveal("new-follow"); -assert.equal( - element.groups[0].open, - true, - "Latest can reveal an event inside a collapsed group.", -); -assert.equal(scrolled, "new-follow"); -journal.render(appended, options); -assert.equal(element.groups[0].open, true); -journal.reveal("new-lost"); -assert.equal( - scrolled, - "new-lost", - "Navigation finds the selected record across bucket order.", -); -journal.render(events, { ...options, lens: "none" }); -assert.equal(element.groups.length, 0); -assert.deepEqual( - element.buttons.map((button) => button.dataset.event), - events.map((event) => event.event_id), - "Ungrouped retains the full input order.", -); -for (const module of modules.filter((module) => module.enabled)) { +const day = (record) => (record.provider_at || record.received_at).slice(0, 10); +const collapsed = new Set(); +const model = (records, lens = "activity") => + createJournalModel(records, {modules, lens, collapsed, day}); +const eventsIn = (journal) => + journal.rows.filter((row) => row.type === "event").map((row) => row.event.event_id); + +assert.deepEqual(eventsIn(model(events)), ["new-follow", "old-follow", "unknown-size", "new-lost"]); +assert.deepEqual(eventsIn(model(events, "none")), events.map((record) => record.event_id)); +assert.deepEqual(model([], "activity").rows, []); +assert.equal(model([], "none").heights.at(0), -1); + +for (const module of modules.filter((item) => item.enabled)) { const records = module.buckets.map((bucket, index) => ({ ...events[0], event_id: `${module.key}-${index}`, audience_groups: { [module.key]: bucket.key }, })); - journal.render(records, { ...options, lens: module.key }); - assert.equal(element.groups.length, module.buckets.length); - assert.deepEqual( - element.buttons.map((button) => button.dataset.event), - records.map((event) => event.event_id), - `${module.label} renders all registered buckets and keeps every record.`, - ); + const journal = model(records, module.key); + assert.deepEqual(journal.groups.map((group) => group.key), module.buckets.map((bucket) => bucket.key)); + assert.deepEqual(eventsIn(journal), records.map((record) => record.event_id)); } + +const dateChanges = [ + {...events[0], event_id:"first"}, + {...events[1], event_id:"next", provider_at:"2026-10-25T00:00:00Z"}, + {...events[2], event_id:"earlier-again"}, + {...events[3], event_id:"same-day"}, +]; +const ungroupedDates = model(dateChanges, "none").rows; +assert.deepEqual( + ungroupedDates.map((row) => row.type === "date" ? row.text : row.event.event_id), + ["2026-10-26", "first", "2026-10-25", "next", "2026-10-26", "earlier-again", "same-day"], + "Each change in the input date creates one heading, including a repeated nonconsecutive day.", +); +const groupedDates = model(dateChanges); +assert.deepEqual(groupedDates.rows.filter((row) => row.type === "date").map(({text, first}) => [text, first]), + [["2026-10-26", true], ["2026-10-25", true]], + "Each group starts its own date heading."); + +const groupedBefore = model(events); +const oldEventKey = groupedBefore.byEvent.get("old-follow").key; +const oldDateKey = groupedBefore.rows.find((row) => row.type === "date").key; +collapsed.add("activity:follow.follow"); +const appended = [...events, event("older", "4", 0, "follow.follow", "under-100")]; +const closedJournal = model(appended); +const closedGroup = closedJournal.groups[0]; +assert.equal(closedGroup.events.length, 4); +assert.equal(closedGroup.open, false); +assert.deepEqual( + closedJournal.rows.slice(closedGroup.start, closedGroup.end).map((row) => row.type), + ["group", "gap"], +); +assert.deepEqual(eventsIn(closedJournal), ["new-lost"]); +assert.equal(closedJournal.byEvent.get("older").index, -1, "Collapsed records retain logical lookup."); +assert.equal(closedJournal.byEvent.get("new-lost").position, 5, "Logical positions include collapsed events."); +assert.equal(closedJournal.byKey.get(oldEventKey), closedJournal.byEvent.get("old-follow")); +assert.equal(closedJournal.byKey.get(oldDateKey).index, -1); +assert.equal(model(appended, "account-size").groups[0].open, true, "Collapse is scoped to its lens."); +collapsed.delete("activity:follow.follow"); +const reopened = model(appended); +assert.equal(reopened.rows[reopened.byEvent.get("older").index].event, appended.at(-1)); +assert.deepEqual(eventsIn(reopened), ["new-follow", "old-follow", "unknown-size", "older", "new-lost"]); + +const prepended = model([{...events[0], event_id:"latest"}, ...events]); +assert.equal(prepended.byKey.get(oldEventKey).event, events[2]); +assert.equal(prepended.byKey.get(oldDateKey).text, "2026-10-26"); + +const duplicate = {...events[0], name:"Second record with the same ID"}; +const duplicateAcrossGroup = {...events[1], event_id:duplicate.event_id}; +const duplicates = model([events[0], duplicate, duplicateAcrossGroup]); +const duplicateRows = duplicates.rows.filter((row) => row.type === "event"); +assert.equal(new Set(duplicateRows.map((row) => row.key)).size, 3); +assert.deepEqual(duplicateRows.map((row) => row.event), [events[0], duplicate, duplicateAcrossGroup]); +assert.equal(duplicates.byEvent.get(duplicate.event_id), duplicateRows[0], "Reveal resolves the first match."); +assert.ok(duplicateRows.every((row) => duplicates.byKey.get(row.key) === row)); + const locations = modules.find((module) => module.key === "profile-location"); assert.equal(locations.dynamic_buckets, true); const locationRecord = (index, label) => ({ @@ -212,23 +163,11 @@ assert.deepEqual( ["Unknown", 1], ], ); -journal.render(locationRows, { ...options, lens: locations.key }); -assert.equal(element.groups.length, 3); -element.groups[0].open = false; -element.groups[0].handlers.toggle(); -journal.reveal("location-3"); -assert.equal(element.groups[0].open, true); -assert.equal(scrolled, "location-3"); -const manyLocations = Array.from({ length: 30 }, (_, index) => - locationRecord(index, `City ${index}`), -); -journal.render(manyLocations, { ...options, lens: locations.key }); -assert.equal( - element.groups.length, - 30, - "Journal retains every named location.", -); -assert.equal(element.buttons.length, 30); +const locationsJournal = model(locationRows, locations.key); +assert.deepEqual(locationsJournal.groups.map((group) => [group.label, group.events.length]), + [["London", 2], ["New York City", 1], ["Unknown", 1]]); +assert.deepEqual(eventsIn(locationsJournal), ["location-1", "location-3", "location-2", "unknown-location"]); + for (const descriptor of [ null, {}, @@ -256,17 +195,38 @@ assert.equal( )[0].key, "location-1", ); -journal.render([locationRecord(1, '')], { - ...options, - lens: locations.key, -}); -assert.ok(element.html.includes("<img")); -assert.ok(!element.html.includes("' }], { - ...options, - modules: [extension], - lens: "future", -}); -assert.ok(element.html.includes("<Future>")); -assert.ok(element.html.includes("<img")); -assert.ok(!element.html.includes(" heights.height(index)); + for (const index of [1, rows.length - 1, 0, 2, 1]) { + const measured = 1.25 * (index + 1) + 12.5; + sizes[index] = measured; + heights.update(index, measured); + let top = 0; + sizes.forEach((size, row) => { + assert.equal(heights.prefix(row), top, "Incremental correction equals a direct sum."); + assert.equal(heights.at(top), row, "A row starts at its exact prefix."); + assert.equal(heights.at(top + size / 2), row, "Offsets inside a row retain their logical index."); + top += size; + }); + assert.equal(heights.total, top); + assert.equal(heights.at(top), rows.length - 1); + assert.equal(heights.at(-20), 0); + } +} +checkHeights(reopened); + +const manyLocations = Array.from({length: 5000}, (_, index) => + locationRecord(index, `City ${String(index).padStart(4, "0")}`)); +const largeJournal = model(manyLocations, locations.key); +assert.equal(largeJournal.groups.length, 5000); +assert.equal(largeJournal.byEvent.size, 5000); +for (const index of [0, 1, 2499, 4901, 4999]) { + const row = largeJournal.byEvent.get(`location-${index}`); + const top = largeJournal.heights.prefix(row.index); + assert.equal(largeJournal.rows[largeJournal.heights.at(top + 1)], row); + const first = largeJournal.heights.at(Math.max(0, top - 480)); + const last = largeJournal.heights.at(top + 1100); + assert.ok(last - first < 40, "A fixed viewport and overscan find a bounded slice among many group headers."); +} diff --git a/tests/test_assets.py b/tests/test_assets.py index 6a54cd4..c03aa62 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -104,7 +104,7 @@ def test_selected_constellation_record_is_the_one_opened(): assert result.returncode == 0, result.stderr -def test_journal_groups_keep_records_order_and_collapsed_selection(): +def test_journal_model_preserves_order_and_indexes_variable_height_rows(): import json from xflog import audience_groups diff --git a/tests/test_browser_scene_lifecycle.py b/tests/test_browser_scene_lifecycle.py index 70b23bb..bb6237f 100644 --- a/tests/test_browser_scene_lifecycle.py +++ b/tests/test_browser_scene_lifecycle.py @@ -1,4 +1,4 @@ -"""Exercise Constellation zoom and scene replacement against the real offline UI.""" +"""Exercise timeline interactions and scene replacement against the real offline UI.""" import importlib.util import os @@ -6,7 +6,7 @@ import subprocess import sys import time -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path from urllib.error import URLError from urllib.request import urlopen @@ -81,6 +81,7 @@ def offline_browser_app(tmp_path_factory): window.enableFrames = () => { controlled = true; }; window.paintIds = () => [...frames].filter(([, cb]) => cb.name === 'paint').map(([id]) => id); window.paintCount = () => window.paintIds().length; + window.journalFrameCount = () => [...frames.values()].filter(cb => cb.name === 'paintJournal').length; window.stepFrames = (now = performance.now()) => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(cb => cb(now)); @@ -828,3 +829,390 @@ def dense_demo(route): page.evaluate("zoomProbe.finish()") page.set_viewport_size({"width": 1050, "height": 800}) page.wait_for_function("!zoomProbe.unchanged()", polling=50) + + +def journal_events(size): + anchor = datetime.now(UTC).replace(microsecond=0) - timedelta(minutes=1) + events = [] + for index in range(size): + kind = "follow.follow" if index < size * 3 // 4 else "follow.unfollow" + city = f"city-{index // 3:03}" + city_name = f"City {index // 3:03}" + if index < 3: + city_name += ' ' + location = ( + { + "audience_groups": {"activity": kind, "profile-location": city}, + "audience_group_buckets": {"profile-location": {"key": city, "label": city_name}}, + } + if index < size - 3 + else {"audience_groups": {"activity": kind}} + ) + events.append( + { + "event_id": f"journal-{index}", + "actor_id": str(index % 31 + 1000), + "account_id": "demo", + "sequence": size - index, + "kind": kind, + "name": ( + 'Name ' + if index == 0 + else f"Dummy actor {index}" + " long name" * (30 if index % 11 == 0 else 0) + ), + "handle": f"actor_{index % 31}", + "received_at": (anchor - timedelta(seconds=index)).isoformat(), + "provider_at": (anchor - timedelta(days=index // 12)).isoformat(), + **location, + } + ) + return events + + +JOURNAL_CONTROLS = """() => { + const history = document.querySelector('#history'); + const viewport = () => history.getBoundingClientRect(); + const buttons = () => [...history.querySelectorAll('.event-button')]; + const visible = () => buttons().filter(button => { + const rect = button.getBoundingClientRect(); + return rect.height > 0 && rect.bottom > viewport().top && rect.top < viewport().bottom; + }); + const settle = async () => { + for (let index = 0; index < 4; index++) { + stepFrames(); + await new Promise(resolve => setTimeout(resolve, 20)); + } + }; + window.journalTest = { + history, buttons, visible, viewport, settle, + async scroll(top) { + history.scrollTop = top; + history.dispatchEvent(new Event('scroll')); + await settle(); + }, + async reveal(index) { + const scrubber = document.querySelector('#memory-scrubber'); + scrubber.value = String(index); + scrubber.dispatchEvent(new Event('input', {bubbles: true})); + await settle(); + }, + }; +}""" + + +@pytest.fixture +def journal_page(offline_page): + page = offline_page + page.evaluate("document.querySelector('[data-layout=vertical]').click()") + + def history(route): + kind = route.request.url.split("kind=", 1)[1].split("&", 1)[0] + route.fulfill(json={"events": events if kind == "all" else [], "next": None}) + + events = journal_events(2400) + page.route("**/api/demo?*", history) + page.evaluate("document.querySelector('[data-filter=all]').click()") + page.wait_for_function( + """() => document.querySelector('#history [data-event="journal-0"]') + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + page.evaluate(JOURNAL_CONTROLS) + page.evaluate("journalTest.settle()") + return page + + +def test_journal_mounts_bounded_rows_and_reveals_a_record_after_a_jump(journal_page): + page = journal_page + result = page.evaluate("""async () => { + const {history, buttons, visible, scroll, reveal} = journalTest; + const first = visible()[0]?.dataset.event; + const escapedName = buttons()[0]?.querySelector('.event-name')?.textContent; + const escapedMarkup = !history.querySelector('img'); + const initialExtent = history.scrollHeight; + const initialCount = buttons().length; + const initialHeadings = history.querySelectorAll('.date-group').length; + + await scroll(initialExtent / 2); + const middle = visible().map(button => Number(button.dataset.event.split('-')[1])); + const middleCount = buttons().length; + const oldRemoved = !history.querySelector('[data-event=journal-0]'); + + await scroll(history.scrollHeight); + const last = visible().at(-1)?.dataset.event; + const endCount = buttons().length; + + await reveal(601); + const selected = visible().find(button => button.dataset.event === 'journal-601'); + const pressed = selected?.getAttribute('aria-pressed'); + const ordinal = selected?.closest('article')?.getAttribute('aria-label'); + const nearDate = history.querySelectorAll('.date-group').length; + + await scroll(0); + const restored = visible()[0]?.dataset.event; + return {first, escapedName, escapedMarkup, initialExtent, initialCount, initialHeadings, + middle, middleCount, oldRemoved, last, endCount, pressed, ordinal, nearDate, + restored, unmountedSelection: !history.querySelector('[aria-pressed=true][data-event]')}; + }""") + assert result["first"] == result["restored"] == "journal-0" + assert result["escapedName"] == 'Name ' + assert result["escapedMarkup"] + assert result["initialExtent"] > 200_000 + assert max(result[key] for key in ("initialCount", "middleCount", "endCount")) < 35 + assert result["initialHeadings"] > 0 and result["nearDate"] > 0 + assert result["middle"] and all(1000 < index < 1400 for index in result["middle"]) + assert result["oldRemoved"] and result["last"] == "journal-2399" + assert result["pressed"] == "true" and result["ordinal"] == "Event 602 of 2400" + assert result["unmountedSelection"] + page.evaluate("journalTest.reveal(601)") + assert ( + page.locator('#history [data-event="journal-601"]').get_attribute("aria-pressed") == "true" + ) + + +def test_journal_preserves_keyboard_focus_across_window_boundaries(journal_page): + page = journal_page + page.evaluate("""async () => { + await journalTest.scroll(0); + window.journalFocused = journalTest.buttons().at(-1); + journalFocused.focus({preventScroll: true}); + }""") + original = page.evaluate("Number(journalFocused.dataset.event.split('-')[1])") + page.evaluate("journalTest.scroll(journalTest.history.scrollHeight * 0.65)") + assert page.evaluate("journalFocused.isConnected && document.activeElement === journalFocused") + assert page.evaluate("journalTest.buttons().length") < 35 + page.keyboard.press("Tab") + assert page.evaluate("document.activeElement.dataset.event") == f"journal-{original + 1}" + page.keyboard.press("Shift+Tab") + assert page.evaluate("document.activeElement.dataset.event") == f"journal-{original}" + page.keyboard.press("Enter") + assert page.locator("#detail-dialog").evaluate("dialog => dialog.open") + assert ( + page.locator(f'#history [data-event="journal-{original}"]').get_attribute("aria-pressed") + == "true" + ) + + +def test_journal_bounds_group_headers_and_reveals_a_collapsed_group(journal_page): + page = journal_page + page.locator("#lens-trigger").click() + page.get_by_role("menuitemradio", name="Activity", exact=True).click() + page.wait_for_function( + """() => document.querySelector('#history details[data-group]') + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + page.evaluate("journalTest.scroll(0)") + summary = page.locator('#history details[data-group="follow.follow"] summary') + assert "1800 events" in summary.inner_text() + assert page.locator("#history .event-button").count() < 35 + page.evaluate("journalTest.scroll(journalTest.history.scrollHeight * 0.4)") + assert ( + abs( + summary.evaluate("node => node.getBoundingClientRect().top") + - page.evaluate( + "journalTest.viewport().top + parseFloat(getComputedStyle(journalTest.history).paddingTop)" + ) + ) + <= 1 + ) + summary.press("Space") + page.wait_for_function( + """() => document.activeElement.matches('summary') + && !document.activeElement.parentElement.open + && document.activeElement.parentElement.dataset.group === 'follow.follow'""", + polling=50, + ) + assert page.locator('#history details[data-group="follow.follow"] .event-button').count() == 0 + page.evaluate("journalTest.reveal(900)") + assert page.locator('#history details[data-group="follow.follow"]').evaluate( + "node => node.open" + ) + assert ( + page.locator('#history [data-event="journal-900"]').get_attribute("aria-pressed") == "true" + ) + assert page.evaluate("""() => { + const row = document.querySelector('[data-event=journal-900]').getBoundingClientRect(); + const summary = document.querySelector('#history details[data-group="follow.follow"] summary'); + return row.top >= summary.getBoundingClientRect().bottom - 1 + && row.bottom <= journalTest.viewport().bottom + 1; + }""") + + # Enable a dynamic lens using the same preference response shape as the app. + groups = page.evaluate("fetch('/api/status').then(response => response.json())")[ + "audience_groups" + ] + for group in groups: + if group["key"] == "profile-location": + group["enabled"] = True + page.route( + "**/api/audience-groups", lambda route: route.fulfill(json={"audience_groups": groups}) + ) + page.evaluate("""() => { + const input = document.querySelector('#group-profile-location'); + input.checked = true; + input.dispatchEvent(new Event('change')); + }""") + page.wait_for_function( + """() => document.querySelector('#audience-lens').textContent.includes('Profile location') + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + page.locator("#lens-trigger").click() + page.get_by_role("menuitemradio", name="Profile location", exact=True).click() + page.wait_for_function( + """() => document.querySelector('#history details[data-group^=city-]') + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + page.evaluate("journalTest.scroll(0)") + assert page.locator("#history details[data-group]").count() < 20 + first = page.locator("#history details[data-group]").first + assert first.get_attribute("data-group") == "city-000" + assert '' in first.locator("summary").inner_text() + assert page.locator("#history img").count() == 0 + page.evaluate("journalTest.scroll(journalTest.history.scrollHeight)") + assert page.locator("#history details[data-group]").count() < 20 + assert ( + page.locator("#history details[data-group]").last.get_attribute("data-group") == "unknown" + ) + assert page.locator('#history [data-event="journal-2399"]').count() == 1 + + +def test_journal_height_correction_preserves_the_visible_anchor(journal_page): + page = journal_page + page.set_viewport_size({"width": 390, "height": 844}) + page.evaluate("journalTest.scroll(journalTest.history.scrollHeight * 0.5)") + result = page.evaluate("""async () => { + const {buttons, visible, history, viewport, settle} = journalTest; + const rect = node => node.getBoundingClientRect(); + const before = visible()[0]; + const top = rect(before).top; + const above = buttons().findLast(button => rect(button).bottom <= viewport().top); + const oldHeight = rect(above).height; + const scrollTop = history.scrollTop; + above.querySelector('.event-name').textContent = 'Wrapped name '.repeat(65); + await settle(); + return {same: visible()[0]?.dataset.event === before.dataset.event, + growth: rect(above).height - oldHeight, scrollDelta: history.scrollTop - scrollTop, + offsetDelta: rect(before).top - top}; + }""") + assert result["growth"] > 100, result + assert result["same"] and abs(result["offsetDelta"]) <= 2, result + assert abs(result["growth"] - result["scrollDelta"]) <= 2, result + + +def test_journal_route_layout_and_empty_history_cancel_pending_work(journal_page): + page = journal_page + result = page.evaluate("""async () => { + const {history, scroll} = journalTest; + await scroll(history.scrollHeight * 0.4); + const transitions = []; + for (const selector of ['[data-nav=profile]', '[data-layout=horizontal]', '[data-filter=lost]']) { + const old = history.querySelector('.journal-window'); + const observer = new MutationObserver(() => {}); + observer.observe(old, {attributes: true, childList: true, subtree: true}); + history.scrollTop += 190; + history.dispatchEvent(new Event('scroll')); + const queued = journalFrameCount(); + document.querySelector(selector).click(); + stepFrames(); + transitions.push({queued, pending: journalFrameCount(), detached: !old.isConnected, + writes: observer.takeRecords().length}); + observer.disconnect(); + if (selector === '[data-nav=profile]') document.querySelector('[data-nav=timeline]').click(); + if (selector === '[data-layout=horizontal]') document.querySelector('[data-layout=vertical]').click(); + if (selector !== '[data-filter=lost]') await journalTest.settle(); + } + return transitions; + }""") + assert result == [{"queued": 1, "pending": 0, "detached": True, "writes": 0}] * 3 + page.wait_for_function( + """() => document.querySelector('#history .empty-state') + && document.querySelector('#history').getAttribute('aria-busy') === 'false'""", + polling=50, + ) + page.evaluate("document.querySelector('[data-filter=all]').click()") + page.wait_for_selector("#history .event-button") + assert 0 < page.locator("#history .event-button").count() < 35 + + +def test_journal_live_history_keeps_the_anchor_and_offscreen_focus(offline_page): + page = offline_page + records = journal_events(850) + first_arrival = datetime.fromisoformat(records[0]["received_at"]) + inserted = [ + { + **records[0], + "event_id": f"new-{index}", + "sequence": 851 + index, + "received_at": (first_arrival + timedelta(seconds=index + 1)).isoformat(), + } + for index in range(25) + ] + appended = [ + { + **records[-1], + "event_id": f"old-{index}", + "sequence": -index - 1, + "received_at": (first_arrival - timedelta(seconds=1000 + index)).isoformat(), + } + for index in range(12) + ] + revision = "initial" + + def history(route): + route.fulfill(json={"events": records, "next": None}) + + def status(route): + response = route.fetch() + payload = response.json() + payload["timeline_revision"] = revision + route.fulfill(response=response, json=payload) + + page.route("**/api/timeline?*", history) + page.route("**/api/status", status) + page.add_init_script("""(() => { + const nativeTimeout = window.setTimeout; + window.setTimeout = (callback, delay, ...args) => { + if (callback?.name === 'poll' && delay === 10000) { + window.runJournalPoll = callback; + return 0; + } + return nativeTimeout(callback, delay, ...args); + }; + })()""") + page.goto(page.url.split("/#", 1)[0], wait_until="networkidle") + assert page.evaluate("typeof window.runJournalPoll") == "function", page.locator( + "#notice" + ).text_content() + page.evaluate("window.enableFrames(); document.querySelector('[data-layout=vertical]').click()") + page.evaluate(JOURNAL_CONTROLS) + page.evaluate("""async () => { + await journalTest.scroll(0); + journalTest.buttons()[0].focus({preventScroll: true}); + await journalTest.scroll(journalTest.history.scrollHeight * 0.55); + }""") + assert page.evaluate("document.activeElement.dataset.event") == "journal-0" + anchor = page.evaluate("""() => { + const row = journalTest.visible()[0]; + return {identity: row.dataset.event, offset: row.getBoundingClientRect().top}; + }""") + + records[:0] = inserted + records.extend(appended) + revision = "updated" + result = page.evaluate("""async () => { + await runJournalPoll(); + await journalTest.settle(); + const row = journalTest.visible()[0]; + return {identity: row?.dataset.event, offset: row?.getBoundingClientRect().top, + focus: document.activeElement.dataset.event, extent: journalTest.history.scrollHeight, + count: journalTest.buttons().length}; + }""") + assert result["identity"] == anchor["identity"] + assert abs(result["offset"] - anchor["offset"]) <= 2 + assert result["focus"] == "journal-0" + assert result["count"] < 35 + page.evaluate("journalTest.scroll(journalTest.history.scrollHeight)") + assert page.evaluate("journalTest.visible().at(-1).dataset.event") == "old-11" From 18f0121270efa1db1e44a056e7fb0dec93b7f635 Mon Sep 17 00:00:00 2001 From: Aakash Date: Fri, 13 Nov 2026 13:22:34 +0530 Subject: [PATCH 8/9] Page saved history on demand and aggregate timeline totals --- README.md | 4 +- src/xflog/app.py | 91 +++-- src/xflog/assets/app.js | 287 ++++++++------- src/xflog/assets/audience-groups.js | 29 +- src/xflog/assets/constellation-colors.js | 9 +- src/xflog/assets/constellation-layout.js | 20 +- src/xflog/assets/constellation.js | 82 ++++- src/xflog/assets/format.js | 2 +- src/xflog/assets/journal-model.js | 93 +++-- src/xflog/assets/journal.js | 125 ++++++- src/xflog/assets/live-history.js | 252 +++++++++---- src/xflog/assets/shell.html | 4 + src/xflog/assets/timeline.css | 23 +- src/xflog/audience_groups/__init__.py | 24 +- src/xflog/history/demo.py | 94 ++++- src/xflog/history/store.py | 2 + src/xflog/history/timeline.py | 313 +++++++++------- src/xflog/history/timeline_aggregate.py | 133 +++++++ src/xflog/history/timeline_cursor.py | 88 +++++ src/xflog/history/timeline_queries.py | 195 ++++++++++ src/xflog/interactions/store.py | 2 + tests/constellation_selection_check.mjs | 31 +- tests/journal_check.mjs | 36 ++ tests/live_history_check.mjs | 415 +++++++++++----------- tests/test_browser_scene_lifecycle.py | 431 +++++++++++++++++++++-- tests/test_timeline.py | 6 +- tests/test_timeline_aggregate.py | 416 ++++++++++++++++++++++ 27 files changed, 2499 insertions(+), 708 deletions(-) create mode 100644 src/xflog/history/timeline_aggregate.py create mode 100644 src/xflog/history/timeline_cursor.py create mode 100644 src/xflog/history/timeline_queries.py create mode 100644 tests/test_timeline_aggregate.py 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..fc75141 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,28 @@ async def timeline_history( since: str = "", local_only: bool = False, limit: int = 25, + lens: str = "none", + after_revision: str = "", + selected: 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, } - 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"]: + if await profile_names.ensure(result["events"]): + 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 +262,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 9b1384e..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,7 +93,7 @@ label: eventLabel, initials, onOpen: (identity) => selectEvent(identity, true), - onNearEnd: requestOlder, + onNearEnd: () => requestOlder(false), }); const { createActivityHud } = await import("/static/xf/activity-hud.js"); const activityHud = createActivityHud({ @@ -140,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(), @@ -204,7 +245,10 @@ } } function view(name) { - if (name !== "timeline") journal.clear({remember: true}); + 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) => { @@ -227,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(); @@ -238,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); @@ -298,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(); @@ -313,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; @@ -323,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); @@ -342,14 +395,8 @@ 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"; @@ -365,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]") @@ -376,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); @@ -426,71 +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(); - journal.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(); - journal.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); @@ -498,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(); @@ -559,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) => @@ -582,7 +574,7 @@ $$("[data-layout]").forEach((item) => item.setAttribute("aria-pressed", item === button), ); - renderEvents(); + if (historyPhase !== "initial") renderEvents(); announce(`${state.layout} timeline selected.`); }), ); @@ -606,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, @@ -669,41 +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(); - journal.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 a83e8da..a2ff6dc 100644 --- a/src/xflog/assets/constellation-layout.js +++ b/src/xflog/assets/constellation-layout.js @@ -113,6 +113,7 @@ export function prepareConstellation({ height, modules, lens, + aggregate, }) { const people = entriesFromEvents(events); const upper = Math.min(90, height * 0.14); @@ -122,28 +123,24 @@ export function prepareConstellation({ 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 occupied = groups.filter(group => group.people.length); const positions = new Map(); occupied.forEach((group, groupIndex) => { group.people.forEach((person, member) => { positions.set(person, { group, member, groupIndex }); }); }); - const ordered = buckets ? [...occupied].reverse().flatMap(group => group.people) : people; + 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; @@ -162,7 +159,8 @@ export function prepareConstellation({ return {...person, group: group.key, tone: group.tone, color: group.color}; }), groups: occupied, - grouped: buckets !== null, + grouped: rawBuckets !== null, + unloadedGroups: rawBuckets && totals ? totals.length - rawBuckets.filter(bucket => bucket.eventCount !== undefined).length : 0, upper, }; const grouped = geometry.grouped; diff --git a/src/xflog/assets/constellation.js b/src/xflog/assets/constellation.js index efdeb10..bba2bde 100644 --- a/src/xflog/assets/constellation.js +++ b/src/xflog/assets/constellation.js @@ -13,6 +13,7 @@ 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, @@ -37,12 +38,14 @@ export function createConstellation({ selected = null, modules = [], lens = "none", + 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, @@ -74,6 +77,7 @@ export function createConstellation({ !document.hidden; const moving = () => !reduced.matches && root.dataset.motion !== "off"; const maximum = () => Math.max(0, element.scrollWidth - element.clientWidth); + const singletonCanLoad = () => Boolean(geometry?.people.length === 1 && hasOlder()); const position = () => selection?.person; const selectedProfileIndex = () => selection?.index ?? -1; function personLabel(person, event = person.event) { @@ -137,8 +141,10 @@ export function createConstellation({ 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"); @@ -161,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], ]) { @@ -243,10 +250,10 @@ export function createConstellation({ activeSparks = next; return true; } - function layoutLabels(left) { + function layoutLabels(left, focusedControl = document.activeElement) { const chosenPerson = position(); const chosenLabel = labels[selectedProfileIndex()]; - const focusedLabel = labelsByNode.get(document.activeElement?.closest("[data-label]")); + const focusedLabel = labelsByNode.get(focusedControl?.closest("[data-label]")); const relevant = new Set(viewportLabels); if (chosenLabel) relevant.add(chosenLabel); if (focusedLabel) relevant.add(focusedLabel); @@ -260,7 +267,7 @@ export function createConstellation({ // Read invalidated sizes together before positioning any label. for (const item of relevant) { const previewShown = item.preview && - (item === chosenLabel || item.preview === document.activeElement); + (item === chosenLabel || item.preview === focusedControl); if (item.width === undefined || item.previewShown !== previewShown) { item.previewShown = previewShown; const box = item.label.getBoundingClientRect(); @@ -455,6 +462,7 @@ export function createConstellation({ height: sceneHeight, modules, lens, + aggregate, }); geometry = layoutAtZoom(zoom); if (countDirty) { @@ -470,8 +478,9 @@ export function createConstellation({ key.hidden = !geometry.grouped; history.max = 1000; zoomControls(); - const countText = - `${personCount} ${personCount === 1 ? "person" : "people"} in ${geometry.grouped ? "saved" : "loaded"} history`; + 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 }, @@ -570,7 +579,7 @@ export function createConstellation({ } function prepareGroupButtons() { const renderedVersion = sceneVersion; - groupLayer.innerHTML = geometry.groups.map((group) => ``).join(""); + groupLayer.innerHTML = geometry.groups.map((group) => ``).join(""); groupButtons = [...groupLayer.querySelectorAll("[data-group]")]; groupButtons.forEach((button, index) => { button.addEventListener("click", () => { @@ -701,13 +710,16 @@ export function createConstellation({ 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; @@ -853,7 +865,7 @@ export function createConstellation({ 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 = [ @@ -893,19 +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 point = position(); - const offset = point ? point.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, @@ -913,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); @@ -920,6 +966,7 @@ export function createConstellation({ clear: () => { stop(); geometry = null; + aggregate = null; events = []; releaseScene(); personCount = 0; @@ -933,6 +980,7 @@ export function createConstellation({ destroy: () => { stop(); geometry = null; + aggregate = null; events = []; releaseScene(); observer.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 index 40bb063..6346492 100644 --- a/src/xflog/assets/journal-model.js +++ b/src/xflog/assets/journal-model.js @@ -1,8 +1,9 @@ import { groupEvents } from "./audience-groups.js"; function createHeightIndex(estimates) { - const heights = Float64Array.from(estimates); - const tree = new Float64Array(heights.length + 1); + 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]; @@ -10,7 +11,7 @@ function createHeightIndex(estimates) { if (parent < tree.length) tree[parent] += tree[index]; } let searchBit = 1; - while (searchBit * 2 <= heights.length) searchBit *= 2; + while (searchBit * 2 <= length) searchBit *= 2; function prefix(end) { let offset = 0; @@ -21,31 +22,48 @@ function createHeightIndex(estimates) { return { prefix, - get total() { return prefix(heights.length); }, + 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 < tree.length && tree[next] <= offset) { + if (next <= length && tree[next] <= offset) { index = next; offset -= tree[next]; } } - return Math.min(index, heights.length - 1); + 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 < tree.length; parent += parent & -parent) + for (let parent = index + 1; parent <= length; parent += parent & -parent) tree[parent] += difference; }, }; } -export function createJournalModel(events, {modules, lens, collapsed, day}) { - const groups = groupEvents(events, modules, lens); +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; @@ -58,7 +76,7 @@ export function createJournalModel(events, {modules, lens, collapsed, day}) { } } - function addEvents(records, group, visible) { + function eventAppender(group, visible) { const prefix = [group ? lens : "none", group?.key ?? null]; const occurrences = new Map(); function key(type, identity) { @@ -67,35 +85,50 @@ export function createJournalModel(events, {modules, lens, collapsed, day}) { occurrences.set(scopedIdentity, count + 1); return JSON.stringify([...prefix, type, identity, count]); } - let prior = ""; - records.forEach((event, index) => { - const text = day(event); - if (text !== prior) { - const first = 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, - }; - add(row, 102, visible); - if (!byEvent.has(event.event_id)) byEvent.set(event.event_id, row); - }); + 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 = !collapsed.has(`${lens}:${group.key}`); - add({type:"group", key:JSON.stringify([lens, group.key, "group"]), group}, 57); - addEvents(group.events, group, group.open); + 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 { - addEvents(events, null, true); + appendEvents = eventAppender(null, true); + appendEvents(events); } - return {groups, rows, byEvent, byKey, heights: createHeightIndex(estimates)}; + 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 b748c29..e1e9aae 100644 --- a/src/xflog/assets/journal.js +++ b/src/xflog/assets/journal.js @@ -16,6 +16,7 @@ export function createJournal({ 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); @@ -24,22 +25,38 @@ export function createJournal({ const event = row.event; const tone = eventTone(event); const lost = tone === "negative"; - return `
`; + 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 node = document.createElement("details"); + const summary = !group.events.length; + const node = document.createElement(summary ? "div" : "details"); node.className = `journal-group ${group.tone || ""}`; node.dataset.group = group.key; - node.open = group.open; - node.innerHTML = `${esc(group.label)} ${group.events.length} ${group.events.length === 1 ? "event" : "events"}
`; + 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 (row.type === "group") { + if (isHeader(row)) { node = groups.get(row.group).firstElementChild; } else if (row.type === "date") { node = document.createElement("div"); @@ -61,7 +78,7 @@ export function createJournal({ observer.unobserve(node); observed.delete(node); mounted.delete(row); - if (row.type !== "group") node.remove(); + if (!isHeader(row)) node.remove(); } function locate(key) { @@ -106,7 +123,7 @@ export function createJournal({ node.style.height = `${model.heights.prefix(group.end) - top - 20}px`; } for (const [row, node] of mounted) { - if (row.type === "group") continue; + if (isHeader(row)) continue; const base = row.group ? model.heights.prefix(row.group.start) + 1 : 0; setTop(node, model.heights.prefix(row.index) - base); } @@ -147,8 +164,8 @@ export function createJournal({ let groupNode; if (row.group) groupNode = groups.get(row.group) ?? mountGroup(row.group); const node = mounted.get(row) ?? mountRow(row); - const parent = row.group && row.type !== "group" ? groupNode.lastElementChild : track; - const child = row.type === "group" ? groupNode : node; + 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); } @@ -185,7 +202,7 @@ export function createJournal({ 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 : row.type === "group" ? 1 : 0); + 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); @@ -197,6 +214,7 @@ export function createJournal({ placeMounted(); if (keepRevealed) alignRow(revealRow); else restoreAnchor(previous); + renderedScrollTop = element.scrollTop; queue(); } } @@ -210,6 +228,7 @@ export function createJournal({ observed.clear(); track?.remove(); track = model = events = options = revealRow = null; + renderedScrollTop = null; measured.clear(); } @@ -227,12 +246,83 @@ export function createJournal({ 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 (row.type === "group") - return row.group.label === next.group.label && row.group.events.length === next.group.events.length; + 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; @@ -243,7 +333,7 @@ export function createJournal({ events = nextEvents; options = nextOptions; selected = options.selected; - model = createJournalModel(events, {...options, collapsed, day: event => date.format(displayDate(event))}); + model = buildModel(); track = document.createElement("div"); track.className = "journal-window"; element.replaceChildren(track); @@ -253,6 +343,7 @@ export function createJournal({ 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); @@ -284,7 +375,7 @@ export function createJournal({ if (!row) return; if (row.index < 0) { collapsed.delete(`${options.lens}:${row.group.key}`); - render(events, {...options, selected}); + render(events, {...options, selected, append:false}); row = model.byEvent.get(identity); } scrollToRow(row); @@ -295,7 +386,7 @@ export function createJournal({ if (open) collapsed.delete(key); else collapsed.add(key); const inGroup = model.byKey.get(anchor()?.key)?.group === row.group; - render(events, {...options, selected}); + render(events, {...options, selected, append:false}); if (!open && inGroup) scrollToRow(model.byKey.get(row.key)); } @@ -319,7 +410,7 @@ export function createJournal({ function control(row) { const node = mounted.get(row); - return row?.type === "event" ? node?.querySelector("button") : node; + return row?.type === "event" ? node?.querySelector("button") : row?.type === "group" ? node : null; } function keydown(event) { @@ -340,6 +431,8 @@ export function createJournal({ function scroll() { if (!track?.isConnected) return; queue(); + if (renderedScrollTop === element.scrollTop) return; + renderedScrollTop = null; if (element.scrollTop + element.clientHeight >= element.scrollHeight - 80) onNearEnd(); } diff --git a/src/xflog/assets/live-history.js b/src/xflog/assets/live-history.js index 8e6ba65..96acf42 100644 --- a/src/xflog/assets/live-history.js +++ b/src/xflog/assets/live-history.js @@ -10,18 +10,16 @@ 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); return `/api/${state.demo ? "demo" : "timeline"}?${params}`; } @@ -32,64 +30,196 @@ 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, 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 result = await page(ticket, {...state, since: accepted.since}, state.next, accepted.cursors); + 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 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; + 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; + incoming.push(...result.events); + if (result.selected_event) incoming.push(result.selected_event); + 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.some(event => event.event_id !== state.selected) && + 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(); + const events = syncProfiles( + inRange(mergeHistory([...incoming, ...current.events]), batch.since), + [...profileRows, ...incoming], + ); + 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 +
+ + +