08織り模様 — 糸が交わって生まれる格子文様
2方向の糸を位相をずらして交差させると、モアレが整った文様タイルに落ち着きます。巻き規則をいくつか変えるだけで模様全体が織り直される実験項目です。
使いどころ背景パターン、テキスタイルデザイン
Math.sinlineToglobalAlpharequestAnimationFrame

<main class="stage">
<div class="sh is-demo" id="shRoot" role="application" aria-label="織り実験盤" tabindex="0">
<canvas class="sh__cv" id="shCv"></canvas>
<p class="sh__hint">
<span class="sh__title">織り模様</span>
<span class="sh__desc">2方向の糸を位相をずらして交差させると、モアレが文様タイルに落ち着きます</span>
</p>
<div class="sh__bar">
<span class="sh__meta">糸2方向 · 位相差0.5 · 周期2秒</span>
<span class="sh__meta sh__meta--dim">globalAlpha · sin</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 직조 무늬(08) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(경위사 감기 + 출렁임)가 만든다.
// 무대 종이 — 밝은 판 위 두 파스텔 실로 직조 타일을 짠다. 캔버스가 판 전체다.
$sh-stage: #f1ecdf;
@include stage($sh-stage);
$sh-panel: #f9f6ee; // 직조판 바닥 — 종이보다 한 단계 밝은 크림
$sh-text: #3a3448; // 라벨 글자 — 진먹보라 잉크
$sh-thread: #8fafc9; // 경사 실 — 파스텔 블루그레이
$sh-cross: #d9a8a0; // 위사 실 — 파스텔 코랄 로즈
$sh-pin: #35304a; // 직조 핀 — 진먹보라
$duration: $dur-loop;
$easing: $ease-out;
$color: $sh-thread;
.sh {
--sh-panel: #{$sh-panel};
--sh-thread: #{$sh-thread};
--sh-cross: #{$sh-cross};
--sh-pin: #{$sh-pin};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sh__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sh__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: $sh-text;
text-shadow: 0 1px 4px rgba($sh-panel, .9);
}
.sh__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sh-text, .78);
text-shadow: 0 1px 4px rgba($sh-panel, .9);
}
.sh__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;
}
.sh__meta {
font-size: 10px; font-weight: 500; color: rgba($sh-text, .78);
background: rgba($sh-panel, .85);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sh:focus-visible { outline: 2px solid $sh-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sh__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 다 짜진 직조 타일 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sh.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: #f1ecdf;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sh {
--sh-panel: #f9f6ee;
--sh-thread: #8fafc9;
--sh-cross: #d9a8a0;
--sh-pin: #35304a;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sh__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sh__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: #3a3448;
text-shadow: 0 1px 4px rgba(249, 246, 238, 0.9);
}
.sh__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(58, 52, 72, 0.78);
text-shadow: 0 1px 4px rgba(249, 246, 238, 0.9);
}
.sh__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sh__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 52, 72, 0.78);
background: rgba(249, 246, 238, 0.85);
border-radius: 4px;
padding: 4px 4px;
}
.sh:focus-visible {
outline: 2px solid #8fafc9;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sh__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sh.is-still {
cursor: default !important;
}
}// 스트링아트 織り模様(08) — 두 실 방향이 위상차를 두고 교차하며 짜는 직조 계산.
// 세로 경사(warp)와 가로 위사(weft)를 각각 실 격자로 놓고, 위상차 PHASE 만큼
// 어긋나게 흘려 보낸다. globalAlpha 로 두 방향을 반투명하게 겹치면 교차점이
// 밝아지며 모아레 문양 타일이 정착된다 — 베틀의 경위사 교차를 축소한 판.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NW = 26; // 경사(세로 실) 올 수
var NF = 18; // 위사(가로 실) 올 수
var PHASE = 0.5; // 두 방향 위상차(주기 비율) — 0.25 로 줄이면 문양이 촘촘해진다(변형 3)
var BASE = 0.5; // 실 기본 globalAlpha — 0.25 로 내리면 더 성긴 레이스가 된다(변형 3)
var CYCLE = 2.0; // 직조 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('shRoot');
var cvEl = document.getElementById('shCv');
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('--sh-thread');
var COL_CROSS = tokRGB('--sh-cross');
var COL_PIN = tokRGB('--sh-pin');
var COL_PANEL = tokRGB('--sh-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var pad = 0;
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);
pad = Math.min(W, H) * 0.06;
}
// 실 한 올 — 사인 곡선으로 출렁이는 직선(경사·위사 공통)
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();
}
function draw(progress, t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var iw = W - 2 * pad, ih = H - 2 * pad;
var doneW = Math.floor(progress * NW);
var i, u, recency, alpha;
ctx.lineCap = 'round';
ctx.shadowBlur = 4;
ctx.shadowColor = 'rgba(' + COL_THRD.join(',') + ',0.3)';
// 경사(세로) — 위상 0. 왼→오른 순서로 감는다
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);
}
// 위사(가로) — 위상차 PHASE 만큼 늦게 흐른다
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;
ctx.shadowBlur = 0;
// 핀 — 네 변에 박힌 직조 핀. 감긴 핀은 밝게
var k;
for (k = 0; k < NW; k++) {
u = k / (NW - 1);
var done = k < doneW;
ctx.fillStyle = done ? 'rgb(' + COL_PIN.join(',') + ')' : 'rgba(' + COL_PIN.join(',') + ',0.35)';
var r0 = done ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad + iw * u, pad, r0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(pad + iw * u, H - pad, r0, 0, Math.PI * 2); ctx.fill();
}
for (k = 0; k < NF; k++) {
u = k / (NF - 1);
var done2 = k < doneF2;
ctx.fillStyle = done2 ? 'rgb(' + COL_PIN.join(',') + ')' : 'rgba(' + COL_PIN.join(',') + ',0.35)';
var r2 = done2 ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad, pad + ih * u, r2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(W - pad, pad + ih * u, r2, 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 유지 — 유지 구간에도 실 출렁임(wobble)이 계속 움직인다
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);
}
})();// StringLoomWeavePattern — vanilla 판(run/326/demo/08-string-loom-weave-pattern/index.html) 의 React 포트.
// 두 실 방향이 위상차를 두고 교차하며 짜는 직조 계산. 세로 경사(warp)와 가로 위사(weft)를
// 각각 실 격자로 놓고, 위상차 PHASE 만큼 어긋나게 흘려 보낸다. globalAlpha 로 두 방향을
// 반투명하게 겹치면 교차점이 밝아지며 모아레 문양 타일이 정착된다 — 베틀의 경위사 교차를 축소한 판.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringLoomWeavePattern.module.scss";
export type StringLoomWeavePatternProps = {
duration?: string;
easing?: string;
color?: string;
nw?: number; // 경사(세로 실) 올 수
nf?: number; // 위사(가로 실) 올 수
phase?: number; // 두 방향 위상차(주기 비율) — 0.25 로 줄이면 문양이 촘촘해진다(변형 3)
base?: number; // 실 기본 globalAlpha — 0.25 로 내리면 더 성긴 레이스가 된다(변형 3)
cycle?: number; // 직조 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NW = 26; // 경사(세로 실) 올 수
const NF = 18; // 위사(가로 실) 올 수
const PHASE = 0.5; // 두 방향 위상차(주기 비율)
const BASE = 0.5; // 실 기본 globalAlpha
const CYCLE = 2.0; // 직조 주기(초) — 설정 초(2s)와 같아 한 직조가 한 루프
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const BOARD_LABEL = "직조 실험판";
const META_LEFT = "실 2방향 · 위상차 0.5 · 주기 2초";
const META_RIGHT = "globalAlpha · sin";
const HINT_TITLE = "직조 무늬";
const HINT_DESC = "두 방향 실을 위상 어긋나게 교차시키면 모아레가 문양 타일로 정착된다";
export default function StringLoomWeavePattern({
duration = "2s",
easing = "ease-out",
color = "#8fafc9",
nw = NW,
nf = NF,
phase = PHASE,
base = BASE,
cycle = CYCLE
}: StringLoomWeavePatternProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; cross: RGB; pin: RGB; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const padRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 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);
padRef.current = Math.min(wRef.current, hRef.current) * 0.06;
}
// 실 한 올 — 사인 곡선으로 출렁이는 직선(경사·위사 공통)
function thread(x0: number, y0: number, x1: number, y1: number, wob: number, t: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const K = 14;
ctx.beginPath();
ctx.moveTo(x0, y0);
for (let k = 1; k <= K; k++) {
const u = k / K;
const x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u;
// 진행 방향의 법선으로 wobble — 실의 장력 출렁임
const dx = x1 - x0, dy = y1 - y0;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = -dy / len, ny = dx / len;
const off = Math.sin(u * Math.PI * 2 + t * 2) * wob;
ctx.lineTo(x + nx * off, y + ny * off);
}
ctx.stroke();
}
function draw(progress: number, t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current, pad = padRef.current;
if (!ctx || !col) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const iw = W - 2 * pad, ih = H - 2 * pad;
const doneW = Math.floor(progress * nw);
let i: number, u: number, recency: number, alpha: number;
ctx.lineCap = "round";
ctx.shadowBlur = 4;
ctx.shadowColor = "rgba(" + col.thrd.join(",") + ",0.3)";
// 경사(세로) — 위상 0. 왼→오른 순서로 감는다
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);
}
// 위사(가로) — 위상차 phase 만큼 늦게 흐른다
const pF = Math.max(0, progress - phase) / (1 - phase);
const 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;
ctx.shadowBlur = 0;
// 핀 — 네 변에 박힌 직조 핀. 감긴 핀은 밝게
let k: number;
for (k = 0; k < nw; k++) {
u = k / (nw - 1);
const done = k < doneW;
ctx.fillStyle = done ? "rgb(" + col.pin.join(",") + ")" : "rgba(" + col.pin.join(",") + ",0.35)";
const r0 = done ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad + iw * u, pad, r0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(pad + iw * u, H - pad, r0, 0, Math.PI * 2); ctx.fill();
}
for (k = 0; k < nf; k++) {
u = k / (nf - 1);
const done2 = k < doneF2;
ctx.fillStyle = done2 ? "rgb(" + col.pin.join(",") + ")" : "rgba(" + col.pin.join(",") + ",0.35)";
const r2 = done2 ? 1.8 : 1;
ctx.beginPath(); ctx.arc(pad, pad + ih * u, r2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(W - pad, pad + ih * u, r2, 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 유지 — 유지 구간에도 실 출렁임(wobble)이 계속 움직인다
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, "--sh-thread"),
cross: tokRGB(root, "--sh-cross"),
pin: tokRGB(root, "--sh-pin"),
panel: tokRGB(root, "--sh-panel")
};
ctxRef.current = cv.getContext("2d");
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
}, [nw, nf, phase, base, 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>
);
}08 織り模様 — 糸が交わって生まれる格子文様 — 計測 累積面積 50.901% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ストリングアート9種 — 糸の一本一本を計算するキャンバス
このデモが属する記事
ストリングアート9種 — 糸の一本一本を計算するキャンバスピンの間を糸が規則で飛び移ると曲線がひとりでに生まれます。どこからどこへ巻くかを毎回計算するキャンバスエンジン — ライブラリなしのコピペです。ストリングアートとは、板に打ったピンの間に糸を規則で渡していくと、どこにも描いていない円弧や心臓 この記事のデモをすべて見る


