buildmymcpserver/apps/web/components/particle-hero/ParticleField.tsx
Marco Sadjadi 6197ee7f5e
All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s
feat: particle cloud (no discrete dots) + geo-IP country preselect on login
Two coordinated polish moves the owner asked for.

## 1. Hero particle field — "no white dots, just a glow that follows the mouse and is always in motion"

Previous tuning (uPointSize 2.8, uBaseAlpha 0.6) gave discrete indigo
dots that additively saturated to near-white in dense clusters. The
owner wanted no granular dots visible at all — a continuous indigo
cloud that the cursor pulls toward itself.

Changes:

- **Render fragment**: replaced the anti-aliased disc SDF
  (`smoothstep(0.5, 0.42, d)` — hard edge) with a Gaussian falloff
  (`exp(-d * d * 6.0)` — smooth blob, no edge). Each particle is now
  a soft volume that blends seamlessly with neighbours.

- **Sim fragment**: replaced the outward-gradient ring push with a
  mouse-halo attraction. Particles drift toward an ideal radius
  (~0.20) around the cursor, with exp-bell falloff so they don't
  collapse onto the cursor or feel influenced from across the canvas.
  `ringField()` helper is now unused but kept for future use.

- **JS uniforms**: `uPointSize` 2.8→14 (256-tier) / 3.6→20 (128-tier);
  `uBaseAlpha` 0.6→0.055. Individual particles are below the
  perception threshold for "dot" but 65k of them additively composite
  into a continuous cloud. With the much lower per-particle alpha,
  the cumulative brightness never saturates to white.

- **ParticleField tick loop**: asymmetric ring-active fade — `alpha
  = 0.14` ramping in (fast cursor response), `0.012` decaying out
  (slow glow trail after the pointer moves away). Matches the brief
  "glow longer + attractive to mouse but always in motion".

