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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
const TAU = Math.PI * 2;
const CANVAS_PIXEL_BUDGET = 1_450_000;
const CONTINUOUS_CANVAS_MAX_DIMENSION = 8192;
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 cumulativeLengths(points) {
const cumulative = [0];
for (let index = 1; index < points.length; index += 1) {
cumulative.push(cumulative[index - 1] + Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
));
}
return cumulative;
}
function orientedRoute(points) {
const cumulative = cumulativeLengths(points);
return { points, cumulative, length: cumulative.at(-1) || 0 };
}
function pointAlong(route, distance) {
const target = clamp(distance, 0, route.length);
let upper = 1;
while (upper < route.cumulative.length && route.cumulative[upper] < target) upper += 1;
if (upper >= route.points.length) return route.points.at(-1);
const lower = upper - 1;
const span = Math.max(0.0001, route.cumulative[upper] - route.cumulative[lower]);
const progress = (target - route.cumulative[lower]) / span;
return [
route.points[lower][0] + (route.points[upper][0] - route.points[lower][0]) * progress,
route.points[lower][1] + (route.points[upper][1] - route.points[lower][1]) * progress,
];
}
function strokeInterval(context, route, startDistance, endDistance) {
const start = clamp(startDistance, 0, route.length);
const end = clamp(endDistance, 0, route.length);
if (end - start < 0.05) return false;
context.beginPath();
const first = pointAlong(route, start);
context.moveTo(first[0], first[1]);
for (let index = 1; index < route.points.length - 1; index += 1) {
const distance = route.cumulative[index];
if (distance > start && distance < end) {
context.lineTo(route.points[index][0], route.points[index][1]);
}
}
const last = pointAlong(route, end);
context.lineTo(last[0], last[1]);
context.stroke();
return true;
}
function segmentTouchesRect(start, end, rect) {
return Math.max(start[0], end[0]) >= rect.left
&& Math.min(start[0], end[0]) <= rect.right
&& Math.max(start[1], end[1]) >= rect.top
&& Math.min(start[1], end[1]) <= rect.bottom;
}
function routeTouchesRect(points, rect) {
for (let index = 1; index < points.length; index += 1) {
if (segmentTouchesRect(points[index - 1], points[index], rect)) return true;
}
return false;
}
function brandColor(x, width) {
const position = x / Math.max(1, width);
if (position < 0.42) return "#00e6dc";
if (position < 0.74) return "#49a9ff";
return "#a58cff";
}
export function createHandshakeAnimation(mount, field, config, random, { readingZone = null } = {}) {
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pixelWidth = Math.max(1, mount.clientWidth);
const pixelHeight = Math.max(1, mount.clientHeight);
const pixelBudgetRatio = Math.sqrt(CANVAS_PIXEL_BUDGET / Math.max(1, pixelWidth * pixelHeight));
const legacyPixelRatio = Math.max(0.45, Math.min(window.devicePixelRatio || 1, 1.5, pixelBudgetRatio));
const continuousGeneration = config.continuousGeneration === true;
const pixelRatio = continuousGeneration
? Math.min(
window.devicePixelRatio || 1,
1.5,
pixelBudgetRatio,
CONTINUOUS_CANVAS_MAX_DIMENSION / pixelWidth,
CONTINUOUS_CANVAS_MAX_DIMENSION / pixelHeight,
)
: legacyPixelRatio;
const roundDimension = continuousGeneration ? Math.floor : Math.round;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.setAttribute("data-animation", "via-handshake-canvas");
Object.assign(canvas.style, {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
display: "block",
pointerEvents: "none",
zIndex: "1",
contain: "strict",
});
canvas.width = Math.max(1, roundDimension(pixelWidth * pixelRatio));
canvas.height = Math.max(1, roundDimension(pixelHeight * pixelRatio));
mount.appendChild(canvas);
const context = canvas.getContext("2d", { alpha: true, desynchronized: true })
|| canvas.getContext("2d");
const frameRatePixelCount = Math.max(1, Math.round(pixelWidth * legacyPixelRatio))
* Math.max(1, Math.round(pixelHeight * legacyPixelRatio));
const targetFrameRate = frameRatePixelCount > 1_200_000 ? 30 : 45;
const frameInterval = 1000 / targetFrameRate;
const scale = Math.max(0.25, config.featureScale);
const maxTransactions = Math.max(1, Math.min(4, Math.round(config.maxHandshakes || 3)));
const routes = field.traces.map((trace, index) => {
const forward = orientedRoute(trace.points);
const reverse = orientedRoute([...trace.points].reverse());
const midpoint = trace.points[Math.floor(trace.points.length / 2)];
return { index, trace, forward, reverse, midpoint, length: forward.length };
});
function readingRect() {
const mountRect = mount.getBoundingClientRect();
const zoneRect = readingZone?.getBoundingClientRect?.();
if (zoneRect && mountRect.width > 0 && mountRect.height > 0 && zoneRect.width > 0 && zoneRect.height > 0) {
const scaleX = field.width / mountRect.width;
const scaleY = field.height / mountRect.height;
const paddingX = Math.max(38, zoneRect.width * 0.08) * scaleX;
const paddingY = Math.max(28, zoneRect.height * 0.1) * scaleY;
return {
left: (zoneRect.left - mountRect.left) * scaleX - paddingX,
right: (zoneRect.right - mountRect.left) * scaleX + paddingX,
top: (zoneRect.top - mountRect.top) * scaleY - paddingY,
bottom: (zoneRect.bottom - mountRect.top) * scaleY + paddingY,
};
}
return {
left: field.width * 0.13,
right: field.width * 0.87,
top: field.height * 0.26,
bottom: field.height * 0.74,
};
}
const quietZone = readingRect();
const minimumLength = Math.max(28 * scale, Math.hypot(field.width, field.height) * 0.035);
const maximumLength = Math.hypot(field.width, field.height) * 0.64;
const inset = config.viaOuterRadius * scale * 1.4;
const insetRoutes = routes.filter(({ trace, length }) => {
const start = trace.points[0];
const end = trace.points.at(-1);
return trace.viaStart && trace.viaEnd
&& length >= minimumLength
&& length <= maximumLength
&& start[0] > inset && start[0] < field.width - inset
&& start[1] > inset && start[1] < field.height - inset
&& end[0] > inset && end[0] < field.width - inset
&& end[1] > inset && end[1] < field.height - inset;
});
const outsideCopy = insetRoutes.filter(({ trace }) => !routeTouchesRect(trace.points, quietZone));
const outsideMidpoint = insetRoutes.filter(({ midpoint }) => !(
midpoint[0] >= quietZone.left && midpoint[0] <= quietZone.right
&& midpoint[1] >= quietZone.top && midpoint[1] <= quietZone.bottom
));
const eligibleRoutes = outsideCopy.length >= 12
? outsideCopy
: outsideMidpoint.length >= 12 ? outsideMidpoint : insetRoutes.length ? insetRoutes : routes;
let frameRequest;
let running = false;
let frameCount = 0;
let transactionsCompleted = 0;
let transactionsTriggered = 0;
let lastPaintTime = 0;
let nextSpawnAt = 0;
let pointer = null;
const transactions = [];
const recentRoutes = [];
function life() {
return clamp(config.liveliness, 0, 1.5);
}
function activeRoute(index) {
return transactions.some((transaction) => transaction.route.index === index);
}
function selectRoute(origin = null) {
const available = eligibleRoutes.filter(({ index }) => !activeRoute(index));
if (!available.length) return null;
let selected = available[Math.floor(random() * available.length)];
let bestScore = -Infinity;
const attempts = Math.min(36, available.length);
const source = origin || (pointer && performance.now() - pointer.at < 2400 ? pointer : null);
for (let attempt = 0; attempt < attempts; attempt += 1) {
const candidate = available[Math.floor(random() * available.length)];
const targetLength = Math.hypot(field.width, field.height) * 0.16;
const lengthScore = Math.exp(-Math.abs(candidate.length - targetLength) / Math.max(1, targetLength));
const recentPenalty = recentRoutes.includes(candidate.index) ? 1.2 : 0;
let proximityScore = 0;
if (source) {
const first = candidate.trace.points[0];
const last = candidate.trace.points.at(-1);
const distance = Math.min(
Math.hypot(first[0] - source.x, first[1] - source.y),
Math.hypot(last[0] - source.x, last[1] - source.y),
);
proximityScore = 1 - distance / Math.max(field.width, field.height);
}
const score = random() * 0.7 + lengthScore * 0.8 + proximityScore * 1.35 - recentPenalty;
if (score > bestScore) {
bestScore = score;
selected = candidate;
}
}
return selected;
}
function spawnTransaction(origin = null, timestamp = performance.now(), triggered = false) {
if (!running || reducedMotion || !config.motion || transactions.length >= maxTransactions) return false;
const route = selectRoute(origin);
if (!route || route.length < 1) return false;
const first = route.trace.points[0];
const last = route.trace.points.at(-1);
const reverse = origin
? Math.hypot(last[0] - origin.x, last[1] - origin.y) < Math.hypot(first[0] - origin.x, first[1] - origin.y)
: random() < 0.5;
const outbound = reverse ? route.reverse : route.forward;
const acknowledgement = reverse ? route.forward : route.reverse;
const source = outbound.points[0];
const destination = outbound.points.at(-1);
const packetLength = clamp(
(config.handshakeLength || 48) * scale,
route.length * 0.12,
route.length * 0.42,
);
const speed = (config.handshakeSpeed || 230) * scale * (0.84 + life() * 0.2);
const outboundDuration = clamp((route.length + packetLength) / speed * 1000, 940, 2350);
transactions.push({
route,
outbound,
acknowledgement,
source,
destination,
packetLength,
sourceColor: brandColor(source[0], field.width),
destinationColor: brandColor(destination[0], field.width),
startedAt: timestamp,
chargeDuration: 320,
outboundDuration,
holdDuration: 190,
acknowledgementDuration: Math.max(820, outboundDuration * 0.82),
settleDuration: 360,
triggered,
});
recentRoutes.push(route.index);
if (recentRoutes.length > 7) recentRoutes.shift();
if (triggered) transactionsTriggered += 1;
return true;
}
function scheduleNext(timestamp, initial = false) {
const interval = (config.handshakeInterval || 1650) / (0.82 + life() * 0.18);
nextSpawnAt = timestamp + (initial ? 320 : interval * (0.72 + random() * 0.62));
}
function drawPacket(route, packetLength, progress, color, opacity) {
const travel = progress * (route.length + packetLength);
const head = clamp(travel, 0, route.length);
const tail = clamp(travel - packetLength, 0, route.length);
if (head - tail < 0.05) return false;
context.strokeStyle = color;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = config.traceWidth * scale * 4.4;
context.globalAlpha = opacity * 0.1;
strokeInterval(context, route, tail, head);
context.lineWidth = config.traceWidth * scale * 1.3;
context.globalAlpha = opacity * 0.82;
strokeInterval(context, route, tail, head);
return true;
}
function drawVia(point, color, energy) {
if (energy <= 0.002) return;
const radius = (config.viaOuterRadius + config.viaInnerRadius) * scale * 0.5;
const ringWidth = Math.max(0.7 * scale, (config.viaOuterRadius - config.viaInnerRadius) * scale * 0.72);
context.strokeStyle = color;
context.lineCap = "round";
context.lineWidth = ringWidth * 3.1;
context.globalAlpha = energy * 0.08;
context.beginPath();
context.arc(point[0], point[1], radius, 0, TAU);
context.stroke();
context.lineWidth = ringWidth;
context.globalAlpha = energy * 0.72;
context.beginPath();
context.arc(point[0], point[1], radius, 0, TAU);
context.stroke();
}
function drawTransaction(transaction, timestamp) {
const elapsed = timestamp - transaction.startedAt;
const chargeEnd = transaction.chargeDuration;
const outboundEnd = chargeEnd + transaction.outboundDuration;
const holdEnd = outboundEnd + transaction.holdDuration;
const acknowledgementEnd = holdEnd + transaction.acknowledgementDuration;
const end = acknowledgementEnd + transaction.settleDuration;
let sourceEnergy = 0;
let destinationEnergy = 0;
if (elapsed < chargeEnd) {
sourceEnergy = smoothstep(elapsed / transaction.chargeDuration);
} else if (elapsed < outboundEnd) {
const progress = (elapsed - chargeEnd) / transaction.outboundDuration;
drawPacket(transaction.outbound, transaction.packetLength, progress, transaction.sourceColor, 0.92);
sourceEnergy = 1 - progress * 0.76;
destinationEnergy = smoothstep((progress - 0.72) / 0.28) * 0.92;
} else if (elapsed < holdEnd) {
sourceEnergy = 0.18;
destinationEnergy = 0.92;
} else if (elapsed < acknowledgementEnd) {
const progress = (elapsed - holdEnd) / transaction.acknowledgementDuration;
drawPacket(transaction.acknowledgement, transaction.packetLength * 0.72, progress, transaction.destinationColor, 0.5);
destinationEnergy = 0.92 - progress * 0.7;
sourceEnergy = 0.18 + smoothstep((progress - 0.72) / 0.28) * 0.58;
} else if (elapsed < end) {
const progress = smoothstep((elapsed - acknowledgementEnd) / transaction.settleDuration);
sourceEnergy = 0.76 * (1 - progress);
destinationEnergy = 0.22 * (1 - progress);
} else {
return false;
}
drawVia(transaction.source, transaction.sourceColor, sourceEnergy);
drawVia(transaction.destination, transaction.destinationColor, destinationEnergy);
return true;
}
function renderFrame(timestamp) {
if (!running || !canvas.isConnected || !context) return;
frameRequest = window.requestAnimationFrame(renderFrame);
if (document.hidden) return;
const sincePaint = lastPaintTime ? timestamp - lastPaintTime : frameInterval;
if (sincePaint < frameInterval) return;
lastPaintTime = lastPaintTime ? timestamp - (sincePaint % frameInterval) : timestamp;
frameCount += 1;
if (timestamp >= nextSpawnAt) {
spawnTransaction(null, timestamp);
scheduleNext(timestamp);
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
context.setTransform(canvas.width / field.width, 0, 0, canvas.height / field.height, 0, 0);
context.globalCompositeOperation = "source-over";
for (let index = transactions.length - 1; index >= 0; index -= 1) {
if (!drawTransaction(transactions[index], timestamp)) {
transactions.splice(index, 1);
transactionsCompleted += 1;
}
}
context.globalAlpha = 1;
}
function pointerMoved(point) {
pointer = point ? { ...point, at: performance.now() } : null;
}
function pointerLeft() {
if (pointer) pointer.at = performance.now() - 3000;
}
function trigger(origin = null) {
return spawnTransaction(origin, performance.now(), true);
}
function start() {
if (running || reducedMotion || !config.motion || !context || config.liveliness <= 0.02) return;
running = true;
frameCount = 0;
lastPaintTime = 0;
const now = performance.now();
scheduleNext(now, true);
frameRequest = window.requestAnimationFrame(renderFrame);
}
function stop() {
running = false;
window.cancelAnimationFrame(frameRequest);
frameRequest = undefined;
lastPaintTime = 0;
}
function destroy() {
stop();
transactions.length = 0;
canvas.remove();
}
return {
start,
stop,
destroy,
pointerMoved,
pointerLeft,
clicked: trigger,
trigger,
get stats() {
return {
system: "adom-handshake",
renderer: "canvas-range-strokes",
animationNodes: canvas.isConnected ? 1 : 0,
activeTransactions: transactions.length,
maxTransactions,
eligibleRoutes: eligibleRoutes.length,
totalRoutes: routes.length,
transactionsCompleted,
transactionsTriggered,
frameCount,
targetFrameRate,
rendererPixelRatio: pixelRatio,
quietZoneProtected: Boolean(readingZone),
perFrameTraceBudget: maxTransactions,
reducedMotion,
};
},
};
}