app
Circuit Trace Generators
Public Made by Adomby adom
Two dependency-free, seeded circuit-background generators: a seamless torus tile with ambient or progressive reveal, and the v9 continuous field with obstacles, scroll reveal, and five ambient systems.
master
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042
const SVG_NS = "http://www.w3.org/2000/svg";
let instanceCount = 0;
export const STREAM_REVEAL_DEFAULTS = Object.freeze({
spawnMode: "flow",
spawnAngle: 90,
spawnSwirl: 0.075,
spawnOriginX: 0.5,
unlockLead: 0.2,
traceDuration: 950,
revealLaunchRate: 24,
scrollSpeedResponse: 0.72,
maxActiveTraces: 72,
maxLaunchesPerFrame: 12,
maxOldSettlesPerFrame: 48,
revealGlow: 0.78,
revealPower: 0.72,
autoStart: true,
});
function clamp(value, minimum = 0, maximum = 1) {
return Math.max(minimum, Math.min(maximum, value));
}
function smoothstep(value) {
const amount = clamp(value);
return amount * amount * (3 - 2 * amount);
}
function finiteNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function hashSeed(value) {
let hash = 2166136261;
for (const character of String(value)) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hashUnit(seed, index, salt = 0) {
let value = seed ^ Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 1, 0x85ebca6b);
value = Math.imul(value ^ (value >>> 16), 0x7feb352d);
value = Math.imul(value ^ (value >>> 15), 0x846ca68b);
return ((value ^ (value >>> 16)) >>> 0) / 4294967296;
}
function svgElement(name, attributes = {}) {
const element = document.createElementNS(SVG_NS, name);
for (const [key, value] of Object.entries(attributes)) {
element.setAttribute(key, String(value));
}
return element;
}
function pathData(points) {
return points.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" ");
}
function polylineLength(points) {
let length = 0;
for (let index = 1; index < points.length; index += 1) {
length += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
);
}
return Math.max(1, length);
}
function brandColor(x, width) {
const position = clamp(x / Math.max(1, width));
if (position < 0.5) return `hsl(${176 + position * 58} 92% 63%)`;
return `hsl(${205 + (position - 0.5) * 86} 91% 70%)`;
}
function normalizeVelocity(value, viewportHeight) {
const velocity = Math.max(0, finiteNumber(value, 0));
const pixelsPerSecond = velocity > 20 ? velocity : velocity * 1000;
return pixelsPerSecond / Math.max(1, viewportHeight);
}
/**
* Reveals an append-only stream of absolute world-space traces.
*
* Viewport movement only unlocks work. Once launched, every trace follows its
* own fixed timeline and never scrubs backward with scroll position.
*/
export function createStreamingTraceReveal(svg, config = {}, options = {}) {
if (!svg || svg.namespaceURI !== SVG_NS) {
throw new TypeError("svg must be an SVG element");
}
let settings = {
...STREAM_REVEAL_DEFAULTS,
...config,
...options,
};
const instanceId = `circuit-stream-reveal-${++instanceCount}`;
const seed = hashSeed(`${config.seed ?? "adom"}:${settings.seed ?? ""}:stream`);
const viewBox = svg.viewBox?.baseVal;
const fieldWidth = Math.max(
1,
finiteNumber(settings.width, viewBox?.width || svg.clientWidth || 1),
);
const featureScale = Math.max(0.25, finiteNumber(settings.featureScale, 1));
const layer = svgElement("g", {
"data-animation": "stream-trace-reveal",
"data-spawn-mode": settings.spawnMode,
"data-export-ready": "true",
"aria-hidden": "true",
});
const pathLayer = svgElement("g", {
"data-stream-trace-paths": "",
fill: "none",
stroke: traceColor(),
"stroke-width": traceWidth(),
"stroke-linecap": "butt",
"stroke-linejoin": "miter",
});
const viaLayer = svgElement("g", {
"data-stream-trace-vias": "",
fill: holeColor(),
stroke: traceColor(),
"stroke-width": Math.max(0.5, viaOuterRadius() - viaInnerRadius()),
});
layer.append(pathLayer, viaLayer);
svg.append(layer);
const entries = [];
const entriesById = new Map();
const active = new Set();
const stateCounts = {
hidden: 0,
queued: 0,
active: 0,
complete: 0,
};
let hiddenFrontier = [];
let hiddenHead = 0;
let queue = [];
let queueHead = 0;
let appendIndex = 0;
let running = false;
let destroyed = false;
let frameRequest = 0;
let previousFrameTime = 0;
let stoppedAt = 0;
let launchCredit = 1;
let viewport = {
topWorldY: 0,
bottomWorldY: 0,
height: 1,
velocity: 0,
};
let hasViewport = false;
let furthestBottomWorldY = Number.NEGATIVE_INFINITY;
let generatedFrontier = Number.NEGATIVE_INFINITY;
let revealedThroughWorldY = Number.NEGATIVE_INFINITY;
let completedTotal = 0;
let exportReady = true;
const reducedMotion = typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: { matches: false };
function traceWidth() {
return Math.max(0.1, finiteNumber(settings.traceWidth, 1)) * featureScale;
}
function viaOuterRadius() {
const baseTraceWidth = Math.max(0.1, finiteNumber(settings.traceWidth, 1));
return Math.max(
baseTraceWidth,
finiteNumber(settings.viaOuterRadius, baseTraceWidth * 2.2),
) * featureScale;
}
function viaInnerRadius() {
const baseOuterRadius = viaOuterRadius() / featureScale;
return clamp(
finiteNumber(settings.viaInnerRadius, baseOuterRadius * 0.48) * featureScale,
0,
viaOuterRadius() * 0.92,
);
}
function traceColor() {
return settings.traceColor || "currentColor";
}
function holeColor() {
return settings.holeColor || "var(--circuit-hole, #0d1117)";
}
function setRenderAttribute(entry, element, name, value, cacheKey) {
const textValue = String(value);
if (entry[cacheKey] === textValue) return;
element.setAttribute(name, textValue);
entry[cacheKey] = textValue;
}
function applyActiveStyles(entry, glow) {
if (!entry.hasActiveStyles) {
entry.path.setAttribute("stroke", entry.activeColor);
entry.startVia.setAttribute("fill", entry.hiddenHoleColor);
entry.endVia.setAttribute("fill", entry.hiddenHoleColor);
entry.startVia.setAttribute("stroke", entry.activeColor);
entry.endVia.setAttribute("stroke", entry.activeColor);
entry.hasActiveStyles = true;
}
if (entry.activeGlow === glow) return;
if (glow > 0.01) {
entry.path.style.filter = `drop-shadow(0 0 ${1.5 + glow * 4 * featureScale}px ${entry.activeColor})`;
entry.hasGlowFilter = true;
} else if (entry.hasGlowFilter) {
entry.path.style.removeProperty("filter");
entry.hasGlowFilter = false;
}
entry.activeGlow = glow;
}
function clearActiveStyles(entry) {
if (entry.hasActiveStyles) {
entry.path.removeAttribute("stroke");
entry.startVia.removeAttribute("fill");
entry.endVia.removeAttribute("fill");
entry.startVia.removeAttribute("stroke");
entry.endVia.removeAttribute("stroke");
entry.hasActiveStyles = false;
}
if (entry.hasGlowFilter) {
entry.path.style.removeProperty("filter");
entry.hasGlowFilter = false;
}
entry.activeGlow = Number.NaN;
}
function spawnMetadata(entry) {
const points = entry.originalPoints;
const middle = points[Math.floor(points.length / 2)];
const localSpan = Math.max(1, entry.maxY - entry.minY, fieldWidth * 0.08);
const originX = clamp(finiteNumber(settings.spawnOriginX, 0.5)) * fieldWidth;
const angle = finiteNumber(settings.spawnAngle, 90) * Math.PI / 180;
const direction = [Math.cos(angle), Math.sin(angle)];
const across = [-direction[1], direction[0]];
const swirl = clamp(finiteNumber(settings.spawnSwirl, 0.075), 0, 0.5);
const phase = hashUnit(seed, entry.hashIndex, 11) * Math.PI * 2;
const normalizedPoint = (point) => [
(point[0] - fieldWidth * 0.5) / fieldWidth,
(point[1] - entry.minY) / localSpan - 0.5,
];
const potential = (point) => {
const normalized = normalizedPoint(point);
if (settings.spawnMode === "radial") {
return Math.hypot(
(point[0] - originX) / fieldWidth,
normalized[1] * 0.55,
);
}
if (settings.spawnMode === "edges") {
return Math.min(
point[0] / fieldWidth,
(fieldWidth - point[0]) / fieldWidth,
Math.abs(normalized[1] + 0.5),
Math.abs(0.5 - normalized[1]),
);
}
if (settings.spawnMode === "scatter") {
return hashUnit(seed, entry.hashIndex, 19);
}
if (settings.spawnMode === "index") return entry.hashIndex;
return normalized[0] * direction[0] + normalized[1] * direction[1];
};
const orientByPotential = !["scatter", "index"].includes(settings.spawnMode);
const reverse = orientByPotential
&& potential(points[0]) > potential(points.at(-1));
const orientedPoints = reverse ? [...points].reverse() : points;
const normalizedMiddle = normalizedPoint(middle);
let offsetUnit;
if (settings.spawnMode === "radial") {
offsetUnit = Math.hypot((middle[0] - originX) / fieldWidth, normalizedMiddle[1] * 0.4) - 0.28;
} else if (settings.spawnMode === "edges") {
offsetUnit = Math.min(middle[0], fieldWidth - middle[0]) / fieldWidth - 0.2;
} else if (settings.spawnMode === "scatter") {
offsetUnit = hashUnit(seed, entry.hashIndex, 23) * 2 - 1;
} else if (settings.spawnMode === "index") {
offsetUnit = (hashUnit(seed, entry.hashIndex, 29) - 0.5) * 0.6;
} else {
const projection = normalizedMiddle[0] * direction[0] + normalizedMiddle[1] * direction[1];
const cross = normalizedMiddle[0] * across[0] + normalizedMiddle[1] * across[1];
offsetUnit = cross * 0.7
+ Math.sin(projection * Math.PI * 2.2 + phase) * swirl
+ Math.sin(cross * Math.PI * 3.1 - phase) * swirl * 0.38;
}
return {
points: orientedPoints,
offsetUnit: clamp(offsetUnit, -1, 1),
orderJitter: hashUnit(seed, entry.hashIndex, 31),
};
}
function applyGeometry(entry) {
const metadata = spawnMetadata(entry);
entry.points = metadata.points;
entry.offsetUnit = metadata.offsetUnit;
entry.orderJitter = metadata.orderJitter;
entry.activeColor = brandColor(
entry.points[Math.floor(entry.points.length / 2)][0],
fieldWidth,
);
entry.path.setAttribute("d", pathData(entry.points));
entry.startVia.setAttribute(
"transform",
`translate(${entry.points[0][0]} ${entry.points[0][1]})`,
);
entry.endVia.setAttribute(
"transform",
`translate(${entry.points.at(-1)[0]} ${entry.points.at(-1)[1]})`,
);
}
function transitionState(entry, nextState) {
const previousState = entry.state;
if (previousState === nextState) return;
if (previousState && Object.hasOwn(stateCounts, previousState)) {
stateCounts[previousState] -= 1;
}
if (nextState && Object.hasOwn(stateCounts, nextState)) {
stateCounts[nextState] += 1;
}
entry.state = nextState;
}
function setHidden(entry) {
active.delete(entry);
entry.hiddenHoleColor = holeColor();
setRenderAttribute(entry, entry.path, "stroke-dashoffset", entry.length, "pathOffset");
setRenderAttribute(entry, entry.path, "opacity", 0, "pathOpacity");
clearActiveStyles(entry);
setRenderAttribute(entry, entry.startVia, "opacity", 0, "startViaOpacity");
setRenderAttribute(entry, entry.endVia, "opacity", 0, "endViaOpacity");
transitionState(entry, "hidden");
entry.progress = 0;
entry.startedAt = 0;
entry.duration = 0;
}
function setPartial(entry, amount) {
const progress = smoothstep(amount);
const power = clamp(finiteNumber(settings.revealPower, 0.72), 0, 1.5);
const glow = clamp(finiteNumber(settings.revealGlow, 0.78), 0, 1.5);
applyActiveStyles(entry, glow);
setRenderAttribute(
entry,
entry.path,
"stroke-dashoffset",
entry.length * (1 - progress),
"pathOffset",
);
setRenderAttribute(
entry,
entry.path,
"opacity",
clamp(0.38 + progress * 0.62),
"pathOpacity",
);
setRenderAttribute(
entry,
entry.startVia,
"opacity",
smoothstep((progress + 0.03) / 0.1),
"startViaOpacity",
);
setRenderAttribute(
entry,
entry.endVia,
"opacity",
smoothstep((progress - 0.82 + power * 0.025) / 0.16),
"endViaOpacity",
);
if (entry.state !== "active") transitionState(entry, "active");
entry.progress = progress;
}
function setComplete(entry) {
if (entry.state === "complete") return;
active.delete(entry);
setRenderAttribute(entry, entry.path, "stroke-dashoffset", 0, "pathOffset");
setRenderAttribute(entry, entry.path, "opacity", 1, "pathOpacity");
clearActiveStyles(entry);
setRenderAttribute(entry, entry.startVia, "opacity", 1, "startViaOpacity");
setRenderAttribute(entry, entry.endVia, "opacity", 1, "endViaOpacity");
transitionState(entry, "complete");
entry.progress = 1;
completedTotal += 1;
revealedThroughWorldY = Math.max(revealedThroughWorldY, entry.maxY);
}
function unlockY(entry) {
const localOffsetBound = viewport.height * 0.32;
return entry.minY + entry.offsetUnit * localOffsetBound;
}
function updateUnlockKey(entry) {
entry.unlockWorldY = unlockY(entry);
return entry.unlockWorldY;
}
function compareRevealOrder(first, second) {
return first.unlockWorldY - second.unlockWorldY
|| first.orderJitter - second.orderJitter
|| first.hashIndex - second.hashIndex;
}
function compactHiddenFrontier() {
hiddenFrontier = hiddenFrontier
.slice(hiddenHead)
.filter((entry) => entry.state === "hidden");
hiddenHead = 0;
}
function addToHiddenFrontier(newEntries) {
if (!newEntries.length) return;
for (const entry of newEntries) updateUnlockKey(entry);
newEntries.sort(compareRevealOrder);
if (hiddenHead >= hiddenFrontier.length) {
hiddenFrontier = newEntries;
hiddenHead = 0;
return;
}
const lastPending = hiddenFrontier.at(-1);
if (compareRevealOrder(lastPending, newEntries[0]) <= 0) {
hiddenFrontier.push(...newEntries);
return;
}
compactHiddenFrontier();
const merged = [];
let currentIndex = 0;
let newIndex = 0;
while (currentIndex < hiddenFrontier.length && newIndex < newEntries.length) {
if (compareRevealOrder(hiddenFrontier[currentIndex], newEntries[newIndex]) <= 0) {
merged.push(hiddenFrontier[currentIndex]);
currentIndex += 1;
} else {
merged.push(newEntries[newIndex]);
newIndex += 1;
}
}
merged.push(
...hiddenFrontier.slice(currentIndex),
...newEntries.slice(newIndex),
);
hiddenFrontier = merged;
}
function rebuildEligibilityIndex() {
hiddenFrontier = [];
hiddenHead = 0;
for (const entry of entries) {
if (entry.state === "hidden" || entry.state === "queued") {
updateUnlockKey(entry);
}
if (entry.state === "hidden") hiddenFrontier.push(entry);
}
hiddenFrontier.sort(compareRevealOrder);
if (queueHead < queue.length) sortQueue();
}
function compactQueue() {
queue = queue
.slice(queueHead)
.filter((entry) => entry.state === "queued");
queueHead = 0;
}
function sortQueue() {
compactQueue();
queue.sort(compareRevealOrder);
}
function addToQueue(newEntries) {
if (!newEntries.length) return;
if (queueHead >= queue.length) {
queue = newEntries;
queueHead = 0;
return;
}
const lastPending = queue.at(-1);
if (compareRevealOrder(lastPending, newEntries[0]) <= 0) {
queue.push(...newEntries);
return;
}
compactQueue();
const merged = [];
let currentIndex = 0;
let newIndex = 0;
while (currentIndex < queue.length && newIndex < newEntries.length) {
if (compareRevealOrder(queue[currentIndex], newEntries[newIndex]) <= 0) {
merged.push(queue[currentIndex]);
currentIndex += 1;
} else {
merged.push(newEntries[newIndex]);
newIndex += 1;
}
}
merged.push(...queue.slice(currentIndex), ...newEntries.slice(newIndex));
queue = merged;
}
function evaluateEligibility() {
if (!hasViewport || destroyed) return;
const lead = clamp(finiteNumber(settings.unlockLead, 0.2), 0, 3) * viewport.height;
const eligibleThrough = furthestBottomWorldY + lead;
const newlyQueued = [];
while (hiddenHead < hiddenFrontier.length) {
const entry = hiddenFrontier[hiddenHead];
if (entry.unlockWorldY > eligibleThrough) break;
hiddenHead += 1;
if (entry.state !== "hidden") continue;
transitionState(entry, "queued");
newlyQueued.push(entry);
}
addToQueue(newlyQueued);
if (hiddenHead > 2048 && hiddenHead > hiddenFrontier.length / 2) {
compactHiddenFrontier();
}
if (settings.revealMotion === false || reducedMotion.matches) {
for (let index = queueHead; index < queue.length; index += 1) {
setComplete(queue[index]);
}
compactQueue();
}
updateExportReadiness();
}
function baseTraceDuration() {
const explicit = finiteNumber(settings.traceDuration, Number.NaN);
const fallback = finiteNumber(settings.revealDuration, STREAM_REVEAL_DEFAULTS.traceDuration);
return clamp(Number.isFinite(explicit) ? explicit : fallback, 120, 12000);
}
function launch(entry, now) {
const viewportsPerSecond = normalizeVelocity(viewport.velocity, viewport.height);
const response = clamp(finiteNumber(settings.scrollSpeedResponse, 0.72), 0, 2);
const speedAmount = clamp(viewportsPerSecond / 2.2);
const speedDurationFactor = 1 - clamp(speedAmount * response * 0.2, 0, 0.32);
const lengthReference = Math.max(80, viewport.height * 0.55);
const lengthFactor = clamp(0.78 + entry.length / lengthReference * 0.22, 0.78, 1.35);
const jitter = 0.88 + hashUnit(seed, entry.hashIndex, 41) * 0.24;
entry.startedAt = now;
entry.duration = clamp(
baseTraceDuration() * lengthFactor * jitter * speedDurationFactor,
120,
12000,
);
setPartial(entry, 0);
active.add(entry);
}
function settleOldQueue() {
const pendingCount = queue.length - queueHead;
if (!pendingCount) return;
const maximumActive = Math.round(clamp(
finiteNumber(settings.maxActiveTraces, 72),
1,
320,
));
const backlogLimit = maximumActive * 3;
const settleLimit = Math.round(clamp(
finiteNumber(settings.maxOldSettlesPerFrame, 48),
1,
240,
));
const oldThreshold = viewport.topWorldY - viewport.height * 0.2;
let settled = 0;
for (let index = queueHead; index < queue.length; index += 1) {
if (pendingCount - settled <= backlogLimit || settled >= settleLimit) break;
const entry = queue[index];
if (entry.state !== "queued" || entry.maxY >= oldThreshold) continue;
setComplete(entry);
settled += 1;
}
if (settled) compactQueue();
}
function launchQueued(now, elapsed) {
settleOldQueue();
const maximumActive = Math.round(clamp(
finiteNumber(settings.maxActiveTraces, 72),
1,
320,
));
const viewportsPerSecond = normalizeVelocity(viewport.velocity, viewport.height);
const response = clamp(finiteNumber(settings.scrollSpeedResponse, 0.72), 0, 2);
const speedAmount = clamp(viewportsPerSecond / 2);
const baseRate = clamp(finiteNumber(settings.revealLaunchRate, 24), 0.5, 240);
const launchRate = Math.min(360, baseRate * (1 + speedAmount * response * 2.2));
const maximumLaunches = Math.round(clamp(
finiteNumber(settings.maxLaunchesPerFrame, 12),
1,
48,
));
launchCredit = Math.min(maximumLaunches, launchCredit + elapsed * launchRate / 1000);
let launched = 0;
while (
launchCredit >= 1
&& launched < maximumLaunches
&& active.size < maximumActive
&& queueHead < queue.length
) {
const entry = queue[queueHead];
queueHead += 1;
if (entry.state !== "queued") continue;
launch(entry, now);
launchCredit -= 1;
launched += 1;
}
if (queueHead >= queue.length) {
queue.length = 0;
queueHead = 0;
launchCredit = Math.min(1, launchCredit);
} else if (queueHead > 1024 && queueHead > queue.length / 2) {
compactQueue();
}
}
function updateActive(now) {
for (const entry of active) {
const amount = clamp((now - entry.startedAt) / Math.max(1, entry.duration));
if (amount >= 1) setComplete(entry);
else setPartial(entry, amount);
}
}
function updateExportReadiness() {
const ready = active.size === 0 && queueHead >= queue.length;
if (ready === exportReady) return;
exportReady = ready;
layer.setAttribute("data-export-ready", String(ready));
}
function renderFrame(now) {
frameRequest = 0;
if (!running || destroyed) return;
const elapsed = previousFrameTime ? clamp(now - previousFrameTime, 0, 80) : 16;
previousFrameTime = now;
updateActive(now);
launchQueued(now, elapsed);
viewport.velocity *= Math.pow(0.9, elapsed / 16.67);
updateExportReadiness();
if (active.size > 0 || queueHead < queue.length) scheduleFrame();
else previousFrameTime = 0;
}
function scheduleFrame() {
if (!running || destroyed || frameRequest) return;
frameRequest = window.requestAnimationFrame(renderFrame);
}
function appendTraces(traces) {
if (destroyed) return api;
if (!Array.isArray(traces)) throw new TypeError("traces must be an array");
const pathFragment = document.createDocumentFragment();
const viaFragment = document.createDocumentFragment();
const newEntries = [];
const viaRadius = (viaOuterRadius() + viaInnerRadius()) * 0.5;
for (const trace of traces) {
if (!trace || !Array.isArray(trace.points) || trace.points.length < 2) {
throw new TypeError("each trace must contain at least two finite points");
}
const id = String(trace.id ?? appendIndex);
if (entriesById.has(id)) throw new Error(`duplicate streaming trace id: ${id}`);
let originalPoints = trace.points;
let normalizedPoints = null;
let derivedMinY = Number.POSITIVE_INFINITY;
let derivedMaxY = Number.NEGATIVE_INFINITY;
for (let pointIndex = 0; pointIndex < trace.points.length; pointIndex += 1) {
const point = trace.points[pointIndex];
if (!Array.isArray(point) || point.length < 2) {
throw new TypeError("each trace must contain at least two finite points");
}
const x = Number(point[0]);
const y = Number(point[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new TypeError("each trace must contain at least two finite points");
}
const reusable = point.length === 2
&& typeof point[0] === "number"
&& typeof point[1] === "number";
if (!reusable && !normalizedPoints) {
normalizedPoints = trace.points.slice(0, pointIndex)
.map((previous) => [Number(previous[0]), Number(previous[1])]);
}
normalizedPoints?.push([x, y]);
derivedMinY = Math.min(derivedMinY, y);
derivedMaxY = Math.max(derivedMaxY, y);
}
if (normalizedPoints) originalPoints = normalizedPoints;
const minY = finiteNumber(trace.minY, derivedMinY);
const maxY = finiteNumber(trace.maxY, derivedMaxY);
const suppliedLength = Number(trace.length);
const length = Math.max(
1,
Number.isFinite(suppliedLength) ? suppliedLength : polylineLength(originalPoints),
);
const path = svgElement("path", {
"stroke-dasharray": `${length} ${length + 1}`,
"data-stream-trace": id,
});
const viaAttributes = {
cx: 0,
cy: 0,
r: viaRadius,
};
const startVia = svgElement("circle", {
...viaAttributes,
"data-stream-via": "start",
"data-trace-id": id,
});
const endVia = svgElement("circle", {
...viaAttributes,
"data-stream-via": "end",
"data-trace-id": id,
});
const entry = {
id,
hashIndex: appendIndex,
trace,
originalPoints,
points: originalPoints,
length,
minY: Math.min(minY, maxY),
maxY: Math.max(minY, maxY),
path,
startVia,
endVia,
offsetUnit: 0,
orderJitter: 0,
state: null,
progress: 0,
startedAt: 0,
duration: 0,
};
appendIndex += 1;
applyGeometry(entry);
setHidden(entry);
entries.push(entry);
newEntries.push(entry);
entriesById.set(id, entry);
pathFragment.append(path);
viaFragment.append(startVia, endVia);
generatedFrontier = Math.max(generatedFrontier, entry.maxY);
}
pathLayer.append(pathFragment);
viaLayer.append(viaFragment);
addToHiddenFrontier(newEntries);
evaluateEligibility();
scheduleFrame();
return api;
}
function pruneBefore(worldY) {
if (destroyed) return 0;
const threshold = finiteNumber(worldY, Number.NEGATIVE_INFINITY);
let removed = 0;
let retained = 0;
for (const entry of entries) {
if (entry.state === "complete" && entry.maxY < threshold && !active.has(entry)) {
entry.path.remove();
entry.startVia.remove();
entry.endVia.remove();
entriesById.delete(entry.id);
transitionState(entry, null);
removed += 1;
} else {
entries[retained] = entry;
retained += 1;
}
}
entries.length = retained;
updateExportReadiness();
return removed;
}
function setViewport(nextViewport = {}) {
if (destroyed) return api;
const height = Math.max(1, finiteNumber(
nextViewport.height,
finiteNumber(nextViewport.bottomWorldY, viewport.bottomWorldY)
- finiteNumber(nextViewport.topWorldY, viewport.topWorldY),
));
const heightChanged = height !== viewport.height;
const topWorldY = finiteNumber(nextViewport.topWorldY, viewport.topWorldY);
const bottomWorldY = finiteNumber(nextViewport.bottomWorldY, topWorldY + height);
viewport = {
topWorldY,
bottomWorldY: Math.max(topWorldY, bottomWorldY),
height,
velocity: Math.max(0, finiteNumber(nextViewport.velocity, 0)),
};
if (heightChanged) rebuildEligibilityIndex();
hasViewport = true;
furthestBottomWorldY = Math.max(furthestBottomWorldY, viewport.bottomWorldY);
evaluateEligibility();
scheduleFrame();
return api;
}
function updateOptions(next = {}) {
if (destroyed) return api;
const previousSpawnMode = settings.spawnMode;
const previousSpawnAngle = settings.spawnAngle;
const previousSpawnSwirl = settings.spawnSwirl;
const previousSpawnOriginX = settings.spawnOriginX;
const previousTraceWidth = traceWidth();
const previousViaRadius = (viaOuterRadius() + viaInnerRadius()) * 0.5;
const previousViaStrokeWidth = Math.max(0.5, viaOuterRadius() - viaInnerRadius());
const previousTraceColor = traceColor();
const previousHoleColor = holeColor();
settings = { ...settings, ...next };
const nextTraceWidth = traceWidth();
const nextViaRadius = (viaOuterRadius() + viaInnerRadius()) * 0.5;
const nextViaStrokeWidth = Math.max(0.5, viaOuterRadius() - viaInnerRadius());
const nextTraceColor = traceColor();
const nextHoleColor = holeColor();
const spawnChanged = previousSpawnMode !== settings.spawnMode
|| previousSpawnAngle !== settings.spawnAngle
|| previousSpawnSwirl !== settings.spawnSwirl
|| previousSpawnOriginX !== settings.spawnOriginX;
if (previousSpawnMode !== settings.spawnMode) {
layer.setAttribute("data-spawn-mode", settings.spawnMode);
}
if (previousTraceWidth !== nextTraceWidth) {
pathLayer.setAttribute("stroke-width", String(nextTraceWidth));
}
if (previousViaStrokeWidth !== nextViaStrokeWidth) {
viaLayer.setAttribute("stroke-width", String(nextViaStrokeWidth));
}
if (previousTraceColor !== nextTraceColor) {
pathLayer.setAttribute("stroke", nextTraceColor);
viaLayer.setAttribute("stroke", nextTraceColor);
}
if (previousHoleColor !== nextHoleColor) {
viaLayer.setAttribute("fill", nextHoleColor);
}
if (previousViaRadius !== nextViaRadius) {
for (const entry of entries) {
entry.startVia.setAttribute("r", String(nextViaRadius));
entry.endVia.setAttribute("r", String(nextViaRadius));
}
}
if (spawnChanged) {
for (const entry of entries) {
if (entry.state === "hidden" || entry.state === "queued") applyGeometry(entry);
}
rebuildEligibilityIndex();
}
evaluateEligibility();
scheduleFrame();
return api;
}
function complete() {
if (destroyed) return api;
active.clear();
queue.length = 0;
queueHead = 0;
hiddenFrontier.length = 0;
hiddenHead = 0;
for (const entry of entries) setComplete(entry);
furthestBottomWorldY = Math.max(furthestBottomWorldY, generatedFrontier);
updateExportReadiness();
return api;
}
function start() {
if (destroyed || running) return api;
const now = performance.now();
if (stoppedAt) {
const pauseDuration = now - stoppedAt;
for (const entry of active) entry.startedAt += pauseDuration;
}
stoppedAt = 0;
running = true;
previousFrameTime = 0;
evaluateEligibility();
scheduleFrame();
return api;
}
function stop() {
if (destroyed || !running) return api;
running = false;
stoppedAt = performance.now();
window.cancelAnimationFrame(frameRequest);
frameRequest = 0;
previousFrameTime = 0;
return api;
}
function reset() {
if (destroyed) return api;
active.clear();
queue.length = 0;
queueHead = 0;
completedTotal = 0;
revealedThroughWorldY = Number.NEGATIVE_INFINITY;
launchCredit = 1;
for (const entry of entries) {
applyGeometry(entry);
setHidden(entry);
}
rebuildEligibilityIndex();
furthestBottomWorldY = hasViewport
? viewport.bottomWorldY
: Number.NEGATIVE_INFINITY;
evaluateEligibility();
scheduleFrame();
return api;
}
function stats() {
return {
retained: entries.length,
unlocked: entries.length - stateCounts.hidden,
queued: stateCounts.queued,
active: active.size,
completed: stateCounts.complete,
completedTotal,
generatedFrontier: Number.isFinite(generatedFrontier) ? generatedFrontier : null,
revealedThroughWorldY: Number.isFinite(revealedThroughWorldY)
? revealedThroughWorldY
: null,
velocity: viewport.velocity,
};
}
function exportState() {
const settled = entries
.filter((entry) => entry.state === "complete")
.map((entry) => ({
id: entry.id,
points: entry.points.map((point) => [...point]),
length: entry.length,
minY: entry.minY,
maxY: entry.maxY,
}));
return {
ready: active.size === 0 && queueHead >= queue.length,
generatedFrontier: Number.isFinite(generatedFrontier) ? generatedFrontier : null,
revealedThroughWorldY: Number.isFinite(revealedThroughWorldY)
? revealedThroughWorldY
: null,
traces: settled,
};
}
function createExportLayer() {
const exportLayer = svgElement("g", {
"data-animation": "stream-trace-reveal",
"data-export-ready": "true",
"aria-hidden": "true",
});
const exportPaths = svgElement("g", {
"data-stream-trace-paths": "",
fill: "none",
stroke: traceColor(),
"stroke-width": traceWidth(),
"stroke-linecap": "butt",
"stroke-linejoin": "miter",
});
const exportVias = svgElement("g", {
"data-stream-trace-vias": "",
fill: holeColor(),
stroke: traceColor(),
"stroke-width": Math.max(0.5, viaOuterRadius() - viaInnerRadius()),
});
for (const entry of entries) {
if (entry.state !== "complete") continue;
exportPaths.append(entry.path.cloneNode(true));
exportVias.append(
entry.startVia.cloneNode(true),
entry.endVia.cloneNode(true),
);
}
exportLayer.append(exportPaths, exportVias);
return exportLayer;
}
function destroy() {
if (destroyed) return;
stop();
destroyed = true;
entries.length = 0;
entriesById.clear();
active.clear();
stateCounts.hidden = 0;
stateCounts.queued = 0;
stateCounts.active = 0;
stateCounts.complete = 0;
hiddenFrontier.length = 0;
hiddenHead = 0;
queue.length = 0;
queueHead = 0;
layer.remove();
}
const api = {
appendTraces,
pruneBefore,
setViewport,
updateOptions,
complete,
start,
stop,
reset,
destroy,
exportState,
createExportLayer,
layer,
get stats() {
return stats();
},
get settledState() {
return exportState();
},
};
if (settings.autoStart !== false) start();
return api;
}