GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

스트링아트 9종 — 실 감기 한 올 한 올을 계산하는 캔버스

스트링아트는 핀에 실을 감아 곡선과 명암을 만드는 공예인데, 이 글은 감기의 전 과정을 핀을 어디에 박을지부터 실을 어느 순서로 감을지까지 통째로 계산하는 캔버스 엔진 아홉 판을 줍니다. 원 둘레의 핀들 사이를 실이 규칙으로 건너뛰면 곡선이 저절로 생깁니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

데모

아홉 판 전부 파일 하나씩으로 열리자마자 감기를 시작하고, 화면의 모든 변화는 CSS 키프레임이 아니라 핀 좌표와 감기 순서를 다시 계산해 실을 다시 그린 결과입니다. 24프레임 녹화에서 아홉 판 모두 23프레임이 움직였고, 감기 진행이 주기 2초 안에 차오르며 방금 감은 올이 가장 밝아 감는 손의 자리를 눈이 따라갑니다. 04 는 컨트롤 핀을 직접 끌어 보고, 09 는 감기가 끝날 무렵 한 프레임에 면적 43.653% 까지 차오르는 완성 국면까지 지켜볼 판입니다.

24프레임 실측 한눈에

항목 초점 누적면적(%) 강도 평균
01 원핀 기본 120핀 49칸 건너뛰기의 최소 구현 30.758 40.2
02 곡선 착시 두 변 32쌍 직선 현의 원호 포락 30.674 40.5
03 마스카레이드 배수 k 2·3·4 순환의 곡선 교실 33.049 31.9
04 베지어 감기 컨트롤 핀 끌기의 실시간 재감기 30.737 62.5
05 사진 실화 밝기를 실 밀도로 바꾼 초상 30.144 51.4
06 욕심쇠 감기 후보 180현 점수 비교의 최적 선택 32.857 47.0
07 윤곽 핀 배치 그림 경계에서 뽑은 90핀 32.144 35.9
08 직조 무늬 경사 26·위사 18의 위상차 교차 50.97 38.6
09 대문 감기 장면 글자 핀 64개로 짜는 워드마크 42.744 32.8

수치는 구성의 성격으로 읽는다

누적면적이 일곱 판에서 30~33% 대에 모이는 것은 실이 원판이나 모양 안쪽에 모이는 구성 탓이고, 08 직조 무늬만 50.97% 로 유일하게 판 전체를 덮습니다. 세로 경사와 가로 위사가 네 모서리까지 실을 걸기 때문입니다. 09 대문 감기는 42.744% 이고 감기가 끝날 무렵 한 프레임이 43.653% 까지 차올라 프레임 면적 기준으로는 아홉 판 중 가장 찹니다. 강도 평균은 04 베지어 감기가 62.5 로 가장 밝은데 굵기 2.4에서 4.2px 까지 굵어지는 실과 방금 감은 올의 대비가 크기 때문이고, 05 사진 실화 51.4 · 06 욕심쇠 감기 47.0 이 뒤따릅니다. 누적면적이 가장 낮은 05(30.144%)는 어두운 곳에만 실을 놓는 초상 구성이라 낮은 것이지 빈 판이 아닙니다.

원 둘레 감기에서 사진 명암까지, 스트링아트 규칙을 넓힌 순서

순서는 화려함의 서열이 아니라 감기 규칙이 어디에서 나오는가를 한 단계씩 넓히는 서열입니다. 먼저 칸수 하나를 정해 원 둘레를 도는 가장 단순한 규칙으로 곡선을 얻고(01), 프레임을 원에서 두 변으로 옮겨 직선이 곡선을 만든다는 원리를 드러냅니다(02). 세 번째는 건너뛰기를 배수 규칙으로 일반화해 규칙 하나가 곡선의 종류를 가르는 것을 보이고(03), 넷째는 반대로 핀 배치가 움직여 규칙 전체가 다시 짜이게 합니다(04). 다섯째부터 규칙이 그림을 읽습니다 — 사진의 어두움이 다음 올을 고르고(05), 그 고르기가 점수 비교의 최적화가 되며(06), 핀 배치 자체를 그림 경계에서 뽑습니다(07). 여덟 번째는 감는 대상이 곡선에서 직조 문양으로 바뀌고(08), 마지막에 글자 핀·스포트라이트까지 얹어 대문 장면으로 조립합니다(09). 스트링아트 도안을 손으로 그리지 않고 계산으로 얻는다는 점에서 평면 무늬 속에 입체를 감추는 매직아이 스테레오그램과 같은 착시 축의 이웃이고, 핀 좌표와 현 밝기를 프레임마다 굽는 방식은 거리장을 한 걸음씩 밟아 장면을 세우는 SDF 레이마칭·먹이 향을 좇아 스스로 그물을 다시 잇는 슬라임 몰드 시뮬레이션과도 계산으로 그림을 얻는 계보입니다.

