Something went wrong. Try again.
vibescrobble! every agent, live on stage. vibescrobble.com
atproto agent llm did
Something went wrong. Try again.
13 kB · 312 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313/* The frame loop, and the sleep it takes when nothing moves. */
import { CALLOUT_TTL, HEAT_DECAY, MARK_TTL, NOTHING, PULSE_HOP, RECLUSTER, feed, living, pageClock, state, tally, tallyCanvas, workIndex } from "./state.js";import { visible } from "./util.js";import { apply, forget, unspool } from "./feed.js";import { mesh, noteGroups, step } from "./layout.js";import { draw, easing, fold, powQueue, powShown, recedeEasing, turnPows, unfold } from "./draw.js";import { blink, drawTally, renderClusters, setFocus, updateStats } from "./panel.js";import { cameraGoal, dragging, glideCamera, moveGround } from "./input.js";import { setWork } from "./controls.js";import { writeAddress } from "./address.js";import { IDLE_FPS, stirring } from "./ambient.js";
// --- the frame loop -------------------------------------------------------
let last = performance.now();let reclusterDue = 0;let reclustering = false;
/** * Takes the groups the index worked out and draws with them. * * Members that this page has not heard of are dropped rather than waited * for: the index announces a group the moment it recomputes one, and a tab * that opened a second ago may not have the agent yet. It will on the next * pass, and a group missing one dot for two seconds is a better answer than * no group at all. *//** * A work group named in the address bar that has not arrived yet. * * Cleared once it is applied, or once a set of groups arrives without it — * the group has gone, which a link to an ephemeral thing has to survive. */let wantedGroup = null;
/** Asks for the group `id` to be focused once it is here. */function wantGroup(id) { wantedGroup = id;}
/** Focuses the group the address bar asked for, if it is here. */function applyWantedGroup() { if (wantedGroup === null) return; const cluster = state.work.clusters.find((entry) => entry.id === wantedGroup); if (!cluster) { // Only give up once groups have actually arrived; before that there is // nothing to conclude from not finding it. if (state.work.clusters.length > 0) wantedGroup = null; return; } wantedGroup = null; setFocus({ kind: "work", id: cluster.id }); setWork(true);}
function adoptGroups(groups) { const clusters = []; const of = new Map(); for (const group of groups || []) { const members = (group.members || []).filter((did) => state.agents.has(did)); if (members.length < 2) continue; const cluster = { id: group.id, label: group.label, // Carried through, not rebuilt: the index is the one place that knows // which groups share a subject, and a group without its theme is kin // to nothing. theme: group.theme || "", members, size: members.length, }; cluster.edges = mesh(members); clusters.push(cluster); for (const did of members) of.set(did, cluster.id); } state.work = { clusters, of }; noteGroups(clusters); renderClusters(); applyWantedGroup();}
/** * Reclusters the population, out of the way of the frame. * * Only when this page is running on the mock. A live page is told what the * groups are: the index computes them once so that two people looking at the * same feed see the same groups under the same ids, and a tab that * recomputed its own would be back to inventing a private answer — which is * the whole thing that moving the algorithm behind the index was for. The * mock has no backend to ask, so it still clusters here. * * The pass is the single most expensive thing this page does — a few * milliseconds over several hundred agents — and running it inside a frame * costs that frame. `requestIdleCallback` hands it whatever slack the * browser has instead, with a deadline so it still happens on a busy page. */function scheduleRecluster() { if (living || !workIndex) return; if (reclustering || state.now < reclusterDue) return; reclustering = true; reclusterDue = state.now + RECLUSTER;
const run = () => { // Checked again here, not only at scheduling. The pass is handed to // `requestIdleCallback`, so it runs a moment later — and connecting to a // source happens in that moment. A pass queued while the mock was // running would otherwise land after the switch and overwrite the groups // the index just sent with a local answer computed over a population // that has since been cleared. if (living) { reclustering = false; return; } const grouped = workIndex.cluster(); // The index hands out string ids; the local pass hands out counters. // Made strings here so that nothing downstream — the hue, the address // bar, the focus comparison — has to know which one it is looking at. for (const cluster of grouped.clusters) cluster.id = String(cluster.id); grouped.of = new Map([...grouped.of].map(([did, id]) => [did, String(id)])); state.work = grouped; for (const cluster of state.work.clusters) cluster.edges = mesh(cluster.members); noteGroups(state.work.clusters); renderClusters(); reclustering = false; };
if (typeof requestIdleCallback === "function") { requestIdleCallback(run, { timeout: RECLUSTER * 1000 }); } else { setTimeout(run, 0); }}
/** The last time a stuck frame was reported, so a repeat does not flood. */let frameErrorAt = -Infinity;
function frame(stamp) { // One frame's worth of work, guarded: a single malformed event or an // edge case in the physics must not stop `requestAnimationFrame` from // being asked for again, or the canvas and the panel both freeze right // where they were and never say why. scheduled = false; try { const dt = Math.min(0.05, (stamp - last) / 1000); last = stamp; state.now = pageClock();
if (!state.paused) { for (const event of feed.tick(dt, state.rate)) apply(event, !event.backlog); } // Whatever that batch held back, or a seed applied before the first // frame. Outside the pause, because a page opened stopped has a backlog // to show and nothing else is ever going to come and flush it. unspool();
for (const agent of state.agents.values()) { if (agent.heat > 0) agent.heat = Math.max(0, agent.heat - dt / HEAT_DECAY); if (agent.dying && state.now - agent.dying > 1.2) { if (agent.tucked) { forget(agent); continue; } // A session that ends leaves a cross where it stood, for a few // seconds: agents leave, and a plate where they only ever vanish // cannot show that a whole tree just finished. state.marks.push({ x: agent.rx, y: agent.ry, hue: agent.operator.hue, born: state.now }); if (state.marks.length > 240) state.marks.shift(); forget(agent); } } // What an agent that has ended said goes with it, rather than pointing // a leader at the cross it left. const here = (agent) => state.agents.get(agent.did) === agent; state.floats = state.floats.filter((float) => state.now - float.born < CALLOUT_TTL && here(float.agent)); state.pulses = state.pulses.filter((pulse) => state.now - pulse.born < PULSE_HOP * 12 && here(pulse.agent)); state.marks = state.marks.filter((mark) => state.now - mark.born < MARK_TTL);
scheduleRecluster();
if (state.now >= addressDue) { addressDue = state.now + 0.5; writeAddress(); }
fold(); unfold(false); // Every frame, whether or not agents are drawn: zoomed out past // `SWARM_ZOOM` they are not, and a POW left waiting there kept the // loop awake for good. turnPows(); // The layout runs only for a while after something changes it, and is // still otherwise: a settled network that kept integrating jittered in // place forever, below a pixel and visibly all the same. if (state.warm > 0) { step(dt); state.warm -= dt; } glideCamera(); moveGround(); draw(state.now); drawTally(dt); updateStats(); blink(); } catch (error) { if (state.now - frameErrorAt > 5) { frameErrorAt = state.now; console.error("frame failed; carrying on", error); } } frames += 1; if (moving()) { scheduled = true; requestAnimationFrame(frame); } else if (stirring()) { // Only the idle motion moves: a frame at its own slower rate, asked of // the browser so a hidden tab is drawn none. scheduled = true; setTimeout(() => requestAnimationFrame(frame), 1000 / IDLE_FPS); }}
// --- sleeping -------------------------------------------------------------//// A canvas that is not moving is not drawn. The loop runs while anything on// it is — a layout settling, a callout typing, the camera gliding, a level// unfolding — and for a moment after anything wakes it, and then it stops.// A heartbeat draws one frame a second while it sleeps, for what changes// with the clock alone: an agent cooling down the recency ladder, the// clock, the ages in the stream. While the board's idle motion has// anything to move (ambient.js), it draws `IDLE_FPS` frames a second// instead.
/** Seconds a wake keeps the loop running with nothing else moving. */const AFTERGLOW = 2;/** How often a sleeping canvas is drawn anyway, in ms. */const HEARTBEAT = 1000;
/** Whether a frame is already asked for. */let scheduled = false;/** The page clock until which the loop runs whatever else is true. */let busyUntil = 0;/** Frames drawn, for `window.appview` and anyone measuring the cost. */let frames = 0;
/** Asks for frames for at least `seconds`, starting the loop if it slept. */function wake(seconds = AFTERGLOW) { busyUntil = Math.max(busyUntil, pageClock() + seconds); if (scheduled) return; scheduled = true; requestAnimationFrame(frame);}
// A vendor's mark arrives after the first frame that asked for it.Look.onLogo(() => wake(0.1));
/** Whether the next frame would differ from this one for any reason but the clock. */function moving() { if (state.now < busyUntil) return true; // An invented or replayed population happens only as it is ticked. if (!state.paused && feed !== NOTHING && !(feed instanceof Live.LiveFeed)) return true; if (state.warm > 0 || cameraGoal || dragging || easing || recedeEasing) return true; // The emoji tally is dirty while a bar eases or flashes, and clean once // still. Off the screen it is not stepped, so it would stay dirty forever. if (tally.dirty && visible(tallyCanvas)) return true; if (state.floats.length > 0 || state.pulses.length > 0 || state.marks.length > 0) return true; if (powShown.size > 0 || powQueue.length > 0) return true; for (const agent of state.agents.values()) { if (agent.heat > 0 || agent.dying || state.now - agent.born < 1) return true; } return false;}
setInterval(() => { if (scheduled) return; scheduled = true; requestAnimationFrame(frame);}, HEARTBEAT);
// Anything a person does may change what is drawn.for (const kind of ["pointermove", "pointerdown", "pointerup", "wheel", "keydown", "input", "change", "click", "resize"]) { window.addEventListener(kind, () => wake(), { capture: true, passive: true });}document.addEventListener("visibilitychange", () => wake());if (document.fonts) document.fonts.ready.then(() => wake());
// --- the address bar ------------------------------------------------------
/** * What of the view survives being copied out of the address bar. * * Camera, controls and focus, including a focused work group — which it did * not used to. Group ids were assigned in the order leaders appeared in one * tab, so a link to one opened on somebody else's group. The index hands out * the ids now, derived from the DID of the agent a group was opened on, so * two people asking get the same answer and a link means what it says. * * Two caveats, and the link is written anyway because a group that has gone * is handled the same way a deleted agent already is — the focus clears and * the page is otherwise as the link asked for. A group's id does not survive * the index restarting, because the population is re-observed from scratch * and the groups are genuinely different; and a page running on the mock * invents its own ids, so a link from one only means something in another * mock with the same seed. * * An operator or an agent is a DID: the same identifier for everyone who has * one, which is the whole argument for building on this protocol. */let addressDue = 0;
export { adoptGroups, applyWantedGroup, frames, wake, wantGroup };