Skip to content
Merged
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
35 changes: 25 additions & 10 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,19 @@ body {
position: absolute;
display: block;
border-radius: 50%;
filter: blur(82px);
mix-blend-mode: multiply;
background: radial-gradient(circle at 50% 50%, var(--accent), transparent 66%);
/* Softness lives in the gradient falloff itself — a `filter: blur()` here
forces the GPU to re-filter a ~52vw texture every animation frame. */
background: radial-gradient(
circle at 50% 50%,
var(--accent),
color-mix(in oklab, var(--accent) 45%, transparent) 34%,
transparent 72%
);
will-change: transform;
}
.dark .aurora b {
mix-blend-mode: screen;
filter: blur(72px);
}
.aurora .a1 {
width: 52vw;
Expand Down Expand Up @@ -124,11 +129,19 @@ body {
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
mask-image: radial-gradient(120% 100% at 50% 0%, #000 30%, transparent 78%);
}
/* Grid lives on an oversized child so drift can animate `transform` (composited)
instead of `background-position` (repaints every frame). */
.blueprint::before {
content: "";
position: absolute;
inset: -46px;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 46px 46px;
mask-image: radial-gradient(120% 100% at 50% 0%, #000 30%, transparent 78%);
}
.hero-net {
position: absolute;
Expand All @@ -149,9 +162,13 @@ body {
position: absolute;
display: block;
border-radius: 50%;
filter: blur(90px);
mix-blend-mode: multiply;
background: radial-gradient(circle at 50% 50%, var(--accent), transparent 64%);
background: radial-gradient(
circle at 50% 50%,
var(--accent),
color-mix(in oklab, var(--accent) 45%, transparent) 32%,
transparent 70%
);
will-change: transform;
}
.dark .section-glow b {
Expand Down Expand Up @@ -196,7 +213,7 @@ body {
.aurora .a3 {
animation: bloom-3 15s ease-in-out infinite alternate;
}
.blueprint {
.blueprint::before {
animation: grid-drift 36s linear infinite;
}
}
Expand Down Expand Up @@ -226,9 +243,7 @@ body {
}
@keyframes grid-drift {
to {
background-position:
46px 46px,
46px 46px;
transform: translate(46px, 46px);
}
}

Expand Down
62 changes: 41 additions & 21 deletions src/components/hero-net.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export function HeroNet() {

let W = 0;
let H = 0;
let heroLeft = 0;
let heroTopDoc = 0;
let nodes: Node[] = [];
let rgb: [number, number, number] = [31, 157, 84];
let raf: number | null = null;
Expand All @@ -51,6 +53,8 @@ export function HeroNet() {
const r = hero.getBoundingClientRect();
W = Math.max(1, r.width);
H = Math.max(1, r.height);
heroLeft = r.left;
heroTopDoc = r.top + window.scrollY;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
canvas.style.width = `${W}px`;
Expand All @@ -70,8 +74,31 @@ export function HeroNet() {
}
};

// Segments are grouped into a few alpha buckets so each frame issues one
// stroke() per bucket instead of one per link (~300 at full density).
const BUCKETS = 8;
const linkBuckets: number[][] = Array.from({ length: BUCKETS }, () => []);

const bucketFor = (t: number) => Math.min(BUCKETS - 1, Math.floor(t * BUCKETS));

const strokeBuckets = (maxAlpha: number) => {
for (let k = 0; k < BUCKETS; k++) {
const seg = linkBuckets[k];
if (seg.length === 0) continue;
ctx.strokeStyle = rgba(((k + 0.5) / BUCKETS) * maxAlpha);
ctx.beginPath();
for (let s = 0; s < seg.length; s += 4) {
ctx.moveTo(seg[s], seg[s + 1]);
ctx.lineTo(seg[s + 2], seg[s + 3]);
}
ctx.stroke();
seg.length = 0;
}
};

const draw = () => {
ctx.clearRect(0, 0, W, H);
ctx.lineWidth = 1;

for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
Expand All @@ -81,42 +108,34 @@ export function HeroNet() {
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < LINK * LINK) {
const d = Math.sqrt(d2);
ctx.strokeStyle = rgba((1 - d / LINK) * 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
linkBuckets[bucketFor(1 - Math.sqrt(d2) / LINK)].push(a.x, a.y, b.x, b.y);
}
}
}
strokeBuckets(0.22);

if (mouse.on) {
for (const a of nodes) {
const dx = a.x - mouse.x;
const dy = a.y - mouse.y;
const d2 = dx * dx + dy * dy;
if (d2 < MOUSE_LINK * MOUSE_LINK) {
const d = Math.sqrt(d2);
ctx.strokeStyle = rgba((1 - d / MOUSE_LINK) * 0.5);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
a.vx += (dx > 0 ? -1 : 1) * 0.0009 * (1 - d / MOUSE_LINK);
a.vy += (dy > 0 ? -1 : 1) * 0.0009 * (1 - d / MOUSE_LINK);
const t = 1 - Math.sqrt(d2) / MOUSE_LINK;
linkBuckets[bucketFor(t)].push(a.x, a.y, mouse.x, mouse.y);
a.vx += (dx > 0 ? -1 : 1) * 0.0009 * t;
a.vy += (dy > 0 ? -1 : 1) * 0.0009 * t;
}
}
strokeBuckets(0.5);
}

ctx.fillStyle = rgba(0.55);
ctx.beginPath();
for (const a of nodes) {
ctx.beginPath();
ctx.moveTo(a.x + a.r, a.y);
ctx.arc(a.x, a.y, a.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fill();
};

const step = () => {
Expand Down Expand Up @@ -155,10 +174,11 @@ export function HeroNet() {
draw();
}, 150);
};
// Uses offsets cached in build() — hero sits at a fixed document position,
// so a getBoundingClientRect() here would force layout on every move.
const onMove = (e: PointerEvent) => {
const r = hero.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
mouse.x = e.clientX - heroLeft;
mouse.y = e.clientY - (heroTopDoc - window.scrollY);
mouse.on = true;
};
const onLeave = () => {
Expand Down
Loading