GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ストリングアート9種 — 糸の一本一本を計算するキャンバス

ピンの間を糸が規則で飛び移ると曲線がひとりでに生まれます。どこからどこへ巻くかを毎回計算するキャンバスエンジン — ライブラリなしのコピペです。ストリングアートとは、板に打ったピンの間に糸を規則で渡していくと、どこにも描いていない円弧や心臓形が浮かび上がる仕掛けのことです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

デモ

各盤は index.html と style.css の2ファイル単独で動き、追加の読み込みは一切ありません。24フレームの録画の間、九つのすべてが23フレームで動いていました(23/24)。04だけは pointermove で制御ピンを引く手が入り、ほかの8つは開いた瞬間からひとりでに巻き進みます。

盤 何が巻かれるか 累積面積(%) フレーム最大(%) 強度平均 フレーム
01 円ピン基本 スキップ49で咲く曲線の花 30.758 30.051 40.2 23/24
02 曲線錯視 2辺32ピンが浮かべる円弧 30.674 30.422 40.5 23/24
03 マスカレイド 倍数k=2・3・4で織り直す心臓形 33.049 25.442 31.9 23/24
04 ベジェ巻き 制御ピン3つが追う曲線 30.737 18.419 62.5 23/24
05 写真の糸絵 暗さを糸の密度に変えた肖像 30.144 29.881 51.4 23/24
06 貪欲巻き 候補180弦から選ぶ5角星 32.857 32.465 47.0 23/24
07 輪郭ピン配置 葉の外側の輪郭に沿う90ピン 32.144 32.082 35.9 23/24
08 織り模様 位相差0.5で交差する経緯 50.97 32.46 38.6 23/24
09 表紙の巻きシーン 文字の輪郭リング64ピン 42.744 43.653 32.8 23/24

累積面積がどの盤も30%を超え、08 織り模様だけが50.97%に跳ねるのは性質であって欠陥ではありません。08 は2方向の糸が盤全面を覆う唯一の構成で、09 は42.744%、残る7つは円盤や形の内側に糸が集まるため30〜33%台に収まります。盤の差は強度平均と面積最大で開きます — 強度平均は04 ベジェ巻きの62.5が最も明るく(糸が太く巻きたての対比が強い)、面積最大は09 表紙の巻きシーンが巻き終わり近くの1フレームで43.653%まで満ち、1フレームの面積としては九つで最も大きくなります。

円周の糸掛けから写真の明暗まで、ストリングアートの規則を広げた順序

並び順の基準は、巻き規則をどこまで遠くへ連れて行けるかです。01は円周120ピンに一定スキップで巻く最小構成、02は曲線を1本も描かずに円弧を見せる包絡線の正統な構成です。03で初めて規則そのもの(倍数k)を曲線の種類に結びつけ、04はピンを手で引いてその場で巻き直すインタラクションを加えます。05は明るさを糸の密度に読み替え、06は巻く順序そのものを毎段階の貪欲選択に委ね、07ではピンの位置を絵の輪郭から自動で抜き出します。08が2方向の糸の交差で模様を織り、09が文字の輪郭リングをピンにして表紙の場面へ組み上げて締めくくります。図案を探す人にも作り方から入りたい人にも、この順で読めば糸のかけ方が規則から計算へ育つ様子が追えます。1枚のビットマップから立体視を起こすマジックアイの9盤が画素の明るさを行シフトに使うのに対し、この9種はすべて「ピン位置を決め・弦の順番を決めて直線だけを重ねる」1本のパイプラインを共有します。

項目別コード

ここからは各盤の定数と、動いている関数をそのまま引きます。切り詰めた断片ではなく動いているコードそのもので、この画面から自分のページへ貼って動かせます。コメントだけこの記事の言葉に置き換えてあり、変数名と関数名は zip の中身と同じ英語のままです。

01円ピン基本 — 一定間隔のスキップだけで花が咲く

