Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions site/beacon/connections.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
} from './data.js';
import { getScene, onAnimate } from './scene.js';
import { getAgentPosition } from './agents.js';
import { contractPulseFrame } from './contract-pulse.mjs';

const contractLines = [];
const calibrationLines = [];
const particles = [];
const contractPulses = [];

export function buildConnections() {
const scene = getScene();
Expand Down Expand Up @@ -95,6 +97,17 @@ export function buildConnections() {
p.mesh.position.lerpVectors(p.from, p.to, p.t);
p.mesh.material.opacity = 0.5 + Math.sin(elapsed * 4 + p.phase) * 0.3;
}

for (let i = contractPulses.length - 1; i >= 0; i--) {
const pulse = contractPulses[i];
if (pulse.startedAt === null) pulse.startedAt = elapsed;

const frame = contractPulseFrame(elapsed - pulse.startedAt, pulse.baseOpacity);
pulse.glow.material.opacity = frame.glowOpacity;
pulse.glow.scale.setScalar(frame.glowScale);

if (frame.done) disposeContractPulse(i);
}
});
}

Expand Down Expand Up @@ -128,20 +141,21 @@ export function addContractLine(contract) {

const style = CONTRACT_STYLES[contract.type] || CONTRACT_STYLES.rent;
const opacity = CONTRACT_STATE_OPACITY[contract.state] || 0.3;
const lineColor = contract.state === 'breached' ? '#ff4444' : style.color;

const points = [fromPos, toPos];
const geo = new THREE.BufferGeometry().setFromPoints(points);

let mat;
if (style.dash.length > 0) {
mat = new THREE.LineDashedMaterial({
color: contract.state === 'breached' ? '#ff4444' : style.color,
color: lineColor,
transparent: true, opacity,
dashSize: style.dash[0], gapSize: style.dash[1], linewidth: 1,
});
} else {
mat = new THREE.LineBasicMaterial({
color: contract.state === 'breached' ? '#ff4444' : style.color,
color: lineColor,
transparent: true, opacity, linewidth: 1,
});
}
Expand All @@ -151,6 +165,7 @@ export function addContractLine(contract) {
line.userData = { type: 'contract', contractId: contract.id };
scene.add(line);
contractLines.push({ line, contract });
startContractPulse(scene, contract.id, fromPos, toPos, lineColor, opacity);

if (contract.state === 'active' || contract.state === 'renewed' || contract.state === 'offered') {
const particle = createFlowParticle(fromPos, toPos, style.color);
Expand All @@ -160,8 +175,53 @@ export function addContractLine(contract) {
}
}

function startContractPulse(scene, contractId, from, to, color, baseOpacity) {
const midpoint = from.clone().add(to).multiplyScalar(0.5);
const glowGeometry = new THREE.BufferGeometry().setFromPoints([
from.clone().sub(midpoint),
to.clone().sub(midpoint),
]);
const initialFrame = contractPulseFrame(0, baseOpacity);
const glowMaterial = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: initialFrame.glowOpacity,
blending: THREE.AdditiveBlending,
depthWrite: false,
linewidth: 2,
});
const glow = new THREE.Line(glowGeometry, glowMaterial);
glow.position.copy(midpoint);
glow.scale.setScalar(initialFrame.glowScale);
glow.renderOrder = 2;
glow.userData = { type: 'contract-pulse', contractId };
scene.add(glow);

contractPulses.push({
contractId,
glow,
baseOpacity,
startedAt: null,
});
}

function disposeContractPulse(index) {
const pulse = contractPulses[index];
if (!pulse) return;

getScene().remove(pulse.glow);
pulse.glow.geometry.dispose();
pulse.glow.material.dispose();
contractPulses.splice(index, 1);
}

