04Grid-snap canvas
A ruled background; turn snap on and every point jumps to the nearest gridline.
Best forQuick layout sketch, seating chart drawing
repeating-linear-gradientMath.roundaria-pressed

<main class="stage">
<div class="gs is-demo" id="gs">
<div class="gs__card">
<div class="gs__bar">
<button type="button" class="gs__snap" id="snapBtn" aria-pressed="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="gs__ico" aria-hidden="true"><path d="M208,56V200a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H200A8,8,0,0,1,208,56Z" opacity="0.2"/><path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z"/></svg><span>Snap to grid</span><span class="gs__pill" aria-hidden="true"><span class="gs__knob"></span></span></button>
<button type="button" class="gs__btn" id="clearBtn"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="gs__ico" aria-hidden="true"><path d="M160,168l-48,48H66.75L36.69,185.94a16,16,0,0,1,0-22.63L96,104Z" opacity="0.2"/><path d="M225,80.4,183.6,39a24,24,0,0,0-33.94,0L31,157.66a24,24,0,0,0,0,33.94l30.06,30.06A8,8,0,0,0,66.74,224H216a8,8,0,0,0,0-16h-84.7L225,114.34A24,24,0,0,0,225,80.4ZM108.68,208H70.05L42.33,180.28a8,8,0,0,1,0-11.31L96,115.31,148.69,168Zm105-105L160,156.69,107.31,104,161,50.34a8,8,0,0,1,11.32,0l41.38,41.38a8,8,0,0,1,0,11.31Z"/></svg><span>Clear board</span></button>
</div>
<div class="gs__board" id="board">
<canvas class="gs__ink" id="ink" role="img" aria-label="Layout board, 0 points"></canvas>
<svg class="gs__ghost" viewBox="0 0 300 104" preserveAspectRatio="none" aria-hidden="true">
<path class="gs__line" pathLength="100" d="M50,78 L50,26 L125,26 L125,52 L200,52 L200,78 L250,78"/>
<path class="gs__diag" pathLength="100" d="M25,78 L275,26"/>
<circle class="gs__ring gs__ring--1" cx="50" cy="78" r="5"/>
<circle class="gs__ring gs__ring--2" cx="50" cy="26" r="5"/>
<circle class="gs__ring gs__ring--3" cx="125" cy="26" r="5"/>
<circle class="gs__ring gs__ring--4" cx="125" cy="52" r="5"/>
<circle class="gs__ring gs__ring--5" cx="200" cy="52" r="5"/>
<circle class="gs__ring gs__ring--6" cx="200" cy="78" r="5"/>
<circle class="gs__ring gs__ring--7" cx="250" cy="78" r="5"/>
</svg>
</div>
<p class="gs__live">
<span class="gs__liveReal" id="live" aria-live="polite">Snap on, 0 points</span>
<span class="gs__liveDemo gs__liveDemo--off" aria-hidden="true">Snap off</span>
<span class="gs__liveDemo gs__liveDemo--on" aria-hidden="true">Snap on</span>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 격자 눈금 캔버스 — 실제 배치도는 canvas 2D 가 그린다(클릭마다 Math.round 로 가장 가까운 교차점에 붙인 폴리라인).
// 판 바탕의 눈금은 repeating-linear-gradient 두 겹(가로·세로)이고, 격자 미리보기(.is-demo)는 canvas 를 감춘 뒤
// 같은 교차점을 지나는 SVG 폴리라인을 stroke-dashoffset 으로 긋는다.
// demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로 canvas 로 그린 그림은 24장이 전부 같다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-ink);
.gs { display: flex; justify-content: center; width: 100%; }
.gs__card {
width: min(100%, 340px); background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.gs__bar {
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
background: $subject-ink; padding: $sp-2 $sp-3;
}
.gs__snap, .gs__btn {
display: flex; align-items: center; gap: $sp-1; cursor: pointer;
border: 1px solid rgba(255, 255, 255, .34); background: transparent; color: $stage-paper;
border-radius: $r-pill; font-family: inherit; font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px); padding: $sp-1 $sp-2; line-height: 1.3;
}
.gs__snap:focus-visible, .gs__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.gs__ico { width: 14px; height: 14px; flex: 0 0 auto; }
// 켬·끔 표시 — 버튼 전체 색을 바꾸면 poster 가 그 한 장을 고른다(면적이 크다). 작은 알약만 바뀐다
.gs__pill {
position: relative; width: 18px; height: 10px; flex: 0 0 auto;
border-radius: $r-pill; background: rgba(255, 255, 255, .3);
}
.gs__knob {
position: absolute; top: 2px; left: 2px; width: 6px; height: 6px;
border-radius: 50%; background: $stage-paper;
}
.gs__snap[aria-pressed="true"] .gs__pill { background: $subject-blue; }
.gs__snap[aria-pressed="true"] .gs__knob { transform: translateX(8px); }
.gs__board {
position: relative; background: $subject-cream; aspect-ratio: 300 / 104; touch-action: none;
// 눈금 — 가로 12칸 · 세로 4칸. 칸을 %로 잡아야 판 폭이 변해도 JS 의 Math.round 격자와 어긋나지 않는다
background-image:
repeating-linear-gradient(to right, rgba(23, 20, 26, .16) 0 1px, transparent 1px calc(100% / 12)),
repeating-linear-gradient(to bottom, rgba(23, 20, 26, .16) 0 1px, transparent 1px calc(100% / 4));
}
.gs__ink, .gs__ghost { position: absolute; inset: 0; width: 100%; height: 100%; }
.gs__ghost { display: none; fill: none; }
.gs__line {
stroke: $color; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 100; stroke-dashoffset: 100;
}
.gs__diag {
stroke: $subject-blue; stroke-width: 8; stroke-linecap: round;
stroke-dasharray: 100; stroke-dashoffset: 100;
}
.gs__ring {
stroke: $subject-blue; stroke-width: 2; fill: $stage-paper;
// SVG 요소의 transform-origin 은 viewBox 기준이라 fill-box 로 바꿔야 링이 자기 중심에서 커진다 (규칙 8)
transform-box: fill-box; transform-origin: 50% 50%;
}
.gs__live {
position: relative; margin: 0; padding: $sp-1 $sp-3 $sp-2; font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px); color: rgba(23, 20, 26, .72);
}
.gs__liveDemo { display: none; position: absolute; left: $sp-3; top: $sp-1; }
// ── 미리보기 루프 (2s)
// 10% 스냅이 켜짐으로 컷 · 14~54% 폴리라인이 교차점을 따라 그어지고 꺾이는 점마다 스냅 링이 톡 튄다
// 56~60% 굵은 대각선 한 획을 빠르게 그어 poster 가 이 장이 되게 한다
// 64~88% 대각선 → 폴리라인 순으로 되감겨 빈 판으로 돌아오고, 92% 에 스냅이 다시 꺼져 0% 와 같은 상태가 된다
.gs.is-demo .gs__ink { visibility: hidden; }
.gs.is-demo .gs__ghost { display: block; }
.gs.is-demo .gs__line {
animation-name: gsDraw;
animation-duration: $duration;
animation-timing-function: linear; // 펜이 일정한 속도로 가야 꺾인 점의 스냅 링이 제때 튄다
animation-iteration-count: infinite;
}
@keyframes gsDraw {
0%, 14% { stroke-dashoffset: 100; }
54%, 68% { stroke-dashoffset: 0; }
88%, 100% { stroke-dashoffset: 100; }
}
.gs.is-demo .gs__diag {
animation-name: gsDiag;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gsDiag {
0%, 56% { stroke-dashoffset: 100; }
60%, 64% { stroke-dashoffset: 0; }
82%, 100% { stroke-dashoffset: 100; }
}
// 스냅 링 — 펜이 꺾인 점에 닿는 순간 자기 중심에서 튀어오르고, 되감기가 그 점을 지날 때 줄어든다
.gs.is-demo .gs__ring {
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.gs.is-demo .gs__ring--1 { animation-name: gsRing1; }
.gs.is-demo .gs__ring--2 { animation-name: gsRing2; }
.gs.is-demo .gs__ring--3 { animation-name: gsRing3; }
.gs.is-demo .gs__ring--4 { animation-name: gsRing4; }
.gs.is-demo .gs__ring--5 { animation-name: gsRing5; }
.gs.is-demo .gs__ring--6 { animation-name: gsRing6; }
.gs.is-demo .gs__ring--7 { animation-name: gsRing7; }
@keyframes gsRing1 {
0%, 14% { transform: scale(0); }
19% { transform: scale(1.6); }
22%, 86% { transform: scale(1); }
89%, 100% { transform: scale(0); }
}
@keyframes gsRing2 {
0%, 21% { transform: scale(0); }
26% { transform: scale(1.6); }
29%, 84% { transform: scale(1); }
87%, 100% { transform: scale(0); }
}
@keyframes gsRing3 {
0%, 31% { transform: scale(0); }
36% { transform: scale(1.6); }
39%, 80% { transform: scale(1); }
83%, 100% { transform: scale(0); }
}
@keyframes gsRing4 {
0%, 34% { transform: scale(0); }
39% { transform: scale(1.6); }
42%, 78% { transform: scale(1); }
81%, 100% { transform: scale(0); }
}
@keyframes gsRing5 {
0%, 44% { transform: scale(0); }
49% { transform: scale(1.6); }
52%, 73% { transform: scale(1); }
76%, 100% { transform: scale(0); }
}
@keyframes gsRing6 {
0%, 47% { transform: scale(0); }
52% { transform: scale(1.6); }
55%, 71% { transform: scale(1); }
74%, 100% { transform: scale(0); }
}
@keyframes gsRing7 {
0%, 54% { transform: scale(0); }
59% { transform: scale(1.6); }
62%, 68% { transform: scale(1); }
71%, 100% { transform: scale(0); }
}
// 켬·끔은 steps(1, end) 로 한 프레임에 갈아 끼운다 — 반투명 겹침이 없다. 알약 옆 손잡이는 미끄러진다
.gs.is-demo .gs__pill {
animation-name: gsPillOn;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes gsPillOn {
0%, 9% { background-color: rgba(255, 255, 255, .3); }
10%, 91% { background-color: $subject-blue; }
92%, 100% { background-color: rgba(255, 255, 255, .3); }
}
.gs.is-demo .gs__knob {
animation-name: gsKnob;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes gsKnob {
0%, 9% { transform: translateX(0); }
14%, 89% { transform: translateX(8px); }
94%, 100% { transform: translateX(0); }
}
.gs.is-demo .gs__snap .gs__ico {
animation-name: gsPop;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes gsPop {
0%, 9% { transform: scale(1); }
13% { transform: scale(1.3); }
18%, 100% { transform: scale(1); }
}
.gs.is-demo .gs__liveReal { color: transparent; }
.gs.is-demo .gs__liveDemo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.gs.is-demo .gs__liveDemo--off { animation-name: gsLabelOff; }
.gs.is-demo .gs__liveDemo--on { animation-name: gsLabelOn; }
@keyframes gsLabelOff {
0%, 9% { opacity: 1; }
10%, 91% { opacity: 0; }
92%, 100% { opacity: 1; }
}
@keyframes gsLabelOn {
0%, 9% { opacity: 0; }
10%, 91% { opacity: 1; }
92%, 100% { opacity: 0; }
}
// 되감기가 끝나는 88% 뒤에 「판 비우기」가 자기 중심에서 눌린다 (transform-origin 기본값 50% 50%)
.gs.is-demo .gs__btn {
animation-name: gsPress;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes gsPress {
0%, 64% { transform: scale(1); }
68% { transform: scale(.9); }
74%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.gs__bar { padding: $sp-1 $sp-2; }
.gs__live { padding: $sp-1 $sp-2; }
.gs__liveDemo { left: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.gs.is-demo .gs__line,
.gs.is-demo .gs__diag,
.gs.is-demo .gs__ring,
.gs.is-demo .gs__pill,
.gs.is-demo .gs__knob,
.gs.is-demo .gs__snap .gs__ico,
.gs.is-demo .gs__btn,
.gs.is-demo .gs__liveDemo { animation: none !important; }
// 움직임 없이 배치도가 다 그어진 최종 상태로 세운다
.gs.is-demo .gs__line,
.gs.is-demo .gs__diag { stroke-dashoffset: 0 !important; }
.gs.is-demo .gs__ring { transform: scale(1) !important; }
.gs.is-demo .gs__pill { background-color: $subject-blue !important; }
.gs.is-demo .gs__knob { transform: translateX(8px) !important; }
.gs.is-demo .gs__liveDemo--off { opacity: 0 !important; }
.gs.is-demo .gs__liveDemo--on { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gs {
display: flex;
justify-content: center;
width: 100%;
}
.gs__card {
width: min(100%, 340px);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.gs__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: #17141a;
padding: 8px 12px;
}
.gs__snap, .gs__btn {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.34);
background: transparent;
color: #ffffff;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
padding: 4px 8px;
line-height: 1.3;
}
.gs__snap:focus-visible, .gs__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.gs__ico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.gs__pill {
position: relative;
width: 18px;
height: 10px;
flex: 0 0 auto;
border-radius: 999px;
background: rgba(255, 255, 255, 0.3);
}
.gs__knob {
position: absolute;
top: 2px;
left: 2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ffffff;
}
.gs__snap[aria-pressed=true] .gs__pill {
background: #2f6df6;
}
.gs__snap[aria-pressed=true] .gs__knob {
transform: translateX(8px);
}
.gs__board {
position: relative;
background: #fff7e6;
aspect-ratio: 300/104;
touch-action: none;
background-image: repeating-linear-gradient(to right, rgba(23, 20, 26, 0.16) 0 1px, transparent 1px 8.3333333333%), repeating-linear-gradient(to bottom, rgba(23, 20, 26, 0.16) 0 1px, transparent 1px 25%);
}
.gs__ink, .gs__ghost {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.gs__ghost {
display: none;
fill: none;
}
.gs__line {
stroke: #17141a;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.gs__diag {
stroke: #2f6df6;
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.gs__ring {
stroke: #2f6df6;
stroke-width: 2;
fill: #ffffff;
transform-box: fill-box;
transform-origin: 50% 50%;
}
.gs__live {
position: relative;
margin: 0;
padding: 4px 12px 8px;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.72);
}
.gs__liveDemo {
display: none;
position: absolute;
left: 12px;
top: 4px;
}
.gs.is-demo .gs__ink {
visibility: hidden;
}
.gs.is-demo .gs__ghost {
display: block;
}
.gs.is-demo .gs__line {
animation-name: gsDraw;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gsDraw {
0%, 14% {
stroke-dashoffset: 100;
}
54%, 68% {
stroke-dashoffset: 0;
}
88%, 100% {
stroke-dashoffset: 100;
}
}
.gs.is-demo .gs__diag {
animation-name: gsDiag;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gsDiag {
0%, 56% {
stroke-dashoffset: 100;
}
60%, 64% {
stroke-dashoffset: 0;
}
82%, 100% {
stroke-dashoffset: 100;
}
}
.gs.is-demo .gs__ring {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.gs.is-demo .gs__ring--1 {
animation-name: gsRing1;
}
.gs.is-demo .gs__ring--2 {
animation-name: gsRing2;
}
.gs.is-demo .gs__ring--3 {
animation-name: gsRing3;
}
.gs.is-demo .gs__ring--4 {
animation-name: gsRing4;
}
.gs.is-demo .gs__ring--5 {
animation-name: gsRing5;
}
.gs.is-demo .gs__ring--6 {
animation-name: gsRing6;
}
.gs.is-demo .gs__ring--7 {
animation-name: gsRing7;
}
@keyframes gsRing1 {
0%, 14% {
transform: scale(0);
}
19% {
transform: scale(1.6);
}
22%, 86% {
transform: scale(1);
}
89%, 100% {
transform: scale(0);
}
}
@keyframes gsRing2 {
0%, 21% {
transform: scale(0);
}
26% {
transform: scale(1.6);
}
29%, 84% {
transform: scale(1);
}
87%, 100% {
transform: scale(0);
}
}
@keyframes gsRing3 {
0%, 31% {
transform: scale(0);
}
36% {
transform: scale(1.6);
}
39%, 80% {
transform: scale(1);
}
83%, 100% {
transform: scale(0);
}
}
@keyframes gsRing4 {
0%, 34% {
transform: scale(0);
}
39% {
transform: scale(1.6);
}
42%, 78% {
transform: scale(1);
}
81%, 100% {
transform: scale(0);
}
}
@keyframes gsRing5 {
0%, 44% {
transform: scale(0);
}
49% {
transform: scale(1.6);
}
52%, 73% {
transform: scale(1);
}
76%, 100% {
transform: scale(0);
}
}
@keyframes gsRing6 {
0%, 47% {
transform: scale(0);
}
52% {
transform: scale(1.6);
}
55%, 71% {
transform: scale(1);
}
74%, 100% {
transform: scale(0);
}
}
@keyframes gsRing7 {
0%, 54% {
transform: scale(0);
}
59% {
transform: scale(1.6);
}
62%, 68% {
transform: scale(1);
}
71%, 100% {
transform: scale(0);
}
}
.gs.is-demo .gs__pill {
animation-name: gsPillOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes gsPillOn {
0%, 9% {
background-color: rgba(255, 255, 255, 0.3);
}
10%, 91% {
background-color: #2f6df6;
}
92%, 100% {
background-color: rgba(255, 255, 255, 0.3);
}
}
.gs.is-demo .gs__knob {
animation-name: gsKnob;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes gsKnob {
0%, 9% {
transform: translateX(0);
}
14%, 89% {
transform: translateX(8px);
}
94%, 100% {
transform: translateX(0);
}
}
.gs.is-demo .gs__snap .gs__ico {
animation-name: gsPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes gsPop {
0%, 9% {
transform: scale(1);
}
13% {
transform: scale(1.3);
}
18%, 100% {
transform: scale(1);
}
}
.gs.is-demo .gs__liveReal {
color: transparent;
}
.gs.is-demo .gs__liveDemo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.gs.is-demo .gs__liveDemo--off {
animation-name: gsLabelOff;
}
.gs.is-demo .gs__liveDemo--on {
animation-name: gsLabelOn;
}
@keyframes gsLabelOff {
0%, 9% {
opacity: 1;
}
10%, 91% {
opacity: 0;
}
92%, 100% {
opacity: 1;
}
}
@keyframes gsLabelOn {
0%, 9% {
opacity: 0;
}
10%, 91% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
.gs.is-demo .gs__btn {
animation-name: gsPress;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes gsPress {
0%, 64% {
transform: scale(1);
}
68% {
transform: scale(0.9);
}
74%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.gs__bar {
padding: 4px 8px;
}
.gs__live {
padding: 4px 8px;
}
.gs__liveDemo {
left: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.gs.is-demo .gs__line,
.gs.is-demo .gs__diag,
.gs.is-demo .gs__ring,
.gs.is-demo .gs__pill,
.gs.is-demo .gs__knob,
.gs.is-demo .gs__snap .gs__ico,
.gs.is-demo .gs__btn,
.gs.is-demo .gs__liveDemo {
animation: none !important;
}
.gs.is-demo .gs__line,
.gs.is-demo .gs__diag {
stroke-dashoffset: 0 !important;
}
.gs.is-demo .gs__ring {
transform: scale(1) !important;
}
.gs.is-demo .gs__pill {
background-color: #2f6df6 !important;
}
.gs.is-demo .gs__knob {
transform: translateX(8px) !important;
}
.gs.is-demo .gs__liveDemo--off {
opacity: 0 !important;
}
.gs.is-demo .gs__liveDemo--on {
opacity: 1 !important;
}
}var gs = document.getElementById('gs');
var board = document.getElementById('board');
var ink = document.getElementById('ink');
var live = document.getElementById('live');
var snapBtn = document.getElementById('snapBtn');
var ctx = ink.getContext('2d');
var COLS = 12, ROWS = 4; // 판을 가로 12칸 · 세로 4칸으로 나눈 눈금 (CSS 눈금과 같은 수)
var points = [], snap = true;
var PAD_LABEL = 'Layout board, %d points';
var LIVE_LABEL = 'Snap %s, %d points';
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function resize() {
var r = board.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
ink.width = Math.round(r.width * dpr);
ink.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
repaint();
}
// 눈금 한 칸은 판 크기의 1/12 · 1/4 다 — 판이 줄어도 CSS 눈금과 같은 자리에 떨어진다
function stepSize() {
var r = board.getBoundingClientRect();
return { x: r.width / COLS, y: r.height / ROWS };
}
// 스냅이 켜져 있으면 가장 가까운 교차점으로 붙인다 — 반올림 한 번이 전부다
function snapPoint(p) {
if (!snap) { return p; }
var s = stepSize();
return { x: Math.round(p.x / s.x) * s.x, y: Math.round(p.y / s.y) * s.y };
}
function repaint() {
ctx.clearRect(0, 0, ink.width, ink.height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#17141a';
ctx.lineWidth = 2.4;
ctx.beginPath();
for (var i = 0; i < points.length; i++) {
if (i === 0) { ctx.moveTo(points[i].x, points[i].y); } else { ctx.lineTo(points[i].x, points[i].y); }
}
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#2f6df6';
ctx.lineWidth = 1.6;
for (var k = 0; k < points.length; k++) { // 붙은 자리마다 작은 표식
ctx.beginPath();
ctx.arc(points[k].x, points[k].y, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ink.setAttribute('aria-label', PAD_LABEL.replace('%d', points.length));
live.textContent = LIVE_LABEL.replace('%s', snap ? 'on' : 'off').replace('%d', points.length);
}
board.addEventListener('pointerdown', function (e) {
gs.classList.remove('is-demo');
var r = ink.getBoundingClientRect();
points.push(snapPoint({ x: e.clientX - r.left, y: e.clientY - r.top }));
repaint();
});
snapBtn.addEventListener('click', function () {
gs.classList.remove('is-demo');
snap = !snap;
snapBtn.setAttribute('aria-pressed', snap ? 'true' : 'false');
repaint();
});
document.getElementById('clearBtn').addEventListener('click', function () { points = []; repaint(); });
window.addEventListener('resize', resize);
resize();// GridSnapCanvas — vanilla 판(run/287/demo/04-canvas-grid-snap/index.html) 의 React 포트.
// 클릭한 점을 Math.round 로 가장 가까운 눈금 교차점에 붙여 폴리라인으로 잇고, 붙은 자리마다 작은 표식을 그린다.
// 판 바탕의 눈금은 CSS repeating-linear-gradient 두 겹이고 칸 수(COLS·ROWS)는 양쪽이 같은 값을 쓴다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./GridSnapCanvas.module.scss";
export type GridSnapCanvasProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Point = { x: number; y: number };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const COLS = 12;
const ROWS = 4;
const PAD_LABEL = "배치도 칸, 점 %d개";
const LIVE_LABEL = "눈금에 맞추기 %s, 점 %d개";
function applyRepaint(ctx: CanvasRenderingContext2D, points: Point[], width: number, height: number): void {
ctx.clearRect(0, 0, width, height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#17141a";
ctx.lineWidth = 2.4;
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.moveTo(points[i].x, points[i].y);
} else {
ctx.lineTo(points[i].x, points[i].y);
}
}
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = "#2f6df6";
ctx.lineWidth = 1.6;
for (const p of points) { // 붙은 자리마다 작은 표식
ctx.beginPath();
ctx.arc(p.x, p.y, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
export default function GridSnapCanvas({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: GridSnapCanvasProps) {
const boardRef = useRef<HTMLDivElement | null>(null);
const inkRef = useRef<HTMLCanvasElement | null>(null);
const pointsRef = useRef<Point[]>([]) as Box<Point[]>;
const [demo, setDemo] = useState(true);
const [snap, setSnap] = useState(true);
const [count, setCount] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const repaint = useCallback(() => {
const canvas = inkRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
applyRepaint(ctx, pointsRef.current, canvas.width, canvas.height);
setCount(pointsRef.current.length);
}, []);
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
const resize = useCallback(() => {
const board = boardRef.current;
const canvas = inkRef.current;
if (!board || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const r = board.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
repaint();
}, [repaint]);
useEffect(() => {
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [resize]);
// 스냅이 켜져 있으면 가장 가까운 교차점으로 붙인다 — 반올림 한 번이 전부다
function snapPoint(p: Point): Point {
const board = boardRef.current;
if (!snap || !board) return p;
const r = board.getBoundingClientRect();
const stepX = r.width / COLS;
const stepY = r.height / ROWS;
return { x: Math.round(p.x / stepX) * stepX, y: Math.round(p.y / stepY) * stepY };
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
setDemo(false);
const canvas = inkRef.current;
if (!canvas) return;
const r = canvas.getBoundingClientRect();
pointsRef.current.push(snapPoint({ x: event.clientX - r.left, y: event.clientY - r.top }));
repaint();
}
function onToggleSnap(): void {
setDemo(false);
setSnap(!snap);
}
function onClear(): void {
pointsRef.current = [];
repaint();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.bar}>
<button type="button" className={styles.snap} aria-pressed={snap} onClick={onToggleSnap}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M208,56V200a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H200A8,8,0,0,1,208,56Z" opacity="0.2"/><path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z"/></svg>
<span>눈금에 맞추기</span>
<span className={styles.pill} aria-hidden="true"><span className={styles.knob}></span></span>
</button>
<button type="button" className={styles.btn} onClick={onClear}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M160,168l-48,48H66.75L36.69,185.94a16,16,0,0,1,0-22.63L96,104Z" opacity="0.2"/><path d="M225,80.4,183.6,39a24,24,0,0,0-33.94,0L31,157.66a24,24,0,0,0,0,33.94l30.06,30.06A8,8,0,0,0,66.74,224H216a8,8,0,0,0,0-16h-84.7L225,114.34A24,24,0,0,0,225,80.4ZM108.68,208H70.05L42.33,180.28a8,8,0,0,1,0-11.31L96,115.31,148.69,168Zm105-105L160,156.69,107.31,104,161,50.34a8,8,0,0,1,11.32,0l41.38,41.38a8,8,0,0,1,0,11.31Z"/></svg>
<span>판 비우기</span>
</button>
</div>
<div className={styles.board} ref={boardRef} onPointerDown={onPointerDown}>
<canvas className={styles.ink} ref={inkRef} role="img" aria-label={PAD_LABEL.replace("%d", String(count))}></canvas>
<svg className={styles.ghost} viewBox="0 0 300 104" preserveAspectRatio="none" aria-hidden="true">
<path className={styles.line} pathLength="100" d="M50,78 L50,26 L125,26 L125,52 L200,52 L200,78 L250,78"/>
<path className={styles.diag} pathLength="100" d="M25,78 L275,26"/>
<circle className={`${styles.ring} ${styles.ring1}`} cx="50" cy="78" r="5"/>
<circle className={`${styles.ring} ${styles.ring2}`} cx="50" cy="26" r="5"/>
<circle className={`${styles.ring} ${styles.ring3}`} cx="125" cy="26" r="5"/>
<circle className={`${styles.ring} ${styles.ring4}`} cx="125" cy="52" r="5"/>
<circle className={`${styles.ring} ${styles.ring5}`} cx="200" cy="52" r="5"/>
<circle className={`${styles.ring} ${styles.ring6}`} cx="200" cy="78" r="5"/>
<circle className={`${styles.ring} ${styles.ring7}`} cx="250" cy="78" r="5"/>
</svg>
</div>
<p className={styles.live}>
<span className={styles.liveReal} aria-live="polite">
{LIVE_LABEL.replace("%s", snap ? "켬" : "끔").replace("%d", String(count))}
</span>
<span className={`${styles.liveDemo} ${styles.liveOff}`} aria-hidden="true">눈금에 맞추기 끔</span>
<span className={`${styles.liveDemo} ${styles.liveOn}`} aria-hidden="true">눈금에 맞추기 켬</span>
</p>
</div>
</div>
</main>
);
}04 Grid-snap canvas — Measured area 5.368% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · Signature Pad JavaScript: 9 Canvas Drawing UIs
The post this demo lives in
Signature Pad JavaScript: 9 Canvas Drawing UIsThese signature pad javascript UIs use only the browser's canvas and Pointer Events — no library. Paste the files and pr See all demos in this post