円周に120本のピンを打ち、i番から i+49番へ1周(120本)を巻きます。曲線は1本も描いていないのに、弦が重なって明るくなる場所が円盤の中に花の包絡線を浮かべます。巻きたての糸ほど recency で明るく太くなり、アルファは 0.34→0.96・線幅は 1→2.4 まで開きます。累積面積30.758%・強度平均40.2は、円盤の内側に糸が集まる構成として妥当な値です。

円ピン基本 — 一定間隔のスキップだけで花が咲く
新しいタブで開く

01 · 円ピン基本 — 一定間隔のスキップだけで花が咲く — 計測 累積面積 30.685% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sinMath.coslineTobeginPath

pinXY — 円周の等角配置

ピンはすべて 2πi/N の等角配置です。−π/2 のオフセットで0番ピンを12時の位置に置き、以後時計回りに番号が進みます。

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

draw の巻きループ — recency で明るさを段付けする

progress が1周の何本目まで巻いたかを決め、各糸は (full − i) / total の recency で明るさと太さを受け取ります。N=120 と SKIP=49 は互いに素なので、1周の120本がすべてのピンを一度ずつ通ります。

    var total = N;                      // 1周 = N本 (i → i+SKIP、N回)
    var seg = progress * total;
    var full = Math.floor(seg);
    var frac = seg - full;
    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.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)番を結びます。すると32本すべての弦が同じ円弧に触れ、目はどこにもない円弧を見ます。直線が曲線を包絡する原理の正統な構成で、累積面積30.674%・強度平均40.5です。

曲線錯視 — 直線の糸だけで円を描く包絡線
新しいタブで開く

02 · 曲線錯視 — 直線の糸だけで円を描く包絡線 — 計測 累積面積 30.669% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lineTobeginPathstroke

pinL・pinB — 2辺のピン配置

左辺のピンは盤の縦を、下辺のピンは盤の横を余すところなく使います。2つの辺が作る直角の内側に、弦の群れが円弧を刻みます。

  // 左辺ピン 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番

巻きは毎段階、左辺の上から i番と下辺の右から i番を結びます。辺の両端から中央へ対称に進むので、弦の群れが1つの円弧に接する位置を揃えます。

    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.lineWidth = 1.8 + recency * 1.6;
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }

03マスカレイド — 円上の2倍ピンの数学

円上の n番ピンから k×n番ピンへ巻く倍数規則だけで曲線の種類が決まります。k=2 はカルジオイド(マスカレイド)、k=3 はネフロイド、k=4 は3弁のエピサイクロイドです。k が区間ごとに 2→3→4 へ変わると曲線全体が織り直され、各区間は前70%で巻いて後30%を保ちます。累積面積33.049%・強度平均31.9。

マスカレード — 円上の2倍ピンの数学
新しいタブで開く

03 · マスカレード — 円上の2倍ピンの数学 — 計測 累積面積 33.918% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sinMath.coslineTostrokeStyle

倍数規則 — k だけで曲線が決まる

盤は2秒ごとに区間が進み、k = 2 + floor(phase) で現在の倍数が決まります。1本の糸はピン i から (i × k) % N へ渡るだけで、それ以外に曲線の情報はありません。

    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
    var progress = Math.min(1, local / 0.7);   // 前70%で巻き・後30%で保持
    var total = N;
    var full = Math.floor(progress * total);
    for (var i = 0; i < full; i++) {
      var a = pinXY(i), b = pinXY((i * k) % N);
      var recency = (full - i) / total;
      var alpha = 0.58 + 0.26 * Math.pow(recency, 1.5);
      ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
    }

reduce-motion でも心臓形は残る

動きを減らした訪問者にはループを回さず、k=2 のカルジオイドを巻き途中の1場面(progress 0.45)を描いて固定し、盤に .is-still クラスを付けます。静止していても心臓形の包絡線は残ります。

  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (reduce) {
    draw(0.45);   // reduce — k=2 カルジオイドを巻き終えた1枚だけ固定
    root.classList.add('is-still');
  }

