mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Replace static greeting with animated node graph visualization behind the greeting text. Adds GraphAnimation component with procedural graph generation, smooth node motion, and gradient backdrop effect.
183 lines
5.9 KiB
Svelte
183 lines
5.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { fade } from "svelte/transition";
|
|
|
|
const NODE_COUNT = 20;
|
|
const EXTRA_EDGE_TARGET = 12;
|
|
const MIN_DIST = 0.15;
|
|
const SHADES = [
|
|
"var(--text-normal)",
|
|
"var(--text-muted)",
|
|
"var(--text-faint)",
|
|
"var(--background-modifier-border)",
|
|
"var(--interactive-accent)",
|
|
"var(--color-accent)",
|
|
];
|
|
const EDGE_STROKE = "var(--text-faint)";
|
|
const ACCENT_EDGE_STROKE = "var(--interactive-accent)";
|
|
const ACCENT_EDGE_CHANCE = 0.2;
|
|
|
|
type GraphNode = { x: number; y: number; r: number; shade: string };
|
|
type GraphEdge = { a: number; b: number; stroke: string };
|
|
type Motion = { ax: number; ay: number; fa: number; fb: number; pa: number; pb: number };
|
|
type RenderNode = { cx: number; cy: number; r: number; fill: string; opacity: number };
|
|
type RenderEdge = { x1: number; y1: number; x2: number; y2: number; opacity: number; stroke: string };
|
|
|
|
function generateGraph(n: number): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
|
const pts: { x: number; y: number }[] = [];
|
|
let attempts = 0;
|
|
while (pts.length < n && attempts < 5000) {
|
|
attempts++;
|
|
const x = 0.07 + Math.random() * 0.86;
|
|
const y = 0.07 + Math.random() * 0.86;
|
|
let ok = true;
|
|
for (const p of pts) {
|
|
const dx = p.x - x, dy = p.y - y;
|
|
if (Math.sqrt(dx * dx + dy * dy) < MIN_DIST) { ok = false; break; }
|
|
}
|
|
if (ok) pts.push({ x, y });
|
|
}
|
|
while (pts.length < n) {
|
|
pts.push({ x: 0.1 + Math.random() * 0.8, y: 0.1 + Math.random() * 0.8 });
|
|
}
|
|
|
|
const nodes: GraphNode[] = pts.map(p => ({
|
|
x: p.x,
|
|
y: p.y,
|
|
r: 2.6 + Math.random() * 4.2,
|
|
shade: SHADES[Math.floor(Math.random() * SHADES.length)],
|
|
}));
|
|
|
|
const order = [...Array(n).keys()];
|
|
for (let i = order.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[order[i], order[j]] = [order[j], order[i]];
|
|
}
|
|
const inTree = new Set([order[0]]);
|
|
const edges: [number, number][] = [];
|
|
for (let k = 1; k < order.length; k++) {
|
|
const node = order[k];
|
|
const treeArr = [...inTree];
|
|
let best = treeArr[0], bestD = Infinity;
|
|
for (const cand of treeArr) {
|
|
const dx = nodes[cand].x - nodes[node].x, dy = nodes[cand].y - nodes[node].y;
|
|
const d = dx * dx + dy * dy;
|
|
if (d < bestD) { bestD = d; best = cand; }
|
|
}
|
|
edges.push([node, best]);
|
|
inTree.add(node);
|
|
}
|
|
|
|
const degree = new Array(n).fill(0);
|
|
edges.forEach(([a, b]) => { degree[a]++; degree[b]++; });
|
|
const leaves: number[] = [];
|
|
for (let i = 0; i < n; i++) if (degree[i] === 1) leaves.push(i);
|
|
const protectedLeaves = new Set(leaves.slice(0, Math.max(2, Math.min(3, leaves.length))));
|
|
|
|
const edgeKey = (a: number, b: number) => (a < b ? a + "-" + b : b + "-" + a);
|
|
const existing = new Set(edges.map(([a, b]) => edgeKey(a, b)));
|
|
let added = 0, tries = 0;
|
|
while (added < EXTRA_EDGE_TARGET && tries < 600) {
|
|
tries++;
|
|
const a = Math.floor(Math.random() * n);
|
|
const b = Math.floor(Math.random() * n);
|
|
if (a === b) continue;
|
|
if (protectedLeaves.has(a) || protectedLeaves.has(b)) continue;
|
|
const key = edgeKey(a, b);
|
|
if (existing.has(key)) continue;
|
|
const dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
|
|
if (Math.sqrt(dx * dx + dy * dy) > 0.4) continue;
|
|
existing.add(key);
|
|
edges.push([a, b]);
|
|
added++;
|
|
}
|
|
|
|
const coloredEdges: GraphEdge[] = edges.map(([a, b]) => ({
|
|
a,
|
|
b,
|
|
stroke: Math.random() < ACCENT_EDGE_CHANCE ? ACCENT_EDGE_STROKE : EDGE_STROKE,
|
|
}));
|
|
|
|
return { nodes, edges: coloredEdges };
|
|
}
|
|
|
|
function makeMotion(nodesArr: GraphNode[]): Motion[] {
|
|
return nodesArr.map(() => ({
|
|
ax: 0.03 + Math.random() * 0.03,
|
|
ay: 0.026 + Math.random() * 0.03,
|
|
fa: 0.16 + Math.random() * 0.2,
|
|
fb: 0.45 + Math.random() * 0.32,
|
|
pa: Math.random() * Math.PI * 2,
|
|
pb: Math.random() * Math.PI * 2,
|
|
}));
|
|
}
|
|
|
|
const graph = generateGraph(NODE_COUNT);
|
|
const motion = makeMotion(graph.nodes);
|
|
|
|
const VIEW_SIZE = 460;
|
|
let renderNodes: RenderNode[] = [];
|
|
let renderEdges: RenderEdge[] = [];
|
|
let rafId: number | undefined;
|
|
let startTime: number | undefined;
|
|
|
|
function computeFrame(t: number) {
|
|
const nodes: RenderNode[] = graph.nodes.map((n, i) => {
|
|
const m = motion[i];
|
|
const dx = m.ax * Math.sin(t * m.fa + m.pa) + m.ax * 0.4 * Math.sin(t * m.fb + m.pb);
|
|
const dy = m.ay * Math.cos(t * m.fa * 0.9 + m.pb) + m.ay * 0.4 * Math.cos(t * m.fb * 1.1 + m.pa);
|
|
return {
|
|
cx: (n.x + dx) * VIEW_SIZE,
|
|
cy: (n.y + dy) * VIEW_SIZE,
|
|
r: n.r,
|
|
fill: n.shade,
|
|
opacity: 0.55 + 0.4 * Math.sin(t * 0.65 + i * 1.3),
|
|
};
|
|
});
|
|
const edges: RenderEdge[] = graph.edges.map((e, i) => ({
|
|
x1: nodes[e.a].cx,
|
|
y1: nodes[e.a].cy,
|
|
x2: nodes[e.b].cx,
|
|
y2: nodes[e.b].cy,
|
|
opacity: 0.26 + 0.16 * Math.sin(t * 0.4 + i),
|
|
stroke: e.stroke,
|
|
}));
|
|
renderNodes = nodes;
|
|
renderEdges = edges;
|
|
}
|
|
|
|
onMount(() => {
|
|
const loop = (now: number) => {
|
|
if (startTime === undefined) startTime = now;
|
|
computeFrame((now - startTime) / 1000);
|
|
rafId = requestAnimationFrame(loop);
|
|
};
|
|
rafId = requestAnimationFrame(loop);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (rafId !== undefined) cancelAnimationFrame(rafId);
|
|
});
|
|
</script>
|
|
|
|
<svg
|
|
class="graph-animation"
|
|
viewBox="0 0 {VIEW_SIZE} {VIEW_SIZE}"
|
|
preserveAspectRatio="xMidYMid meet"
|
|
in:fade={{ duration: 400 }}
|
|
>
|
|
{#each renderEdges as e}
|
|
<line x1={e.x1} y1={e.y1} x2={e.x2} y2={e.y2} stroke={e.stroke} stroke-width="1.2" style="stroke-linecap: round; opacity: {e.opacity}"></line>
|
|
{/each}
|
|
{#each renderNodes as n}
|
|
<circle cx={n.cx} cy={n.cy} r={n.r} fill={n.fill} style="opacity: {n.opacity}"></circle>
|
|
{/each}
|
|
</svg>
|
|
|
|
<style>
|
|
.graph-animation {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
</style>
|