07Constellations Where Nearby Stars Join by Lines
Only neighbors inside the same grid cells are measured, and closer pairs get darker lines, so constellations link and break as the stars drift.
Best forNetwork and connection backdrops
LINK_RADIUSMAX_LINKSGRID_CELLLINE_ALPHA

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="Constellations Where Nearby Stars Join by Lines"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Only neighboring cells are checked — near stars get lines</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">Constellations Where Nearby Stars Join by Lines</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = 'Pause';
const BTN_PLAY = 'Play';
const BTN_NEXT = 'Next scene';
const BG = [14, 12, 32];
const STAR_RGBS = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx, alpha) {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(0, 0, W, H);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINK_RADIUS = 72;
const MAX_LINKS = 3;
const GRID_CELL = LINK_RADIUS;
const LINE_ALPHA = 0.6;
const NODE_COUNT = 110;
const CURSOR_LINKS = 6;
const TXT_GRID = 'Only neighboring cells are checked — near stars get lines';
const TXT_CURSOR = 'The pointer becomes one star';
function makeScene(inp, reduce) {
const rnd = lcg(450707);
const nodes = [];
for (let i = 0; i <= NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes.push({ x: rnd() * W, y: rnd() * H, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, c: STAR_RGBS[i % STAR_RGBS.length], n: 0 });
}
// 마지막 노드는 커서 별 — 포인터가 없으면 자동 곡선을 돈다
const cur = nodes[NODE_COUNT];
cur.vx = 0;
cur.vy = 0;
let t = 0;
const cols = Math.ceil(W / GRID_CELL);
const rows = Math.ceil(H / GRID_CELL);
const scene = {
step() {
t += 1;
for (let i = 0; i < NODE_COUNT; i += 1) {
const s = nodes[i];
s.x += s.vx;
s.y += s.vy;
if (s.x < 0 || s.x > W) {
s.vx = -s.vx;
s.x = clamp(s.x, 0, W);
}
if (s.y < 0 || s.y > H) {
s.vy = -s.vy;
s.y = clamp(s.y, 0, H);
}
}
let tx = W / 2 + Math.cos(t * 0.021) * 170;
let ty = H / 2 + Math.sin(t * 0.034) * 90;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
cur.x = lerp(cur.x, tx, 0.12);
cur.y = lerp(cur.y, ty, 0.12);
},
draw(ctx) {
paintBg(ctx, 1);
// 격자 칸마다 별 번호를 모아 두면 이웃 3x3 칸만 보면 된다
const cells = [];
for (let i = 0; i < cols * rows; i += 1) {
cells.push([]);
}
for (let i = 0; i < nodes.length; i += 1) {
nodes[i].n = 0;
const cx = clamp(Math.floor(nodes[i].x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(nodes[i].y / GRID_CELL), 0, rows - 1);
cells[cy * cols + cx].push(i);
}
ctx.lineCap = 'round';
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
const cx = clamp(Math.floor(a.x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(a.y / GRID_CELL), 0, rows - 1);
const cap = i === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
for (let gy = Math.max(0, cy - 1); gy <= Math.min(rows - 1, cy + 1); gy += 1) {
for (let gx = Math.max(0, cx - 1); gx <= Math.min(cols - 1, cx + 1); gx += 1) {
const list = cells[gy * cols + gx];
for (let q = 0; q < list.length; q += 1) {
const j = list[q];
const b = nodes[j];
if (j <= i || a.n >= cap) {
continue;
}
const capB = j === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
if (b.n >= capB) {
continue;
}
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d >= LINK_RADIUS) {
continue;
}
a.n += 1;
b.n += 1;
const k = 1 - d / LINK_RADIUS;
ctx.strokeStyle = rgba(mix(a.c, b.c, 0.5), LINE_ALPHA * Math.pow(k, 1.3));
ctx.lineWidth = 0.6 + 1.4 * k;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
}
for (let i = 0; i < nodes.length; i += 1) {
const s = nodes[i];
glow(ctx, s.x, s.y, i === NODE_COUNT ? 22 : 9, s.c, i === NODE_COUNT ? 0.6 : 0.35);
ctx.fillStyle = rgba(s.c, 0.95);
ctx.beginPath();
ctx.arc(s.x, s.y, i === NODE_COUNT ? 3.4 : 1.7, 0, PI2);
ctx.fill();
}
},
label() {
return inp.idle < IDLE_TICKS ? TXT_CURSOR : TXT_GRID;
},
tap() {
for (let i = 0; i < NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes[i].vx = Math.cos(a) * sp;
nodes[i].vy = Math.sin(a) * sp;
}
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./SfConstellationLinks.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
const BG: RGB = [14, 12, 32];
const STAR_RGBS: RGB[] = [[226, 238, 255], [255, 222, 196], [208, 196, 255], [196, 240, 228], [255, 206, 226]];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function paintBg(ctx: CanvasRenderingContext2D, alpha: number): void {
ctx.fillStyle = rgba(BG, alpha);
ctx.fillRect(0, 0, W, H);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999 };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
inp.idle += 1;
scene.step();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
inp.x = clamp((e.clientX - r.left) * W / r.width, 0, W);
inp.y = clamp((e.clientY - r.top) * H / r.height, 0, H);
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
inp.idle += 1;
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
advance(1400);
show();
}
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
} else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: Event): void {
toLocal(e as PointerEvent);
refresh();
});
listen(canvas, 'pointerdown', function (e: Event): void {
toLocal(e as PointerEvent);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.idle = 0;
refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LINK_RADIUS = 72;
const MAX_LINKS = 3;
const GRID_CELL = LINK_RADIUS;
const LINE_ALPHA = 0.6;
const NODE_COUNT = 110;
const CURSOR_LINKS = 6;
const TXT_GRID = '이웃 칸만 계산 — 가까운 별끼리 선';
const TXT_CURSOR = '포인터가 별 하나가 된다';
interface Mote { x: number; y: number; vx: number; vy: number; c: RGB; n: number; }
function makeScene(inp: Input, reduce: boolean): Scene {
const rnd = lcg(450707);
const nodes: Mote[] = [];
for (let i = 0; i <= NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes.push({ x: rnd() * W, y: rnd() * H, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, c: STAR_RGBS[i % STAR_RGBS.length], n: 0 });
}
// 마지막 노드는 커서 별 — 포인터가 없으면 자동 곡선을 돈다
const cur = nodes[NODE_COUNT];
cur.vx = 0;
cur.vy = 0;
let t = 0;
const cols = Math.ceil(W / GRID_CELL);
const rows = Math.ceil(H / GRID_CELL);
const scene: Scene = {
step(): void {
t += 1;
for (let i = 0; i < NODE_COUNT; i += 1) {
const s = nodes[i];
s.x += s.vx;
s.y += s.vy;
if (s.x < 0 || s.x > W) { s.vx = -s.vx; s.x = clamp(s.x, 0, W); }
if (s.y < 0 || s.y > H) { s.vy = -s.vy; s.y = clamp(s.y, 0, H); }
}
let tx = W / 2 + Math.cos(t * 0.021) * 170;
let ty = H / 2 + Math.sin(t * 0.034) * 90;
if (inp.idle < IDLE_TICKS) {
tx = inp.x;
ty = inp.y;
}
cur.x = lerp(cur.x, tx, 0.12);
cur.y = lerp(cur.y, ty, 0.12);
},
draw(ctx: CanvasRenderingContext2D): void {
paintBg(ctx, 1);
// 격자 칸마다 별 번호를 모아 두면 이웃 3x3 칸만 보면 된다
const cells: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) {
cells.push([]);
}
for (let i = 0; i < nodes.length; i += 1) {
nodes[i].n = 0;
const cx = clamp(Math.floor(nodes[i].x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(nodes[i].y / GRID_CELL), 0, rows - 1);
cells[cy * cols + cx].push(i);
}
ctx.lineCap = 'round';
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
const cx = clamp(Math.floor(a.x / GRID_CELL), 0, cols - 1);
const cy = clamp(Math.floor(a.y / GRID_CELL), 0, rows - 1);
const cap = i === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
for (let gy = Math.max(0, cy - 1); gy <= Math.min(rows - 1, cy + 1); gy += 1) {
for (let gx = Math.max(0, cx - 1); gx <= Math.min(cols - 1, cx + 1); gx += 1) {
const list = cells[gy * cols + gx];
for (let q = 0; q < list.length; q += 1) {
const j = list[q];
const b = nodes[j];
if (j <= i || a.n >= cap) { continue; }
const capB = j === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
if (b.n >= capB) { continue; }
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d >= LINK_RADIUS) { continue; }
a.n += 1;
b.n += 1;
const k = 1 - d / LINK_RADIUS;
ctx.strokeStyle = rgba(mix(a.c, b.c, 0.5), LINE_ALPHA * Math.pow(k, 1.3));
ctx.lineWidth = 0.6 + 1.4 * k;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
}
for (let i = 0; i < nodes.length; i += 1) {
const s = nodes[i];
glow(ctx, s.x, s.y, i === NODE_COUNT ? 22 : 9, s.c, i === NODE_COUNT ? 0.6 : 0.35);
ctx.fillStyle = rgba(s.c, 0.95);
ctx.beginPath();
ctx.arc(s.x, s.y, i === NODE_COUNT ? 3.4 : 1.7, 0, PI2);
ctx.fill();
}
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_CURSOR : TXT_GRID;
},
tap(): void {
for (let i = 0; i < NODE_COUNT; i += 1) {
const a = rnd() * PI2;
const sp = 0.35 + 0.65 * rnd();
nodes[i].vx = Math.cos(a) * sp;
nodes[i].vy = Math.sin(a) * sp;
}
}
};
if (reduce) {
for (let i = 0; i < 30; i += 1) {
scene.step();
}
}
return scene;
}
export default function SfConstellationLinks() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="가까운 별끼리 선으로 이어지는 별자리" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">이웃 칸만 계산 — 가까운 별끼리 선</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>가까운 별끼리 선으로 이어지는 별자리</p>
</div>
</main>
);
}07 Constellations Where Nearby Stars Join by Lines — Measured area 56.17% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Starfield: 9 Backgrounds Stars Fly Through
The post this demo lives in
CSS Starfield: 9 Backgrounds Stars Fly ThroughA css starfield of a few CSS-animated dots has no depth and a flat constant speed — scenes 01, 02 and 05 solve it with p See all demos in this post


