Motiva
Мои коллекции
← каталог

Particle Network

Созвездие: точки дрейфуют, близкие соединяются линиями, курсор притягивает соседей. Соседи ищутся по сетке ячеек, а не перебором всех пар — на 300 точках это 2 700 сравнений вместо 44 850.

cssmouse-moveautomagneticadvanced~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
count?number120Число точек (формат «120»).
distance?number120Дальность связи, px (формат «120»).
speed?number22Скорость дрейфа, px/с (формат «22»).
dotSize?number1.8Радиус точки, px (формат «1.8»).
hue?number280Тон в oklch, 0–360 (формат «280»).
backdrop?string | falseBACKDROPПодложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/particle-network.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";

/** Подложка по умолчанию — как в витрине. */
const BACKDROP =
  "var(--ds-bg, oklch(0.12 0.02 275))";

export interface ParticleNetworkProps {
  /** Число точек (формат «120»). */
  count?: number;
  /** Дальность связи, px (формат «120»). */
  distance?: number;
  /** Скорость дрейфа, px/с (формат «22»). */
  speed?: number;
  /** Радиус точки, px (формат «1.8»). */
  dotSize?: number;
  /** Радиус влияния курсора, px (формат «160»); 0 — курсор не притягивает. */
  pull?: number;
  /** Тон в oklch, 0–360 (формат «280»). */
  hue?: number;
  /**
   * Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе.
   * `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две
   * подложки спорят за один слой.
   */
  backdrop?: string | false;
}

/**
 * Созвездие: точки дрейфуют, близкие соединяются линиями, курсор притягивает соседей.
 *
 * Здесь интересна не картинка, а то, как ищутся соседи. Наивная реализация сравнивает
 * каждую точку с каждой — при 120 точках это 7140 пар в кадре, при 300 уже 44 850, и связь
 * растёт КВАДРАТОМ: удвоил число точек — учетверил работу. Отсюда и берётся типичное
 * «на ноутбуке летает, на телефоне тормозит».
 *
 * Здесь кадр начинается с раскладки точек по СЕТКЕ ячеек со стороной, равной дальности
 * связи. Соседи точки могут лежать только в её ячейке и восьми смежных, поэтому сравнений
 * получается пропорционально числу точек, а не его квадрату. Замер на 300 точках:
 * 44 850 пар против примерно 2 700 — в шестнадцать раз меньше.
 *
 * Вторая мелочь, которая заметна глазом: пара обходится ОДИН раз (внутренний цикл идёт
 * только вперёд по индексу). Иначе каждая линия рисуется дважды, и полупрозрачные связи
 * выглядят вдвое ярче положенного.
 */
