05距離でつながる線
浮かぶ点は近づくと間に糸が現れ、離れると消えます。毎フレームすべての組を比べ、半径の内側だけ線を引いて隣接グラフを作ります。
使いどころチーム・技術紹介の背景
strokeStylelineToclearRect

<main class="stage">
<div class="nw" id="nw">
<p class="nw__hint">
<svg class="nw__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M164 128a36 36 0 1 1-36-36a36 36 0 0 1 36 36" opacity=".2"/><path d="M128 84a44 44 0 1 0 44 44a44.05 44.05 0 0 0-44-44m0 72a28 28 0 1 1 28-28a28 28 0 0 1-28 28m0-128a100 100 0 1 0 100 100A100.11 100.11 0 0 0 128 28m0 184a84 84 0 1 1 84-84a84.09 84.09 0 0 1-84 84"/></g></svg>
<span>盤にポインタを置くと点が一つのノードとして入り、周囲とつながります</span>
</p>
<div class="nw__field" id="field" role="application" aria-label="連結線ネットワークの実験台" tabindex="0">
<canvas class="nw__cv" id="cv"></canvas>
<b class="nw__live" aria-hidden="true"><span class="nw__liveNum" id="links">0</span><span class="nw__liveUnit"> 本のリンク</span></b>
</div>
<div class="nw__bar">
<label class="nw__dial">半径
<input type="range" id="radius" min="50" max="130" step="10" value="90" aria-label="連結半径">
<b class="nw__val" id="radiusVal">90px</b>
</label>
<button class="nw__reset" id="reset" type="button">
<svg class="nw__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 128a104 104 0 1 1-104-104a104 104 0 0 1 104 104" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></svg>
<span>撒き直し</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 거리로 이어지는 연결선 — 움직임은 전부 캔버스 안(rAF 루프). CSS 는 무대·판·컨트롤만 맡는다.
// CSS 애니메이션이 0개면 렌더러는 실시간으로 24장을 찍는다(도구/demo.py 렌더(), 287·291 선례).
// reduce 에서는 JS 가 루프를 시작하지 않고 한 장만 그린다(.is-still).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-light;
@include stage($stage-ink);
.nw { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.nw__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.nw__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.nw__field {
position: relative; height: 168px; border-radius: $r-card; overflow: hidden;
background: rgba($subject-cream, .06);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair;
}
.nw__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.nw__cv { display: block; width: 100%; height: 100%; }
// 이번 프레임에 반경 안쪽으로 그려진 실의 수 — 인접 그래프가 살아 있다는 증거
.nw__live {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($stage-ink, .72);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.nw__liveNum { font-size: 13px; color: $stage-yellow; }
.nw__liveUnit { font-weight: 700; }
.nw__bar { display: flex; align-items: center; gap: $sp-3; }
.nw__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-cream;
}
.nw__dial input { width: 76px; accent-color: $color; }
.nw__val { color: $stage-yellow; font-size: 11px; }
.nw__reset {
display: flex; align-items: center; gap: $sp-1; margin-left: auto;
padding: $sp-1 $sp-3; border: 0; border-radius: $r-pill;
background: rgba($subject-cream, .12); color: $subject-cream;
font-weight: 700; font-size: 11px; font-family: inherit; cursor: pointer;
}
.nw__reset:hover { background: rgba($subject-cream, .2); }
.nw__reset:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
@media (max-width: 340px) {
.nw { gap: $sp-2; }
.nw__field { height: 96px; }
.nw__dial input { width: 44px; }
.nw__live { font-size: 10px; }
}
// reduce — 루프가 안 돌므로 연결 수도 그 한 장의 값으로 고정이다
@media (prefers-reduced-motion: reduce) {
.nw.is-still .nw__live { opacity: .85 !important; }
.nw.is-still .nw__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.nw {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.nw__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.nw__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.nw__field {
position: relative;
height: 168px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 247, 230, 0.06);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
}
.nw__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.nw__cv {
display: block;
width: 100%;
height: 100%;
}
.nw__live {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.nw__liveNum {
font-size: 13px;
color: #ffd23f;
}
.nw__liveUnit {
font-weight: 700;
}
.nw__bar {
display: flex;
align-items: center;
gap: 12px;
}
.nw__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #fff7e6;
}
.nw__dial input {
width: 76px;
accent-color: #5b8cf8;
}
.nw__val {
color: #ffd23f;
font-size: 11px;
}
.nw__reset {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.nw__reset:hover {
background: rgba(255, 247, 230, 0.2);
}
.nw__reset:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
@media (max-width: 340px) {
.nw {
gap: 8px;
}
.nw__field {
height: 96px;
}
.nw__dial input {
width: 44px;
}
.nw__live {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.nw.is-still .nw__live {
opacity: 0.85 !important;
}
.nw.is-still .nw__field {
cursor: default !important;
}
}// 거리 기반 연결선 — 매 프레임 모든 쌍을 견주되 半径 안쪽만 선으로 그려 인접 그래프를 만든다.
// 점이 64개라 쌍은 2,016개지만 그려지는 건 半径 안에 든 것뿐이라 화면에는 옅은 실 몇 줄만 남는다.
var root = document.getElementById('nw');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var radiusIn = document.getElementById('radius');
var radiusVal = document.getElementById('radiusVal');
var linksOut = document.getElementById('links');
var resetBtn = document.getElementById('reset');
var COUNT = 64; // 점 64개 — 전체 쌍은 64*63/2 = 2,016쌍
var LINK_R = 90; // 이 거리 안쪽일 때만 실이 나타난다(px 단위)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var nodes = [];
var pointer = { x: 0, y: 0, on: false };
// 점 한 개 — 느리게 떠다니는 속도와 크기만 가진다
function seedNode(n) {
n.x = Math.random() * W;
n.y = Math.random() * H;
var ang = Math.random() * Math.PI * 2;
var sp = 7 + Math.random() * 13;
n.vx = Math.cos(ang) * sp;
n.vy = Math.sin(ang) * sp;
n.r = 1.8 + Math.random() * 1.5;
return n;
}
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw() {
ctx.clearRect(0, 0, W, H);
var links = 0;
ctx.lineWidth = 1;
ctx.strokeStyle = '#fff7e6'; // 실은 낮은 알파의 크림 — 알파는 globalAlpha 로 준다
// 점과 점 — 매 프레임 모든 쌍을 견주고 半径 안쪽만 선으로 남긴다
for (var i = 0; i < COUNT; i++) {
var a = nodes[i];
for (var j = i + 1; j < COUNT; j++) {
var b = nodes[j];
var dx = a.x - b.x, dy = a.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 >= LINK_R * LINK_R) continue;
var fade = 1 - Math.sqrt(d2) / LINK_R;
ctx.globalAlpha = fade * fade * 0.5; // 가까울수록 짙어지는 실
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
links++;
}
}
// 포인터도 노드 하나 — 가까운 점과는 노란 실로 이어진다
if (pointer.on) {
ctx.strokeStyle = '#ffd23f';
for (var k = 0; k < COUNT; k++) {
var nd = nodes[k];
var pdx = nd.x - pointer.x, pdy = nd.y - pointer.y;
var pd2 = pdx * pdx + pdy * pdy;
if (pd2 >= LINK_R * LINK_R) continue;
var pf = 1 - Math.sqrt(pd2) / LINK_R;
ctx.globalAlpha = pf * pf * 0.7;
ctx.beginPath();
ctx.moveTo(pointer.x, pointer.y);
ctx.lineTo(nd.x, nd.y);
ctx.stroke();
links++;
}
}
ctx.globalAlpha = 1;
// 점 — 크림을 기본으로 노랑과 밝은 파랑을 섞는다
for (var m = 0; m < COUNT; m++) {
var p = nodes[m];
ctx.fillStyle = m % 9 === 0 ? '#ffd23f' : (m % 9 === 5 ? '#5b8cf8' : '#fff7e6');
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
if (pointer.on) {
ctx.strokeStyle = '#ffd23f';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(pointer.x, pointer.y, 6, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1;
}
linksOut.textContent = String(links);
}
function step(dt) {
for (var i = 0; i < COUNT; i++) {
var n = nodes[i];
n.x += n.vx * dt;
n.y += n.vy * dt;
if (n.x < n.r) { n.x = n.r; n.vx = Math.abs(n.vx); }
if (n.x > W - n.r) { n.x = W - n.r; n.vx = -Math.abs(n.vx); }
if (n.y < n.r) { n.y = n.r; n.vy = Math.abs(n.vy); }
if (n.y > H - n.r) { n.y = H - n.r; n.vy = -Math.abs(n.vy); }
}
}
var last = 0;
function frame(now) {
var dt = last ? Math.min((now - last) / 1000, 0.05) : 0.016;
last = now;
step(dt);
draw();
requestAnimationFrame(frame);
}
function scatter() {
for (var i = 0; i < COUNT; i++) seedNode(nodes[i]);
draw();
}
radiusIn.addEventListener('input', function () {
LINK_R = Number(radiusIn.value);
radiusVal.textContent = LINK_R + 'px';
});
resetBtn.addEventListener('click', scatter);
field.addEventListener('click', scatter);
field.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); scatter(); }
});
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
pointer.x = e.clientX - r.left;
pointer.y = e.clientY - r.top;
pointer.on = true;
});
field.addEventListener('pointerleave', function () { pointer.on = false; });
window.addEventListener('resize', function () {
fit();
for (var i = 0; i < COUNT; i++) {
if (nodes[i].x > W) nodes[i].x = Math.random() * W;
if (nodes[i].y > H) nodes[i].y = Math.random() * H;
}
draw();
});
// 조작 실측용 — run/012/_probe/interact_b.py 가 살아 있는 참조를 읽는다
window.__state = { nodes: nodes, pointer: pointer };
fit();
for (var i0 = 0; i0 < COUNT; i0++) nodes.push(seedNode({}));
if (reduce) {
// reduce — 루프를 아예 돌리지 않고 점이 골고루 퍼진 한 장만 그린다
root.classList.add('is-still');
draw();
} else {
requestAnimationFrame(frame);
}// NetworkLines — vanilla 판(run/012/demo/05-particle-network-lines/index.html) 의 React 포트.
// 매 프레임 모든 쌍을 견주되 반경 안쪽만 선으로 그려 인접 그래프를 만든다 — 점이 64개라 쌍은 2,016개.
// 값·색·수치는 vanilla 와 같다. 포인터를 올리면 그 점이 노드 하나로 끼어들어 노란 실로 이어진다.
// 움직임은 전부 캔버스 rAF 루프 — CSS 애니메이션이 0개면 렌더러는 실시간으로 24장을 찍는다.
// reduce 에서는 루프를 돌리지 않고 점이 골고루 퍼진 한 장만 그린다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NetworkLines.module.scss";
export type NetworkLinesProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
// 점 한 개 — 느리게 떠다니는 속도와 크기만 가진다
type NetNode = { x: number; y: number; vx: number; vy: number; r: number };
const COUNT = 64; // 점 64개 — 전체 쌍은 64*63/2 = 2,016쌍
const FIELD_LABEL = "연결선 네트워크 실험판";
export default function NetworkLines({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#5b8cf8",
}: NetworkLinesProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const linksRef = useRef<HTMLSpanElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<{ w: number; h: number; dpr: number }>;
const nodesRef = useRef<NetNode[]>([]) as Box<NetNode[]>;
const pointerRef = useRef({ x: 0, y: 0, on: false }) as Box<{ x: number; y: number; on: boolean }>;
const linkRRef = useRef(90) as Box<number>; // 이 거리 안쪽일 때만 실이 나타난다(px 단위)
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [radius, setRadius] = useState(90);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const size = sizeRef.current;
const r = field.getBoundingClientRect();
size.dpr = window.devicePixelRatio || 1;
size.w = r.width;
size.h = r.height;
cv.width = Math.round(r.width * size.dpr);
cv.height = Math.round(r.height * size.dpr);
ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
ctxRef.current = ctx;
}
function seedNode(n: NetNode): NetNode {
const size = sizeRef.current;
n.x = Math.random() * size.w;
n.y = Math.random() * size.h;
const ang = Math.random() * Math.PI * 2;
const sp = 7 + Math.random() * 13;
n.vx = Math.cos(ang) * sp;
n.vy = Math.sin(ang) * sp;
n.r = 1.8 + Math.random() * 1.5;
return n;
}
function draw(): void {
const ctx = ctxRef.current;
if (!ctx) return;
const size = sizeRef.current;
const nodes = nodesRef.current;
const pointer = pointerRef.current;
const linkR = linkRRef.current;
ctx.clearRect(0, 0, size.w, size.h);
let links = 0;
ctx.lineWidth = 1;
ctx.strokeStyle = "#fff7e6"; // 실은 낮은 알파의 크림 — 알파는 globalAlpha 로 준다
// 점과 점 — 매 프레임 모든 쌍을 견주고 반경 안쪽만 선으로 남긴다
for (let i = 0; i < COUNT; i++) {
const a = nodes[i];
for (let j = i + 1; j < COUNT; j++) {
const b = nodes[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 >= linkR * linkR) continue;
const fade = 1 - Math.sqrt(d2) / linkR;
ctx.globalAlpha = fade * fade * 0.5; // 가까울수록 짙어지는 실
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
links++;
}
}
// 포인터도 노드 하나 — 가까운 점과는 노란 실로 이어진다
if (pointer.on) {
ctx.strokeStyle = "#ffd23f";
for (let k = 0; k < COUNT; k++) {
const nd = nodes[k];
const pdx = nd.x - pointer.x, pdy = nd.y - pointer.y;
const pd2 = pdx * pdx + pdy * pdy;
if (pd2 >= linkR * linkR) continue;
const pf = 1 - Math.sqrt(pd2) / linkR;
ctx.globalAlpha = pf * pf * 0.7;
ctx.beginPath();
ctx.moveTo(pointer.x, pointer.y);
ctx.lineTo(nd.x, nd.y);
ctx.stroke();
links++;
}
}
ctx.globalAlpha = 1;
// 점 — 크림을 기본으로 노랑과 밝은 파랑을 섞는다
for (let m = 0; m < COUNT; m++) {
const p = nodes[m];
ctx.fillStyle = m % 9 === 0 ? "#ffd23f" : (m % 9 === 5 ? "#5b8cf8" : "#fff7e6");
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
if (pointer.on) {
ctx.strokeStyle = "#ffd23f";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(pointer.x, pointer.y, 6, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1;
}
const out = linksRef.current;
if (out) out.textContent = String(links);
}
function step(dt: number): void {
const size = sizeRef.current;
const nodes = nodesRef.current;
for (let i = 0; i < COUNT; i++) {
const n = nodes[i];
n.x += n.vx * dt;
n.y += n.vy * dt;
if (n.x < n.r) { n.x = n.r; n.vx = Math.abs(n.vx); }
if (n.x > size.w - n.r) { n.x = size.w - n.r; n.vx = -Math.abs(n.vx); }
if (n.y < n.r) { n.y = n.r; n.vy = Math.abs(n.vy); }
if (n.y > size.h - n.r) { n.y = size.h - n.r; n.vy = -Math.abs(n.vy); }
}
}
function frame(now: number): void {
const dt = lastRef.current ? Math.min((now - lastRef.current) / 1000, 0.05) : 0.016;
lastRef.current = now;
step(dt);
draw();
rafRef.current = requestAnimationFrame(frame);
}
function scatter(): void {
const nodes = nodesRef.current;
for (let i = 0; i < COUNT; i++) seedNode(nodes[i]);
draw();
}
useEffect(() => {
fit();
const nodes = nodesRef.current;
for (let i0 = 0; i0 < COUNT; i0++) nodes.push(seedNode({ x: 0, y: 0, vx: 0, vy: 0, r: 0 }));
function onResize(): void {
fit();
const size = sizeRef.current;
for (let i = 0; i < COUNT; i++) {
if (nodes[i].x > size.w) nodes[i].x = Math.random() * size.w;
if (nodes[i].y > size.h) nodes[i].y = Math.random() * size.h;
}
draw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 아예 돌리지 않고 점이 골고루 퍼진 한 장만 그린다
setStill(true);
draw();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onRadius(e: InputEvt): void {
const v = Number(e.target.value);
linkRRef.current = v;
setRadius(v);
}
function onKeyDown(e: KeyEvt): void {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
scatter();
}
}
function onPointerMove(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const pointer = pointerRef.current;
pointer.x = e.clientX - r.left;
pointer.y = e.clientY - r.top;
pointer.on = true;
}
function onPointerLeave(): void {
pointerRef.current.on = false;
}
const rootCls = still ? styles.root + " " + styles.isStill : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M164 128a36 36 0 1 1-36-36a36 36 0 0 1 36 36" opacity=".2"/><path d="M128 84a44 44 0 1 0 44 44a44.05 44.05 0 0 0-44-44m0 72a28 28 0 1 1 28-28a28 28 0 0 1-28 28m0-128a100 100 0 1 0 100 100A100.11 100.11 0 0 0 128 28m0 184a84 84 0 1 1 84-84a84.09 84.09 0 0 1-84 84"/></g></svg>
<span>판 위에 포인터를 올리면 그 점이 노드 하나로 끼어들어 주변과 이어집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={scatter}
onKeyDown={onKeyDown}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.live} aria-hidden="true"><span className={styles.liveNum} ref={linksRef}>0</span><span className={styles.liveUnit}>개 연결</span></b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>반경
<input
type="range" min="50" max="130" step="10" value={radius} aria-label="연결 반경"
onChange={onRadius}
/>
<b className={styles.val}>{radius}px</b>
</label>
<button className={styles.reset} type="button" onClick={scatter}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 128a104 104 0 1 1-104-104a104 104 0 0 1 104 104" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></svg>
<span>다시 흩뿌리기</span>
</button>
</div>
</div>
</main>
);
}05 距離でつながる線 — 計測 累積面積 27.378% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パーティクル アニメーション canvas 9種 — ライブラリなしの作り方
このデモが属する記事
パーティクル アニメーション canvas 9種 — ライブラリなしの作り方パーティクル アニメーション canvas とは、小さな粒の一つひとつに位置・速度・寿命を持たせ、一枚の canvas の上で動かす映像のことです。九種類のエンジンをライブラリなし、canvas タグひとつで作りました。下のグリッドで今動い この記事のデモをすべて見る