항목별 코드

01원핀 기본 — 건너뛰기 49칸만으로 꽃이 피는 최소판

원 둘레에 120핀을 박고 i번 핀에서 i+49번 핀으로 한 바퀴 120올을 감는, 스트링아트 규칙의 모든 것이 시작되는 최소 구현입니다. 곡선은 한 번도 그리지 않는데 현이 겹쳐 밝아지는 자리가 원판 안에 장미꼴 포락선을 띄우고, 방금 감은 올은 알파 0.34에서 0.96, 굵기 1에서 2.4px 로 밝아져 감는 손이 지나가는 자리가 드러납니다.

원핀 기본 — 일정 칸 건너뛰기만으로 꽃이 핀다
새 창에서 열기

01 · 원핀 기본 — 일정 칸 건너뛰기만으로 꽃이 핀다 — 측정 누적면적 30.685% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.sinMath.coslineTobeginPath

등각 핀 배치와 감기 루프

  function pinXY(i) {
    var a = Math.PI * 2 * i / N - Math.PI / 2;   // 0번 핀을 12시에 두고 시계 방향
    return [W / 2 + Math.cos(a) * R, H / 2 + Math.sin(a) * R];
  }

  // 실 — 감기 순서대로. progress 만큼의 올이 걸려 있다.
  var total = N;                      // 한 바퀴 = N 올 (i → i+SKIP, N번)
  var seg = progress * total;
  var full = Math.floor(seg);

  ctx.lineCap = 'round';
  ctx.shadowBlur = 5;                 // 실 광택 — 올 주위 헤일로그가 겹침 밝기를 더한다
  for (var i = 0; i < full; i++) {
    var a = pinXY(i), b = pinXY(i + SKIP);
    // 감기 근처의 최근 올은 진하게 — 감는 손이 지나가는 자리가 밝다
    var recency = (full - i) / total;           // 1(방금) → 0(처음)
    var alpha = 0.34 + 0.62 * Math.pow(recency, 1.6);
    ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
    ctx.shadowColor = 'rgba(' + COL_GLOW.join(',') + ',' + (alpha * 0.5).toFixed(3) + ')';
    ctx.lineWidth = 1 + recency * 1.4;
    ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
  }

02곡선 착시 — 직선 현만으로 원호가 떠오르는 두 변 감기

왼변 32핀과 아래변 32핀을 박고 왼변 i번을 아래변 (M−1−i)번과 이으면 모든 현이 한 원호에 닿는, 직선의 포락선이 곡선을 만든다는 원리의 정석 구성입니다. 곡선을 그리는 호출은 단 한 번도 없는데 눈은 원호를 봅니다.

곡선 착시 — 직선 실만으로 원을 그리는 포락선
새 창에서 열기

02 · 곡선 착시 — 직선 실만으로 원을 그리는 포락선 — 측정 누적면적 30.669% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lineTobeginPathstroke

두 변의 역순 짝짓기

  // 왼변 핀 i (위에서 아래) — 판 세로를 전부 쓴다
  function pinL(i) { return [pad, pad + (H - 2 * pad) * i / (M - 1)]; }
  // 아래변 핀 j (왼쪽에서 오른쪽) — 판 가로를 전부 쓴다
  function pinB(j) { return [pad + (W - 2 * pad) * j / (M - 1), H - pad]; }

  // 실 — 왼변 i ↔ 아래변 (M-1-i). 감는 손이 지나간 자리일수록 밝다
  ctx.lineCap = 'round';
  ctx.shadowBlur = 5;
  for (var i = 0; i < M; i++) {
    if (i / M >= progress) continue;
    var a = pinL(i), b = pinB(M - 1 - i);
    var recency = (progress * M - i) / M;      // 1(방금 감은 올) → 0(처음 올)
    var alpha = 0.55 + 0.42 * Math.pow(recency, 1.4);
    ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + alpha.toFixed(3) + ')';
    ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
    ctx.lineWidth = 1.8 + recency * 1.6;
    ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
  }

