diff --git a/site/beacon/connections.js b/site/beacon/connections.js index 518490d5e..4503afb0a 100644 --- a/site/beacon/connections.js +++ b/site/beacon/connections.js @@ -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(); @@ -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); + } }); } @@ -128,6 +141,7 @@ 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); @@ -135,13 +149,13 @@ export function addContractLine(contract) { 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, }); } @@ -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); @@ -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]; diff --git a/site/beacon/contract-pulse.mjs b/site/beacon/contract-pulse.mjs new file mode 100644 index 000000000..bcceeb04d --- /dev/null +++ b/site/beacon/contract-pulse.mjs @@ -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, + }; +} diff --git a/tests/beacon_contract_pulse.test.mjs b/tests/beacon_contract_pulse.test.mjs new file mode 100644 index 000000000..6b8c2bb02 --- /dev/null +++ b/tests/beacon_contract_pulse.test.mjs @@ -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); +}); diff --git a/tests/test_beacon_contract_animations.py b/tests/test_beacon_contract_animations.py new file mode 100644 index 000000000..14160b029 --- /dev/null +++ b/tests/test_beacon_contract_animations.py @@ -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()