agency-web/app/components/RevenueField.tsx

206 lines
6.6 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
/**
* Custom WebGL signature: a flowing "revenue field" — animated contour
* lines that rise and surge like a growth chart / topographic gain map,
* rendered as ink hairlines on paper with an acid-green crest threading
* through. Reacts gently to the pointer.
*
* Constraints honored:
* - "use client", all GL created in useEffect, full cleanup
* - no window/document access during SSR/render
* - pauses RAF when the tab is hidden or canvas is offscreen
* - respects prefers-reduced-motion (renders one static frame, no loop)
* - decorative only (aria-hidden); never the sole carrier of meaning
*/
export default function RevenueField() {
const wrap = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = wrap.current;
if (!host) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const renderer = new Renderer({
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio || 1, 2),
});
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
host.appendChild(gl.canvas);
gl.canvas.style.width = "100%";
gl.canvas.style.height = "100%";
gl.canvas.style.display = "block";
const geometry = new Triangle(gl);
const program = new Program(gl, {
uniforms: {
uTime: { value: 0 },
uRes: { value: new Vec2(1, 1) },
uMouse: { value: new Vec2(0.5, 0.5) },
// ink + emerald crest (the hero sits on light paper, so the crest
// uses the deeper "gain" green to stay perceptible, not invisible lime)
uInk: { value: [0.078, 0.075, 0.059] },
uAcid: { value: [0.039, 0.431, 0.278] },
},
vertex: /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`,
fragment: /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uMouse;
uniform vec3 uInk;
uniform vec3 uAcid;
// cheap value noise
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p){
vec2 i = floor(p); vec2 f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
return mix(mix(hash(i), hash(i+vec2(1,0)), u.x),
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), u.x), u.y);
}
float fbm(vec2 p){
float v = 0.0; float a = 0.5;
for(int i=0;i<5;i++){ v += a*noise(p); p *= 2.0; a *= 0.5; }
return v;
}
void main() {
vec2 uv = vUv;
float aspect = uRes.x / max(uRes.y, 1.0);
vec2 p = uv;
p.x *= aspect;
// mouse influence — a gentle lift around the pointer
vec2 m = uMouse; m.x *= aspect;
float md = distance(p, m);
float lift = smoothstep(0.6, 0.0, md) * 0.18;
float t = uTime * 0.06;
// a rising surface: base height climbs left->right (growth)
float climb = uv.x * 0.55;
float field = fbm(p * 2.4 + vec2(t, t * 0.4)) + climb + lift;
field += fbm(p * 5.0 - vec2(t * 0.7, 0.0)) * 0.25;
// contour lines from the height field
float lines = abs(fract(field * 9.0) - 0.5);
float w = fwidth(field * 9.0) * 1.2;
float contour = 1.0 - smoothstep(0.0, w, lines);
// crest highlight: the topmost band glows acid (the "gain")
float crest = smoothstep(0.62, 0.86, field) * smoothstep(0.96, 0.7, field);
vec3 col = mix(uInk, uAcid, crest * 0.9);
float alpha = contour * (0.20 + crest * 0.85);
// soft right/top fade so type stays readable, lines vignette off edges
alpha *= smoothstep(0.0, 0.18, uv.x);
alpha *= smoothstep(0.0, 0.14, uv.y) * smoothstep(1.0, 0.82, uv.y);
gl_FragColor = vec4(col, alpha);
}
`,
});
const mesh = new Mesh(gl, { geometry, program });
const resize = () => {
const w = host.clientWidth;
const h = host.clientHeight;
renderer.setSize(w, h);
program.uniforms.uRes.value.set(w, h);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(host);
// pointer (eased)
const target = new Vec2(0.5, 0.5);
const onPointer = (e: PointerEvent) => {
const r = host.getBoundingClientRect();
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
window.addEventListener("pointermove", onPointer);
let raf = 0;
let running = true;
const start = performance.now();
const frame = (now: number) => {
if (!running) return;
const u = program.uniforms;
u.uTime.value = (now - start) / 1000;
(u.uMouse.value as Vec2).x += (target.x - (u.uMouse.value as Vec2).x) * 0.05;
(u.uMouse.value as Vec2).y += (target.y - (u.uMouse.value as Vec2).y) * 0.05;
renderer.render({ scene: mesh });
raf = requestAnimationFrame(frame);
};
// visibility: pause GL when tab hidden or canvas scrolled offscreen
const onVisibility = () => {
if (document.hidden) {
running = false;
cancelAnimationFrame(raf);
} else if (!reduce) {
running = true;
raf = requestAnimationFrame(frame);
}
};
document.addEventListener("visibilitychange", onVisibility);
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !document.hidden && !reduce) {
if (!running) {
running = true;
raf = requestAnimationFrame(frame);
}
} else {
running = false;
cancelAnimationFrame(raf);
}
},
{ threshold: 0 }
);
io.observe(host);
if (reduce) {
// single static frame, no animation loop
program.uniforms.uTime.value = 8;
renderer.render({ scene: mesh });
} else {
raf = requestAnimationFrame(frame);
}
return () => {
running = false;
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onPointer);
document.removeEventListener("visibilitychange", onVisibility);
io.disconnect();
ro.disconnect();
const ext = gl.getExtension("WEBGL_lose_context");
if (ext) ext.loseContext();
if (gl.canvas.parentNode) gl.canvas.parentNode.removeChild(gl.canvas);
};
}, []);
return <div ref={wrap} className="revfield" aria-hidden="true" />;
}