03마스카레이드 — 배수 k 하나로 곡선이 갈리는 수학판

원 위 n번 핀에서 k·n번 핀으로 감는 배수 규칙 하나로 곡선의 종류가 정해지는데 k=2 는 카디오이드(마스카레이드), k=3 은 네프로이드, k=4 는 3꽃 에피사이클로이드입니다. k 가 구간마다 2→3→4 로 바뀌면 곡선 전체가 다시 짜이고, 구간 앞 70% 에 감아 뒤 30% 는 다 감긴 곡선을 보여 줍니다.

마스카레이드 — 원 위 2배수 핀의 수학
새 창에서 열기

03 · 마스카레이드 — 원 위 2배수 핀의 수학 — 측정 누적면적 33.918% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.sinMath.coslineTostrokeStyle

배수 규칙과 구간 순환

    var phase = (t / CYCLE) % 3;            // 0..3 — 구간마다 k
    var k = 2 + Math.floor(phase);          // 2, 3, 4
    var local = phase - Math.floor(phase);  // 구간 안 진행 0..1

    // 감기 진행 — 구간 앞 70% 에 걸어 다 감고 뒤 30% 유지(다 감긴 곡선을 볼 시간)
    var progress = Math.min(1, local / 0.7);
    var total = N;                          // 한 번에 보이는 올 수
    var full = Math.floor(progress * total);

    ctx.lineCap = 'round';
    ctx.shadowBlur = 5;
    for (var i = 0; i < full; i++) {
      var a = pinXY(i), b = pinXY((i * k) % N);
      // 중심에 가까워지는 현(포락선 꼭짓점 쪽)을 강조 + 최근 올 강조
      var mx = (a[0] + b[0]) / 2 - W / 2, my = (a[1] + b[1]) / 2 - H / 2;
      var near = 1 - Math.min(1, Math.sqrt(mx * mx + my * my) / R);
      var recency = (full - i) / total;
      var hot = near > 0.55;
      var alpha = (hot ? 0.72 : 0.58) + 0.26 * Math.pow(recency, 1.5);
      var base = hot ? COL_HOT : COL_THRD;
      ctx.strokeStyle = 'rgba(' + base.join(',') + ',' + Math.min(0.97, alpha).toFixed(3) + ')';
      ctx.shadowColor = 'rgba(' + COL_HOT.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
      ctx.lineWidth = (hot ? 1.6 : 1.2) + recency * 1.1;
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }

04베지어 감기 — 컨트롤 핀을 끌면 실이 다시 감긴다

양끝 고정핀 A·B 와 컨트롤 핀 3개를 사슬로 잇고 사슬의 같은 비율 점끼리 실을 걸면 실의 포락선이 베지어 곡선을 따라가는 드 카스텔료 구성입니다. 핀을 끌면 그 자리에서 다시 감기고 놔두면 1.5초마다 핀이 다음 자리로 옮겨 128+96+84×2올의 부채꼴이 재계산되는데, 굵은 실 탓에 강도평균 62.5 로 아홉 판 중 가장 밝습니다.

베지어 감기 — 컨트롤 핀을 끌면 실이 다시 감긴다
새 창에서 열기

04 · 베지어 감기 — 컨트롤 핀을 끌면 실이 다시 감긴다 — 측정 누적면적 30.967% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lineTobeginPathpointermove