- **ParticleHero index.tsx**: added an always-on indigo radial
  gradient behind the WebGL canvas, so the hero never reads as
  visually empty between frames — the canvas additively paints the
  dynamic cloud on top. Removed the white-dot stipple from the
  static fallback (it was the most likely source of the "weisse
  punkte" complaint for any visitor on the fallback path).

## 2. SMS login — pre-select country picker from visitor's geo-IP

The country picker on `/login` previously defaulted to `'CH'` for
everyone. Visitors from DE / AT / US / etc. had to manually scroll
to their dial code — small friction but it sits on the highest-stakes
conversion step in the funnel.

- **New API route** `apps/api/src/routes/geo.ts` →
  `GET /v1/geo/country` returns `{ country: 'CH' | 'DE' | … | null }`
  by reading Cloudflare's `CF-IPCountry` header. Public, no auth —
  reading a 2-letter country code from a geo-IP header isn't PII
  under GDPR / DSG. `'XX'` and `'T1'` (CF's "unknown" + Tor) are
  normalised to `null`. Outside CF (dev), header is missing → null.

- **Login page** picks up the result in the existing `useEffect`,
  guards against codes not in our country list, and calls `setCountry`
  to override the `'CH'` default. Stays at `'CH'` if the detection
  fails or the visitor is on a Tor exit. Verified live: the endpoint
  returns `{"country":"DE"}` from CF's German edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:17:20 +02:00

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* ParticleField — GPGPU particle simulation backing the marketing hero.
*
* Lean Three.js, no @react-three/fiber. Renders a 256×256 (or 128×128
* on lower-end devices) float texture of positions, ping-ponged each
* frame through a sim shader, then drawn as gl_Points with an
* anti-aliased disc SDF and additive blending.
*
* Callers MUST gate this behind the capability checks in `index.tsx` —
* this component assumes WebGL2 + float-render-target support exists
* and will throw if they don't.
*/
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import {
initFragment,
renderFragment,
renderVertex,
simFragment,
simVertex,
} from './shaders';
export interface ParticleFieldProps {
/** Sqrt of particle count. 256 → 65,536 particles, 128 → 16,384. */
textureSize: 128 | 256;
/**
* Global multiplier on drift + ring-push velocity. 1.0 default; 0.5
* for reduced-motion users. Pointer position is NOT scaled — the ring
* still tracks the cursor at full fidelity, only the ambient motion
* and the gradient-push are damped.
*/
motionScale?: number;
}
export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// ----- Renderer ---------------------------------------------------
// alpha:true so the hero gradient/border behind the canvas shows
// through where particles are sparse. premultipliedAlpha pairs with
// the premultiplied output of the render fragment shader.
const canvas = document.createElement('canvas');
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
container.appendChild(canvas);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: false,
alpha: true,
premultipliedAlpha: true,
powerPreference: 'high-performance',
});
renderer.setClearColor(0x000000, 0);
// Clamp DPR — going above 2 on a 65k-particle field burns laptop GPUs
// with negligible visual gain.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
const initialRect = container.getBoundingClientRect();
renderer.setSize(Math.max(1, initialRect.width), Math.max(1, initialRect.height), false);
// ----- Float-texture support check --------------------------------
// EXT_color_buffer_float is required to render INTO a float target
// on WebGL2. Without it, ping-pong won't work — bail out and let the
// wrapper fall back to the static gradient.
const gl = renderer.getContext() as WebGL2RenderingContext;
const floatExt = gl.getExtension('EXT_color_buffer_float');
if (!floatExt) {
// Tear down what we built and signal failure via the canvas
// remaining empty. The wrapper checks this synchronously before
// we even mount, so this is a belt-and-braces guard.
canvas.remove();
renderer.dispose();
return;
}
// ----- Scenes & camera --------------------------------------------
// Two scenes: one for the simulation pass (fullscreen quad), one
// for the actual particle render. Both use the same OrthographicCamera
// because everything is already in clip space.
const simScene = new THREE.Scene();
const renderScene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// ----- Ping-pong render targets ------------------------------------
const rtParams: THREE.RenderTargetOptions = {
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
format: THREE.RGBAFormat,
type: THREE.FloatType,
depthBuffer: false,
stencilBuffer: false,
generateMipmaps: false,
};
let rtA = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
let rtB = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
// ----- Init pass: seed both targets with the starting field -------
const initMaterial = new THREE.ShaderMaterial({
vertexShader: simVertex,
fragmentShader: initFragment,
});
const fsQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), initMaterial);
simScene.add(fsQuad);
renderer.setRenderTarget(rtA);
renderer.render(simScene, camera);
renderer.setRenderTarget(rtB);
renderer.render(simScene, camera);
renderer.setRenderTarget(null);
// Swap in the actual sim material on the same quad.
const simUniforms = {
uPrev: { value: rtA.texture },
uTime: { value: 0 },
uDelta: { value: 1 / 60 },
uRingPos: { value: new THREE.Vector2(0, 0) },
uRingRadius: { value: 0.22 },
uRingWidth: { value: 0.05 },
uRingActive: { value: 0 },
uMotionScale: { value: motionScale },
};
const simMaterial = new THREE.ShaderMaterial({
vertexShader: simVertex,
fragmentShader: simFragment,
uniforms: simUniforms,
});
fsQuad.material = simMaterial;
initMaterial.dispose();
// ----- Particle geometry: one vertex per texel --------------------
const count = textureSize * textureSize;
const indexUvs = new Float32Array(count * 2);
const positionsAttr = new Float32Array(count * 3); // unused but required
for (let y = 0; y < textureSize; y++) {
for (let x = 0; x < textureSize; x++) {
const i = y * textureSize + x;
// Sample texels at their centers, not corners.
indexUvs[i * 2 + 0] = (x + 0.5) / textureSize;
indexUvs[i * 2 + 1] = (y + 0.5) / textureSize;
}
}
const particleGeo = new THREE.BufferGeometry();
particleGeo.setAttribute('position', new THREE.BufferAttribute(positionsAttr, 3));
particleGeo.setAttribute('aIndexUv', new THREE.BufferAttribute(indexUvs, 2));
// Tell Three.js never to frustum-cull this — positions live in
// the texture, not the bounding box of the buffer geometry.
particleGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 10);
// Brand colors — read from --color-accent (#6366f1) and
// --color-success (#22c55e). Kept as constants here rather than
// reading from CSS variables: those resolve to oklch in modern
// Tailwind builds, which needs parsing. Hardcode the hex values
// the design system already commits to.
const colorCalm = new THREE.Color('#6366f1');
const colorHot = new THREE.Color('#22c55e');
const renderUniforms = {
uPositions: { value: rtB.texture },
// Huge soft blobs at very low per-particle alpha → no individual
// dots are visible, but 65k of them additively composite into a
// continuous indigo cloud. This matches the brief "no white dots,
// just a glow." When dots were 2.8px at 0.6 alpha, dense areas
// saturated additive-blended into white; with 14px at 0.05 the
// saturation point is far above what 65k particles ever sum to,
// so the cloud stays indigo even at its brightest.
uPointSize: { value: textureSize === 256 ? 14.0 : 20.0 },
uDpr: { value: dpr },
uColorCalm: { value: colorCalm },
uColorHot: { value: colorHot },
uBaseAlpha: { value: 0.055 },
};
const particleMat = new THREE.ShaderMaterial({
vertexShader: renderVertex,
fragmentShader: renderFragment,
uniforms: renderUniforms,
transparent: true,
depthTest: false,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const particles = new THREE.Points(particleGeo, particleMat);
renderScene.add(particles);
// ----- Pointer tracking ------------------------------------------
// Raw target (last pointer event), smoothed via EMA into the
// uniform each frame so the ring tracks fluidly even if events
// are sparse (touch / pen / throttled mouse).
const target = new THREE.Vector2(0, 0);
const smoothed = new THREE.Vector2(0, 0);
let hasPointer = false;
const updatePointerFromClient = (clientX: number, clientY: number) => {
const rect = container.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
// Flip Y so up is positive — matches clip space.
const y = -(((clientY - rect.top) / rect.height) * 2 - 1);
target.set(x, y);
hasPointer = true;
};
const onPointerMove = (e: PointerEvent) => {
updatePointerFromClient(e.clientX, e.clientY);
};
const onPointerLeave = () => {
hasPointer = false;
};
// Listen on window so the ring tracks even when the cursor is over
// the codeblocks/CTAs that sit above the canvas. The container is
// pointer-events:none-friendly because we read clientX/clientY.
window.addEventListener('pointermove', onPointerMove, { passive: true });
container.addEventListener('pointerleave', onPointerLeave, { passive: true });
// ----- Resize handling -------------------------------------------
const onResize = () => {
const rect = container.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return;
renderer.setSize(rect.width, rect.height, false);
};
const ro = new ResizeObserver(onResize);
ro.observe(container);
// ----- Animation loop --------------------------------------------
const clock = new THREE.Clock();
let raf = 0;
let running = true;
// Defer the first frame to idle to keep LCP clean — the hero text
// is the LCP element and must paint before we start eating GPU.
const startLoop = () => {
const tick = () => {
if (!running) return;
raf = requestAnimationFrame(tick);
const delta = Math.min(clock.getDelta(), 1 / 30); // tab-switch guard
const t = clock.elapsedTime;
// Smooth the pointer position (EMA, alpha=0.15).
smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
// Asymmetric fade: ramp in quickly when the pointer enters, decay
// slowly when it leaves. The brief said "glow longer + attractive
// to mouse but always in motion" — fast ramp-in keeps the cursor
// feeling responsive, slow decay lets the glow linger after the
// pointer moves away rather than snapping off.
const targetActive = hasPointer ? 1 : 0;
const cur = simUniforms.uRingActive.value;
const alpha = targetActive > cur ? 0.14 : 0.012;
simUniforms.uRingActive.value = cur * (1 - alpha) + targetActive * alpha;
simUniforms.uTime.value = t;
simUniforms.uDelta.value = delta;
simUniforms.uRingPos.value.copy(smoothed);
// Sim pass: read rtA, write rtB.
simUniforms.uPrev.value = rtA.texture;
renderer.setRenderTarget(rtB);
renderer.render(simScene, camera);
renderer.setRenderTarget(null);
// Render pass: draw particles sampling rtB.
renderUniforms.uPositions.value = rtB.texture;
renderer.render(renderScene, camera);
// Swap.
const tmp = rtA;
rtA = rtB;
rtB = tmp;
};
tick();
};
let idleHandle: number | null = null;
const w = window as Window & {
requestIdleCallback?: (cb: IdleRequestCallback) => number;
cancelIdleCallback?: (h: number) => void;
};
if (typeof w.requestIdleCallback === 'function') {
idleHandle = w.requestIdleCallback(() => startLoop());
} else {
idleHandle = window.setTimeout(startLoop, 80);
}
// ----- Cleanup ----------------------------------------------------
// Three.js leaks GPU memory aggressively if we skip any of this.
return () => {
running = false;
if (raf) cancelAnimationFrame(raf);
if (idleHandle !== null) {
if (typeof w.cancelIdleCallback === 'function') {
w.cancelIdleCallback(idleHandle);
} else {
window.clearTimeout(idleHandle);
}
}
window.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('pointerleave', onPointerLeave);
ro.disconnect();
particleGeo.dispose();
particleMat.dispose();
simMaterial.dispose();
(fsQuad.geometry as THREE.BufferGeometry).dispose();
rtA.dispose();
rtB.dispose();
renderer.dispose();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
};
}, [textureSize, motionScale]);
// The container is the surface that receives pointer events.
// Visually transparent — the canvas it owns paints the field.
return (
<div
ref={containerRef}
aria-hidden="true"
className="absolute inset-0 size-full"
/>
);
}