06Greedy wrapping — the optimization that picks the next thread
Draw every candidate chord, keep only the one that brings the weave closest to the photo, repeat four thousand times — the algorithm reproducing how real kits order their wraps.
Best forPattern generators, craft tools
getImageDataImageDatalineTorequestAnimationFrame

<main class="stage">
<div class="sf is-demo" id="sfRoot" role="application" aria-label="Greedy search board" tabindex="0">
<canvas class="sf__cv" id="sfCv"></canvas>
<p class="sf__hint">
<span class="sf__title">Greedy wrapping</span>
<span class="sf__desc">Draw every candidate, keep the single thread that matches best — that search completes the pattern</span>
</p>
<div class="sf__bar">
<span class="sf__meta">180 candidate chords · 1 thread per step · cycle 2s</span>
<span class="sf__meta sf__meta--dim">ImageData · greedy pick</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 욕심쇠 감기(06) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(탐욕 단계 진행)가 만든다.
// 무대 옐로우 — 밝은 판 위 잉크보라 실로 별 도안을 채운다. 캔버스가 판 전체다.
$sf-stage: #ffe9a8;
@include stage($sf-stage);
$sf-panel: #fff7d6; // 감기판 바닥 — 무대보다 한 단계 밝은 크림옐로우
$sf-text: #4a3d20; // 라벨 글자 — 진갈먹 잉크
$sf-thread: #5a5470; // 실 — 잉크보라(밝은 판 위 진한 올)
$sf-cand: #d98fa6; // 탐색 중 후보 올 — 더스티 로즈
$sf-pin: #2e2a3a; // 핀 — 진먹보라
$duration: $dur-loop;
$easing: $ease-out;
$color: $sf-thread;
.sf {
--sf-panel: #{$sf-panel};
--sf-thread: #{$sf-thread};
--sf-cand: #{$sf-cand};
--sf-pin: #{$sf-pin};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sf__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sf__hint {
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 0; margin: 0;
padding: $sp-2 $sp-3 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $sf-text;
text-shadow: 0 1px 4px rgba($sf-panel, .9);
}
.sf__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sf-text, .88);
text-shadow: 0 1px 4px rgba($sf-panel, .9);
}
.sf__bar {
position: relative; z-index: 1;
margin-top: auto;
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-3 $sp-2;
}
.sf__meta {
font-size: 10px; font-weight: 500; color: rgba($sf-text, .78);
background: rgba($sf-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sf:focus-visible { outline: 2px solid $sf-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sf__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 완성된 탐욕 도안 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sf.is-still { 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: #ffe9a8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf {
--sf-panel: #fff7d6;
--sf-thread: #5a5470;
--sf-cand: #d98fa6;
--sf-pin: #2e2a3a;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sf__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sf__hint {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 8px 12px 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #4a3d20;
text-shadow: 0 1px 4px rgba(255, 247, 214, 0.9);
}
.sf__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(74, 61, 32, 0.88);
text-shadow: 0 1px 4px rgba(255, 247, 214, 0.9);
}
.sf__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sf__meta {
font-size: 10px;
font-weight: 500;
color: rgba(74, 61, 32, 0.78);
background: rgba(255, 247, 214, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.sf:focus-visible {
outline: 2px solid #5a5470;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sf__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sf.is-still {
cursor: default !important;
}
}// 스트링아트 Greedy wrapping(06) — 후보를 전부 그려보고 가장 나은 한 올만 남기는 탐색 루프.
// 목표 도안(오프스크린 캔버스에 절차적으로 그린 별 문양)을 getImageData 로 읽어 격자로 두고,
// 감은 판 상태를 같은 크기 보드(Float32Array)에 유지한다. 매 단계에서 후보 180현을 보드에
// 얹었다 되돌리며 점수를 재고, 목표와 가장 가까워지는 한 올만 실제로 남긴다 —
// 이 탐욕 선택을 반복하면 실만으로 별이 그려진다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 180; // 원 둘레 핀 개수
var CAND = 180; // 매 단계 비교하는 후보 현 수
var STEPS = 190; // 주기 안에 완성하는 올 수
var CYCLE = 2.0; // 감기 주기(초)
var SS = 72; // 비교용 축소 격자 크기(SS×SS)
// ────────────────────────────────────────────────────
var root = document.getElementById('sfRoot');
var cvEl = document.getElementById('sfCv');
if (!root || !cvEl) return;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var COL_THRD = tokRGB('--sf-thread');
var COL_CAND = tokRGB('--sf-cand');
var COL_PIN = tokRGB('--sf-pin');
var COL_PANEL = tokRGB('--sf-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var R = 0, CX = 0, CY = 0;
// ── 목표 도안 — 오프스크린에 5각 별을 그리고 ImageData 로 읽어 격자로 둔다 ──
var goal = new Float32Array(SS * SS);
(function buildGoal() {
var oc = document.createElement('canvas');
oc.width = SS; oc.height = SS;
var o = oc.getContext('2d');
o.fillStyle = '#000'; o.fillRect(0, 0, SS, SS);
o.strokeStyle = '#fff'; o.lineWidth = 2.6;
o.beginPath();
for (var k = 0; k <= 5; k++) {
var a = -Math.PI / 2 + Math.PI * 4 * k / 5;
var x = SS / 2 + Math.cos(a) * SS * 0.38;
var y = SS / 2 + Math.sin(a) * SS * 0.38;
if (k === 0) o.moveTo(x, y); else o.lineTo(x, y);
}
o.stroke();
var d = o.getImageData(0, 0, SS, SS).data;
for (var i = 0; i < SS * SS; i++) goal[i] = d[i * 4] / 255;
})();
// ── 감기 상태 — 핀 사용 횟수와 확정 올 목록 ──
var pinsUsed = [];
var woven = []; // [i, j] 확정 올
var board = new Float32Array(SS * SS); // 감은 판의 잉크량
function resetBoard() {
for (var b = 0; b < SS * SS; b++) board[b] = 0;
for (var u = 0; u < N; u++) pinsUsed[u] = 0;
woven = [];
}
// 격자 좌표의 핀 위치
function gPin(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2;
return [SS / 2 + Math.cos(a) * SS * 0.46, SS / 2 + Math.sin(a) * SS * 0.46];
}
// 올 얹기/되돌리기 — 현을 따라 점을 찍어 잉크량을 더거나 뺀다
function paintChord(i, j, sign) {
var a = gPin(i), b = gPin(j);
var K = 60;
for (var k = 0; k <= K; k++) {
var u = k / K;
var x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
var gx = x | 0, gy = y | 0;
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
var idx = gy * SS + gx;
board[idx] += sign * 0.5;
}
}
// 보드가 목표와 얼마나 다른가 — 작을수록 닮았다
function score() {
var s = 0;
for (var i = 0; i < SS * SS; i++) {
var ink = board[i] > 1 ? 1 : board[i]; // 겹친 올은 포화
var diff = ink - goal[i];
s += diff * diff;
}
return s;
}
// 매 단계 — 후보 CAND현을 얹었다 되돌리며 점수를 재고 최선 한 올을 확정한다
function greedyStep() {
var base = score();
var i0 = woven.length ? woven[woven.length - 1][1] : 0; // 이전 올 끝핀에서 이어서
var bestJ = -1, bestSc = base;
for (var c = 1; c <= CAND; c++) {
var j = (i0 + c) % N;
paintChord(i0, j, 1);
var sc = score();
paintChord(i0, j, -1);
if (sc < bestSc) { bestSc = sc; bestJ = j; }
}
if (bestJ >= 0) {
paintChord(i0, bestJ, 1);
woven.push([i0, bestJ]);
pinsUsed[i0]++; pinsUsed[bestJ]++;
return bestJ;
}
// 나아질 후보가 없으면 규칙 올 한 개라도 감아 진행을 지킨다
var fallback = (i0 + Math.round(N * 0.37)) % N;
paintChord(i0, fallback, 1);
woven.push([i0, fallback]);
pinsUsed[i0]++; pinsUsed[fallback]++;
return fallback;
}
// 주기마다 재현할 STEPS 단계의 탐욕 결과를 미리 계산해 둔다(같은 도안이 반복된다)
var endpoints = [];
(function precompute() {
resetBoard();
for (var s = 0; s < STEPS; s++) endpoints.push(greedyStep());
})();
function pinXY(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2;
return [CX + Math.cos(a) * R, CY + Math.sin(a) * R];
}
function fit() {
var r = root.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = Math.round(W * dpr); cvEl.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
R = Math.min(W, H) * 0.48;
CX = W / 2; CY = H / 2;
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var full = Math.min(STEPS, Math.floor(progress * STEPS));
var frac = progress * STEPS - full; // 지금 탐색 중인 올의 진행 0..1
ctx.lineCap = 'round';
ctx.shadowBlur = 4;
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',0.3)';
for (var i = 0; i < full; i++) {
var seg = woven[i];
var a = pinXY(seg[0]), b = pinXY(seg[1]);
var recency = (full - i) / STEPS;
var alpha = 0.6 + 0.34 * Math.pow(recency, 1.2);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
ctx.lineWidth = 2.2 + recency * 1.6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 지금 탐색 중인 올 — 확정되어 자라나는 후보(더스티 로즈)
if (full > 0 && full <= STEPS && frac > 0) {
var i0 = woven[full - 1][1];
var jNow = endpoints[full - 1];
var a2 = pinXY(i0), b2 = pinXY(jNow);
var grow = Math.min(1, frac * 2);
ctx.strokeStyle = 'rgba(' + COL_CAND.join(',') + ',' + (0.6 + 0.25 * Math.sin(t * 9)).toFixed(3) + ')';
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(a2[0], a2[1]);
ctx.lineTo(a2[0] + (b2[0] - a2[0]) * grow, a2[1] + (b2[1] - a2[1]) * grow);
ctx.stroke();
}
// 핀 — 감긴 핀일수록 크고 진하며 순차적으로 숨쉰다(유지 구간에도 맥동)
for (var p = 0; p < N; p++) {
var xy = pinXY(p);
var use = pinsUsed[p];
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
if (use > 0) {
var sz = 1 + Math.min(1.4, use * 0.28) + 0.9 * pulse;
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.6 + 0.35 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], sz, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',0.4)';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1, 0, Math.PI * 2); ctx.fill();
}
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var t = ((now - t0) / 1000) % CYCLE;
// 0→0.85 탐색+감기 · 0.85→1 유지(완성 도안을 볼 시간)
var progress = Math.min(1, t / CYCLE / 0.85);
draw(progress, t);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(1, 0);
});
fit();
if (reduce) {
draw(1, 0); // reduce — 완성된 탐욕 도안 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringThreadGreedyOrder — vanilla 판(run/326/demo/06-string-thread-greedy-order/index.html) 의 React 포트.
// 후보를 전부 그려보고 가장 나은 한 올만 남기는 탐색 루프. 목표 도안(오프스크린 캔버스에
// 절차적으로 그린 별 문양)을 getImageData 로 읽어 격자로 두고, 감은 판 상태를 같은 크기
// 보드(Float32Array)에 유지한다. 매 단계에서 후보 180현을 보드에 얹었다 되돌리며 점수를
// 재고, 목표와 가장 가까워지는 한 올만 실제로 남긴다 — 이 탐욕 선택을 반복하면 실만으로 별이 그려진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringThreadGreedyOrder.module.scss";
export type StringThreadGreedyOrderProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 원 둘레 핀 개수
cand?: number; // 매 단계 비교하는 후보 현 수
steps?: number; // 주기 안에 완성하는 올 수
cycle?: number; // 감기 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 180; // 원 둘레 핀 개수
const CAND = 180; // 매 단계 비교하는 후보 현 수
const STEPS = 190; // 주기 안에 완성하는 올 수
const CYCLE = 2.0; // 감기 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
const SS = 72; // 비교용 축소 격자 크기(SS×SS)
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
type Woven = [number, number]; // 확정 올 [시작핀, 끝핀]
const BOARD_LABEL = "욕심쇠 탐색판";
const META_LEFT = "후보 180현 · 매 단계 1올 · 주기 2초";
const META_RIGHT = "ImageData · 탐욕 선택";
const HINT_TITLE = "욕심쇠 감기";
const HINT_DESC = "후보 전부를 그려보고 가장 닮은 한 올만 남기는 탐색이 도안을 완성한다";
export default function StringThreadGreedyOrder({
duration = "2s",
easing = "ease-out",
color = "#5a5470",
n = N,
cand = CAND,
steps = STEPS,
cycle = CYCLE
}: StringThreadGreedyOrderProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; candCol: RGB; pin: RGB; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rRef = useRef(0) as Box<number>;
const cxRef = useRef(0) as Box<number>;
const cyRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const stateRef = useRef<{ woven: Woven[]; endpoints: number[]; pinsUsed: number[] } | null>(null);
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;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// ── 목표 도안 — 오프스크린에 5각 별을 그리고 ImageData 로 읽어 격자로 둔다 ──
function buildGoal(): Float32Array {
const goal = new Float32Array(SS * SS);
const oc = document.createElement("canvas");
oc.width = SS; oc.height = SS;
const o = oc.getContext("2d");
if (!o) return goal;
o.fillStyle = "#000"; o.fillRect(0, 0, SS, SS);
o.strokeStyle = "#fff"; o.lineWidth = 2.6;
o.beginPath();
for (let k = 0; k <= 5; k++) {
const a = -Math.PI / 2 + Math.PI * 4 * k / 5;
const x = SS / 2 + Math.cos(a) * SS * 0.38;
const y = SS / 2 + Math.sin(a) * SS * 0.38;
if (k === 0) o.moveTo(x, y); else o.lineTo(x, y);
}
o.stroke();
const d = o.getImageData(0, 0, SS, SS).data;
for (let i = 0; i < SS * SS; i++) goal[i] = d[i * 4] / 255;
return goal;
}
// 주기마다 재현할 steps 단계의 탐욕 결과를 미리 계산해 둔다(같은 도안이 반복된다)
function precompute(): { woven: Woven[]; endpoints: number[]; pinsUsed: number[] } {
const goal = buildGoal();
const board = new Float32Array(SS * SS); // 감은 판의 잉크량
const pinsUsed: number[] = [];
const woven: Woven[] = [];
const endpoints: number[] = [];
// 격자 좌표의 핀 위치
function gPin(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2;
return [SS / 2 + Math.cos(a) * SS * 0.46, SS / 2 + Math.sin(a) * SS * 0.46];
}
// 올 얹기/되돌리기 — 현을 따라 점을 찍어 잉크량을 더거나 뺀다
function paintChord(i: number, j: number, sign: number): void {
const a = gPin(i), b = gPin(j);
const K = 60;
for (let k = 0; k <= K; k++) {
const u = k / K;
const x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
const gx = x | 0, gy = y | 0;
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
board[gy * SS + gx] += sign * 0.5;
}
}
// 보드가 목표와 얼마나 다른가 — 작을수록 닮았다
function score(): number {
let s = 0;
for (let i = 0; i < SS * SS; i++) {
const ink = board[i] > 1 ? 1 : board[i]; // 겹친 올은 포화
const diff = ink - goal[i];
s += diff * diff;
}
return s;
}
// 매 단계 — 후보 cand현을 얹었다 되돌리며 점수를 재고 최선 한 올을 확정한다
function greedyStep(): number {
const base = score();
const i0 = woven.length ? woven[woven.length - 1][1] : 0; // 이전 올 끝핀에서 이어서
let bestJ = -1, bestSc = base;
for (let c = 1; c <= cand; c++) {
const j = (i0 + c) % n;
paintChord(i0, j, 1);
const sc = score();
paintChord(i0, j, -1);
if (sc < bestSc) { bestSc = sc; bestJ = j; }
}
if (bestJ >= 0) {
paintChord(i0, bestJ, 1);
woven.push([i0, bestJ]);
pinsUsed[i0]++; pinsUsed[bestJ]++;
return bestJ;
}
// 나아질 후보가 없으면 규칙 올 한 개라도 감아 진행을 지킨다
const fallback = (i0 + Math.round(n * 0.37)) % n;
paintChord(i0, fallback, 1);
woven.push([i0, fallback]);
pinsUsed[i0]++; pinsUsed[fallback]++;
return fallback;
}
for (let u = 0; u < n; u++) pinsUsed[u] = 0;
for (let s = 0; s < steps; s++) endpoints.push(greedyStep());
return { woven, endpoints, pinsUsed };
}
function pinXY(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2;
return [cxRef.current + Math.cos(a) * rRef.current, cyRef.current + Math.sin(a) * rRef.current];
}
function fit(): void {
const root = rootRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!root || !cv || !ctx) return;
const r = root.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = Math.round(wRef.current * dpr);
cv.height = Math.round(hRef.current * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
rRef.current = Math.min(wRef.current, hRef.current) * 0.48;
cxRef.current = wRef.current / 2;
cyRef.current = hRef.current / 2;
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const st = stateRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !st) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const full = Math.min(steps, Math.floor(progress * steps));
const frac = progress * steps - full; // 지금 탐색 중인 올의 진행 0..1
ctx.lineCap = "round";
ctx.shadowBlur = 4;
ctx.shadowColor = "rgba(" + col.thrd.join(",") + ",0.3)";
for (let i = 0; i < full; i++) {
const seg = st.woven[i];
const a = pinXY(seg[0]), b = pinXY(seg[1]);
const recency = (full - i) / steps;
const alpha = 0.6 + 0.34 * Math.pow(recency, 1.2);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.95, alpha).toFixed(3) + ")";
ctx.lineWidth = 2.2 + recency * 1.6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 지금 탐색 중인 올 — 확정되어 자라나는 후보(더스티 로즈)
if (full > 0 && full <= steps && frac > 0) {
const i0 = st.woven[full - 1][1];
const jNow = st.endpoints[full - 1];
const a2 = pinXY(i0), b2 = pinXY(jNow);
const grow = Math.min(1, frac * 2);
ctx.strokeStyle = "rgba(" + col.candCol.join(",") + "," + (0.6 + 0.25 * Math.sin(t * 9)).toFixed(3) + ")";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(a2[0], a2[1]);
ctx.lineTo(a2[0] + (b2[0] - a2[0]) * grow, a2[1] + (b2[1] - a2[1]) * grow);
ctx.stroke();
}
// 핀 — 감긴 핀일수록 크고 진하며 순차적으로 숨쉰다(유지 구간에도 맥동)
for (let p = 0; p < n; p++) {
const xy = pinXY(p);
const use = st.pinsUsed[p];
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
if (use > 0) {
const sz = 1 + Math.min(1.4, use * 0.28) + 0.9 * pulse;
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.6 + 0.35 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], sz, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = "rgba(" + col.pin.join(",") + ",0.4)";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1, 0, Math.PI * 2); ctx.fill();
}
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) % cycle;
// 0→0.85 탐색+감기 · 0.85→1 유지(완성 도안을 볼 시간)
const progress = Math.min(1, t / cycle / 0.85);
draw(progress, t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sf-thread"),
candCol: tokRGB(root, "--sf-cand"),
pin: tokRGB(root, "--sf-pin"),
panel: tokRGB(root, "--sf-panel")
};
ctxRef.current = cv.getContext("2d");
stateRef.current = precompute(); // 감기 상태·탐욕 결과를 미리 계산(결정적)
fit();
function onResize(): void {
fit();
if (reduced()) draw(1, 0);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(1, 0); // reduce — 완성된 탐욕 도안 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, cand, steps, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div
className={rootCls}
ref={rootRef}
style={vars}
role="application"
aria-label={BOARD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<p className={styles.hint}>
<span className={styles.title}>{HINT_TITLE}</span>
<span className={styles.desc}>{HINT_DESC}</span>
</p>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}06 Greedy wrapping — the optimization that picks the next thread — Measured area 33.037% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · String Art: 9 Thread-Geometry Demos
The post this demo lives in
String Art: 9 Thread-Geometry DemosLet thread hop between pins by a rule and curves appear on their own. A canvas engine that computes every wrap — copy-pa See all demos in this post
If you like this effect - similar demos
Creative UI07 Chain lightning — hopping from one target point to the next9 Lightning Effect Background Scenes, No WebGL
Creative UI09 Rain pond — drop shockwaves that push the blobs apart9 Metaball Effect Scenes, Marching Squares Canvas
Creative UI07 Acid dissolve — a reaction rule that erases the material it meetsFalling Sand Simulator, 9 Ways (No Library)