같은 비율 점을 잇는 부채꼴

  function px(p) { return [p[0] * W, p[1] * H]; }
  function lerp(p, q, u) { return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]; }

  // 실 — 두 부채꼴. 방금 감은 올일수록 밝고 굵다(recency)
  for (i = 0; i < FAN; i++) {
    u = i / (FAN - 1);
    if (u > progress) continue;
    a = px(lerp(A, C1, u)); b = px(lerp(C3, B, u));
    recency = Math.max(0, 1 - (progress - u));
    alpha = 0.44 + 0.5 * Math.pow(recency, 1.1);
    ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + Math.min(0.95, alpha).toFixed(3) + ')';
    ctx.lineWidth = 2.4 + recency * 1.8;
    ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
  }

  // 1.5초마다 다음 자리로 목표 갱신 — 컨트롤 핀이 스스로 움직여 재계산을 보여준다
  var pi = Math.floor(t / RECOMPUTE) % PRESETS.length;
  for (var k = 0; k < NCTRL; k++) {
    if (drag !== k) tgt[k] = PRESETS[pi][k];
    cur[k][0] += (tgt[k][0] - cur[k][0]) * 0.09;
    cur[k][1] += (tgt[k][1] - cur[k][1]) * 0.09;
  }

05사진 실화 — 밝기를 실 밀도로 바꾸는 초상

오프스크린에 프로필 실루엣을 절차적으로 그려 96×96 격자의 어두움으로 읽고, 매 올을 후보 4개(SKIP 74 의 1~4배) 중 지나는 길이 더 어두운 쪽으로 연결하는 결정적 욕심 스케줄로 600올을 감습니다. 어두운 영역에만 실이 쌓여 초상이 떠오르고 실 알파·굵기는 그 길의 어두움에 비례합니다.

사진 실화 — 밝기를 실 밀도로 바꾸는 초상
새 창에서 열기

05 · 사진 실화 — 밝기를 실 밀도로 바꾸는 초상 — 측정 누적면적 31.687% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDatagetImageDatalineTorequestAnimationFrame

결정적 욕심 스케줄

  var SKIP = Math.round(N * 0.41);

  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;
    }
  })();

06욕심쇠 감기 — 후보 전부 점쳐 한 올만 남기는 최적화

목표 도안 5각 별을 72×72 격자로 두고 감은 판 상태를 같은 크기 보드에 유지한 뒤, 후보 180현을 얹었다 되돌리며 점수 Σ(포화 ink − goal)² 를 재고 가장 낮아지는 한 올만 남기는 탐욕 선택을 190올 반복합니다. 나아질 후보가 없으면 0.37N 건너뛰기 올로 진행을 지킵니다.

욕심쇠 감기 — 다음 실을 고르는 최적화
새 창에서 열기

06 · 욕심쇠 감기 — 다음 실을 고르는 최적화 — 측정 누적면적 33.037% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDataImageDatalineTorequestAnimationFrame

보드 점수와 탐욕 한 걸음

  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;
  }

07윤곽 핀 배치 — 그림 가장자리에서 핀을 뽑는다

두 톤 평면에 그린 잎 문양을 바깥에서 홍수 채우기로 흰 배경을 지워 외곽 경계만 남기고 각도순 정렬로 등간격 90핀을 박는, 도안을 이미지에서 자동으로 만드는 파이프라인입니다. 잎맥은 어둠에 둘러싸여 바깥과 이어지지 않아 핀에서 빠지고 판이 가로로 길어 가로 좌표만 1.7배로 늘려 펼치는데, 380올 중 첫 90올은 이웃 핀만 이어 테두리를 그리고 나머지는 7·11·16칸 순환으로 속을 채웁니다.

윤곽 핀 배치 — 그림의 가장자리에서 핀을 뽑는다
새 창에서 열기

07 · 윤곽 핀 배치 — 그림의 가장자리에서 핀을 뽑는다 — 측정 누적면적 32.166% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDatagetImageDatalineTobeginPath

홍수 채우기로 외곽 경계만 남기기

    // 바깥 경계만 — 바깥에서 흰 배경을 홍수 채우기(BFS)해 어두운 몸통에 닿는 지점.
    // 잎맥(속의 하얀 줄)은 어두운 몸에 둘러싸여 바깥과 이어지지 않으니 핀에서 제외된다.
    var dark = new Uint8Array(SS * SS);
    for (var b0 = 0; b0 < SS * SS; b0++) dark[b0] = lum[b0] < 128 ? 1 : 0;
    var seen = new Uint8Array(SS * SS);
    var bnd  = new Uint8Array(SS * SS);
    var stack = [0];
    seen[0] = 1;
    var NX = [1, -1, 0, 0], NY = [0, 0, 1, -1];
    while (stack.length) {
      var q = stack.pop();
      var qx = q % SS, qy = (q - qx) / SS;
      for (var d4 = 0; d4 < 4; d4++) {
        var nx = qx + NX[d4], ny = qy + NY[d4];
        if (nx < 0 || ny < 0 || nx >= SS || ny >= SS) continue;
        var qi = ny * SS + nx;
        if (dark[qi]) bnd[q] = 1;            // 흰 픽셀 옆이 몸통 → 외곽선 바로 바깥 점
        else if (!seen[qi]) { seen[qi] = 1; stack.push(qi); }
      }
    }

      var K = i < n ? 1 : [7, 11, 16][(i - n) % 3];

