From 19f08207e814cc66e44cc8716b92511aa15839ea Mon Sep 17 00:00:00 2001 From: Yevgeniy Tyan <102475316+ytvee@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:32:43 +0300 Subject: [PATCH 1/5] chore: scaffold Astro static site --- README.md | 2 -- astro.config.mjs | 14 ++++++++ package.json | 28 ++++++++++++++++ public/robots.txt | 2 ++ src/layouts/BaseLayout.astro | 32 +++++++++++++++++++ src/pages/index.astro | 11 +++++++ src/styles/global.css | 62 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 9 ++++++ 8 files changed, 158 insertions(+), 2 deletions(-) delete mode 100644 README.md create mode 100644 astro.config.mjs create mode 100644 package.json create mode 100644 public/robots.txt create mode 100644 src/layouts/BaseLayout.astro create mode 100644 src/pages/index.astro create mode 100644 src/styles/global.css create mode 100644 tsconfig.json diff --git a/README.md b/README.md deleted file mode 100644 index 103372b..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# webdev-agent-kit-frontend -Landing for https://github.com/ytvee-dev/webdev-agent-kit diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..01fd110 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from 'astro/config' + +export default defineConfig({ + output: 'static', + compressHTML: true, + build: { + inlineStylesheets: 'auto', + }, + vite: { + build: { + target: 'es2022', + }, + }, +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..60620b8 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "webdev-agent-kit-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5" + }, + "scripts": { + "dev": "astro dev", + "build": "astro check && astro build", + "check": "astro check", + "preview": "astro preview", + "test": "node --import tsx --test tests/**/*.test.ts" + }, + "dependencies": { + "astro": "7.0.7", + "three": "0.185.1" + }, + "devDependencies": { + "@astrojs/check": "0.9.9", + "@types/node": "26.1.1", + "@types/three": "0.185.1", + "tsx": "4.23.0", + "typescript": "6.0.3" + } +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..35b6bfe --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,32 @@ +--- +import '../styles/global.css' + +interface Props { + title: string + description: string +} + +const { title, description } = Astro.props +--- + + + + + + + + + + + + + + + + + {title} + + + + + diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..710fd89 --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro' + +const title = 'WebDev Agent Kit — safer frontend coding agents' +const description = + 'A project-local operating kit that helps AI coding agents plan, implement, debug, review and verify frontend work with controlled scope.' +--- + + +
+
diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..17775ee --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,62 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + --page-background: #090807; + --surface: #11100e; + --surface-border: rgba(255, 224, 178, 0.12); + --text-primary: #fffaf0; + --text-secondary: #bbb5ab; + --text-muted: #827c73; + --accent: #ffb431; + --accent-hot: #ff5a1f; + --focus: #ffd36a; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--page-background); +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + overflow-x: hidden; + background: + radial-gradient(circle at 72% 46%, rgba(121, 34, 8, 0.12), transparent 32rem), + linear-gradient(180deg, #0a0908 0%, #070706 100%); + color: var(--text-primary); +} + +button, +a { + font: inherit; +} + +a { + color: inherit; +} + +::selection { + background: rgba(255, 164, 45, 0.28); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7f677ba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true + } +} From 9d87b50480f96da457e634d9c4f958231b3ff24e Mon Sep 17 00:00:00 2001 From: Yevgeniy Tyan <102475316+ytvee@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:40:44 +0300 Subject: [PATCH 2/5] feat: build interactive Calcifer graph hero --- src/components/CalciferGraph.astro | 284 +++++++++++++++ src/components/Hero.astro | 287 +++++++++++++++ src/pages/index.astro | 5 +- src/scripts/graph/generateGraph.ts | 404 +++++++++++++++++++++ src/scripts/graph/mountCalciferGraph.ts | 462 ++++++++++++++++++++++++ src/scripts/graph/physics.ts | 163 +++++++++ src/scripts/graph/random.ts | 11 + src/scripts/graph/types.ts | 54 +++ 8 files changed, 1669 insertions(+), 1 deletion(-) create mode 100644 src/components/CalciferGraph.astro create mode 100644 src/components/Hero.astro create mode 100644 src/scripts/graph/generateGraph.ts create mode 100644 src/scripts/graph/mountCalciferGraph.ts create mode 100644 src/scripts/graph/physics.ts create mode 100644 src/scripts/graph/random.ts create mode 100644 src/scripts/graph/types.ts diff --git a/src/components/CalciferGraph.astro b/src/components/CalciferGraph.astro new file mode 100644 index 0000000..87688df --- /dev/null +++ b/src/components/CalciferGraph.astro @@ -0,0 +1,284 @@ +--- +import { createCalciferGraph } from '../scripts/graph/generateGraph' + +const fallbackGraph = createCalciferGraph({ nodeCount: 60, seed: 20260710 }) +const toColor = (color: readonly [number, number, number]): string => + `rgb(${Math.round(color[0] * 255)} ${Math.round(color[1] * 255)} ${Math.round(color[2] * 255)})` + +const codeWall = ` +type AgentTask = { + scope: "frontend" + evidence: Verification[] + approvalRequired: boolean +} + +const workflow = async (task: AgentTask) => { + const context = await inspectProject(task.scope) + const plan = createSmallestSafePlan(context) + const result = await executeApprovedScope(plan) + + return verifyWithEvidence(result) +} + +export const guardrails = { + broadRewrites: false, + unapprovedPackages: false, + fakeVerification: false, +} +` +--- + + + + + + + {fallbackGraph.edges.slice(0, 260).map((edge) => { + const source = fallbackGraph.nodes[edge.source] + const target = fallbackGraph.nodes[edge.target] + + if (!source || !target) { + return null + } + + return ( + + ) + })} + {fallbackGraph.nodes.map((node) => ( + + ))} + + + + + + The graph is decorative. Drag a visible node to pull its connected neighbours and reveal code in + the darkness behind it. + + + + + + diff --git a/src/components/Hero.astro b/src/components/Hero.astro new file mode 100644 index 0000000..0b9ac1b --- /dev/null +++ b/src/components/Hero.astro @@ -0,0 +1,287 @@ +--- +import CalciferGraph from './CalciferGraph.astro' + +const stack = ['React', 'Next.js', 'TypeScript', 'CSS Modules', 'Redux', 'TanStack', 'Axios', 'Codex', 'Claude'] +--- + +
+ + +
+
+