04ベジェ巻き — 制御ピンを引くと糸が巻き直る

両端の固定ピン A・B と制御ピン3つ(C1・C2・C3)を鎖で繋ぎ、鎖の同じ比率の点どうしを糸で渡すド・カステリョの構成です。糸の包絡線がベジェ曲線を追い、ピンを pointermove で引ればその場で全体が巻き直ります。放っておけば1.5秒ごとに制御ピンが次の候補へ自ら移動します。強度平均62.5は九つで最も明るく、線幅 2.4→4.2 の recency の対比がその理由です。

ベジェ巻き — 制御ピンを引くと糸が巻き直る
新しいタブで開く

04 · ベジェ巻き — 制御ピンを引くと糸が巻き直る — 計測 累積面積 30.967% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lineTobeginPathpointermove

lerp — 鎖の同じ比率の点

2点を比率 u で割る内分だけで糸の両端を決めます。ベジェ曲線の式はどこにも現れず、糸の群れの包絡線としてだけ現れます。

  var A = [0.05, 0.50], B = [0.95, 0.50];
  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]; }

2つの扇形 — 弦が包絡線になるとき

外側の扇(FAN=128本)は A→C1 と C3→B の同じ比率を結び、内側の扇(FAN2=96本)は C1→C2 と C2→C3 を結びます。さらに鎖の前後を開く扇(FAN3=84本×2)が加わり、計4群の弦が1本の曲線を包みます。

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

05写真の糸絵 — 明るさを糸の密度に変える肖像

外部画像は使いません。オフスクリーンの canvas に頭の円・首・肩の曲線・頬の陰影・下の両端の柱からなる影絵を手続き的に描き、96×96 格子の暗さ(1 − 明るさ/255)として読みます。毎本、候補4つ(SKIP=74 の1〜4倍)のうち弦の通る道(内部7標本)がより暗い方へ結ぶ決定論的な貪欲スケジュールを600本 — 暗い領域にだけ糸が積もり、肖像が浮かびます。糸のアルファと太さはその道の暗さに比例します。累積面積30.144%・強度平均51.4。

写真の糸絵 — 明るさを糸の密度に変える肖像
新しいタブで開く

05 · 写真の糸絵 — 明るさを糸の密度に変える肖像 — 計測 累積面積 31.687% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDatagetImageDatalineTorequestAnimationFrame

chordDark0 — 弦の通る道の暗さ

候補の弦を引く前に、弦の内部を7点でサンプルして暗さの平均を取ります。この値が大きい道ほど肖像の陰に当たるため、濃い糸がそこに積もります。

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

plan — 決定論的な貪欲スケジュール

スケジュールは起動時に一度だけ組み、配列に置きます。毎フレーム組み直すと絵が揺らぐので、同じ配列から常に同じ順が出ます。巻き終えた糸の太さは道の暗さ dens をそのまま使います。

  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 格子に置き、巻いた盤の状態を同じ大きさのボード(Float32Array)で保ちます。毎段階、候補180弦をボードに載せては戻し、Σ(飽和 ink − goal)² のスコアを測り、最も下がる1本だけを実際に残す — この貪欲選択190本で星が糸だけで描かれます。良くなる候補がなければ 0.37N スキップの規則糸で進行を守ります。累積面積32.857%・強度平均47.0。

貪欲巻き — 次の糸を選ぶ最適化
新しいタブで開く

06 · 貪欲巻き — 次の糸を選ぶ最適化 — 計測 累積面積 33.037% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDataImageDatalineTorequestAnimationFrame

score — ボードと目標の距離

ボードの値が1を超えたら1で切り詰める飽和が入るため、巻いた糸の密度が図案の明るさに対応します。切らないなら1か所に巻かれた糸がスコアを支配し、他が空きます。

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