08직조 무늬 — 두 방향 실이 위상차로 짜는 격자

세로 경사 26올과 가로 위사 18올을 각각 14분할 폴리라인으로 놓고 법선 방향 사인 출렁임 ±3.5px 을 준 뒤 위상차 0.5 만큼 늦게 흘려 보내는 베틀의 축소판입니다. 반투명 globalAlpha 0.5 로 두 방향을 겹치면 교차점이 밝아지며 모아레가 절제된 문양 타일로 정착되는데, 두 방향이 판 전체를 덮는 유일한 구성이라 누적면적 50.97% 로 가장 넓습니다.

직조 무늬 — 실이 스치며 만드는 격자 문양
새 창에서 열기

08 · 직조 무늬 — 실이 스치며 만드는 격자 문양 — 측정 누적면적 50.901% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.sinlineToglobalAlpharequestAnimationFrame

법선 출렁임과 위상차

  // 실 한 올 — 사인 곡선으로 출렁이는 직선(경사·위사 공통)
  function thread(x0, y0, x1, y1, wob, t) {
    var K = 14;
    ctx.beginPath();
    ctx.moveTo(x0, y0);
    for (var k = 1; k <= K; k++) {
      var u = k / K;
      var x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u;
      // 진행 방향의 법선으로 wobble — 실의 장력 출렁임
      var dx = x1 - x0, dy = y1 - y0;
      var len = Math.sqrt(dx * dx + dy * dy) || 1;
      var nx = -dy / len, ny = dx / len;
      var off = Math.sin(u * Math.PI * 2 + t * 2) * wob;
      ctx.lineTo(x + nx * off, y + ny * off);
    }
    ctx.stroke();
  }

  // 위사(가로) — 위상차 PHASE 만큼 늦게 흐른다
  var pF = Math.max(0, progress - PHASE) / (1 - PHASE);
  var doneF2 = Math.floor(pF * NF);

09대문 감기 장면 — 글자 핀으로 워드마크를 짜는 히어로

오프스크린에 찍은 두 글자(GS)를 바깥 홍수 채우기로 윤곽 링만 뽑아 연결 성분으로 나누고 링을 따라 도는 순서로 64핀을 정해 160올을 감는 대문 장면입니다. 첫 바퀴는 이웃 핀으로 윤곽을 그리되 글자가 바뀌는 다리 올만 알파 0.18 로 엷게 건너가고 둘째 바퀴부터는 같은 글자 안에서만 5·9·13칸 건너뛰어 속을 성기게 채우며, 뒤편 반경 max(W,H)·0.42 의 스포트라이트가 감기가 차오를수록 밝아져 완성 문장을 비춥니다.

대문 감기 장면 — 브랜드 글자를 실로 짜는 히어로
새 창에서 열기

09 · 대문 감기 장면 — 브랜드 글자를 실로 짜는 히어로 — 측정 누적면적 43.626% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.sinlineTocreateRadialGradientrequestAnimationFrame

글자 핀 좌표와 스포트라이트

  function pinPx(i) {
    var u = PINS[i % PINS.length];
    var s = Math.min(W, H);
    // 글자 종횡비 유지 — 늘어나지 않게, 가로만 여유 있게
    return [CX + (u[0] - 0.5) * s * 1.5, CY + (u[1] - 0.5) * s * 1.0];
  }

  // 뒤편 스포트라이트 — createRadialGradient 로 완성 문장을 비춘다
  var lit = 0.5 + 0.5 * progress;
  var sx = W / 2 + Math.sin(t * 0.9) * W * 0.06;
  var sy = H * 0.53 + Math.cos(t * 0.7) * H * 0.05;
  var rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(W, H) * 0.42);
  rg.addColorStop(0, 'rgba(' + COL_GLOW.join(',') + ',' + (0.5 * lit).toFixed(3) + ')');
  rg.addColorStop(1, 'rgba(' + COL_GLOW.join(',') + ',0)');
  ctx.fillStyle = rg;
  ctx.fillRect(0, 0, W, H);

