File size: 9,599 Bytes
627ac39
 
 
 
e83084c
627ac39
deb8696
 
 
 
 
6fdc858
 
deb8696
627ac39
 
e83084c
627ac39
 
 
 
 
4724c43
e83084c
 
deb8696
6fdc858
deb8696
6fdc858
deb8696
 
 
 
 
 
627ac39
 
 
 
 
 
 
deb8696
 
 
627ac39
 
deb8696
627ac39
deb8696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627ac39
deb8696
 
627ac39
deb8696
 
 
 
 
 
 
627ac39
deb8696
 
627ac39
deb8696
 
627ac39
deb8696
627ac39
deb8696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627ac39
deb8696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627ac39
deb8696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627ac39
deb8696
 
 
 
627ac39
deb8696
 
 
627ac39
deb8696
 
 
627ac39
 
deb8696
 
 
4724c43
deb8696
 
 
627ac39
deb8696
 
 
 
 
627ac39
deb8696
 
 
 
627ac39
deb8696
627ac39
deb8696
627ac39
 
deb8696
 
 
 
 
 
 
627ac39
 
deb8696
 
 
627ac39
deb8696
 
 
 
 
 
 
 
627ac39
 
 
e83084c
627ac39
deb8696
 
 
627ac39
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
'use client';

import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { useTheme } from '@/contexts/theme';

/**
 * Binary DNA helix — the two backbone strands are threaded with 0/1 glyphs
 * (billboard sprites from a canvas texture) that tick like a living data
 * stream. Slow rotation, a gentle bob, pointer parallax and ambient particle
 * dust keep it alive without shouting. Reduced motion renders a static frame
 * (apple-design §14). Colors follow the Synteny signal palette in globals.css:
 * mint primary strand + cool blue secondary strand.
 */
