05사진 실화 — 밝기를 실 밀도로 바꾸는 초상
사진의 어두운 곳에 실을 많이 감겠다고 매번 가장 어두워지는 현을 고르면 얼굴이 실로 떠오르는데, 077 급 픽셀 계산이 스트링아트에 얹히는 대표 항목입니다.
쓰임초상 아트, 선물 커스텀
getImageDatagetImageDatalineTorequestAnimationFrame

<main class="stage">
<div class="se is-demo" id="seRoot" role="application" aria-label="밝기 실밀도판" tabindex="0">
<canvas class="se__cv" id="seCv"></canvas>
<p class="se__hint">
<span class="se__title">사진 실화</span>
<span class="se__desc">어두운 곳에 실을 많이 감겠다 매번 고르면 얼굴이 실로 떠오른다</span>
</p>
<div class="se__bar">
<span class="se__meta">프로필 사진 180핀 · 실 600올 · 주기 2초</span>
<span class="se__meta se__meta--dim">getImageData · 밝기</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 사진 실화(05) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(욕심 감기 진행)가 만든다.
// 무대 잉크 — 어두운 판 위 아마 실로 프로필 명암을 띄운다. 캔버스가 판 전체다.
$se-stage: #1a1720;
@include stage($se-stage);
$se-panel: #241f2d; // 감기판 바닥 — 무대보다 한 단계 밝은 먹보라
$se-text: #d5cfe6; // 라벨 글자 — 밝은 라일락 회색
$se-thread: #d9c9a3; // 실 — 아마 골드 파스텔(어두운 판 위 밝은 올)
$se-pin: #f0e6d2; // 감긴 핀 — 크림
$se-dim: #4d4459; // 아직 안 감긴 핀 — 저채도 보라
$duration: $dur-loop;
$easing: $ease-out;
$color: $se-thread;
.se {
--se-panel: #{$se-panel};
--se-thread: #{$se-thread};
--se-pin: #{$se-pin};
--se-dim: #{$se-dim};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.se__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.se__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: $se-text;
text-shadow: 0 1px 6px rgba($se-panel, 1);
}
.se__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($se-text, .78);
text-shadow: 0 1px 6px rgba($se-panel, 1);
}
.se__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;
}
.se__meta {
font-size: 10px; font-weight: 500; color: rgba($se-text, .78);
background: rgba($se-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.se:focus-visible { outline: 2px solid $se-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.se__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 감긴 실 초상 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.se.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: #1a1720;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.se {
--se-panel: #241f2d;
--se-thread: #d9c9a3;
--se-pin: #f0e6d2;
--se-dim: #4d4459;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.se__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.se__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: #d5cfe6;
text-shadow: 0 1px 6px #241f2d;
}
.se__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(213, 207, 230, 0.78);
text-shadow: 0 1px 6px #241f2d;
}
.se__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.se__meta {
font-size: 10px;
font-weight: 500;
color: rgba(213, 207, 230, 0.78);
background: rgba(36, 31, 45, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.se:focus-visible {
outline: 2px solid #d9c9a3;
outline-offset: -2px;
}
@media (max-width: 340px) {
.se__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.se.is-still {
cursor: default !important;
}
}// 스트링아트 사진 실화(05) — 밝기를 실 밀도로 바꾸는 명암 변환.
// 외부 이미지 없이 오프스크린 캔버스에 프로필 실루엣(머리·어깨)을 절차적으로 그리고
// getImageData 로 밝기를 읽는다. 감을 올 후보 둘 중 "지나는 길이 더 어두운 쪽"을
// 매번 골라 걸어가면(욕심 선택) 어두운 영역에만 실이 쌓여 초상이 떠오른다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 180; // 원 둘레 핀 개수
var STR = 600; // 감는 실 올 수
var CYCLE = 2.0; // 감기 완료 주기(초)
var SS = 96; // 오프스크린 밝기 격자 크기(SS×SS)
// ────────────────────────────────────────────────────
var root = document.getElementById('seRoot');
var cvEl = document.getElementById('seCv');
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('--se-thread');
var COL_PIN = tokRGB('--se-pin');
var COL_PANEL = tokRGB('--se-panel');
var COL_DIM = tokRGB('--se-dim');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var R = 0, CX = 0, CY = 0;
// ── 오프스크린 프로필 실루엣 → 밝기 격자 ──
// 머리(원) + 목 + 어깨(둥근 사다리꼴, 판 아래까지) + 뺨 음영 + 하단 좌우 배경 칼럼 — 절차적으로만 그린다.
var lum = new Float32Array(SS * SS);
(function buildProfile() {
var oc = document.createElement('canvas');
oc.width = SS; oc.height = SS;
var o = oc.getContext('2d');
o.fillStyle = '#fff'; o.fillRect(0, 0, SS, SS); // 바탕 밝게
o.fillStyle = '#000';
// 하단 좌우 배경 칼럼 — 사진 테두리 어둡기. 아래쪽에도 실이 감기게 하여 판이 비지 않게
o.fillRect(0, SS * 0.80, SS * 0.20, SS * 0.20);
o.fillRect(SS * 0.80, SS * 0.80, SS * 0.20, SS * 0.20);
// 머리 — 중앙 위쪽 큰 원
o.beginPath(); o.arc(SS * 0.50, SS * 0.30, SS * 0.155, 0, Math.PI * 2); o.fill();
// 목
o.fillRect(SS * 0.455, SS * 0.40, SS * 0.09, SS * 0.10);
// 어깨 — 둥근 사다리꼴, 판 아래까지 이어진다
o.beginPath();
o.moveTo(SS * 0.16, SS * 0.94);
o.quadraticCurveTo(SS * 0.22, SS * 0.585, SS * 0.44, SS * 0.50);
o.lineTo(SS * 0.56, SS * 0.50);
o.quadraticCurveTo(SS * 0.78, SS * 0.585, SS * 0.84, SS * 0.94);
o.lineTo(SS * 0.16, SS * 0.94);
o.closePath(); o.fill();
// 머리카락 음영 — 위쪽 반달 더 어둡게(중첩)
o.beginPath(); o.arc(SS * 0.50, SS * 0.325, SS * 0.158, Math.PI, Math.PI * 2); o.fill();
// 뺨·턱 음영 — 살짝 어두운 타원 2
o.fillStyle = '#555';
o.beginPath(); o.ellipse(SS * 0.445, SS * 0.365, SS * 0.026, SS * 0.017, 0.3, 0, Math.PI * 2); o.fill();
o.beginPath(); o.ellipse(SS * 0.555, SS * 0.365, SS * 0.026, SS * 0.017, -0.3, 0, Math.PI * 2); o.fill();
var d = o.getImageData(0, 0, SS, SS).data;
for (var i = 0; i < SS * SS; i++) {
lum[i] = 1 - d[i * 4] / 255; // 어두움 0..1
}
})();
// 감기 순서 — 매 올을 "남은 후보 중 길이 어두움이 큰 쪽"으로 연결하는
// 결정적 욕심 스케줄(같은 배열이라 매번 같은 그림이 나오도록 시드 고정).
// 계산용 고정 좌표(스케줄은 판 크기와 무관하게 0..1 비율 격자 위에서 짠다)
var SKIP = Math.round(N * 0.41);
var PINU = [];
for (var pi = 0; pi < N; pi++) {
var pa = Math.PI * 2 * pi / N - Math.PI / 2;
PINU.push([0.5 + Math.cos(pa) * 0.5, 0.5 + Math.sin(pa) * 0.5]);
}
function pinXY0(i) { return [PINU[i][0] * SS, PINU[i][1] * SS]; }
function chordDark0(a, b) {
var s = 0, n = 7;
for (var k = 1; k <= n; k++) {
var u = k / (n + 1);
var x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
var gx = Math.round(x), gy = Math.round(y);
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
s += lum[gy * SS + gx];
}
return s / n;
}
var schedule = [];
(function plan() {
var pos = 0, used = {};
for (var s = 0; s < STR; s++) {
var bestJ = -1, bestV = -1;
var base = pinXY0(pos);
for (var c = 1; c <= 4; c++) {
var j = (pos + c * SKIP) % N;
var key = pos + '-' + j;
if (used[key]) continue;
var v = chordDark0(base, pinXY0(j));
if (v > bestV) { bestV = v; bestJ = j; }
}
if (bestJ < 0) bestJ = (pos + SKIP) % N;
used[pos + '-' + bestJ] = 1;
schedule.push([pos, bestJ, Math.min(1, bestV * 1.6)]);
pos = bestJ;
}
})();
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.47;
CX = W / 2; CY = H / 2 + Math.min(H, W) * 0.02;
}
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.floor(progress * STR);
ctx.lineCap = 'round';
ctx.shadowBlur = 4;
for (var i = 0; i < full; i++) {
var seg = schedule[i];
var a = pinXY(seg[0]), b = pinXY(seg[1]);
var dens = seg[2]; // 이 올이 지나는 길의 어두움
var recency = (full - i) / STR;
// 어두운 길의 올은 진하게, 밝은 길의 올은 성겁게 — 밀도가 명암이 된다
var alpha = 0.24 + 0.62 * dens + 0.2 * Math.pow(recency, 1.4);
ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',' + (alpha * 0.3).toFixed(3) + ')';
ctx.lineWidth = 1.1 + dens * 1.1 + recency * 0.9;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감기가 지난 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
var lim = full > 0 ? schedule[full - 1][1] : 0;
for (var p = 0; p < N; p++) {
var xy = pinXY(p);
if (p <= lim) {
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.35);
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.6 + 0.35 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.0 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgb(' + COL_DIM.join(',') + ')';
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.8 감기 · 0.8→1 유지(다 감긴 초상을 볼 시간)
var progress = Math.min(1, t / CYCLE / 0.8);
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);
}
})();// StringChordDensityPhoto — vanilla 판(run/326/demo/05-string-chord-density-photo/index.html) 의 React 포트.
// 밝기를 실 밀도로 바꾸는 명암 변환. 외부 이미지 없이 오프스크린 캔버스에 프로필 실루엣
// (머리·어깨)을 절차적으로 그리고 getImageData 로 밝기를 읽는다. 감을 올 후보 둘 중
// "지나는 길이 더 어두운 쪽"을 매번 골라 걸어가면(욕심 선택) 어두운 영역에만 실이
// 쌓여 초상이 떠오른다. 스케줄은 0..1 비율 격자 위에서 미리 계산해 둔다(결정적).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringChordDensityPhoto.module.scss";
export type StringChordDensityPhotoProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 원 둘레 핀 개수
str?: number; // 감는 실 올 수
cycle?: number; // 감기 완료 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 180; // 원 둘레 핀 개수
const STR = 600; // 감는 실 올 수
const CYCLE = 2.0; // 감기 완료 주기(초) — 설정 초(2s)와 같아 한 감기가 한 루프
const SS = 96; // 오프스크린 밝기 격자 크기(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 Seg = [number, number, number]; // [시작핀, 끝핀, 길 어두움 0..1]
const BOARD_LABEL = "밝기 실밀도판";
const META_LEFT = "프로필 사진 180핀 · 실 600올 · 주기 2초";
const META_RIGHT = "getImageData · 밝기";
const HINT_TITLE = "사진 실화";
const HINT_DESC = "어두운 곳에 실을 많이 감겠다 매번 고르면 얼굴이 실로 떠오른다";
export default function StringChordDensityPhoto({
duration = "2s",
easing = "ease-out",
color = "#d9c9a3",
n = N,
str = STR,
cycle = CYCLE
}: StringChordDensityPhotoProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; pin: RGB; panel: RGB; dim: 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 schedRef = useRef<Seg[] | 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)];
}
// ── 오프스크린 프로필 실루엣 → 밝기 격자 ──
// 머리(원) + 목 + 어깨(둥근 사다리꼴, 판 아래까지) + 뺨 음영 + 하단 좌우 배경 칼럼 — 절차적으로만 그린다.
function buildProfile(): Float32Array {
const lum = new Float32Array(SS * SS);
const oc = document.createElement("canvas");
oc.width = SS; oc.height = SS;
const o = oc.getContext("2d");
if (!o) return lum;
o.fillStyle = "#fff"; o.fillRect(0, 0, SS, SS); // 바탕 밝게
o.fillStyle = "#000";
// 하단 좌우 배경 칼럼 — 사진 테두리 어둡기. 아래쪽에도 실이 감기게 하여 판이 비지 않게
o.fillRect(0, SS * 0.80, SS * 0.20, SS * 0.20);
o.fillRect(SS * 0.80, SS * 0.80, SS * 0.20, SS * 0.20);
// 머리 — 중앙 위쪽 큰 원
o.beginPath(); o.arc(SS * 0.50, SS * 0.30, SS * 0.155, 0, Math.PI * 2); o.fill();
// 목
o.fillRect(SS * 0.455, SS * 0.40, SS * 0.09, SS * 0.10);
// 어깨 — 둥근 사다리꼴, 판 아래까지 이어진다
o.beginPath();
o.moveTo(SS * 0.16, SS * 0.94);
o.quadraticCurveTo(SS * 0.22, SS * 0.585, SS * 0.44, SS * 0.50);
o.lineTo(SS * 0.56, SS * 0.50);
o.quadraticCurveTo(SS * 0.78, SS * 0.585, SS * 0.84, SS * 0.94);
o.lineTo(SS * 0.16, SS * 0.94);
o.closePath(); o.fill();
// 머리카락 음영 — 위쪽 반달 더 어둡게(중첩)
o.beginPath(); o.arc(SS * 0.50, SS * 0.325, SS * 0.158, Math.PI, Math.PI * 2); o.fill();
// 뺨·턱 음영 — 살짝 어두운 타원 2
o.fillStyle = "#555";
o.beginPath(); o.ellipse(SS * 0.445, SS * 0.365, SS * 0.026, SS * 0.017, 0.3, 0, Math.PI * 2); o.fill();
o.beginPath(); o.ellipse(SS * 0.555, SS * 0.365, SS * 0.026, SS * 0.017, -0.3, 0, Math.PI * 2); o.fill();
const d = o.getImageData(0, 0, SS, SS).data;
for (let i = 0; i < SS * SS; i++) {
lum[i] = 1 - d[i * 4] / 255; // 어두움 0..1
}
return lum;
}
// 감기 순서 — 매 올을 "남은 후보 중 길이 어두움이 큰 쪽"으로 연결하는 결정적 욕심 스케줄.
// 계산용 고정 좌표(스케줄은 판 크기와 무관하게 0..1 비율 격자 위에서 짠다)
function plan(): Seg[] {
const lum = buildProfile();
const SKIP = Math.round(n * 0.41);
const PINU: XY[] = [];
for (let pi = 0; pi < n; pi++) {
const pa = Math.PI * 2 * pi / n - Math.PI / 2;
PINU.push([0.5 + Math.cos(pa) * 0.5, 0.5 + Math.sin(pa) * 0.5]);
}
function pinXY0(i: number): XY {
const u = PINU[i];
return [u[0] * SS, u[1] * SS];
}
function chordDark0(a: XY, b: XY): number {
let s = 0;
const cnt = 7;
for (let k = 1; k <= cnt; k++) {
const u = k / (cnt + 1);
const x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
const gx = Math.round(x), gy = Math.round(y);
if (gx < 0 || gy < 0 || gx >= SS || gy >= SS) continue;
s += lum[gy * SS + gx];
}
return s / cnt;
}
const schedule: Seg[] = [];
const used: Record<string, number> = {};
let pos = 0;
for (let s = 0; s < str; s++) {
let bestJ = -1, bestV = -1;
const base = pinXY0(pos);
for (let c = 1; c <= 4; c++) {
const j = (pos + c * SKIP) % n;
const key = pos + "-" + j;
if (used[key]) continue;
const v = chordDark0(base, pinXY0(j));
if (v > bestV) { bestV = v; bestJ = j; }
}
if (bestJ < 0) bestJ = (pos + SKIP) % n;
used[pos + "-" + bestJ] = 1;
schedule.push([pos, bestJ, Math.min(1, bestV * 1.6)]);
pos = bestJ;
}
return schedule;
}
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.47;
cxRef.current = wRef.current / 2;
cyRef.current = hRef.current / 2 + Math.min(hRef.current, wRef.current) * 0.02;
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const schedule = schedRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !schedule) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const full = Math.floor(progress * str);
ctx.lineCap = "round";
ctx.shadowBlur = 4;
for (let i = 0; i < full; i++) {
const seg = schedule[i];
const a = pinXY(seg[0]), b = pinXY(seg[1]);
const dens = seg[2]; // 이 올이 지나는 길의 어두움
const recency = (full - i) / str;
// 어두운 길의 올은 진하게, 밝은 길의 올은 성겁게 — 밀도가 명암이 된다
const alpha = 0.24 + 0.62 * dens + 0.2 * Math.pow(recency, 1.4);
ctx.strokeStyle = "rgba(" + col.thrd.join(",") + "," + Math.min(0.95, alpha).toFixed(3) + ")";
ctx.shadowColor = "rgba(" + col.thrd.join(",") + "," + (alpha * 0.3).toFixed(3) + ")";
ctx.lineWidth = 1.1 + dens * 1.1 + recency * 0.9;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 감기가 지난 핀은 밝게 숨쉰다(유지 구간에도 순차 맥동)
const lim = full > 0 ? schedule[full - 1][1] : 0;
for (let p = 0; p < n; p++) {
const xy = pinXY(p);
if (p <= lim) {
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.35);
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.6 + 0.35 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.0 * pulse, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = "rgb(" + col.dim.join(",") + ")";
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.8 감기 · 0.8→1 유지(다 감긴 초상을 볼 시간)
const progress = Math.min(1, t / cycle / 0.8);
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, "--se-thread"),
pin: tokRGB(root, "--se-pin"),
panel: tokRGB(root, "--se-panel"),
dim: tokRGB(root, "--se-dim")
};
ctxRef.current = cv.getContext("2d");
schedRef.current = plan(); // 주기마다 재현할 감기 순서를 미리 계산(결정적 — 매번 같은 초상)
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, str, 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>
);
}05 사진 실화 — 밝기를 실 밀도로 바꾸는 초상 — 측정 누적면적 31.687% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스트링아트 9종 — 실 감기 한 올 한 올을 계산하는 캔버스
이 데모가 속한 글
스트링아트 9종 — 실 감기 한 올 한 올을 계산하는 캔버스스트링아트는 핀에 실을 감아 곡선과 명암을 만드는 공예인데, 이 글은 감기의 전 과정을 핀을 어디에 박을지부터 실을 어느 순서로 감을지까지 통째로 계산하는 캔버스 엔진 아홉 판을 줍니다. 원 둘레의 핀들 사이를 실이 이 글의 데모 전부 보기