Project-local operating kit

+ +

Make frontend coding agents safer to use.

+ +

+ WebDev Agent Kit helps AI coding agents plan, implement, debug, refactor, review and visually + verify frontend work without broad rewrites, unapproved packages, fake verification or generic + UI output. +

+ + + +
    + {stack.map((item) =>
  • {item}
  • )} +
+
+ +
+ +
+
+
+ + diff --git a/src/pages/index.astro b/src/pages/index.astro index 710fd89..8934a78 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,4 +1,5 @@ --- +import Hero from '../components/Hero.astro' import BaseLayout from '../layouts/BaseLayout.astro' const title = 'WebDev Agent Kit — safer frontend coding agents' @@ -7,5 +8,7 @@ const description = --- -
+
+ +
diff --git a/src/scripts/graph/generateGraph.ts b/src/scripts/graph/generateGraph.ts new file mode 100644 index 0000000..908b91c --- /dev/null +++ b/src/scripts/graph/generateGraph.ts @@ -0,0 +1,404 @@ +import { createRandom } from './random' +import type { + CalciferGraphData, + GraphEdgeSeed, + GraphNodeRole, + GraphNodeSeed, + Rgb, +} from './types' + +interface CreateGraphOptions { + nodeCount: number + seed?: number +} + +interface Point { + x: number + y: number +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)) + +const mix = (from: number, to: number, amount: number): number => + from + (to - from) * amount + +const mixColor = (from: Rgb, to: Rgb, amount: number): Rgb => [ + mix(from[0], to[0], amount), + mix(from[1], to[1], amount), + mix(from[2], to[2], amount), +] + +const pointInsideTongue = ( + x: number, + y: number, + centerX: number, + baseY: number, + height: number, + width: number, + lean: number, +): boolean => { + const progress = (y - baseY) / height + + if (progress < 0 || progress > 1) { + return false + } + + const center = centerX + lean * progress + const taper = Math.pow(1 - progress, 0.72) + const ripple = 0.86 + Math.sin(progress * Math.PI * 2.2 + centerX * 8) * 0.14 + + return Math.abs(x - center) <= width * taper * ripple +} + +const pointInsideFlame = (x: number, y: number): boolean => { + const body = Math.pow(x / 0.79, 2) + Math.pow((y + 0.14) / 0.82, 2) <= 1 + const lowerGlow = Math.pow(x / 0.65, 2) + Math.pow((y + 0.55) / 0.42, 2) <= 1 + const center = pointInsideTongue(x, y, 0.02, 0.2, 0.93, 0.34, 0.08) + const leftInner = pointInsideTongue(x, y, -0.34, 0.19, 0.66, 0.25, -0.08) + const rightInner = pointInsideTongue(x, y, 0.36, 0.18, 0.69, 0.23, 0.1) + const leftOuter = pointInsideTongue(x, y, -0.62, 0.02, 0.48, 0.18, -0.08) + const rightOuter = pointInsideTongue(x, y, 0.63, 0.04, 0.46, 0.17, 0.09) + + return body || lowerGlow || center || leftInner || rightInner || leftOuter || rightOuter +} + +const getFlameColor = (x: number, y: number): Rgb => { + const centerDistance = clamp( + Math.sqrt(Math.pow(x / 0.86, 2) + Math.pow((y + 0.34) / 1.12, 2)), + 0, + 1, + ) + const heightHeat = clamp((y + 0.12) / 1.05, 0, 1) + const edgeHeat = clamp(Math.abs(x) / 0.82, 0, 1) + const heat = clamp(centerDistance * 0.45 + heightHeat * 0.42 + edgeHeat * 0.23, 0, 1) + const yellow: Rgb = [1, 0.82, 0.22] + const orange: Rgb = [1, 0.29, 0.035] + const red: Rgb = [0.72, 0.045, 0.018] + + if (heat < 0.58) { + return mixColor(yellow, orange, heat / 0.58) + } + + return mixColor(orange, red, (heat - 0.58) / 0.42) +} + +const quadraticPoint = (start: Point, control: Point, end: Point, progress: number): Point => { + const inverse = 1 - progress + + return { + x: + inverse * inverse * start.x + + 2 * inverse * progress * control.x + + progress * progress * end.x, + y: + inverse * inverse * start.y + + 2 * inverse * progress * control.y + + progress * progress * end.y, + } +} + +const addNode = ( + nodes: GraphNodeSeed[], + random: () => number, + role: GraphNodeRole, + point: Point, + size: number, + color: Rgb, + anchorStrength: number, + z = 0, +): number => { + const id = nodes.length + + nodes.push({ + id, + role, + x: point.x, + y: point.y, + z, + size, + color, + anchorStrength, + phase: random() * Math.PI * 2, + }) + + return id +} + +const addEdge = ( + edges: GraphEdgeSeed[], + keys: Set, + nodes: GraphNodeSeed[], + source: number, + target: number, + stiffness: number, +): void => { + if (source === target) { + return + } + + const first = Math.min(source, target) + const second = Math.max(source, target) + const key = `${first}:${second}` + + if (keys.has(key)) { + return + } + + const sourceNode = nodes[source] + const targetNode = nodes[target] + + if (!sourceNode || !targetNode) { + return + } + + keys.add(key) + edges.push({ + source: first, + target: second, + restLength: Math.hypot(sourceNode.x - targetNode.x, sourceNode.y - targetNode.y), + stiffness, + }) +} + +const nearestNode = ( + nodes: GraphNodeSeed[], + point: Point, + roles: ReadonlySet, + excluded = -1, +): number => { + let nearest = -1 + let nearestDistance = Number.POSITIVE_INFINITY + + for (const node of nodes) { + if (node.id === excluded || !roles.has(node.role)) { + continue + } + + const distance = Math.hypot(node.x - point.x, node.y - point.y) + + if (distance < nearestDistance) { + nearest = node.id + nearestDistance = distance + } + } + + return nearest +} + +const connectNearestNeighbours = ( + nodes: GraphNodeSeed[], + edges: GraphEdgeSeed[], + keys: Set, + nodeIds: number[], + neighbours: number, + maxDistance: number, + stiffness: number, +): void => { + for (const source of nodeIds) { + const sourceNode = nodes[source] + + if (!sourceNode) { + continue + } + + const candidates = nodeIds + .filter((target) => target !== source) + .map((target) => { + const targetNode = nodes[target] + return { + target, + distance: targetNode + ? Math.hypot(sourceNode.x - targetNode.x, sourceNode.y - targetNode.y) + : Number.POSITIVE_INFINITY, + } + }) + .filter(({ distance }) => distance <= maxDistance) + .sort((left, right) => left.distance - right.distance) + .slice(0, neighbours) + + for (const candidate of candidates) { + addEdge(edges, keys, nodes, source, candidate.target, stiffness) + } + } +} + +export const createCalciferGraph = ({ + nodeCount, + seed = 20260710, +}: CreateGraphOptions): CalciferGraphData => { + const random = createRandom(seed) + const nodes: GraphNodeSeed[] = [] + const edges: GraphEdgeSeed[] = [] + const edgeKeys = new Set() + const flameNodeIds: number[] = [] + const desiredFlameCount = Math.max(54, Math.floor(nodeCount * 0.72)) + let attempts = 0 + + while (flameNodeIds.length < desiredFlameCount && attempts < desiredFlameCount * 80) { + attempts += 1 + const x = mix(-0.92, 0.92, random()) + const y = mix(-0.96, 1.12, random()) + + if (!pointInsideFlame(x, y)) { + continue + } + + const coreBias = 1 - clamp(Math.hypot(x / 0.9, (y + 0.15) / 1.16), 0, 1) + const size = mix(3.2, 7.4, random() * 0.64 + coreBias * 0.36) + const id = addNode( + nodes, + random, + 'flame', + { x, y }, + size, + getFlameColor(x, y), + mix(4.6, 7.8, 1 - coreBias), + mix(-0.03, 0.03, random()), + ) + flameNodeIds.push(id) + } + + const armNodeIds: number[] = [] + const armDefinitions = [ + { + start: { x: -0.66, y: -0.15 }, + control: { x: -0.98, y: -0.52 }, + end: { x: -1.19, y: -0.23 }, + }, + { + start: { x: 0.66, y: -0.15 }, + control: { x: 0.98, y: -0.52 }, + end: { x: 1.19, y: -0.23 }, + }, + ] + + for (const definition of armDefinitions) { + const arm: number[] = [] + + for (let index = 0; index < 11; index += 1) { + const progress = index / 10 + const point = quadraticPoint(definition.start, definition.control, definition.end, progress) + const id = addNode( + nodes, + random, + 'arm', + point, + mix(4.4, 6.8, 1 - Math.abs(progress - 0.85)), + mixColor([1, 0.34, 0.045], [0.83, 0.07, 0.018], progress), + 6.8, + 0.015, + ) + arm.push(id) + armNodeIds.push(id) + + if (index > 0) { + addEdge(edges, edgeKeys, nodes, arm[index - 1] ?? id, id, 16) + } + } + + const root = arm[0] + const rootNode = root === undefined ? undefined : nodes[root] + + if (root !== undefined && rootNode) { + const nearest = nearestNode(nodes, rootNode, new Set(['flame']), root) + addEdge(edges, edgeKeys, nodes, root, nearest, 17) + } + } + + const eyeNodeIds: number[] = [] + const pupilNodeIds: number[] = [] + + for (const centerX of [-0.27, 0.27]) { + const ring: number[] = [] + + for (let index = 0; index < 14; index += 1) { + const angle = (index / 14) * Math.PI * 2 + const point = { + x: centerX + Math.cos(angle) * 0.155, + y: -0.12 + Math.sin(angle) * 0.135, + } + const id = addNode(nodes, random, 'eye', point, 5.9, [1, 0.95, 0.79], 12, 0.09) + ring.push(id) + eyeNodeIds.push(id) + } + + for (let index = 0; index < ring.length; index += 1) { + const current = ring[index] + const next = ring[(index + 1) % ring.length] + + if (current !== undefined && next !== undefined) { + addEdge(edges, edgeKeys, nodes, current, next, 22) + } + } + + for (let index = 0; index < 4; index += 1) { + const angle = (index / 4) * Math.PI * 2 + const point = { + x: centerX + (centerX < 0 ? 0.025 : -0.025) + Math.cos(angle) * 0.035, + y: -0.135 + Math.sin(angle) * 0.035, + } + const id = addNode(nodes, random, 'pupil', point, 6.8, [0.06, 0.045, 0.035], 16, 0.13) + pupilNodeIds.push(id) + + const ringTarget = ring[(index * 3 + 1) % ring.length] + if (ringTarget !== undefined) { + addEdge(edges, edgeKeys, nodes, id, ringTarget, 18) + } + } + } + + const mouthNodeIds: number[] = [] + + for (let index = 0; index < 15; index += 1) { + const progress = index / 14 + const point = quadraticPoint( + { x: -0.21, y: -0.39 }, + { x: 0, y: -0.56 }, + { x: 0.21, y: -0.39 }, + progress, + ) + const id = addNode(nodes, random, 'mouth', point, 5.2, [0.31, 0.025, 0.018], 15, 0.12) + mouthNodeIds.push(id) + + if (index > 0) { + addEdge(edges, edgeKeys, nodes, mouthNodeIds[index - 1] ?? id, id, 22) + } + } + + connectNearestNeighbours(nodes, edges, edgeKeys, flameNodeIds, 3, 0.31, 10.5) + connectNearestNeighbours(nodes, edges, edgeKeys, armNodeIds, 2, 0.3, 14) + + const connectedNodeIds = new Set() + for (const edge of edges) { + connectedNodeIds.add(edge.source) + connectedNodeIds.add(edge.target) + } + + for (const nodeId of flameNodeIds) { + if (connectedNodeIds.has(nodeId)) { + continue + } + + const node = nodes[nodeId] + if (!node) { + continue + } + + const nearest = nearestNode(nodes, node, new Set(['flame']), nodeId) + addEdge(edges, edgeKeys, nodes, nodeId, nearest, 10.5) + } + + for (const nodeId of [...eyeNodeIds, ...pupilNodeIds, ...mouthNodeIds]) { + const node = nodes[nodeId] + + if (!node) { + continue + } + + const nearest = nearestNode(nodes, node, new Set(['flame']), nodeId) + addEdge(edges, edgeKeys, nodes, nodeId, nearest, 8) + } + + return { nodes, edges } +} diff --git a/src/scripts/graph/mountCalciferGraph.ts b/src/scripts/graph/mountCalciferGraph.ts new file mode 100644 index 0000000..bc08cd1 --- /dev/null +++ b/src/scripts/graph/mountCalciferGraph.ts @@ -0,0 +1,462 @@ +import * as THREE from 'three' +import { createCalciferGraph } from './generateGraph' +import { createDragInfluence, createPhysicsState, stepGraphPhysics } from './physics' +import type { CalciferGraphData, Rgb } from './types' + +interface PointerSample { + x: number + y: number + time: number +} + +const vertexShader = ` + attribute float aSize; + attribute float aHeat; + varying vec3 vColor; + varying float vHeat; + uniform float uPixelRatio; + uniform float uViewportScale; + + void main() { + vColor = color; + vHeat = aHeat; + vec4 viewPosition = modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * viewPosition; + gl_PointSize = aSize * uPixelRatio * uViewportScale; + } +` + +const fragmentShader = ` + varying vec3 vColor; + varying float vHeat; + + void main() { + float distanceToCenter = distance(gl_PointCoord, vec2(0.5)); + if (distanceToCenter > 0.5) discard; + + float core = 1.0 - smoothstep(0.08, 0.34, distanceToCenter); + float halo = 1.0 - smoothstep(0.18, 0.5, distanceToCenter); + float alpha = clamp(core + halo * (0.36 + vHeat * 0.18), 0.0, 1.0); + vec3 glow = mix(vColor, vec3(1.0, 0.86, 0.52), halo * 0.18 + vHeat * 0.12); + gl_FragColor = vec4(glow, alpha); + } +` + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)) + +const getQualityNodeCount = (): number => { + const coarsePointer = window.matchMedia('(pointer: coarse)').matches + const width = window.innerWidth + + if (coarsePointer || width < 640) { + return 160 + } + + if (width < 1100) { + return 220 + } + + return 310 +} + +const writeColor = (target: Float32Array, offset: number, color: Rgb, multiplier = 1): void => { + target[offset] = color[0] * multiplier + target[offset + 1] = color[1] * multiplier + target[offset + 2] = color[2] * multiplier +} + +const createPointGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => { + const positions = new Float32Array(graph.nodes.length * 3) + const colors = new Float32Array(graph.nodes.length * 3) + const sizes = new Float32Array(graph.nodes.length) + const heat = new Float32Array(graph.nodes.length) + + for (const node of graph.nodes) { + const offset = node.id * 3 + positions[offset] = node.x + positions[offset + 1] = node.y + positions[offset + 2] = node.z + writeColor(colors, offset, node.color) + sizes[node.id] = node.size + heat[node.id] = 0 + } + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)) + geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)) + geometry.setAttribute('aHeat', new THREE.BufferAttribute(heat, 1)) + + return geometry +} + +const createLineGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => { + const positions = new Float32Array(graph.edges.length * 6) + const colors = new Float32Array(graph.edges.length * 6) + + for (let index = 0; index < graph.edges.length; index += 1) { + const edge = graph.edges[index] + + if (!edge) { + continue + } + + const source = graph.nodes[edge.source] + const target = graph.nodes[edge.target] + + if (!source || !target) { + continue + } + + const offset = index * 6 + positions[offset] = source.x + positions[offset + 1] = source.y + positions[offset + 2] = source.z + positions[offset + 3] = target.x + positions[offset + 4] = target.y + positions[offset + 5] = target.z + writeColor(colors, offset, source.color, 0.58) + writeColor(colors, offset + 3, target.color, 0.58) + } + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)) + + return geometry +} + +const getPointerWorld = ( + event: PointerEvent, + canvas: HTMLCanvasElement, + camera: THREE.OrthographicCamera, +): THREE.Vector3 => { + const rect = canvas.getBoundingClientRect() + const pointer = new THREE.Vector3( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + 0, + ) + + pointer.unproject(camera) + pointer.z = 0 + + return pointer +} + +const findNearestNode = ( + graph: CalciferGraphData, + state: ReturnType, + point: THREE.Vector3, + threshold: number, +): number => { + let nearest = -1 + let nearestDistance = threshold + + for (let index = 0; index < graph.nodes.length; index += 1) { + const node = state.nodes[index] + + if (!node) { + continue + } + + const distance = Math.hypot(node.x - point.x, node.y - point.y) + + if (distance < nearestDistance) { + nearest = index + nearestDistance = distance + } + } + + return nearest +} + +export const mountCalciferGraph = ( + host: HTMLElement, + canvas: HTMLCanvasElement, +): (() => void) => { + const graph = createCalciferGraph({ nodeCount: getQualityNodeCount() }) + const state = createPhysicsState(graph) + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: !window.matchMedia('(pointer: coarse)').matches, + powerPreference: 'high-performance', + }) + const scene = new THREE.Scene() + const camera = new THREE.OrthographicCamera(-1.45, 1.45, 1.35, -1.35, 0.1, 20) + camera.position.z = 5 + + const pointGeometry = createPointGeometry(graph) + const pointMaterial = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + transparent: true, + vertexColors: true, + depthWrite: false, + depthTest: false, + uniforms: { + uPixelRatio: { value: 1 }, + uViewportScale: { value: 1 }, + }, + }) + const points = new THREE.Points(pointGeometry, pointMaterial) + points.renderOrder = 2 + + const lineGeometry = createLineGeometry(graph) + const lineMaterial = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.34, + depthWrite: false, + depthTest: false, + blending: THREE.AdditiveBlending, + }) + const lines = new THREE.LineSegments(lineGeometry, lineMaterial) + lines.renderOrder = 1 + + scene.add(lines, points) + + let previousFrameTime = performance.now() + let elapsedSeconds = 0 + const resizeObserver = new ResizeObserver(() => resize()) + const positionAttribute = pointGeometry.getAttribute('position') as THREE.BufferAttribute + const colorAttribute = pointGeometry.getAttribute('color') as THREE.BufferAttribute + const heatAttribute = pointGeometry.getAttribute('aHeat') as THREE.BufffWrAttribute + const sizeAttribute = pointGeometry.getAttribute('aSize') as THREE.BufferAttribute + const linePositionAttribute = lineGeometry.getAttribute('position') as THREE.BufferAttribute + const lineColorAttribute = lineGeometry.getAttribute('color') as THREE.BufferAttribute + let frameId = 0 + let disposed = false + let paused = document.hidden + let draggedIndex = -1 + let draggedPoint = new THREE.Vector3() + let dragInfluence: Float32Array | undefined + let pointerSample: PointerSample | undefined + + const resize = (): void => { + const width = Math.max(1, host.clientWidth) + const height = Math.max(1, host.clientHeight) + const pixelRatio = Math.min(window.devicePixelRatio, width < 700 ? 1.25 : 1.6) + const aspect = width / height + const vertical = 1.28 + + camera.top = vertical + camera.bottom = -vertical + camera.left = -vertical * aspect + camera.right = vertical * aspect + camera.updateProjectionMatrix() + + renderer.setPixelRatio(pixelRatio) + renderer.setSize(width, height, false) + pointMaterial.uniforms.uPixelRatio = { value: pixelRatio } + pointMaterial.uniforms.uViewportScale = { + value: clamp(Math.min(width, height) / 620, 0.78, 1.18), + } + } + + const setTorch = (event: PointerEvent, intensity: number): void => { + const rect = host.getBoundingClientRect() + const x = clamp(event.clientX - rect.left, 0, rect.width) + const y = clamp(event.clientY - rect.top, 0, rect.height) + host.style.setProperty('--torch-x', `${x}px`) + host.style.setProperty('--torch-y', `${y}px`) + host.style.setProperty('--torch-intensity', intensity.toFixed(3)) + } + + const updateGeometry = (elapsed: number): void => { + const heatArray = heatAttribute.array as Float32Array + const sizeArray = sizeAttribute.array as Float32Array + const colorArray = colorAttribute.array as Float32Array + + for (let index = 0; index < graph.nodes.length; index += 1) { + const seed = graph.nodes[index] + const node = state.nodes[index] + + if (!seed || !node) { + continue + } + + const offset = index * 3 + const influence = dragInfluence?.[index] ?? 0 + const flicker = + seed.role === 'flame' || seed.role === 'arm' + ? (Math.sin(elapsed * 4.3 + seed.phase) + Math.sin(elapsed * 7.1 + seed.phase * 1.7)) * + 0.055 + : 0 + const activeHeat = clamp(influence * 0.9 + Math.max(0, flicker), 0, 1) + const brightness = 1 + activeHeat * 0.2 + + positionAttribute.setXYZ(index, node.x, node.y, node.z) + writeColor(colorArray, offset, seed.color, brightness) + heatArray[index] = activeHeat + sizeArray[index] = seed.size * (1 + flicker * 0.6 + influence * 0.18) + } + + for (let index = 0; index < graph.edges.length; index += 1) { + const edge = graph.edges[index] + + if (!edge) { + continue + } + + const source = state.nodes[edge.source] + const target = state.nodes[edge.target] + const sourceSeed = graph.nodes[edge.source] + const targetSeed = graph.nodes[edge.target] + + if (!source || !target || !sourceSeed || !targetSeed) { + continue + } + + const offset = index * 6 + const edgeHeat = Math.max(dragInfluence?.[edge.source] ?? 0, dragInfluence?.[edge.target] ?? 0) + const multiplier = 0.56 + edgeHeat * 0.62 + + linePositionAttribute.setXYZ(index * 2, source.x, source.y, source.z) + linePositionAttribute.setXYZ(index * 2 + 1, target.x, target.y, target.z) + writeColor(lineColorAttribute.array as Float32Array, offset, sourceSeed.color, multiplier) + writeColor(lineColorAttribute.array as Float32Array, offset + 3, targetSeed.color, multiplier) + } + + positionAttribute.needsUpdate = true + colorAttribute.needsUpdate = true + heatAttribute.needsUpdate = true + sizeAttribute.needsUpdate = true + linePositionAttribute.needsUpdate = true + lineColorAttribute.needsUpdate = true + } + + const animate = (): void => { + if (disposed) { + return + } + + const currentFrameTime = performance.now() + const delta = Math.min((currentFrameTime - previousFrameTime) / 1000, 1 / 30) + previousFrameTime = currentFrameTime + + if (!paused) { + elapsedSeconds += delta + stepGraphPhysics(state, graph.edges, elapsedSeconds, delta, { + draggedIndex: draggedIndex >= 0 ? draggedIndex : undefined, + draggedX: draggedIndex >= 0 ? draggedPoint.x : undefined, + draggedY: draggedIndex >= 0 ? draggedPoint.y : undefined, + dragInfluence, + }) + updateGeometry(elapsedSeconds) + renderer.render(scene, camera) + } + + frameId = window.requestAnimationFrame(animate) + } + + const onPointerDown = (event: PointerEvent): void => { + const point = getPointerWorld(event, canvas, camera) + const threshold = event.pointerType === 'touch' ? 0.24 : 0.16 + const nearest = findNearestNode(graph, state, point, threshold) + + if (nearest < 0) { + return + } + + event.preventDefault() + draggedIndex = nearest + draggedPoint = point + dragInfluence = createDragInfluence(graph.nodes.length, graph.edges, nearest) + pointerSample = { x: event.clientX, y: event.clientY, time: performance.now() } + canvas.setPointerCapture(event.pointerId) + canvas.style.cursor = 'grabbing' + host.dataset.dragging = 'true' + setTorch(event, 0.72) + } + + const onPointerMove = (event: PointerEvent): void => { + if (draggedIndex < 0) { + const point = getPointerWorld(event, canvas, camera) + canvas.style.cursor = findNearestNode(graph, state, point, 0.16) >= 0 ? 'grab' : 'default' + return + } + + event.preventDefault() + draggedPoint = getPointerWorld(event, canvas, camera) + const now = performance.now() + const previous = pointerSample + const elapsed = Math.max(8, now - (previous?.time ?? now)) + const distance = Math.hypot( + event.clientX - (previous?.x ?? event.clientX), + event.clientY - (previous?.y ?? event.clientY), + ) + const speed = distance / elapsed + const intensity = clamp(0.58 + speed * 0.7, 0.58, 1) + + pointerSample = { x: event.clientX, y: event.clientY, time: now } + setTorch(event, intensity) + } + + const releasePointer = (event: PointerEvent): void => { + if (draggedIndex < 0) { + return + } + + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId) + } + + draggedIndex = -1 + dragInfluence = undefined + pointerSample = undefined + canvas.style.cursor = 'default' + host.dataset.dragging = 'false' + host.style.setProperty('--torch-intensity', '0') + } + + const onVisibilityChange = (): void => { + paused = document.hidden + } + + const onContextLost = (event: Event): void => { + event.preventDefault() + host.dataset.failed = 'true' + host.dataset.ready = 'false' + } + + const onContextRestored = (): void => { + host.dataset.failed = 'false' + resize() + } + + canvas.addEventListener('pointerdown', onPointerDown) + canvas.addEventListener('pointermove', onPointerMove) + canvas.addEventListener('pointerup', releasePointer) + canvas.addEventListener('pointercancel', releasePointer) + canvas.addEventListener('webglcontextlost', onContextLost) + canvas.addEventListener('webglcontextrestored', onContextRestored) + document.addEventListener('visibilitychange', onVisibilityChange) + resizeObserver.observe(host) + resize() + updateGeometry(0) + renderer.render(scene, camera) + host.dataset.ready = 'true' + host.dataset.failed = 'false' + frameId = window.requestAnimationFrame(animate) + + return () => { + disposed = true + window.cancelAnimationFrame(frameId) + resizeObserver.disconnect() + canvas.removeEventListener('pointerdown', onPointerDown) + canvas.removeEventListener('pointermove', onPointerMove) + canvas.removeEventListener('pointerup', releasePointer) + canvas.removeEventListener('pointercancel', releasePointer) + canvas.removeEventListener('webglcontextlost', onContextLost) + canvas.removeEventListener('webglcontextrestored', onContextRestored) + document.removeEventListener('visibilitychange', onVisibilityChange) + pointGeometry.dispose() + pointMaterial.dispose() + lineGeometry.dispose() + lineMaterial.dispose() + renderer.dispose() + } +} diff --git a/src/scripts/graph/physics.ts b/src/scripts/graph/physics.ts new file mode 100644 index 0000000..16ff6ec --- /dev/null +++ b/src/scripts/graph/physics.ts @@ -0,0 +1,163 @@ +import type { + CalciferGraphData, + GraphEdgeSeed, + PhysicsState, + PhysicsStepOptions, +} from './types' + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)) + +export const createPhysicsState = (graph: CalciferGraphData): PhysicsState => ({ + nodes: graph.nodes.map((node) => ({ + x: node.x, + y: node.y, + z: node.z, + targetX: node.x, + targetY: node.y, + targetZ: node.z, + vx: 0, + vy: 0, + vz: 0, + anchorStrength: node.anchorStrength, + phase: node.phase, + role: node.role, + })), +}) + +const applyEdgeSpring = ( + state: PhysicsState, + edge: GraphEdgeSeed, + accelerationX: Float32Array, + accelerationY: Float32Array, + accelerationZ: Float32Array, +): void => { + const source = state.nodes[edge.source] + const target = state.nodes[edge.target] + + if (!source || !target) { + return + } + + const dx = target.x - source.x + const dy = target.y - source.y + const dz = target.z - source.z + const distance = Math.max(0.0001, Math.hypot(dx, dy, dz)) + const displacement = distance - edge.restLength + const force = displacement * edge.stiffness + const inverseDistance = 1 / distance + const forceX = dx * inverseDistance * force + const forceY = dy * inverseDistance * force + const forceZ = dz * inverseDistance * force + + accelerationX[edge.source] = (accelerationX[edge.source] ?? 0) + forceX + accelerationY[edge.source] = (accelerationY[edge.source] ?? 0) + forceY + accelerationZ[edge.source] = (accelerationZ[edge.source] ?? 0) + forceZ + accelerationX[edge.target] = (accelerationX[edge.target] ?? 0) - forceX + accelerationY[edge.target] = (accelerationY[edge.target] ?? 0) - forceY + accelerationZ[edge.target] = (accelerationZ[edge.target] ?? 0) - forceZ +} + +export const stepGraphPhysics = ( + state: PhysicsState, + edges: GraphEdgeSeed[], + elapsedSeconds: number, + deltaSeconds: number, + options: PhysicsStepOptions = {}, +): void => { + const delta = clamp(deltaSeconds, 1 / 240, 1 / 30) + const count = state.nodes.length + const accelerationX = new Float32Array(count) + const accelerationY = new Float32Array(count) + const accelerationZ = new Float32Array(count) + const motionScale = options.motionScale ?? 1 + + for (const edge of edges) { + applyEdgeSpring(state, edge, accelerationX, accelerationY, accelerationZ) + } + + for (let index = 0; index < count; index += 1) { + const node = state.nodes[index] + + if (!node) { + continue + } + + if (index === options.draggedIndex) { + node.x = options.draggedX ?? node.x + node.y = options.draggedY ?? node.y + node.vx = 0 + node.vy = 0 + node.vz = 0 + continue + } + + const faceScale = node.role === 'flame' || node.role === 'arm' ? 1 : 0.2 + const driftX = Math.sin(elapsedSeconds * 1.17 + node.phase) * 0.013 * faceScale * motionScale + const driftY = Math.cos(elapsedSeconds * 1.43 + node.phase * 1.31) * 0.015 * faceScale * motionScale + const influence = options.dragInfluence?.[index] ?? 0 + const anchorScale = 1 - influence * 0.72 + const anchor = node.anchorStrength * anchorScale + const targetX = node.targetX + driftX + const targetY = node.targetY + driftY + const targetZ = node.targetZ + Math.sin(elapsedSeconds * 0.88 + node.phase) * 0.008 * faceScale + + accelerationX[index] = + (accelerationX[index] ?? 0) + (targetX - node.x) * anchor + accelerationY[index] = + (accelerationY[index] ?? 0) + (targetY - node.y) * anchor + accelerationZ[index] = + (accelerationZ[index] ?? 0) + (targetZ - node.z) * anchor + + node.vx += (accelerationX[index] ?? 0) * delta + node.vy += (accelerationY[index] ?? 0) * delta + node.vz += (accelerationZ[index] ?? 0) * delta + + const damping = Math.exp(-5.8 * delta) + node.vx *= damping + node.vy *= damping + node.vz *= damping + + node.x += node.vx * delta + node.y += node.vy * delta + node.z += node.vz * delta + } +} + +export const createDragInfluence = ( + nodeCount: number, + edges: GraphEdgeSeed[], + draggedIndex: number, +): Float32Array => { + const adjacency = Array.from({ length: nodeCount }, () => [] as number[]) + + for (const edge of edges) { + adjacency[edge.source]?.push(edge.target) + adjacency[edge.target]?.push(edge.source) + } + + const influence = new Float32Array(nodeCount) + const queue: Array<{ index: number; depth: number }> = [{ index: draggedIndex, depth: 0 }] + const visited = new Set([draggedIndex]) + + while (queue.length > 0) { + const current = queue.shift() + + if (!current || current.depth > 4) { + continue + } + + influence[current.index] = Math.max(influence[current.index] ?? 0, Math.pow(0.58, current.depth)) + + for (const neighbour of adjacency[current.index] ?? []) { + if (visited.has(neighbour)) { + continue + } + + visited.add(neighbour) + queue.push({ index: neighbour, depth: current.depth + 1 }) + } + } + + return influence +} diff --git a/src/scripts/graph/random.ts b/src/scripts/graph/random.ts new file mode 100644 index 0000000..8a5022a --- /dev/null +++ b/src/scripts/graph/random.ts @@ -0,0 +1,11 @@ +export const createRandom = (seed: number): (() => number) => { + let state = seed >>> 0 + + return () => { + state += 0x6d2b79f5 + let value = state + value = Math.imul(value ^ (value >>> 15), value | 1) + value ^= value + Math.imul(value ^ (value >>> 7), value | 61) + return ((value ^ (value >>> 14)) >>> 0) / 4294967296 + } +} diff --git a/src/scripts/graph/types.ts b/src/scripts/graph/types.ts new file mode 100644 index 0000000..f4252d8 --- /dev/null +++ b/src/scripts/graph/types.ts @@ -0,0 +1,54 @@ +export type GraphNodeRole = 'flame' | 'arm' | 'eye' | 'pupil' | 'mouth' + +export type Rgb = readonly [number, number, number] + +export interface GraphNodeSeed { + id: number + role: GraphNodeRole + x: number + y: number + z: number + size: number + color: Rgb + anchorStrength: number + phase: number +} + +export interface GraphEdgeSeed { + source: number + target: number + restLength: number + stiffness: number +} + +export interface CalciferGraphData { + nodes: GraphNodeSeed[] + edges: GraphEdgeSeed[] +} + +export interface PhysicsNodeState { + x: number + y: number + z: number + targetX: number + targetY: number + targetZ: number + vx: number + vy: number + vz: number + anchorStrength: number + phase: number + role: GraphNodeRole +} + +export interface PhysicsState { + nodes: PhysicsNodeState[] +} + +export interface PhysicsStepOptions { + draggedIndex?: number | undefined + draggedX?: number | undefined + draggedY?: number | undefined + dragInfluence?: Float32Array | undefined + motionScale?: number | undefined +} From fdd9676f80cc8811331ec273348a87168b6c76f5 Mon Sep 17 00:00:00 2001 From: Yevgeniy Tyan <102475316+ytvee@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:41:22 +0300 Subject: [PATCH 3/5] test: verify graph generation and interaction physics --- .github/workflows/ci.yml | 23 +++++++++++++++ docs/animation-architecture.md | 46 ++++++++++++++++++++++++++++++ tests/generateGraph.test.ts | 40 ++++++++++++++++++++++++++ tests/physics.test.ts | 52 ++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/animation-architecture.md create mode 100644 tests/generateGraph.test.ts create mode 100644 tests/physics.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee9a092 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.12.0 + cache: npm + - run: npm install --no-audit --no-fund + - run: npm test + - run: npm run build diff --git a/docs/animation-architecture.md b/docs/animation-architecture.md new file mode 100644 index 0000000..5daa2c0 --- /dev/null +++ b/docs/animation-architecture.md @@ -0,0 +1,46 @@ +# Calcifer graph hero + +## Rendering + +The section is statically rendered by Astro. The heading, description, actions and technology labels are present in the first HTML response. The animated canvas is decorative and does not contain SEO-critical content. + +The graph uses two Three.js draw calls: + +- `THREE.Points` with a custom shader for every node; +- `THREE.LineSegments` for every connection. + +No post-processing pass is used. Soft glow is calculated inside the point fragment shader, which avoids an additional framebuffer and reduces GPU cost on laptops and mobile devices. + +## Shape generation + +The flame body is generated deterministically from a seeded pseudo-random function. Points are accepted inside a union of a rounded body and five tapered flame tongues. Arms, eyes, pupils and the mouth are generated from explicit curves so the face remains readable at every quality level. + +The graph connects flame points to nearby neighbours. Face rings and mouth curves have stronger internal springs. Each facial group is also attached to nearby flame nodes. + +## Physics + +Every node stores its current position, velocity and immutable target position. Each frame applies: + +1. spring forces along graph edges; +2. a target-anchor force that preserves the silhouette; +3. velocity damping; +4. a very small role-aware idle drift. + +While a node is dragged, its target anchor is weakened for connected nodes up to four graph steps away. Influence decays by graph distance. The selected node follows the pointer directly, while its neighbours follow through the spring network. After release, target anchors restore the original silhouette. + +## Torch reveal + +The code wall is a DOM layer behind the canvas. It remains transparent until a node is dragged. Pointer position updates CSS custom properties that control a radial mask. Pointer speed controls opacity, producing a torch-like reveal without rendering code into WebGL. + +## Loading and fallbacks + +The first render displays an inline SVG graph generated from the same deterministic model. Three.js is code-split and loaded after browser idle time or the first pointer interaction. + +The implementation also: + +- caps device pixel ratio; +- reduces node count for coarse pointers and small viewports; +- pauses animation when the document is hidden; +- disposes GPU resources on disconnect; +- preserves the SVG fallback after WebGL context failure; +- keeps the static SVG when `prefers-reduced-motion: reduce` is enabled. diff --git a/tests/generateGraph.test.ts b/tests/generateGraph.test.ts new file mode 100644 index 0000000..ebb0455 --- /dev/null +++ b/tests/generateGraph.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createCalciferGraph } from '../src/scripts/graph/generateGraph' + +test('graph generation is deterministic for a fixed seed', () => { + const first = createCalciferGraph({ nodeCount: 180, seed: 42 }) + const second = createCalciferGraph({ nodeCount: 180, seed: 42 }) + + assert.deepEqual(first.nodes, second.nodes) + assert.deepEqual(first.edges, second.edges) +}) + +test('graph contains a connected flame, face and arms', () => { + const graph = createCalciferGraph({ nodeCount: 180, seed: 7 }) + const roles = new Set(graph.nodes.map((node) => node.role)) + const degree = new Uint16Array(graph.nodes.length) + + for (const edge of graph.edges) { + degree[edge.source] = (degree[edge.source] ?? 0) + 1 + degree[edge.target] = (degree[edge.target] ?? 0) + 1 + } + + assert.ok(graph.nodes.length >= 170) + assert.deepEqual(roles, new Set(['flame', 'arm', 'eye', 'pupil', 'mouth'])) + assert.ok(Array.from(degree).every((value) => value > 0)) + assert.ok(graph.nodes.every((node) => node.x >= -1.3 && node.x <= 1.3)) + assert.ok(graph.nodes.every((node) => node.y >= -1 && node.y <= 1.2)) +}) + +test('all edges reference valid distinct nodes', () => { + const graph = createCalciferGraph({ nodeCount: 220, seed: 2026 }) + + for (const edge of graph.edges) { + assert.notEqual(edge.source, edge.target) + assert.ok(edge.source >= 0 && edge.source < graph.nodes.length) + assert.ok(edge.target >= 0 && edge.target < graph.nodes.length) + assert.ok(edge.restLength > 0) + assert.ok(edge.stiffness > 0) + } +}) diff --git a/tests/physics.test.ts b/tests/physics.test.ts new file mode 100644 index 0000000..8ade16c --- /dev/null +++ b/tests/physics.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createCalciferGraph } from '../src/scripts/graph/generateGraph' +import { createDragInfluence, createPhysicsState, stepGraphPhysics } from '../src/scripts/graph/physics' + +test('anchor force returns a displaced node toward its target', () => { + const graph = createCalciferGraph({ nodeCount: 80, seed: 3 }) + const state = createPhysicsState(graph) + const node = state.nodes[0] + + if (!node) { + throw new Error('Expected at least one physics node') + } + + node.x += 0.8 + const initialDistance = Math.abs(node.x - node.targetX) + + for (let step = 0; step < 180; step += 1) { + stepGraphPhysics(state, graph.edges, step / 60, 1 / 60) + } + + assert.ok(Math.abs(node.x - node.targetX) < initialDistance) +}) + +test('drag influence decays over graph distance', () => { + const graph = createCalciferGraph({ nodeCount: 140, seed: 9 }) + const source = graph.edges[0]?.source + const target = graph.edges[0]?.target + + assert.notEqual(source, undefined) + assert.notEqual(target, undefined) + + const influence = createDragInfluence(graph.nodes.length, graph.edges, source ?? 0) + + assert.equal(influence[source ?? 0], 1) + assert.ok((influence[target ?? 0] ?? 0) > 0) + assert.ok((influence[target ?? 0] ?? 0) < 1) +}) + +test('dragged node is pinned to the pointer target', () => { + const graph = createCalciferGraph({ nodeCount: 100, seed: 11 }) + const state = createPhysicsState(graph) + + stepGraphPhysics(state, graph.edges, 1, 1 / 60, { + draggedIndex: 0, + draggedX: 0.91, + draggedY: -0.73, + }) + + assert.equal(state.nodes[0]?.x, 0.91) + assert.equal(state.nodes[0]?.y, -0.73) +}) From 4a699f9501ef87fedab410ba5fd5ace610e08df0 Mon Sep 17 00:00:00 2001 From: Yevgeniy Tyan <102475316+ytvee@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:42:36 +0300 Subject: [PATCH 4/5] fix: correct Three.js buffer attribute type --- src/scripts/graph/mountCalciferGraph.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scripts/graph/mountCalciferGraph.ts b/src/scripts/graph/mountCalciferGraph.ts index bc08cd1..1617a93 100644 --- a/src/scripts/graph/mountCalciferGraph.ts +++ b/src/scripts/graph/mountCalciferGraph.ts @@ -223,7 +223,7 @@ export const mountCalciferGraph = ( const resizeObserver = new ResizeObserver(() => resize()) const positionAttribute = pointGeometry.getAttribute('position') as THREE.BufferAttribute const colorAttribute = pointGeometry.getAttribute('color') as THREE.BufferAttribute - const heatAttribute = pointGeometry.getAttribute('aHeat') as THREE.BufffWrAttribute + const heatAttribute = pointGeometry.getAttribute('aHeat') as THREE.BufferAttribute const sizeAttribute = pointGeometry.getAttribute('aSize') as THREE.BufferAttribute const linePositionAttribute = lineGeometry.getAttribute('position') as THREE.BufferAttribute const lineColorAttribute = lineGeometry.getAttribute('color') as THREE.BufferAttribute @@ -260,6 +260,7 @@ export const mountCalciferGraph = ( const rect = host.getBoundingClientRect() const x = clamp(event.clientX - rect.left, 0, rect.width) const y = clamp(event.clientY - rect.top, 0, rect.height) + host.style.setProperty('--torch-x', `${x}px`) host.style.setProperty('--torch-y', `${y}px`) host.style.setProperty('--torch-intensity', intensity.toFixed(3)) From 7893bfc87d4b9b6c3cf1dab25fc5a03586474266 Mon Sep 17 00:00:00 2001 From: Yevgeniy Tyan <102475316+ytvee@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:43:20 +0300 Subject: [PATCH 5/5] ci: run verification without lockfile cache --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee9a092..0a2388d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22.12.0 - cache: npm - run: npm install --no-audit --no-fund - run: npm test - run: npm run build