export default function DNAHelix({ className }: { className?: string }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const { theme } = useTheme();

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const isLight = theme === 'light';

    const digitA   = isLight ? '#15803D' : '#4ADE80';
    const digitB   = isLight ? '#2563EB' : '#60A5FA';
    const strandA  = isLight ? '#15803D' : '#22C55E';
    const strandB  = isLight ? '#2563EB' : '#60A5FA';
    const rung     = isLight ? '#059669' : '#2FBF6E';
    const particle = isLight ? '#15803D' : '#4ADE80';
    const additive = !isLight;

    const W = container.clientWidth || 1;
    const H = container.clientHeight || 1;

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setSize(W, H);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setClearColor(0x000000, 0);
    container.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 100);
    camera.position.set(0, 0, 9);

    const group = new THREE.Group();
    scene.add(group);

    const TURNS = 2.2;
    const HEIGHT = 7.4;
    const RADIUS = 1.35;
    const N_RUNG = 15;
    const N_DIGITS = 44;

    const helix = (offset: number) => {
      const pts: THREE.Vector3[] = [];
      const steps = 160;
      for (let i = 0; i <= steps; i++) {
        const frac = i / steps;
        const theta = frac * TURNS * Math.PI * 2 + offset;
        const y = frac * HEIGHT - HEIGHT / 2;
        pts.push(new THREE.Vector3(Math.cos(theta) * RADIUS, y, Math.sin(theta) * RADIUS));
      }
      return new THREE.CatmullRomCurve3(pts);
    };

    const curveA = helix(0);
    const curveB = helix(Math.PI);

    const tubeMat = new THREE.MeshBasicMaterial({
      color: strandA,
      transparent: true,
      opacity: isLight ? 0.28 : 0.16,
      depthWrite: false,
    });
    group.add(new THREE.Mesh(new THREE.TubeGeometry(curveA, 120, 0.032, 4, false), tubeMat));

    const tubeMatB = new THREE.MeshBasicMaterial({
      color: strandB,
      transparent: true,
      opacity: isLight ? 0.22 : 0.12,
      depthWrite: false,
    });
    group.add(new THREE.Mesh(new THREE.TubeGeometry(curveB, 120, 0.032, 4, false), tubeMatB));

    const rungMat = new THREE.MeshBasicMaterial({
      color: rung,
      transparent: true,
      opacity: isLight ? 0.22 : 0.11,
      depthWrite: false,
    });
    const rungGeom = new THREE.CylinderGeometry(0.014, 0.014, 1, 4);
    rungGeom.translate(0, 0.5, 0);
    for (let i = 0; i < N_RUNG; i++) {
      const t = i / (N_RUNG - 1);
      const p1 = curveA.getPointAt(t);
      const p2 = curveB.getPointAt(t);
      const dir = p2.clone().sub(p1);
      const mesh = new THREE.Mesh(rungGeom, rungMat);
      mesh.position.copy(p1);
      mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
      mesh.scale.set(1, dir.length(), 1);
      group.add(mesh);
    }

    const makeGlyph = (char: string, color: string) => {
      const size = 96;
      const cv = document.createElement('canvas');
      cv.width = size;
      cv.height = size;
      const ctx = cv.getContext('2d');
      if (ctx) {
        ctx.clearRect(0, 0, size, size);
        ctx.font = `600 ${Math.round(size * 0.72)}px "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace`;
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillStyle = color;
        ctx.fillText(char, size / 2, size / 2 + size * 0.04);
      }
      return new THREE.CanvasTexture(cv);
    };

    const mkSpriteMat = (tex: THREE.Texture, opacity: number) =>
      new THREE.SpriteMaterial({
        map: tex,
        transparent: true,
        depthWrite: false,
        opacity,
        blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
      });

    const texA0 = makeGlyph('0', digitA);
    const texA1 = makeGlyph('1', digitA);
    const texB0 = makeGlyph('0', digitB);
    const texB1 = makeGlyph('1', digitB);
    const matsA = [mkSpriteMat(texA0, isLight ? 0.95 : 0.9), mkSpriteMat(texA1, isLight ? 0.95 : 0.9)];
    const matsB = [mkSpriteMat(texB0, isLight ? 0.9 : 0.72), mkSpriteMat(texB1, isLight ? 0.9 : 0.72)];

    type Glyph = {
      sprite: THREE.Sprite;
      mats: THREE.SpriteMaterial[];
      base: number;
      curve: THREE.CatmullRomCurve3;
      rate: number;
      index: number;
    };
    const glyphs: Glyph[] = [];

    const spawnGlyphs = (curve: THREE.CatmullRomCurve3, mats: THREE.SpriteMaterial[], rateBase: number) => {
      for (let i = 0; i < N_DIGITS; i++) {
        const sprite = new THREE.Sprite(mats[i % 2]);
        sprite.scale.setScalar(0.36);
        group.add(sprite);
        glyphs.push({
          sprite,
          mats,
          base: i / N_DIGITS,
          curve,
          rate: rateBase + (i % 7) * 0.06,
          index: i,
        });
      }
    };
    spawnGlyphs(curveA, matsA, 1.4);
    spawnGlyphs(curveB, matsB, 0.9);

    const particleCount = 150;
    const particlePos = new Float32Array(particleCount * 3);
    const particleSpeed: number[] = [];
    for (let i = 0; i < particleCount; i++) {
      particlePos[i * 3] = (Math.random() - 0.5) * 14;
      particlePos[i * 3 + 1] = (Math.random() - 0.5) * 10;
      particlePos[i * 3 + 2] = (Math.random() - 0.5) * 8;
      particleSpeed.push(0.05 + Math.random() * 0.2);
    }
    const particleGeom = new THREE.BufferGeometry();
    particleGeom.setAttribute('position', new THREE.BufferAttribute(particlePos, 3));
    const particleMat = new THREE.PointsMaterial({
      color: particle,
      size: 0.045,
      transparent: true,
      opacity: isLight ? 0.28 : 0.2,
      depthWrite: false,
      sizeAttenuation: true,
      blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
    });
    const particles = new THREE.Points(particleGeom, particleMat);
    group.add(particles);

    let targetRotX = 0;
    let targetRotY = 0;
    const onPointerMove = (e: PointerEvent) => {
      const nx = (e.clientX / window.innerWidth) * 2 - 1;
      const ny = (e.clientY / window.innerHeight) * 2 - 1;
      targetRotY = nx * 0.24;
      targetRotX = ny * 0.14;
    };
    window.addEventListener('pointermove', onPointerMove);

    let lastW = W;
    let lastH = H;
    const resize = () => {
      const w = container.clientWidth || 1;
      const h = container.clientHeight || 1;
      if (w === lastW && h === lastH) return;
      lastW = w;
      lastH = h;
      renderer.setSize(w, h);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
    };
    const ro = new ResizeObserver(resize);
    ro.observe(container);

    const placeGlyph = (g: Glyph, t: number, bit: number) => {
      g.sprite.position.copy(g.curve.getPointAt(t));
      g.sprite.material = g.mats[bit % 2];
    };

    let raf = 0;
    let elapsed = 0;
    const clock = new THREE.Clock();

    const render = () => {
      renderer.render(scene, camera);
      if (!reducedMotion) raf = requestAnimationFrame(tick);
    };

    const tick = () => {
      const dt = Math.min(clock.getDelta(), 0.05);
      elapsed += dt;

      group.rotation.y = elapsed * 0.22 + targetRotY * 0.35;
      group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, targetRotX, dt * 2.5);
      group.position.y = Math.sin(elapsed * 0.5) * 0.12;

      for (const g of glyphs) {
        const flow = (elapsed * g.rate * 0.035) % 1;
        const bit = (g.index + Math.floor(elapsed * g.rate)) % 2;
        placeGlyph(g, (g.base + flow) % 1, bit);
      }

      const posArr = particleGeom.attributes.position.array as Float32Array;
      for (let i = 0; i < particleCount; i++) {
        posArr[i * 3 + 1] += particleSpeed[i] * dt;
        if (posArr[i * 3 + 1] > 5.2) posArr[i * 3 + 1] = -5.2;
      }
      (particleGeom.attributes.position as THREE.BufferAttribute).needsUpdate = true;

      render();
    };

    if (reducedMotion) {
      for (const g of glyphs) placeGlyph(g, g.base, g.index % 2);
      group.rotation.y = 0.5;
      render();
    } else {
      raf = requestAnimationFrame(tick);
    }

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('pointermove', onPointerMove);
      ro.disconnect();
      renderer.dispose();
      particleGeom.dispose();
      particleMat.dispose();
      tubeMat.dispose();
      tubeMatB.dispose();
      rungMat.dispose();
      rungGeom.dispose();
      [texA0, texA1, texB0, texB1].forEach((t) => t.dispose());
      if (renderer.domElement.parentNode === container) {
        container.removeChild(renderer.domElement);
      }
    };
  }, [theme]);

  return (
    <div ref={containerRef} className={className} aria-hidden aria-label="Animated double helix made of binary digits" />
  );
}