greedyStep — 候補を試しては戻す1段階

前の糸の終点ピンから候補 CAND=180弦を順に載せては戻し、スコアが最も下がる1本を確定します。この段階を STEPS=190 回、起動時に一度だけ先に計算して配列に置き、毎周期同じ星が再現されます。

  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]);
      return bestJ;
    }
    var fallback = (i0 + Math.round(N * 0.37)) % N;   // 規則糸で進行を守る
    paintChord(i0, fallback, 1);
    woven.push([i0, fallback]);
    return fallback;
  }

07輪郭ピン配置 — 絵の縁からピンを抜き出す

オフスクリーンに2トーン平面で葉の文様(葉脈込み)を描き、外側からフラッドフィル(BFS)を回します。白い背景が暗い胴体に触れる場所だけが外側の輪郭 — 葉脈は闇に囲まれ外側と繋がらないのでピンから外れます。輪郭を角度順に並べて等間隔に90ピン、盤が横長なので横に1.7倍(縦1.02)に広げます。1周目は隣ピンで輪郭を描き、2周目から 7・11・16 ピンスキップを循環して中を満たします。累積面積32.144%・強度平均35.9。

輪郭ピン配置 — 絵の縁からピンを抜き出す
新しいタブで開く

07 · 輪郭ピン配置 — 絵の縁からピンを抜き出す — 計測 累積面積 32.166% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDatagetImageDatalineTobeginPath

外側からのフラッドフィル — 輪郭だけを残す

白背景を BFS で埋め、埋まる途中で暗い画素に隣接した点だけを輪郭として印します。閾値は128 — 2トーンだからこそ境界がくっきり出ます。

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

pinPx — 角度順に等間隔・横1.7倍

輪郭点を中心まわりの角度で並べ替え、環に沿って等間隔の90点をピンにします。盤の幅480が高さ300より広いので、形を横に1.7倍・縦は1.02倍に引き伸ばして盤を埋めます。

  (function buildPins() {
    var ring = [];
    for (var r0 = 0; r0 < SS * SS; r0++) if (bnd[r0]) ring.push([r0 % SS, (r0 - r0 % SS) / SS]);
    var cxg = SS / 2, cyg = SS / 2;
    ring.sort(function (a, b) {
      return Math.atan2(a[1] - cyg, a[0] - cxg) - Math.atan2(b[1] - cyg, b[0] - cxg);
    });
    var stepR = ring.length / NPIN;
    for (var p = 0; p < NPIN; p++) {
      var q2 = ring[Math.floor(p * stepR) % ring.length];
      PINS.push([(q2[0] / SS - 0.5) * 1.12 + 0.5, (q2[1] / SS - 0.5) * 1.12 + 0.5]);
    }
  })();

  function pinPx(i) {
    var u = PINS[i % PINS.length];
    var s = Math.min(W, H);
    return [CX + (u[0] - 0.5) * s * 1.7, CY + (u[1] - 0.5) * s * 1.02];
  }

08織り模様 — 糸が交わって生まれる格子文様

縦の経糸26本・横の緯糸18本を、それぞれ14分割のポリライン(法線方向の sin 揺れ ±3.5px)で置き、緯糸を位相差 0.5 だけ遅れて流します。半透明の globalAlpha(0.5)で2方向を重ねると交点が明るくなり、モアレが整った文様タイルに落ち着きます。累積面積50.97%は九つで最も高く、2方向の糸が盤全面を覆う唯一の構成だからです。

織り模様 — 糸が交わって生まれる格子文様
新しいタブで開く

08 · 織り模様 — 糸が交わって生まれる格子文様 — 計測 累積面積 50.901% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sinlineToglobalAlpharequestAnimationFrame

thread — 法線方向に揺れる1本