export function removeContractLine(contractId) {
const scene = getScene();
for (let i = contractPulses.length - 1; i >= 0; i--) {
if (contractPulses[i].contractId === contractId) {
disposeContractPulse(i);
}
}
for (let i = contractLines.length - 1; i >= 0; i--) {
if (contractLines[i].contract.id === contractId) {
const { line } = contractLines[i];
Expand Down
28 changes: 28 additions & 0 deletions site/beacon/contract-pulse.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
// Deterministic animation envelope for newly created Beacon Atlas contracts.

export const CONTRACT_PULSE_DURATION_SECONDS = 2.4;

function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}

export function contractPulseFrame(elapsedSeconds, baseOpacity = 0.3) {
const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
const opacity = Number.isFinite(baseOpacity) ? clamp(baseOpacity, 0, 1) : 0.3;
const progress = clamp(elapsed / CONTRACT_PULSE_DURATION_SECONDS, 0, 1);

// Three bright beats make a new connection noticeable, while the envelope
// guarantees that the temporary additive line fades completely and can be
// disposed after a bounded lifetime.
const wave = Math.cos(progress * Math.PI * 3) ** 2;
const envelope = (1 - progress) ** 1.5;
const intensity = wave * envelope;
const peakOpacity = clamp(opacity + 0.55, 0.35, 1);

return {
glowOpacity: peakOpacity * intensity,
glowScale: 1 + Math.sin(progress * Math.PI) * 0.08 + intensity * 0.04,
done: elapsed >= CONTRACT_PULSE_DURATION_SECONDS,
};
}
46 changes: 46 additions & 0 deletions tests/beacon_contract_pulse.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT

import assert from 'node:assert/strict';
import test from 'node:test';

import {
CONTRACT_PULSE_DURATION_SECONDS,
contractPulseFrame,
} from '../site/beacon/contract-pulse.mjs';

test('a new contract starts with a visible additive glow', () => {
const frame = contractPulseFrame(0, 0.3);

assert.equal(frame.done, false);
assert.ok(frame.glowOpacity >= 0.85);
assert.ok(frame.glowScale > 1);
});

test('three beats decay inside the bounded animation envelope', () => {
const firstPeak = contractPulseFrame(0, 0.3);
const firstValley = contractPulseFrame(CONTRACT_PULSE_DURATION_SECONDS / 6, 0.3);
const secondPeak = contractPulseFrame(CONTRACT_PULSE_DURATION_SECONDS / 3, 0.3);

assert.ok(firstValley.glowOpacity < 1e-12);
assert.ok(secondPeak.glowOpacity > firstValley.glowOpacity);
assert.ok(secondPeak.glowOpacity < firstPeak.glowOpacity);
});

test('the glow reaches a disposable neutral frame at the deadline', () => {
const frame = contractPulseFrame(CONTRACT_PULSE_DURATION_SECONDS, 0.6);

assert.equal(frame.done, true);
assert.equal(frame.glowOpacity, 0);
assert.equal(frame.glowScale, 1);
});

test('invalid timing and opacity inputs remain bounded', () => {
const invalid = contractPulseFrame(Number.NaN, Number.NaN);
const high = contractPulseFrame(0, 5);
const low = contractPulseFrame(-10, -5);

assert.equal(invalid.done, false);
assert.ok(invalid.glowOpacity >= 0 && invalid.glowOpacity <= 1);
assert.ok(high.glowOpacity >= 0 && high.glowOpacity <= 1);
assert.ok(low.glowOpacity >= 0 && low.glowOpacity <= 1);
});
33 changes: 33 additions & 0 deletions tests/test_beacon_contract_animations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: MIT

from pathlib import Path
import unittest


REPO_ROOT = Path(__file__).resolve().parents[1]
CONNECTIONS = REPO_ROOT / "site" / "beacon" / "connections.js"


class BeaconContractAnimationIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.source = CONNECTIONS.read_text(encoding="utf-8")

def test_new_contract_lines_start_the_bounded_pulse(self):
self.assertIn("startContractPulse(scene, contract.id", self.source)
self.assertIn("contractPulseFrame(elapsed - pulse.startedAt", self.source)
self.assertIn("if (frame.done) disposeContractPulse(i)", self.source)

def test_pulse_uses_a_disposable_additive_glow(self):
self.assertIn("blending: THREE.AdditiveBlending", self.source)
self.assertIn("pulse.glow.geometry.dispose()", self.source)
self.assertIn("pulse.glow.material.dispose()", self.source)

def test_contract_removal_also_cleans_an_active_pulse(self):
removal = self.source.split("export function removeContractLine", 1)[1]
self.assertIn("contractPulses[i].contractId === contractId", removal)
self.assertIn("disposeContractPulse(i)", removal)


if __name__ == "__main__":
unittest.main()