건너뛰기 칸수가 서로소가 아니면 그림이 여기서 깨지는 함정

아홉 판을 실제로 만들며 걸린 지점은 매번 같은 두 갈래였습니다 — 핀 사이 건너뛰기 칸수를 잘못 고르는 경우와 그림에서 핀 좌표를 뽑아내는 규칙을 어기는 경우입니다. 둘 다 코드는 멀쩡히 돌아가고 에러 없이 그려지는데 결과물만 원래 도안과 달라진다는 공통점이 있어 처음 붙여 볼 때 가장 자주 걸립니다.

서로소가 아닌 건너뛰기 칸수

01 의 N=120 과 SKIP=49 는 서로소라 한 바퀴에 120올이 전부 다른 핀을 지난다. 공약수가 있는 칸수를 고르면 실이 같은 핀 몇 개만 돌아 포락선에 구멍이 뚫리는데 이것이 처음에 가장 자주 만나는 깨짐입니다.

그림에서 핀을 뽑을 때 속 무늬가 넘는 순간

05·06 의 스케줄을 매 프레임 다시 짜면 그림이 떨리면서 계산 비용이 폭주하는데 한 번 짠 배열로 매번 같은 순서를 재생해야 합니다. 06 은 보드 값이 1 을 넘으면 1 로 자르는 포화가 있어야 실 밀도가 도안 밝기와 대응합니다. 07·09 에서 어두운 픽셀 전부를 핀으로 쓰면 잎맥과 글자 속 줄까지 핀이 되어 외곽선이 지워지고, 07 의 배경을 그라디언트로 그리면 밝기 임계값(128) 이 흐려져 핀이 어긋납니다. 09 의 글자 사이 다리 올을 진하게 감으면 실 다발이 글자 밖으로 흐르니 다리는 알파 0.18·굵기 1.0 으로 엷게 두는 것이 정해입니다. 이 아홉 판은 핀 배치부터 감기 루프까지 통째로 index.html 하나씩으로 들어 있는데 그 원본을 그대로 묶은 암호 zip 의 비밀번호는 77qjdznz 이고 풀면 본문의 코드 조각과 줄 단위로 나란히 대조해 보실 수 있습니다.

세 변형 값 중 하나가 곧바로 접근성 대응이 되는 이유

값 하나를 바꾼 세 변형

이름 바꾼 값 느낌
건너뛰기 칸수 01 건너뛰기 2칸 → 5칸 2칸씩 감던 실이 5칸씩 건너뛰면 곡선의 꽃잎 수가 늘고 무늬가 촘촘해진다
핀 개수 03 핀 60개 → 180개 핀이 세 배로 늘면 곡선이 매끈해지되 실이 겹쳐 번지는 지점이 생긴다
실 명도 08 globalAlpha 0.5 → 0.25 실을 반투명하게 낼수록 겹친 곳이 어두워져 문양의 대비가 뒤집힌다

동작을 줄인 사용자에게 주는 장면

모든 판은 시작할 때 prefers-reduced-motion: reduce 를 검사해 켜져 있으면 애니메이션 루프를 돌리지 않고 감기가 진행된 장면 한 장을 그려 고정한 뒤 .is-still 클래스를 붙입니다. 여덟 판은 다 감긴 장면(04 의 완성 부채꼴 등)을 고정하고 03 만 감기 중간의 k=2 카디오이드를 고정하니 정지 사용자에게도 빈 캔버스가 아니라 도안이 남습니다.

손으로 만지는 판과 화면 낭독

04 가 pointermove 로 컨트롤 핀을 끌 수 있고 나머지 판은 자동 재생입니다. 모든 실험판에는 사람이 알 수 있는 aria-label 과 tabindex 가 붙고 핀 수·실 올 수·주기 초 같은 측정 메타가 화면 아래 한 줄로 항상 표시됩니다.