1本の糸は始点と終点を14分割し、各点を進行方向の法線へ sin で ±3.5px 揺らしたポリラインです。ここでも曲線らしい命令は使わず、短い直線の連続で張りを持たせます。

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

位相差 0.5 の経緯 — 交点の明るさが文様になる

経糸は上から、緯糸は progress が PHASE=0.5 を超えてから流れ始めます。2方向が半透明で重なる交点だけが明るく、重なる瞬間の順番がモアレの位相を決めます。

    var doneW = Math.floor(progress * NW);
    for (i = 0; i < doneW; i++) {
      u = i / (NW - 1);
      recency = Math.max(0, 1 - (progress - u));
      alpha = BASE * (0.72 + 0.5 * Math.pow(recency, 1.2));
      ctx.globalAlpha = Math.min(0.95, alpha);
      ctx.strokeStyle = 'rgb(' + COL_THRD.join(',') + ')';
      ctx.lineWidth = 2.6 + recency * 1.8;
      thread(pad + iw * u, pad, pad + iw * u, H - pad, 3.5, t);
    }
    var pF = Math.max(0, progress - PHASE) / (1 - PHASE);
    var doneF2 = Math.floor(pF * NF);
    for (i = 0; i < doneF2; i++) {
      u = i / (NF - 1);
      recency = Math.max(0, 1 - (pF - u));
      alpha = BASE * (0.72 + 0.5 * Math.pow(recency, 1.2));
      ctx.globalAlpha = Math.min(0.95, alpha);
      ctx.strokeStyle = 'rgb(' + COL_CROSS.join(',') + ')';
      ctx.lineWidth = 2.6 + recency * 1.8;
      thread(pad, pad + ih * u, W - pad, pad + ih * u, 3.5, t + PHASE * 6);
    }
    ctx.globalAlpha = 1;

09表紙の巻きシーン — ブランド文字を糸で織るヒーロー

オフスクリーンにワードマーク2文字(G・S)を大きく打ち、外側からのフラッドフィルで外側の輪郭リングだけを抜き、連結成分(Gの輪・Sの輪)に分けて、輪を巡る順に64ピンを決めます。1周目は隣ピンで輪郭を濃く(文字が変わる橋の糸だけアルファ 0.18 で薄く)描き、2周目からは同じ文字の中だけで 5·9·13 ピンスキップで中を粗く満たします。背後の createRadialGradient スポットライト(半径 max(W,H)・0.42)が巻きが満ちるほど明るくなり、完成した文字を照らします。累積面積42.744%・面積最大43.653%(巻き終わり近くの1フレーム)・強度平均32.8。

表紙の巻きシーン — ブランド文字を糸で織るヒーロー
新しいタブで開く

09 · 表紙の巻きシーン — ブランド文字を糸で織るヒーロー — 計測 累積面積 43.626% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sinlineTocreateRadialGradientrequestAnimationFrame

輪郭リングの連結成分 — 文字ごとに分ける

外側のフラッドフィルで印の付いた輪郭点を、隣接関係でつないで連結成分に分けます。G の輪と S の輪が別の成分になり、左から順に並べて G を巻き終えてから S へ移ります。

    var ring = [];
    for (var r0 = 0; r0 < SS * SS; r0++) if (isIn[r0]) ring.push(r0);
    var taken = new Uint8Array(SS * SS);
    var comps = [];
    for (var r1 = 0; r1 < ring.length; r1++) {
      if (taken[ring[r1]]) continue;
      var comp = [ring[r1]], st2 = [ring[r1]];
      taken[ring[r1]] = 1;
      while (st2.length) {
        var q2 = st2.pop();
        var qx2 = q2 % SS, qy2 = (q2 - qx2) / SS;
        for (var dx = -1; dx <= 1; dx++) {
          for (var dy = -1; dy <= 1; dy++) {
            var nx2 = qx2 + dx, ny2 = qy2 + dy;
            if (nx2 < 0 || ny2 < 0 || nx2 >= SS || ny2 >= SS) continue;
            var qi2 = ny2 * SS + nx2;
            if (isIn[qi2] && !taken[qi2]) { taken[qi2] = 1; comp.push(qi2); st2.push(qi2); }
          }
        }
      }
      comps.push(comp);
    }