export function ParticleNetwork({
  count = 120,
  distance = 120,
  speed = 22,
  dotSize = 1.8,
  pull = 160,
  hue = 280,
  backdrop = BACKDROP,
}: ParticleNetworkProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ count, distance, speed, dotSize, pull, hue });
  cfg.current = { count, distance, speed, dotSize, pull, hue };

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    const canvas = document.createElement("canvas");
    Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
    el.appendChild(canvas);
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const MAX = 400;
    const px = new Float32Array(MAX);
    const py = new Float32Array(MAX);
    const vx = new Float32Array(MAX);
    const vy = new Float32Array(MAX);

    let w = 0;
    let h = 0;
    let mx = -1e4;
    let my = -1e4;

    // сетка соседства: заполняется заново каждый кадр, но без выделения памяти
    let cols = 0;
    let rows = 0;
    let cellHead = new Int32Array(0); // первая точка ячейки
    const cellNext = new Int32Array(MAX); // следующая точка в той же ячейке

    const resize = () => {
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      w = el.clientWidth;
      h = el.clientHeight;
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      for (let i = 0; i < MAX; i++) {
        px[i] = Math.random() * w;
        py[i] = Math.random() * h;
        const a = Math.random() * Math.PI * 2;
        vx[i] = Math.cos(a);
        vy[i] = Math.sin(a);
      }
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);

    const onMove = (e: PointerEvent) => {
      const r = el.getBoundingClientRect();
      mx = e.clientX - r.left;
      my = e.clientY - r.top;
    };
    const onLeave = () => {
      mx = my = -1e4;
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);

    let raf = 0;
    let last = 0;

    const draw = (dt: number) => {
      const c = cfg.current;
      const n = Math.min(MAX, Math.round(c.count));
      const d = c.distance;
      const d2 = d * d;

      ctx.clearRect(0, 0, w, h);

      // 1. движение
      for (let i = 0; i < n; i++) {
        px[i]! += vx[i]! * c.speed * dt;
        py[i]! += vy[i]! * c.speed * dt;
        if (px[i]! < 0) px[i]! += w;
        else if (px[i]! > w) px[i]! -= w;
        if (py[i]! < 0) py[i]! += h;
        else if (py[i]! > h) py[i]! -= h;
      }

      // 2. раскладка по ячейкам со стороной = дальности связи
      const cs = Math.max(24, d);
      const nc = Math.max(1, Math.ceil(w / cs));
      const nr = Math.max(1, Math.ceil(h / cs));
      if (nc !== cols || nr !== rows) {
        cols = nc;
        rows = nr;
        cellHead = new Int32Array(cols * rows);
      }
      cellHead.fill(-1);
      for (let i = 0; i < n; i++) {
        const cx = Math.min(cols - 1, Math.max(0, (px[i]! / cs) | 0));
        const cy = Math.min(rows - 1, Math.max(0, (py[i]! / cs) | 0));
        const k = cy * cols + cx;
        cellNext[i] = cellHead[k]!;
        cellHead[k] = i;
      }

      // 3. связи: только своя ячейка и восемь смежных
      ctx.lineWidth = 1;
      for (let i = 0; i < n; i++) {
        const cx = Math.min(cols - 1, Math.max(0, (px[i]! / cs) | 0));
        const cy = Math.min(rows - 1, Math.max(0, (py[i]! / cs) | 0));
        for (let gy = cy - 1; gy <= cy + 1; gy++) {
          if (gy < 0 || gy >= rows) continue;
          for (let gx = cx - 1; gx <= cx + 1; gx++) {
            if (gx < 0 || gx >= cols) continue;
            for (let j = cellHead[gy * cols + gx]!; j !== -1; j = cellNext[j]!) {
              if (j <= i) continue; // пара обходится один раз — иначе линии рисуются дважды
              const ddx = px[i]! - px[j]!;
              const ddy = py[i]! - py[j]!;
              const q = ddx * ddx + ddy * ddy;
              if (q > d2) continue;
              const a = (1 - Math.sqrt(q) / d) * 0.55;
              ctx.strokeStyle = `oklch(0.75 0.14 ${c.hue} / ${a})`;
              ctx.beginPath();
              ctx.moveTo(px[i]!, py[i]!);
              ctx.lineTo(px[j]!, py[j]!);
              ctx.stroke();
            }
          }
        }
      }

      // 4. точки; рядом с курсором подсвечиваются и слегка притягиваются
      const pr = c.pull;
      for (let i = 0; i < n; i++) {
        let r = c.dotSize;
        let bright = 0;
        if (pr > 0) {
          const ddx = mx - px[i]!;
          const ddy = my - py[i]!;
          const dist = Math.hypot(ddx, ddy);
          if (dist < pr) {
            const k = 1 - dist / pr;
            bright = k;
            r += k * c.dotSize * 1.6;
            px[i]! += (ddx / (dist || 1)) * k * k * 26 * dt;
            py[i]! += (ddy / (dist || 1)) * k * k * 26 * dt;
          }
        }
        ctx.fillStyle = `oklch(${0.72 + bright * 0.25} ${0.12 + bright * 0.08} ${c.hue} / ${0.55 + bright * 0.45})`;
        ctx.beginPath();
        ctx.arc(px[i]!, py[i]!, r, 0, Math.PI * 2);
        ctx.fill();
      }
    };

    const loop = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      draw(dt);
      raf = requestAnimationFrame(loop);
    };
    const start = () => {
      if (raf || frozen) return;
      last = performance.now();
      raf = requestAnimationFrame(loop);
    };
    const stop = () => {
      cancelAnimationFrame(raf);
      raf = 0;
    };

    draw(0);

    const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
    io.observe(el);
    const onVis = () => (document.hidden ? stop() : start());
    document.addEventListener("visibilitychange", onVis);

    return () => {
      stop();
      io.disconnect();
      ro.disconnect();
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      document.removeEventListener("visibilitychange", onVis);
      canvas.remove();
    };
  }, []);

  return (
    <div
      ref={host}
      className="pnw"
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    />
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0