DNA Helix
Вертикальная двойная спираль из скруглённых треугольных плиток: две нити вьются вокруг оси, между ними идут перемычки-ступени, вся конструкция медленно поворачивается. Плитки текут вверх по нити, снизу собираются из россыпи, а в середине кадра по каждой проходит шумный скан с горящей кромкой, и печатающийся код на грани проявляется в кадр видео. Тот же приём, что у `shard-ribbon` (россыпь → сборка → скан → медиа), но путь плитки — винтовая линия, и отсюда вся разница в математике. Во-первых, параметр нити — ДЛИНА ДУГИ, а не высота: плитки стоят вдоль нити через полширины, и если мерить этот шаг по высоте, то при подъёме в 60° между ними остаётся вдвое больше дуги, чем сама плитка, — нить рассыпается на отдельные пластины. Во-вторых, грань ленты смотрит РАДИАЛЬНО наружу: иначе плитки идут к зрителю рёбрами и ни кода, ни видео не видно. Ступени темнее ободка плиток втрое и подхватывают цвет скана — на полной яркости восемь длинных прутков перебивают сами нити.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/dna-helix.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import { createDnaHelix, type DnaHelix as Engine, type DnaHelixOptions } from "@/lib/dna-helix-engine";
export type DnaHelixProps = Omit<DnaHelixOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const NIGHT =
"radial-gradient(120% 100% at 50% 40%, oklch(0.21 0.02 265) 0%, oklch(0.13 0.015 262) 52%, oklch(0.08 0.01 258) 100%)";
/**
* DNA Helix — вертикальная двойная спираль из треугольных плиток: нити вьются
* вокруг оси, между ними перемычки, плитки текут вверх, собираются из россыпи
* и в середине кадра переключаются со кода на видео горящим сканом.
*
* Сцена — `createDnaHelix` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function DnaHelix({ backdrop = NIGHT, ...options }: DnaHelixProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engine = useRef<Engine | null>(null);
const first = useRef(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = !(
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
engine.current = createDnaHelix(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { rows, tile, speed, radius, pitch, strands, rungs, spin, scatter, formSpan, glow, tilt } = options;
useEffect(() => {
engine.current?.set({ rows, tile, speed, radius, pitch, strands, rungs, spin, scatter, formSpan, glow, tilt });
}, [rows, tile, speed, radius, pitch, strands, rungs, spin, scatter, formSpan, glow, tilt]);
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* DNA Helix — движок на голом three.js (без React). Вертикальная двойная спираль
* из скруглённых треугольных плиток: две нити вьются вокруг оси, между ними идут
* перемычки-ступени, вся конструкция медленно поворачивается. Плитки текут вверх
* по нити, снизу собираются из россыпи, в середине кадра по каждой проходит шумный
* скан с горящей кромкой, и печатающийся код на грани проявляется в кадр видео.
*
* Это тот же приём, что у `shard-ribbon` (россыпь → сборка → скан → медиа), но путь
* плитки другой: не прямая ось со скруткой, а винтовая линия. Отсюда вся разница
* в математике, и она не косметическая:
*
* 1. Параметр нити — ДЛИНА ДУГИ, а не высота. Плитки стоят вдоль нити через `tile/2`;
* если считать этот шаг по высоте, при подъёме в 60° между соседями остаётся вдвое
* больше дуги, чем сама плитка, и нить рассыпается на отдельные пластины. Высота,
* длина ленты и все опорные точки кадра переводятся в дугу через `arcLen()`.
* 2. Грань плитки смотрит РАДИАЛЬНО наружу — это третья ось базиса. Если строить базис
* «как у ленты» (нормаль = касательная × радиус), грань уходит вбок по окружности:
* плитки идут к зрителю рёбрами, и ни кода, ни видео на них не видно.
*
* `createDnaHelix(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр без видео.
*/
export interface DnaHelixOptions {
/** Ролики (mp4/webm) и/или картинки на грани плиток; одно медиа на группу плиток. */
media?: string[];
/** Рядов плиток поперёк нити. */
rows?: number;
/** Сторона треугольника в единицах сцены. */
tile?: number;
/** Скорость течения плиток по нити, единиц в секунду; 0 — стоп. */
speed?: number;
/** Радиус спирали. */
radius?: number;
/** Шаг витка: сколько единиц высоты приходится на полный оборот. */
pitch?: number;
/** Нитей в спирали: 2 — как у ДНК, 1 или 3 — вариации. */
strands?: number;
/** Плотность перемычек между нитями, 0 — без них. */
rungs?: number;
/** Поворот всей спирали, градусов в секунду. */
spin?: number;
/** Разлёт несобранных плиток. */
scatter?: number;
/** Длина участка, на котором россыпь собирается в нить. */
formSpan?: number;
/** Цвет горящей кромки скана. */
glow?: string;
/** Отклик спирали на курсор, радианы поворота при курсоре у края окна. */
tilt?: number;
/** Печатающийся код на «сырых» гранях. */
snippets?: Snippet[];
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
/** Зерно псевдослучайности раскладки. */
seed?: number;
}
export interface Snippet {
tag: string;
lines: string[];
}
export interface DnaHelix {
set(next: Partial<DnaHelixOptions>): void;
destroy(): void;
}
const DEFAULT_SNIPPETS: Snippet[] = [
{
tag: "TERMINAL",
lines: [
"$ npx shadcn add \\",
" @motiva/dna-helix",
"$ pnpm dev",
"$ open localhost:4321",
"$ git commit -m \"hero\"",
],
},
{
tag: "HERO.TSX",
lines: [
'<section className="hero">',
" <DnaHelix",
" media={clips}",
" radius={3.6}",
" pitch={11} />",
" <h1>Нити собираются",
" из россыпи</h1>",
"</section>",
],
},
{
tag: "HELIX.GLSL",
lines: [
"float d = sy - prog",
" - (noise - 0.5) * uTear;",
"float band = smoothstep(",
" 0.22, 0.0, d);",
"vec3 col = mix(code,",
" video, step(d, 0.0));",
],
},
{
tag: "HELIX.TS",
lines: [
"const L = hypot(1, R * kw);",
"const y = s / L;",
"const a = phase + y * kw;",
"tangent.set(",
" -R * kw * sin(a) / L,",
" 1.0 / L,",
" R * kw * cos(a) / L);",
],
},
];
const DEFAULTS: Required<DnaHelixOptions> = {
media: [],
rows: 1,
tile: 2.4,
speed: 2.4,
radius: 3.6,
pitch: 11,
strands: 2,
rungs: 0.9,
spin: 9,
scatter: 4.2,
formSpan: 4.5,
glow: "#ff4d00",
tilt: 0.3,
snippets: DEFAULT_SNIPPETS,
animate: true,
seed: 7331,
};
// Постоянные раскладки, которые крутить нет смысла.
const DEPTH = 0; // спираль стоит в центре сцены (камера в z = 16)
const GAP = 0.035; // зазор между плитками: масштаб плитки вокруг центроида
const GROUP_GAP = 0.5; // просвет между группами плиток (между «экранами»)
const SPAN = 4; // колонок плиток в группе: одно медиа на SPAN колонок
const FORM_GAP = 10; // насколько (в дуге) россыпь отстаёт от своего места в нити
const LOOSE_ROT = 1.34; // разброс поворота несобранной плитки, радианы
const REVEAL_SPAN = 7; // длина участка, на котором код проявляется в медиа
const OVERSCAN = 5; // запас нити (по высоте) за кромками кадра
const MEDIA_MIX = 0.35; // доля маткапа на медиа-гранях (код — плоский, без бликов)
const CAM = { x: 0, y: 0, z: 16, fov: 46, tiltX: 0.3 };
const CAM_NARROW = { z: 21, fov: 52, tiltX: 0.3 }; // aspect < 1.2
const rad = (deg: number) => (deg * Math.PI) / 180;
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
const remap = (v: number, a: number, b: number) => clamp01((v - a) / (b - a));
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
/* ------------------------------------------------------------------ шейдеры */
const VERT = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vCap;
void main() {
vUv = uv;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vNormal = normalize(normalMatrix * normal);
vViewPos = -mv.xyz;
vCap = normal.z; // +1 лицевая грань плитки, -1 тыльная, ~0 торцы
gl_Position = projectionMatrix * mv;
}
`;
const LIB = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vCap;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
vec2 matcapUV(vec3 n, vec3 v) {
vec3 x = normalize(vec3(v.z, 0.0, -v.x));
vec3 y = cross(v, x);
return vec2(dot(x, n), dot(y, n)) * 0.495 + 0.5;
}
vec3 screen(vec3 a, vec3 b) { return 1.0 - (1.0 - a) * (1.0 - b); }
`;
/**
* Грань плитки. Поле перехода — знаковое расстояние d до кромки скана: вверх по грани
* (sx = uv.y) минус прогресс, плюс шумовой рваный край. d > 0 — ещё код, d < 0 — уже медиа;
* у самой кромки — полоса глитча по коду (сдвиг строк и расщепление каналов) и горящая линия.
*/
const FACE = /* glsl */ `
uniform sampler2D uCode;
uniform sampler2D uMedia;
uniform sampler2D uMatcap;
uniform float uProgress;
uniform float uTime;
uniform float uSeed;
uniform vec2 uFit;
uniform vec4 uRect;
uniform vec2 uCentroid;
uniform vec2 uCurveDir;
uniform float uTileScale;
uniform float uMediaMix;
uniform vec3 uGlowA;
uniform vec3 uGlowB;
__LIB__
void main() {
// скан живёт только пока прогресс между 0 и 1: до и после кромки нет
float act = smoothstep(0.0, 0.02, uProgress) * (1.0 - smoothstep(0.985, 1.0, uProgress));
// содержимое перевёрнуто на 180°, а тыльная грань ещё и отзеркалена —
// скрученная от зрителя плитка не должна читаться задом наперёд
vec2 tUv = vec2(1.0) - vUv;
if (vCap < 0.0) tUv.x = 1.0 - tUv.x;
float prog = uProgress * 1.24 - 0.12;
float en = noise(vUv * 8.5 + uSeed * 13.0 + vec2(0.0, uTime * 0.95));
float d = vUv.y - prog - (en - 0.5) * 0.22 * act;
// полоса глитча у кромки: строки кода дёргаются, каналы расходятся
float band = smoothstep(0.22, 0.0, d) * act;
float row = floor(vUv.y * 28.0 + uSeed * 40.0);
float jitter = (hash(vec2(row, floor(uTime * 22.0))) - 0.5) * 0.27 * band;
vec2 cuv = tUv + vec2(jitter, 0.0);
vec3 code = texture2D(uCode, cuv).rgb;
float split = 0.036 * band;
code.r = texture2D(uCode, cuv + vec2(split, 0.0)).r;
code.b = texture2D(uCode, cuv - vec2(split, 0.0)).b;
// uv грани → uv группы: одно медиа растянуто на несколько плиток. Зазор масштабирует
// плитку вокруг центроида — uv разжимается так же, чтобы кусок остался в решётке
vec2 luv = uCentroid + (vUv - uCentroid) * uTileScale;
vec2 guv = vec2(1.0) - (uRect.xy + luv * uRect.zw);
if (vCap < 0.0) guv.x = 1.0 - guv.x;
float settle = 1.0 + 0.05 * (1.0 - uProgress);
vec2 muv = (guv - 0.5) * uFit * settle + 0.5;
vec3 media = texture2D(uMedia, muv).rgb;
// кадр проявляется из бумажно-белого, как фотография
media = mix(vec3(0.98, 0.97, 0.94), media, smoothstep(0.0, -0.18, d));
float reveal = step(d, 0.0);
vec3 content = mix(code, media, reveal);
float gd = abs(d);
float line = exp(-gd * 60.0 / 3.35) * act * 2.5;
content = mix(content, uGlowA, min(line * 0.85, 1.0));
content += uGlowB * exp(-gd * 220.0 / 3.35) * act * 2.5;
// ложный изгиб нормали: плоская плитка бликует как слегка выпуклое стекло
vec3 viewDir = normalize(vViewPos);
vec2 cc = vUv - 0.5;
vec3 bend = vec3(
uCurveDir.x * cc.x + 0.3 * sin(cc.y * 4.2 + uSeed * 7.0),
uCurveDir.y * cc.y + 0.3 * sin(cc.x * 3.6 + uSeed * 5.0),
0.0) * 0.8;
vec3 n = normalize(normalize(vNormal) + bend);
float fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.5);
vec3 mc = texture2D(uMatcap, matcapUV(n, viewDir)).rgb;
// код — плоский экран без бликов, медиа — стекло с маткапом поверх
vec3 shaded = mc * (0.8 + 0.7 * fresnel) * uMediaMix * reveal;
vec3 col = screen(content * 0.92, shaded);
gl_FragColor = vec4(col, 1.0);
}
`.replace("__LIB__", LIB);
/** Торец плитки: стеклянный ободок — маткап и френель, от кода к медиа чуть ярче. */
const RIM = /* glsl */ `
uniform sampler2D uMatcap;
uniform float uProgress;
uniform float uMediaMix;
__LIB__
void main() {
vec3 n = normalize(vNormal);
vec3 viewDir = normalize(vViewPos);
vec3 mc = texture2D(uMatcap, matcapUV(n, viewDir)).rgb;
float fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.0);
float mixAmt = uProgress * uMediaMix;
vec3 col = mc * (0.7 + 0.9 * mixAmt) + mc * fresnel * 0.8;
gl_FragColor = vec4(col, 1.0);
}
`.replace("__LIB__", LIB);
/**
* Перемычка между нитями. Тот же матовый стеклянный пруток, но втрое темнее ободка
* плитки: на полной яркости RIM восемь длинных боксов через кадр читаются
* водопроводом и перебивают сами нити. Зато пруток подхватывает цвет скана —
* ступени зажигаются волной вместе с плитками, и лестница видна именно в движении.
*/
const RUNG = /* glsl */ `
uniform sampler2D uMatcap;
uniform float uProgress;
uniform vec3 uGlowA;
__LIB__
void main() {
vec3 n = normalize(vNormal);
vec3 viewDir = normalize(vViewPos);
vec3 mc = texture2D(uMatcap, matcapUV(n, viewDir)).rgb;
float fresnel = pow(1.0 - abs(dot(n, viewDir)), 2.0);
float act = smoothstep(0.0, 0.06, uProgress) * (1.0 - smoothstep(0.94, 1.0, uProgress));
vec3 col = mc * 0.26 + mc * fresnel * 0.45 + uGlowA * act * 0.4;
gl_FragColor = vec4(col, 1.0);
}
`.replace("__LIB__", LIB);
/* ------------------------------------------------------------ геометрия плитки */
/** Скруглённый треугольник с торцом и фаской; uv — 0..1 по описанному прямоугольнику. */
function tileGeometry(size: number, flip: boolean): THREE.BufferGeometry {
const h = (size * Math.sqrt(3)) / 2;
const r = 0.12 * size;
const pts = flip
? [
{ x: -size / 2, y: h },
{ x: 0, y: 0 },
{ x: size / 2, y: h },
]
: [
{ x: -size / 2, y: 0 },
{ x: size / 2, y: 0 },
{ x: 0, y: h },
];
const shape = new THREE.Shape();
const ins: { x: number; y: number }[] = [];
const outs: { x: number; y: number }[] = [];
for (let i = 0; i < 3; i++) {
const p = pts[i]!;
const prev = pts[(i + 2) % 3]!;
const next = pts[(i + 1) % 3]!;
const a = { x: p.x - prev.x, y: p.y - prev.y };
const b = { x: next.x - p.x, y: next.y - p.y };
const la = Math.hypot(a.x, a.y);
const lb = Math.hypot(b.x, b.y);
const cut = Math.min(r, la / 2, lb / 2);
ins.push({ x: p.x - (a.x / la) * cut, y: p.y - (a.y / la) * cut });
outs.push({ x: p.x + (b.x / lb) * cut, y: p.y + (b.y / lb) * cut });
}
shape.moveTo(ins[0]!.x, ins[0]!.y);
for (let i = 0; i < 3; i++) {
shape.quadraticCurveTo(pts[i]!.x, pts[i]!.y, outs[i]!.x, outs[i]!.y);
const n = ins[(i + 1) % 3]!;
shape.lineTo(n.x, n.y);
}
shape.closePath();
const k = size / 3;
const geo = new THREE.ExtrudeGeometry(shape, {
depth: 0.09 * k,
bevelEnabled: true,
bevelThickness: 0.0405 * k,
bevelSize: 0.03645 * k,
bevelSegments: 4,
curveSegments: 24,
});
const pos = geo.attributes.position!;
const uv = geo.attributes.uv!;
for (let i = 0; i < pos.count; i++) uv.setXY(i, (pos.getX(i) + size / 2) / size, pos.getY(i) / h);
uv.needsUpdate = true;
// центроид в начало координат, торец симметрично вокруг z = 0
geo.translate(0, flip ? (-2 * h) / 3 : -h / 3, -0.045 * k);
// экструзия не индексирована, нормали у неё уже плоские по граням: крышка ровная,
// фаска гранёная — на ободке в 0,04 единицы гранёности не видно
return geo;
}
/* ------------------------------------------------------------ текстуры на канвасе */
/** Маткап: сфера, освещённая из левого верха, с холодным ободком — рисуется без ассетов. */
function makeMatcap(): THREE.CanvasTexture {
const c = document.createElement("canvas");
c.width = c.height = 256;
const ctx = c.getContext("2d")!;
const base = ctx.createRadialGradient(96, 88, 6, 128, 128, 132);
base.addColorStop(0, "#f6f6f8");
base.addColorStop(0.28, "#c9cad0");
base.addColorStop(0.62, "#6d6e78");
base.addColorStop(0.9, "#2b2b32");
base.addColorStop(1, "#111114");
ctx.fillStyle = base;
ctx.fillRect(0, 0, 256, 256);
const rim = ctx.createRadialGradient(128, 128, 96, 128, 128, 128);
rim.addColorStop(0, "rgba(140,170,255,0)");
rim.addColorStop(0.8, "rgba(140,170,255,0.35)");
rim.addColorStop(1, "rgba(200,220,255,0.7)");
ctx.fillStyle = rim;
ctx.fillRect(0, 0, 256, 256);
const spot = ctx.createRadialGradient(176, 176, 2, 176, 176, 40);
spot.addColorStop(0, "rgba(255,255,255,0.55)");
spot.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = spot;
ctx.fillRect(0, 0, 256, 256);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
const TOKEN =
/(\/\/.*$|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(\b\d+(?:\.\d+)?\b|#[0-9a-fA-F]{3,8}\b)|(\b(?:const|let|var|function|return|if|else|for|while|new|class|import|from|export|float|vec2|vec3|vec4|uniform|mix|step|smoothstep)\b|<\/?[a-zA-Z][\w-]*|[<>]|=>|\$)/gm;
const INK = { bg: "#0b0b0d", ln: "rgba(232,230,223,0.22)", p: "#8d8f98", k: "#e8e6df", s: "#b3b0a2", n: "#ff4d00", c: "rgba(117,119,127,0.5)", tag: "rgba(232,230,223,0.4)" };
interface CodeSheet {
tex: THREE.CanvasTexture;
ctx: CanvasRenderingContext2D;
snippet: Snippet;
W: number;
H: number;
font: number;
lineH: number;
pad: number;
gutter: number;
visible: number;
total: number;
chars: number;
scroll: number;
speed: number;
nextDraw: number;
}
function makeCodeSheet(snippet: Snippet, aspect: number, speed: number): CodeSheet {
const H = 320;
const W = Math.round(H * aspect);
const c = document.createElement("canvas");
c.width = W;
c.height = H;
const font = 15;
const pad = Math.round(Math.min(W, H) * 0.14);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.generateMipmaps = false;
tex.minFilter = THREE.LinearFilter;
const sheet: CodeSheet = {
tex,
ctx: c.getContext("2d")!,
snippet,
W,
H,
font,
lineH: Math.round(font * 1.6),
pad,
gutter: pad + 2.2 * font,
visible: Math.max(3, Math.floor((H - 2.4 * pad) / (font * 1.6))),
total: snippet.lines.reduce((s, l) => s + l.length + 1, 0),
chars: 0,
scroll: 0,
speed,
nextDraw: 0,
};
paintCode(sheet, sheet.total);
return sheet;
}
/** Печатает первые `count` символов сниппета с подсветкой и курсором; прокрутка догоняет строку. */
function paintCode(s: CodeSheet, count: number): void {
const { ctx, W, H, font, lineH, pad, gutter } = s;
const mono = `${font}px ui-monospace, SFMono-Regular, Menlo, monospace`;
ctx.fillStyle = INK.bg;
ctx.fillRect(0, 0, W, H);
ctx.font = `${Math.round(font * 0.8)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
ctx.fillStyle = INK.tag;
ctx.textAlign = "center";
ctx.fillText(s.snippet.tag, W / 2, pad * 0.9);
ctx.textAlign = "left";
let left = Math.floor(count);
let cursorLine = 0;
const shown = s.snippet.lines.map((line, i) => {
if (left < 0) return -1;
const len = line.length + 1;
if (left >= len) {
left -= len;
cursorLine = Math.min(i + 1, s.snippet.lines.length - 1);
return line.length;
}
const n = left;
cursorLine = i;
left = -1;
return n;
});
const typing = count < s.total;
const target = Math.max(0, cursorLine - (s.visible - 1));
s.scroll += (target - s.scroll) * 0.3;
for (let i = 0; i < s.snippet.lines.length && shown[i]! >= 0; i++) {
const y = 1.5 * pad + (i - s.scroll + 1) * lineH;
if (y < pad || y > H - 0.3 * pad) continue;
ctx.font = mono;
ctx.fillStyle = INK.ln;
ctx.fillText(String(i + 1).padStart(2, "0"), 0.62 * pad, y);
let x = gutter;
let budget = shown[i]!;
const line = s.snippet.lines[i]!;
TOKEN.lastIndex = 0;
let last = 0;
const parts: { t: keyof typeof INK; s: string }[] = [];
let m: RegExpExecArray | null;
while ((m = TOKEN.exec(line))) {
if (m.index > last) parts.push({ t: "p", s: line.slice(last, m.index) });
parts.push({ t: m[1] ? "c" : m[2] ? "s" : m[3] ? "n" : "k", s: m[0] });
last = m.index + m[0].length;
}
if (last < line.length) parts.push({ t: "p", s: line.slice(last) });
for (const part of parts) {
if (budget <= 0) break;
const text = part.s.length <= budget ? part.s : part.s.slice(0, budget);
budget -= part.s.length;
ctx.font = part.t === "s" || part.t === "c" ? `italic ${mono}` : mono;
ctx.fillStyle = INK[part.t];
ctx.fillText(text, x, y);
x += ctx.measureText(text).width;
}
if (typing && i === cursorLine) {
ctx.fillStyle = INK.n;
ctx.fillRect(x + 0.15 * font, y - 0.85 * font, 0.55 * font, 1.05 * font);
}
}
s.tex.needsUpdate = true;
}
/* ------------------------------------------------------------------- медиа */
interface Media {
tex: THREE.Texture;
aspect: number;
video: HTMLVideoElement | null;
start(): void;
dispose(): void;
}
function loadMedia(url: string, animate: boolean, onSize: () => void): Media {
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url)) {
const v = document.createElement("video");
v.muted = true;
v.loop = true;
v.playsInline = true;
v.crossOrigin = "anonymous";
v.preload = "auto";
v.src = url;
const tex = new THREE.VideoTexture(v);
tex.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearFilter;
const m: Media = {
tex,
aspect: 16 / 9,
video: v,
start() {
if (!animate) return;
v.play().catch(() => {
const kick = () => void v.play();
window.addEventListener("pointerdown", kick, { once: true });
});
},
dispose() {
tex.dispose();
v.pause();
v.removeAttribute("src");
v.load();
},
};
v.addEventListener("loadedmetadata", () => {
m.aspect = v.videoWidth / v.videoHeight || m.aspect;
onSize();
});
return m;
}
const tex = new THREE.TextureLoader().load(url, (t: THREE.Texture) => {
const img = t.image as { width: number; height: number };
m.aspect = img.width / img.height;
onSize();
});
tex.colorSpace = THREE.SRGBColorSpace;
const m: Media = { tex, aspect: 4 / 3, video: null, start() {}, dispose: () => tex.dispose() };
return m;
}
/* ------------------------------------------------------------------- сцена */
interface Tile {
mesh: THREE.Mesh;
face: THREE.ShaderMaterial;
rim: THREE.ShaderMaterial;
media: Media;
groupAspect: number;
u0: number;
group: number;
vOff: number;
scatJit: THREE.Vector3;
rotJit: THREE.Euler;
loose: THREE.Quaternion;
formJit: number;
/** Номер нити: у каждой свой сдвиг по углу. */
strand: number;
}
export function createDnaHelix(canvas: HTMLCanvasElement, options: DnaHelixOptions = {}): DnaHelix {
const cfg: Required<DnaHelixOptions> = { ...DEFAULTS, ...options };
if (!cfg.media.length) cfg.media = ["https://motiva.pages.dev/video/mesh-1280x720.mp4", "https://motiva.pages.dev/video/smoke-1280x720.mp4", "https://motiva.pages.dev/video/blocks-720x1280.mp4"];
const animate = cfg.animate;
let seed = cfg.seed;
const rnd = () => ((seed = (16807 * seed) % 0x7fffffff) - 1) / 0x7ffffffe;
const range = (a: number, b: number) => a + rnd() * (b - a);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(CAM.fov, 1, 0.1, 90);
camera.position.set(CAM.x, CAM.y, CAM.z);
const rig = new THREE.Group();
scene.add(rig);
const narrow = () => camera.aspect < 1.2;
const matcap = makeMatcap();
const glowA = { value: new THREE.Color(cfg.glow) };
const glowB = { value: new THREE.Color(cfg.glow).lerp(new THREE.Color("#ffffff"), 0.3) };
const uTime = { value: 0 };
const uMediaMix = { value: MEDIA_MIX };
const uTileScale = { value: 1 - GAP };
let medias: Media[] = [];
const startTimers: number[] = [];
/** Ролики стартуют с разбегом: десять одновременных decode на старте страницы видны глазом. */
function openMedia(): void {
medias = cfg.media.map((url) => loadMedia(url, animate, () => tiles.forEach(fitMedia)));
if (!animate) {
for (const m of medias) m.video?.addEventListener("loadeddata", frame, { once: true });
return;
}
medias.forEach((m, i) => startTimers.push(window.setTimeout(() => m.start(), 400 * i)));
}
const sheets: CodeSheet[] = [];
let geoUp: THREE.BufferGeometry | null = null;
let geoDown: THREE.BufferGeometry | null = null;
let tiles: Tile[] = [];
let ribbonLen = 1; // длина нити без просветов
let rungGeo: THREE.BufferGeometry | null = null;
const rungs: { mesh: THREE.Mesh; mat: THREE.ShaderMaterial; u0: number; group: number }[] = [];
let groups = 1;
let travel = 0; // накопленный ход ленты
let built = 0; // число колонок, под которое построена лента
/** Полувысота видимого поля на глубине спирали: по ней и считается длина нити. */
const halfH = () => Math.tan(rad(camera.fov / 2)) * ((narrow() ? CAM_NARROW.z : CAM.z) - DEPTH);
/**
* Сколько ДУГИ витка приходится на единицу высоты: sqrt(1 + (R·ω)²).
*
* Всё, что раньше мерилось по прямой оси, теперь мерится по винтовой линии, и это не
* косметика: плитки стоят вдоль нити через `tile/2`, и если считать этот шаг по высоте,
* то при подъёме в 60° между ними остаётся вдвое больше дуги, чем сама плитка, — нить
* рассыпается на отдельные пластины. Поэтому параметр нити — длина дуги, а высота,
* длина ленты и все опорные точки кадра переводятся в неё этим множителем.
*/
const arcLen = () => Math.hypot(1, (cfg.radius * Math.PI * 2) / Math.max(1, cfg.pitch));
/**
* Сколько колонок плиток нужно, чтобы нить уходила за верхнюю и нижнюю кромки.
* Считается по высоте кадра, переведённой в дугу: спираль вертикальная, ширина её
* не ограничивает, но по дуге путь длиннее высоты в `arcLen()` раз.
*/
const columnsNeeded = () => {
const e = cfg.tile;
const span = (2 * halfH() + OVERSCAN) * arcLen() + 3 * e;
const per = 2 * SPAN;
// Потолок считается по ЧИСЛУ ПЛИТОК, а не колонок: у каждой свой ShaderMaterial,
// а ряды и нити множатся друг на друга — «260 колонок» при трёх нитях в два ряда
// это полторы тысячи материалов. Диапазоны контролов подобраны так, чтобы потолок
// не срабатывал: он здесь страховка от нечитанных options, а не рабочий режим.
const budget = 480 / (Math.max(1, cfg.rows) * Math.max(1, Math.min(3, Math.round(cfg.strands))));
const cap = Math.max(per, Math.floor(budget / per) * per);
return Math.min(Math.ceil(Math.ceil(span / (e / 2)) / per) * per, cap);
};
function fitMedia(t: Tile): void {
const a = t.media.aspect;
const fit = t.face.uniforms.uFit!.value as THREE.Vector2;
if (a > t.groupAspect) fit.set(t.groupAspect / a, 1);
else fit.set(1, a / t.groupAspect);
}
function clear(): void {
for (const t of tiles) {
rig.remove(t.mesh);
t.face.dispose();
t.rim.dispose();
}
for (const r of rungs) {
rig.remove(r.mesh);
r.mat.dispose();
}
rungs.length = 0;
rungGeo?.dispose();
rungGeo = null;
for (const s of sheets) s.tex.dispose();
sheets.length = 0;
geoUp?.dispose();
geoDown?.dispose();
geoUp = geoDown = null;
tiles = [];
}
function build(): void {
clear();
seed = cfg.seed;
travel = 0;
const e = cfg.tile;
const h = (e * Math.sqrt(3)) / 2;
const cols = (built = columnsNeeded());
geoUp = tileGeometry(e, false);
geoDown = tileGeometry(e, true);
const tileAspect = e / h;
ribbonLen = (e / 2) * cols;
groups = cols / SPAN;
const groupAspect = ((e / 2) * (SPAN + 1)) / (Math.max(1, cfg.rows) * h);
const poolSize = Math.min(16, cols * cfg.rows);
for (let i = 0; i < poolSize; i++) {
const snippet = cfg.snippets[Math.floor(rnd() * cfg.snippets.length)]!;
sheets.push(makeCodeSheet(snippet, tileAspect, 7 + 5 * rnd()));
}
const strands = Math.max(1, Math.min(3, Math.round(cfg.strands)));
let k = 0;
for (let strand = 0; strand < strands; strand++) {
for (let row = 0; row < cfg.rows; row++) {
for (let col = 0; col < cols; col++) {
const flip = (col + row) % 2 === 1;
const media = medias[(Math.floor(col / SPAN) + strand) % medias.length]!;
const sheet = sheets[k++ % poolSize]!;
const rect = new THREE.Vector4((col % SPAN) / (SPAN + 1), row / cfg.rows, 2 / (SPAN + 1), 1 / cfg.rows);
const face = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FACE,
uniforms: {
uCode: { value: sheet.tex },
uMedia: { value: media.tex },
uMatcap: { value: matcap },
uProgress: { value: 0 },
uTime,
uSeed: { value: 10 * rnd() },
uFit: { value: new THREE.Vector2(1, 1) },
uRect: { value: rect },
uCentroid: { value: new THREE.Vector2(0.5, flip ? 2 / 3 : 1 / 3) },
uCurveDir: { value: new THREE.Vector2(range(0.5, 1.4) * (rnd() < 0.5 ? -1 : 1), range(0.4, 1.2) * (rnd() < 0.5 ? -1 : 1)) },
uTileScale,
uMediaMix,
uGlowA: glowA,
uGlowB: glowB,
},
});
const rim = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: RIM,
uniforms: { uMatcap: { value: matcap }, uProgress: { value: 0 }, uMediaMix },
});
const mesh = new THREE.Mesh(flip ? geoDown : geoUp, [face, rim]);
rig.add(mesh);
const tile: Tile = {
mesh,
face,
rim,
media,
groupAspect,
u0: (e / 2) * col,
group: Math.floor(col / SPAN),
vOff: row * h + (flip ? (2 * h) / 3 : h / 3),
scatJit: new THREE.Vector3(range(-1, 1), range(-1, 1), range(-0.5, 0.5)),
rotJit: new THREE.Euler(range(-1, 1) * LOOSE_ROT, range(-1, 1) * LOOSE_ROT, range(-1, 1) * LOOSE_ROT),
loose: new THREE.Quaternion(),
formJit: range(-1, 1),
strand,
};
tile.loose.setFromEuler(tile.rotJit);
fitMedia(tile);
tiles.push(tile);
}
}
}
/**
* ПЕРЕМЫЧКИ между нитями. Без них две винтовые линии читаются просто двумя
* лентами: «ДНК» узнаётся именно по ступеням между ними. Ставятся через шаг —
* на каждой колонке они превращают спираль в сплошной цилиндр.
*/
if (strands >= 2 && cfg.rungs > 0.01) {
rungGeo = new THREE.BoxGeometry(1, 0.075 * e, 0.075 * e);
const step = Math.max(2, Math.round(7 - cfg.rungs * 4));
for (let col = 0; col < cols; col += step) {
const mat = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: RUNG,
uniforms: { uMatcap: { value: matcap }, uProgress: { value: 0 }, uGlowA: glowA },
});
const mesh = new THREE.Mesh(rungGeo, mat);
rig.add(mesh);
rungs.push({ mesh, mat, u0: (e / 2) * col, group: Math.floor(col / SPAN) });
}
}
}
/* -------- курсор: лента чуть поворачивается за ним */
const pointer = { x: 0, y: 0 };
const onPointer = (e: PointerEvent) => {
pointer.x = e.clientX / window.innerWidth - 0.5;
pointer.y = e.clientY / window.innerHeight - 0.5;
};
if (animate) window.addEventListener("pointermove", onPointer);
/* -------- кадр */
let last = 0;
let time = 0;
const tangent = new THREE.Vector3();
const across = new THREE.Vector3();
const radial = new THREE.Vector3();
const spot = new THREE.Vector3();
const pA = new THREE.Vector3();
const pB = new THREE.Vector3();
const bar = new THREE.Vector3();
const UNIT_X = new THREE.Vector3(1, 0, 0);
const basis = new THREE.Matrix4();
const quat = new THREE.Quaternion();
let spun = 0;
/**
* Точка нити и её базис (пишет в `spot` и `quat`, чтобы не плодить объекты в кадре).
*
* Грань плитки обязана смотреть РАДИАЛЬНО НАРУЖУ — это третья ось базиса. Если
* строить базис «как у ленты» (касательная, поперёк, нормаль = касательная × радиус),
* грань уходит вбок по окружности: плитки идут к зрителю рёбрами, и ни кода, ни видео
* на них не видно. Поперёк нити смещаемся по `across`, которая лежит В ПЛОСКОСТИ,
* касательной к цилиндру: так нить остаётся лентой, навитой на трубу, а не веером.
*/
function place(n: number, phase: number, r: number): void {
const R = cfg.radius;
const kw = (Math.PI * 2) / Math.max(1, cfg.pitch);
const L = arcLen();
const y = n / L;
const ang = phase + y * kw;
const cs = Math.cos(ang);
const sn = Math.sin(ang);
radial.set(cs, 0, sn);
// d/ds винтовой линии — уже единичной длины, нормировать нечего
tangent.set((-R * kw * sn) / L, 1 / L, (R * kw * cs) / L);
across.crossVectors(radial, tangent);
basis.makeBasis(tangent, across, radial);
quat.setFromRotationMatrix(basis);
spot.set(cs * R + across.x * r, y + across.y * r, sn * R + across.z * r);
}
function frame(): void {
const now = performance.now() / 1000;
const dt = animate && last ? Math.min(now - last, 0.05) : 0;
last = now;
time += dt;
uTime.value = time;
const cam = narrow() ? { ...CAM, ...CAM_NARROW } : CAM;
// спираль поворачивается сама, курсор её доворачивает
spun += rad(cfg.spin) * dt;
rig.rotation.y = spun + pointer.x * cfg.tilt;
rig.rotation.x = lerp(rig.rotation.x, -pointer.y * cfg.tilt * 0.5, 0.045);
if (camera.fov !== cam.fov) {
camera.fov = cam.fov;
camera.updateProjectionMatrix();
}
camera.position.set(cam.x, cam.y, cam.z);
travel += cfg.speed * dt;
const e = cfg.tile;
const h = (e * Math.sqrt(3)) / 2;
const width = Math.max(1, cfg.rows) * h;
const len = ribbonLen + groups * GROUP_GAP;
const R = cfg.radius;
/** Угловая скорость подъёма: полный оборот на `pitch` единиц высоты. */
const kw = (Math.PI * 2) / Math.max(1, cfg.pitch);
const strands = Math.max(1, Math.min(3, Math.round(cfg.strands)));
// опорные точки заданы в высотах кадра — переводим в дугу, иначе при крутом
// подъёме сборка и скан уезжают далеко за кромку
const arc = arcLen();
const formPos = -halfH() * 0.5 * arc;
const formSpan = cfg.formSpan * arc;
const revealSpan = REVEAL_SPAN * arc;
const revealPos = 0;
for (const t of tiles) {
let n = (t.u0 + t.group * GROUP_GAP + travel) % len;
if (n < 0) n += len;
n -= len / 2;
const r = t.vOff - width / 2;
const phase = (t.strand / strands) * Math.PI * 2;
const jit = t.formJit * formSpan * 0.25;
const g = easeInOut(remap(n, formPos - formSpan + jit, formPos + jit));
if (g < 1) {
// россыпь отстаёт ВНИЗ по нити и разлетается в стороны
const back = FORM_GAP * (1 - g);
const sc = cfg.scatter * (1 - g);
place(n - back, phase, r);
t.mesh.position.set(
spot.x + t.scatJit.x * sc,
spot.y + t.scatJit.y * sc * 0.4,
spot.z + t.scatJit.z * sc,
);
t.mesh.quaternion.slerpQuaternions(t.loose, quat, g);
} else {
place(n, phase, r);
t.mesh.position.copy(spot);
t.mesh.quaternion.copy(quat);
}
t.mesh.scale.setScalar(uTileScale.value);
const progress = remap(n, revealPos - revealSpan / 2, revealPos + revealSpan / 2);
t.face.uniforms.uProgress!.value = progress;
t.rim.uniforms.uProgress!.value = progress;
}
/* перемычки: от точки одной нити до точки другой на той же высоте */
if (rungs.length && strands >= 2) {
const step = (Math.PI * 2) / strands;
for (const rg of rungs) {
let n = (rg.u0 + rg.group * GROUP_GAP + travel) % len;
if (n < 0) n += len;
n -= len / 2;
const y = n / arc;
const a0 = y * kw;
const a1 = a0 + step;
pA.set(Math.cos(a0) * R, y, Math.sin(a0) * R);
pB.set(Math.cos(a1) * R, y, Math.sin(a1) * R);
rg.mesh.position.addVectors(pA, pB).multiplyScalar(0.5);
rg.mesh.scale.set(pA.distanceTo(pB), 1, 1);
bar.subVectors(pB, pA).normalize();
quat.setFromUnitVectors(UNIT_X, bar);
rg.mesh.quaternion.copy(quat);
rg.mat.uniforms.uProgress!.value = remap(n, revealPos - revealSpan / 2, revealPos + revealSpan / 2);
// перемычка появляется только там, где нити уже собрались
rg.mesh.visible = easeInOut(remap(n, formPos - formSpan, formPos)) > 0.9;
}
}
for (const s of sheets) {
s.chars += dt * s.speed;
if (s.chars > s.total + 60) {
s.chars = 0;
s.scroll = 0;
}
if (time >= s.nextDraw) {
paintCode(s, s.chars);
s.nextDraw = time + 0.15;
}
}
renderer.render(scene, camera);
}
/* -------- размер, сон, запуск */
let raf = 0;
const loop = () => {
frame();
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
}
let rebuildTimer = 0;
const ro = new ResizeObserver(() => {
resize();
if (tiles.length && columnsNeeded() > built) {
clearTimeout(rebuildTimer);
rebuildTimer = window.setTimeout(() => {
if (columnsNeeded() > built) build();
}, 200);
}
if (!animate) frame();
});
ro.observe(canvas);
resize();
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
openMedia();
build();
// стоячий кадр: плитки уже стоят, медиа дорисуется, когда доедет первый кадр
frame();
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»: такие ключи пропускаем
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<DnaHelixOptions>;
// решётка, листы кода и текстуры живут сборкой — на эти ключи её надо переиздать,
// иначе правка молча не доезжает (остальное читается прямо в кадре из cfg)
const rebuild =
(patch.rows !== undefined && patch.rows !== cfg.rows) ||
(patch.tile !== undefined && patch.tile !== cfg.tile) ||
// радиус и шаг витка задают длину дуги, а по ней считается число колонок
(patch.radius !== undefined && patch.radius !== cfg.radius) ||
(patch.pitch !== undefined && patch.pitch !== cfg.pitch) ||
(patch.strands !== undefined && patch.strands !== cfg.strands) ||
(patch.rungs !== undefined && patch.rungs !== cfg.rungs) ||
(patch.snippets !== undefined && patch.snippets !== cfg.snippets);
const reopen = patch.media !== undefined && patch.media.join("\u0000") !== cfg.media.join("\u0000");
Object.assign(cfg, patch);
if (patch.glow) {
glowA.value.set(cfg.glow);
glowB.value.set(cfg.glow).lerp(new THREE.Color("#ffffff"), 0.3);
}
if (reopen) {
for (const m of medias) m.dispose();
openMedia();
}
if (rebuild || reopen) build();
if (!animate) frame();
},
destroy() {
stop();
clearTimeout(rebuildTimer);
for (const t of startTimers) clearTimeout(t);
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("pointermove", onPointer);
canvas.removeEventListener("webglcontextlost", onLost);
clear();
for (const m of medias) m.dispose();
matcap.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}