橋の糸だけ薄く — 文字の外へはみ出さない

1周目で文字を渡る糸(成分が変わる1本)だけアルファ 0.18・線幅 1.0 で薄く、輪郭の糸は 0.92・2.7 で濃く巻きます。2周目以降の充填は各文字のピン区間の中だけに置かれるので、糸の束が文字の形の外へ漏れません。

    for (var i = 0; i < full; i++) {
      if (i < n) {
        var bridge = PINC[i] !== PINC[(i + 1) % n];
        var a1 = pinPx(i), b1 = pinPx((i + 1) % n);
        ctx.strokeStyle = 'rgba(' + COL_THRD.join(',') + ',' + (bridge ? 0.18 : 0.92) + ')';
        ctx.lineWidth = bridge ? 1.0 : 2.7;
        ctx.beginPath(); ctx.moveTo(a1[0], a1[1]); ctx.lineTo(b1[0], b1[1]); ctx.stroke();
      } else {
        var f = i - n;
        var run = RUNS[f % RUNS.length];
        var m = run[2] - run[1];
        var c = Math.floor(f / RUNS.length);
        var K2 = [5, 9, 13][c % 3] % Math.max(2, m - 1);   // 同じ文字の中だけの飛び幅
        var q0 = run[1] + (c % m);
        var q1 = run[1] + ((c % m) + K2) % m;
        var a2 = pinPx(q0), b2 = pinPx(q1);
        ctx.beginPath(); ctx.moveTo(a2[0], a2[1]); ctx.lineTo(b2[0], b2[1]); ctx.stroke();
      }
    }

N とスキップ数が互いに素でないと、そこで壊れる落とし穴

実物のストリングアートで糸のかけ方に悩む場面と同じところで、このコードも壊れます。ストリングアート 作り方の選択肢が広いぶん、以下の4つは盤を作りながら実際に当たった穴です。

N とスキップ数を互いに素にしない

01 の N=120・SKIP=49 は互いに素だから、1周の120本がすべてのピンを一度ずつ通ります。公約数があると同じ数ピンだけを回って包絡線に穴が開き、花の形が崩れます。

曲線の命令で円弧を直接描く

arc や quadraticCurveTo で本物の曲線を1本でも混ぜると原理が死にます。巻きではなく絵になり、糸の重なる明るさのグラデーション(巻きたてが最も明るい)も失われます。

貪欲スケジュールを毎フレーム組み直す

05・06 のスケジュールは起動時に一度だけ組んで配列に置きます。フレームごとに組み直すと絵が揺らぎ、候補180弦×190段階の計算が毎フレーム走り続けます。

暗い画素すべてをピンにする

07・09 で文字の中の線や葉脈までピンにすると外郭が消えます。外側のフラッドフィルが届く境界だけがピンであってこそ、形が残ります。

zip には九つの盤がそれぞれ index.html と style.css を収めたフォルダーごとに入っています。解凍のパスワードは77qjdznzです。記事で引用した関数はコメントまでそのまま収まっているので、手元で行を見比べて確かめられます。

3つの変形のうち一つは、そのままreduced-motionのアクセシビリティ対応にもなる

名前 変更する値 変わる感触
スキップ数 01 SKIP 49 → 2 49ピンずつ飛んでいた糸が隣近所を結ぶので花弁が大きく・間延びし、2桁のスキップで戻すと花弁が増えて模様が細かくなる
ピン数 03 N 120 → 60 ピンが半分になると弦の本数も減り、曲線が角ばって見える。180に上げれば滑らかになるが糸が重なって滲む場所が出る
糸の明度 08 BASE 0.5 → 0.25 糸を半透明に下げるほど重なった場所との差が開き、文様のコントラストが反転する