이런 화면에서 쓴다 — 브랜드 히어로와 사진 초상 카드

09 는 글자 모양 핀 사이를 실로 채워 로고나 서비스명을 감는 장면이라 랜딩 첫 화면의 히어로 워드마크로 그대로 옮겨 붙일 수 있습니다. 로딩이 끝나기 전까지 감기가 진행 중인 상태를 보여 주다가 완성되는 순간 다음 화면으로 넘기면 기다리는 시간이 빈 스피너보다 덜 지루합니다.

05 는 인물 사진의 밝기를 실 밀도로 옮기는 판이라 프로필 카드나 소개 페이지의 얼굴 그림을 사진 대신 이 초상으로 바꿔 넣는 용도에 맞습니다. 07 은 사진 가장자리만 핀으로 뽑아 그리므로 제품 컷의 윤곽선만 남기는 카드 배경으로 쓰면 사진 없이도 그 물건임을 알아볼 수 있습니다.

셋 다 핀 좌표만 서로 다른 이미지로 바꿔 넣으면 되고 감기 루프와 캔버스 크기는 그대로 두면 됩니다.

원핀 실 그림을 처음 붙일 때 자주 나오는 질문 FAQ

스트링아트 만들기를 이 데모로 처음 시작해도 따라할 수 있나요?

핵심은 핀 좌표를 sin·cos 로 놓고 i번 핀에서 i+SKIP 번 핀으로 현을 걷는 루프 하나인데 01 판의 스무 줄 남짓이 그 전부입니다. zip 안에는 아홉 판을 같은 조건으로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작하고, 그다음은 상수 블록의 칸수·핀 수를 하나씩 바꿔 보는 순서가 가장 빠릅니다.

스트링아트 하트처럼 내가 그린 도안을 감을 수 있나요?

06 이 정확히 그 구조입니다. 목표 도안을 오프스크린 캔버스에 흰 스트로크로 그리는 buildGoal 안의 별 문양을 하트나 로고로 바꾸면 격자가 그 도안을 읽어 탐욕 선택이 같은 방식으로 감아 줍니다. 05 도 실루엣을 그리는 부분만 바꾸면 다른 초상이 됩니다.

감기가 끝나면 화면은 멈추나요?

주기의 유지 구간에도 화면은 살아 있습니다. 핀은 순차적으로 맥동하고(0.5 + 0.5·sin) 방금 감은 올이 가장 밝은 채로 남아 08 의 실 출렁임과 09 의 스포트라이트도 계속 움직입니다. 완성본 한 장만 정적으로 그려 두면 감는 손이 사라져 스트링아트가 아니라는 것이 이 판들의 설계 전제입니다.

다시 보면 — 왜 직선 실만으로 곡선이 보이는가

스트링아트의 곡선은 그린 것이 아니라 감긴 것입니다. 실 한 올은 핀 a 에서 핀 b 로의 직선 현이고 곡선 호출은 단 한 번도 없는데, 현들이 겹치는 자리의 밝기가 연속으로 이어지면서 눈은 그 밝기의 가장자리를 하나의 곡선으로 읽습니다. 02 의 역순 짝짓기가 이 원리를 가장 드러냅니다 — 왼변 i번과 아래변 (M−1−i)번을 이은 32개 현은 저마다 다른 기울기의 직선이지만 모두 한 원호에 닿아, 원호를 그린 적 없는 판에 원호가 떠오릅니다. 01 의 장미꼴과 03 의 카디오이드도 같은 일입니다 — 감기 규칙이 현의 방향 분포를 정하면 곡선은 그 분포가 만드는 밝기 누적의 포락선으로 저절로 나타납니다.

곡선을 직접 그리는 순간 원리가 사라진다

여기에 arc 나 2차 곡선 호출을 한 줄 섞는 순간 화면은 여전히 예뻐 보이지만 스트링아트가 아닙니다. 실 감기의 설득력은 모든 선이 양끝 핀에 걸린 직선이라는 약속에서 나오는데 곡선 한 올이 그 약속을 깨면 남은 직선들의 규칙성도 함께 죽습니다. 그래서 아홉 판의 실을 전부 곡선 없이 moveTo·lineTo 만으로 그립니다 — 핀 점을 찍는 작은 원만 arc 로 그렸습니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.