定数ブロックの1行だけを書き換える

どれもソース頭の定数ブロックの1行だけです。スキップは花の密度・ピン数は曲線の滑らかさ・明度は文様のコントラストと、つまみの性格がそれぞれ違います。

reduce-motion で止まっても巻き終えた形は残る

九つの盤すべてに prefers-reduced-motion: reduce の分岐があり、ループを回さず1場面を描いて .is-still クラスを付けます。8つの盤は巻き終えた場面(04 の完成した扇形など)を固定し、03 だけは k=2 のカルジオイドを巻き途中の progress 0.45 で固定するので、静止していても形の結論は届きます。

手が届く1つと自動の8つ

04 は pointermove で制御ピン3つを引けます。引いた瞬間に全体が巻き直り、放っておけば1.5秒ごとにピンが次の候補へ自ら移動します。ほかの8つは手を触れなくても完結し、盤の下の行にピン数・糸本数・周期秒の計測メタが常に出ています。

この画面はどこで使う — ブランドのヒーロー部と写真の肖像カード

09 は文字の形をしたピンの間を糸で埋めてロゴやサービス名を巻き上げる場面なので、ランディングの最初の画面にヒーローのワードマークとしてそのまま置けます。読み込みが終わるまで巻きの途中を見せておき、完成した瞬間に次の画面へ切り替えると、空のスピナーより待ち時間が短く感じられます。

05 は人物写真の明るさを糸の密度に置き換える盤なので、プロフィールカードや紹介ページの顔写真をこの肖像に差し替える用途に合います。07 は写真の輪郭だけをピンで抜くので、商品カットの背景に置けば写真そのものがなくても何の輪郭かがわかります。

3つとも差し替えるのはピン座標のもとになる画像だけで、巻きのループとキャンバスの大きさはそのままで構いません。

九つの糸絵を試す前によくある質問 FAQ

ストリングアートの作り方を探しています。コードを貼るだけで自分のページに置けますか?

置けます。zip から好きな盤1つのフォルダーを取り出し、index.html と style.css をそのままアップロードすれば動きます。ライブラリ・WebGL・CDN・外部画像を一切使っていないので、読み込みの設定は要りません。

ピンと糸は何本がおすすめですか?

円盤なら N=120・スキップ49(01)から始めるのが安全です。N とスキップは互いに素にしてください。ピンを増やすと曲線は滑らかになりますが、糸の重なりが増えて滲む場所が出ます。

図案を自分の文字や写真に変えられますか?

変えられます。09 はオフスクリーンの fillText の2文字を差し替えるだけで、輪郭の抽出・ピン配置・巻き順が自動で組み直されます。写真は05の影絵を差し替えるか、07 の輪郭抜きと組み合わせます。ストリングアートとは何かを図案から作り直す作業でもあるので、無料の素材画像を持ち込む前に、まずこの2盤の差し替えから試すのが近道です。

あらためて — なぜ直線の糸が曲線に見えるのか

ストリングアートの正体は、直線の集まりが作る明るさの偏りです。1本の糸は moveTo と lineTo の直線弦で、arc のような曲線の命令は一度も使いません(ピンの点だけは ctx.arc で描きます)。それでも円弧や心臓形が見えるのは、弦が重なる回数が場所によって違い、重なりが多い場所ほど明るくなるからです。この明るさの峰の並びが数学でいう包絡線 — 弦の族が接する曲線 — になり、目はその峰を1本の曲線として読みます。02 の2辺ピンはこの原理の正統な構成で、32本の弦すべてが同じ円弧に接する位置に置かれます。距離場をレイで歩いて球面を探すレイマーチングの9盤が画素ごとの距離計算で曲面を出すのに対し、この回は直線の重なりだけですべての曲線を出します。

もっと読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。