밀어서 잠금해제 9종 — 당길수록 무거운 고무줄 트랙부터 호 위 전원 슬라이더까지
밀어서 잠금해제 버튼은 손잡이를 끝까지 밀어야 동작이 실행되는 확인 장치이다. 밀어서 잠금해제는 손가락을 놓는 순간이 승부인데, 02번은 놓은 자리가 짧아도 속도를 더해 끝까지 보내고 03번은 못 미치면 스프링으로 되돌리며 08번은 직선이 아닌 호 위에서 각도로 진행도를 잰다.
밀어서 잠금해제는 버튼이 아니라 위치를 재는 일이다 — 밀어서 잠금해제 UI는 손잡이 위치를 0에서 1 사이 진행도로 바꾸는 데서 시작한다
01 sul-rubber-track-resist
02 sul-flick-velocity-verdict
03 sul-spring-return-overshoot
04 sul-threshold-auto-finish
05 sul-shimmer-label-mask
06 sul-confirm-payment-stages
07 sul-hint-chevron-wave
08 sul-arc-power-slider
09 sul-hero-lock-screen-sharpen
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 끝에 다가갈수록 당기는 힘이 무거워지는 고무줄 트랙
- 02 짧게 놓아도 속도를 더해 끝까지 보내는 플릭 판정
- 03 못 미치면 오버슈트를 남기며 돌아오는 스프링 복귀
- 04 임계를 넘으면 남은 길을 스스로 가고 체크로 바뀌는 자동 완료
- 05 손잡이가 지나간 글자를 흐리게 지우고 광택이 훑는 안내 라벨
- 06 밀수록 색과 금액 문구가 단계로 바뀌는 결제 확인 슬라이더
- 07 가만히 두면 쉐브론이 물결로 일렁이며 방향을 알리는 힌트
- 08 직선이 아니라 호 위를 각도로 재며 도는 전원 슬라이더
- 09 밀수록 배경이 선명해지고 시계가 물러나는 잠금 화면 히어로
손잡이 하나가 진행도 하나로 모든 것을 움직인다
아홉 장면의 손잡이는 모두 role="slider" 이고, 위치는 sl.p 하나로 0 에서 시작해 1 에서 끝난다. 채움·글자·색·그림자는 전부 이 값에서 계산하고, 손잡이와 채움 끝은 translateX() 로, 글자·카드·링은 opacity 와 transform 으로 움직인다. 08 만 SVG 호를 쓰고 나머지는 DOM 만 쓴다.
놓는 순간에만 판정한다
끌고 있는 동안은 완료인지 아닌지 정하지 않는다. 놓는 순간 release() 가 verdict(p, vEma) 를 한 번 부르고, 완료면 끝으로 가는 스프링을, 아니면 처음으로 가는 스프링을 건다. 04 만 예외라서 누르는 중에도 임계를 넘으면 바로 완료로 넘긴다.
이웃한 편과 다른 점
버튼 눌림 효과 UI 편은 눌림 값이 되돌아오는 방식을 다루고, 카드 스와이프 ui 편은 카드를 끌어 넘기는 드래그를 다룬다. 이 편은 끝까지 밀어야 하나의 결과가 정해진다는 점이 다르고, 손을 계산으로 읽는 점은 가상 조이스틱 편과 닿아 있다.
밀어서 잠금해제 9종 — 당길수록 무거워지는 고무줄 트랙·글자를 훑는 광택 라벨·못 미치면 되돌리는 스프링 복귀·넘으면 끝까지 보내는 임계 자동 완료·속도를 더하는 플릭 판정·밀어서 확인하는 결제 단계·힌트로 일렁이는 쉐브론·호 위를 도는 전원 슬라이더·배경이 선명해지는 잠금 화면 히어로
01 은 끝을 넘겨 당기는 손에 저항을 주고, 02~04 는 놓는 순간의 판정을 속도·스프링·임계로 나눈다. 05~07 은 글자·색·힌트로 손잡이 바깥을 채우고, 08 은 직선을 버리고 호로 가며, 09 는 같은 값 하나로 배경 전체를 움직인다.
아홉 장면의 면적·움직임·강도 실측
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 고무줄 트랙 | 19.1% | 14/23 | 83.1 |
| 02 플릭 판정 | 14.5% | 14/23 | 61.2 |
| 03 스프링 복귀 | 12.3% | 18/23 | 99.0 |
| 04 임계 자동 완료 | 18.6% | 23/23 | 70.4 |
| 05 광택 라벨 | 13.6% | 17/23 | 78.4 |
| 06 결제 단계 | 15.7% | 21/23 | 109.5 |
| 07 힌트 쉐브론 | 12.7% | 18/23 | 83.3 |
| 08 호 슬라이더 | 19.8% | 16/23 | 69.8 |
| 09 잠금 화면 | 40.1% | 15/23 | 92.5 |
가장 넓게 바뀌는 것은 풍경·시계·카드가 한 값으로 움직이는 09 의 40.1% 이고, 가장 좁은 것은 03 의 12.3% 이다. 움직인 프레임은 완료 뒤 링·체크·트랙이 이어서 바뀌는 04 가 23/23 으로 가장 많고, 01 과 02 는 14/23 이다. 강도 평균은 06 이 109.5 로 가장 높고 02 가 61.2 로 가장 낮다.
01끝에 다가갈수록 당기는 힘이 무거워지는 고무줄 트랙
끝 근처에서 손가락을 1.3 까지 당겨도 손잡이는 mapDrag 가 1.078 까지만 보내고, 넘긴 만큼 고무줄 띠가 늘어나며 1/(1+over·BAND_THIN) 만큼 가늘어진다. 0.78(KNEE) 까지는 손가락을 그대로 따라가다 그 뒤로는 지수로 줄어드는 곡선이라, 끝에 가까울수록 손에 무게가 실린다. 결제나 삭제를 확인하는 화면에서 손가락이 끝을 넘어도 손잡이가 트랙 밖으로 튀지 않게 하는 기본형이다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="밀어서 잠금해제 트랙이 끝에 다가갈수록 손잡이가 덜 따라가고 트랙 끝 바깥으로 고무줄 띠가 늘어났다 놓으면 끝 위치로 돌아와 멈추며, 아래 작은 트랙은 한계가 없어 손잡이가 끝을 넘어 튀는 잘못된 방식을 보여 준다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">진행도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
// 한 바퀴 길이(초)와 움직임 줄이기에서 몇 초 뒤의 장면을 그릴지
const LOOP_S = 3.6;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 이 진행도 이상으로 놓으면 끝까지 가고, 못 미치면 처음으로 돌아간다
const THRESH = 0.8;
// 고무줄 — 진행도 KNEE 부터 저항이 시작되고 늘어남은 1 + STRETCH 에 점근한다
const KNEE = 0.78;
const STRETCH = 0.2;
// 임계에 못 미쳐 놓았을 때 처음으로 돌아가는 스프링(SPR)과 끝으로 가는 스프링(FIN)의 세기
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 200;
const FIN_C = 16;
// 트랙 끝 바깥으로 늘어나는 띠의 기본 폭(cqw)과 늘어날수록 가늘어지는 정도
const BAND_W = 20;
const BAND_THIN = 6;
const HANDLE_LABEL = '잠금 해제 손잡이 — 오른쪽으로 밀기, 끝을 넘겨 당기면 고무줄처럼 늘어남';
let main;
let mini;
let band;
let ring;
let badX = 0;
// 손가락 값 x 를 손잡이 진행도로 — KNEE 까지는 그대로, 그 뒤는 끝에 다가갈수록 점점 덜 따라간다
function mapDrag(x) {
if (x <= KNEE) {
return Math.max(x, 0);
}
const span = 1 + STRETCH - KNEE;
return KNEE + span * (1 - Math.exp(-(x - KNEE) / span));
}
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝을 넘겨 당겼다 놓고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.45, x: ramp(t, [[0.2, 0], [0.75, 0.85], [1.15, 1.3], [1.45, 1.3]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [0.9, 0.55], [1.3, 0.6]]) };
}
function build() {
scene.textContent = '';
capAt(scene, '당길수록 무거워지는 고무줄 트랙', 11, '');
main = makeLin(scene, 'sl--rubber', '밀어서 잠금 해제', 21, 1);
band = mk('div', 'rub', main.track);
band.style.left = (TRACK_CQW - 4) + 'cqw';
band.style.width = BAND_W + 'cqw';
main.track.insertBefore(band, main.handle);
ring = mk('div', 'fing', main.track);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '한계 없는 트랙 — 손잡이가 끝을 넘어 튄다', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
}
function reset() { badX = 0; }
function tick(h) {
void h;
if (sl.down) {
badX = clamp(sl.fx, 0, 1.3);
}
}
function draw() {
placeHandle(main, sl.p);
const over = Math.max(0, sl.p - 1);
const w = over > 0 ? 4 - PAD_CQW + over * travelCqw() : 0;
band.style.transform = 'scaleX(' + (w / BAND_W).toFixed(3) + ') scaleY(' + (1 / (1 + over * BAND_THIN)).toFixed(3) + ')';
band.style.opacity = over > 0 ? '1' : '0';
// 손가락이 실제로 있는 자리 — 손잡이와의 거리가 곧 저항이다
ring.style.transform = 'translateX(' + (xOf(sl.fx) - 3).toFixed(2) + 'cqw)';
ring.style.opacity = sl.down ? clamp((sl.fx - sl.p - 0.06) / 0.06, 0, 1).toFixed(3) : '0';
placeHandle(mini, badX);
}
function statusText() {
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText() { return '잠금이 해제되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// RubberTrackResist - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RubberTrackResist.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RubberTrackResist() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
// 한 바퀴 길이(초)와 움직임 줄이기에서 몇 초 뒤의 장면을 그릴지
const LOOP_S = 3.6;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 이 진행도 이상으로 놓으면 끝까지 가고, 못 미치면 처음으로 돌아간다
const THRESH = 0.8;
// 고무줄 — 진행도 KNEE 부터 저항이 시작되고 늘어남은 1 + STRETCH 에 점근한다
const KNEE = 0.78;
const STRETCH = 0.2;
// 임계에 못 미쳐 놓았을 때 처음으로 돌아가는 스프링(SPR)과 끝으로 가는 스프링(FIN)의 세기
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 200;
const FIN_C = 16;
// 트랙 끝 바깥으로 늘어나는 띠의 기본 폭(cqw)과 늘어날수록 가늘어지는 정도
const BAND_W = 20;
const BAND_THIN = 6;
const HANDLE_LABEL = '잠금 해제 손잡이 — 오른쪽으로 밀기, 끝을 넘겨 당기면 고무줄처럼 늘어남';
let main: Lin;
let mini: Lin;
let band: HTMLElement;
let ring: HTMLElement;
let badX = 0;
// 손가락 값 x 를 손잡이 진행도로 — KNEE 까지는 그대로, 그 뒤는 끝에 다가갈수록 점점 덜 따라간다
function mapDrag(x: number): number {
if (x <= KNEE) { return Math.max(x, 0); }
const span = 1 + STRETCH - KNEE;
return KNEE + span * (1 - Math.exp(-(x - KNEE) / span));
}
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝을 넘겨 당겼다 놓고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.45, x: ramp(t, [[0.2, 0], [0.75, 0.85], [1.15, 1.3], [1.45, 1.3]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [0.9, 0.55], [1.3, 0.6]]) };
}
function build(): void {
scene.textContent = '';
capAt(scene, '당길수록 무거워지는 고무줄 트랙', 11, '');
main = makeLin(scene, 'sl--rubber', '밀어서 잠금 해제', 21, 1);
band = mk('div', 'rub', main.track);
band.style.left = (TRACK_CQW - 4) + 'cqw';
band.style.width = BAND_W + 'cqw';
main.track.insertBefore(band, main.handle);
ring = mk('div', 'fing', main.track);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '한계 없는 트랙 — 손잡이가 끝을 넘어 튄다', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
}
function reset(): void { badX = 0; }
function tick(h: number): void {
void h;
if (sl.down) { badX = clamp(sl.fx, 0, 1.3); }
}
function draw(): void {
placeHandle(main, sl.p);
const over = Math.max(0, sl.p - 1);
const w = over > 0 ? 4 - PAD_CQW + over * travelCqw() : 0;
band.style.transform = 'scaleX(' + (w / BAND_W).toFixed(3) + ') scaleY(' + (1 / (1 + over * BAND_THIN)).toFixed(3) + ')';
band.style.opacity = over > 0 ? '1' : '0';
// 손가락이 실제로 있는 자리 — 손잡이와의 거리가 곧 저항이다
ring.style.transform = 'translateX(' + (xOf(sl.fx) - 3).toFixed(2) + 'cqw)';
ring.style.opacity = sl.down ? clamp((sl.fx - sl.p - 0.06) / 0.06, 0, 1).toFixed(3) : '0';
placeHandle(mini, badX);
}
function statusText(): string {
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText(): string { return '잠금이 해제되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'밀어서 잠금해제 트랙이 끝에 다가갈수록 손잡이가 덜 따라가고 트랙 끝 바깥으로 고무줄 띠가 늘어났다 놓으면 끝 위치로 돌아와 멈추며, 아래 작은 트랙은 한계가 없어 손잡이가 끝을 넘어 튀는 잘못된 방식을 보여 준다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'진행도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 · 끝에 다가갈수록 당기는 힘이 무거워지는 고무줄 트랙 — 측정 누적면적 19.147% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 손가락 값 x 를 손잡이 진행도로 — KNEE 까지는 그대로, 그 뒤는 끝에 다가갈수록 점점 덜 따라간다
function mapDrag(x) {
if (x <= KNEE) {
return Math.max(x, 0);
}
const span = 1 + STRETCH - KNEE;
return KNEE + span * (1 - Math.exp(-(x - KNEE) / span));
}
function draw() {
placeHandle(main, sl.p);
const over = Math.max(0, sl.p - 1);
const w = over > 0 ? 4 - PAD_CQW + over * travelCqw() : 0;
band.style.transform = 'scaleX(' + (w / BAND_W).toFixed(3) + ') scaleY(' + (1 / (1 + over * BAND_THIN)).toFixed(3) + ')';
band.style.opacity = over > 0 ? '1' : '0';
// 손가락이 실제로 있는 자리 — 손잡이와의 거리가 곧 저항이다
ring.style.transform = 'translateX(' + (xOf(sl.fx) - 3).toFixed(2) + 'cqw)';
ring.style.opacity = sl.down ? clamp((sl.fx - sl.p - 0.06) / 0.06, 0, 1).toFixed(3) : '0';
placeHandle(mini, badX);
}
02짧게 놓아도 속도를 더해 끝까지 보내는 플릭 판정
놓는 순간 위치에 속도를 더해 landing(p, v) = p + max(v, 0)·FLICK_T 로 예상 도착점을 계산하고, 0.80 을 넘으면 남은 거리를 미끄러져 완료한다. 시연의 빠른 손은 위치 0.503·속도 1.35 에서 놓아 예상 도착 0.84 로 1.18초에 끝나고, 같은 자리까지 천천히 끈 손은 위치 0.543·속도 0.39 라 예상 도착 0.64 로 되돌아간다. 가볍게 튕기는 동작을 인정해야 하는 잠금 해제에 맞고, 점선 잔상이 예상 도착점을 미리 보여 준다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="밀어서 잠금해제 트랙에서 놓은 위치에 속도를 더한 예상 도착점이 점선 잔상으로 보이고 임계를 넘으면 남은 거리를 미끄러져 완료되며 느리게 놓으면 같은 자리에서도 되돌아간다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">진행도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 임계 — 놓은 위치에 속도를 더한 예상 도착점이 이 값 이상이면 완료
const THRESH = 0.8;
// 속도를 얼마 앞(초)까지 내다보는가 — 예상 도착점 = 위치 + 속도 × FLICK_T
const FLICK_T = 0.25;
const SPR_K = 120;
const SPR_C = 14;
// 완료 때 남은 길을 미끄러져 가는 스프링(거의 임계감쇠)
const FIN_K = 150;
const FIN_C = 24;
const HANDLE_LABEL = '잠금 해제 손잡이 — 속도를 더해 판정하는 슬라이더';
let main;
let ghost;
let rule;
let note;
let land = 0;
// 장면 02 는 고무줄이 없다 — 손가락 값을 0 과 1 사이로만 자른다
function mapDrag(x) { return clamp(x, 0, 1); }
// 예상 도착점 — 위치 + 속도 × FLICK_T. 뒤로 가는 속도는 보태지 않는다
function landing(p, v) { return p + Math.max(v, 0) * FLICK_T; }
function verdict(p, v) { return landing(p, v) >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 빠르게 훑다 짧게 놓고, 홀수 바퀴는 같은 자리까지 천천히 끌고 가 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 0.6, x: ramp(t, [[0.2, 0], [0.6, 0.55]]) };
}
return { down: t >= 0.2 && t < 1.6, x: ramp(t, [[0.2, 0], [1.6, 0.55]]) };
}
function build() {
scene.textContent = '';
capAt(scene, '짧게 놓아도 속도를 더해 끝까지 보낸다', 11, '');
main = makeLin(scene, 'sl--flick', '밀어서 잠금 해제', 21, 1);
rule = mk('div', 'sl__mark', main.track);
rule.style.left = xOf(THRESH).toFixed(2) + 'cqw';
ghost = mk('div', 'ghost', main.track);
ghost.style.left = PAD_CQW + 'cqw';
ghost.style.top = PAD_CQW + 'cqw';
ghost.style.width = HANDLE_CQW + 'cqw';
ghost.style.height = HANDLE_CQW + 'cqw';
wireHandle(main.handle, HANDLE_LABEL);
note = capAt(scene, '', 42, 'cap--note');
}
function reset() { land = 0; }
function tick(h) {
void h;
if (sl.down) {
land = landing(sl.p, sl.vEma);
}
}
function draw() {
placeHandle(main, sl.p);
// 점선 잔상 — 누르는 동안은 지금 속도로 잰 예상 도착점, 놓은 뒤에는 그 자리에 남았다가 1.2초에 걸쳐 옅어진다
const since = sl.relT < 0 ? 0 : sceneT - sl.relT;
const keep = sl.down ? 1 : (sl.relT < 0 ? 0 : clamp(1 - since / 1.2, 0, 1));
const shown = land;
const hit = shown >= THRESH;
ghost.style.transform = 'translateX(' + (clamp(shown, 0, 1.05) * travelCqw()).toFixed(3) + 'cqw)';
ghost.style.opacity = (keep * (shown > 0.05 ? 1 : 0)).toFixed(3);
ghost.classList.toggle(cls('is-hit'), hit);
const txt = shown <= 0.05 ? '' : (hit ? '예상 도착 {n}% — 임계를 넘어 끝까지 간다' : '예상 도착 {n}% — 임계에 못 미쳐 되돌아간다');
note.textContent = txt.replace('{n}', String(Math.round(shown * 100)));
note.style.opacity = txt ? '1' : '0';
}
function statusText() {
return '위치 {n}% · 속도 {v}'.replace('{n}', String(Math.round(sl.p * 100))).replace('{v}', (Math.max(sl.vEma, 0)).toFixed(1));
}
function finalText() { return '잠금이 해제되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FlickVelocityVerdict - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FlickVelocityVerdict.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FlickVelocityVerdict() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 임계 — 놓은 위치에 속도를 더한 예상 도착점이 이 값 이상이면 완료
const THRESH = 0.8;
// 속도를 얼마 앞(초)까지 내다보는가 — 예상 도착점 = 위치 + 속도 × FLICK_T
const FLICK_T = 0.25;
const SPR_K = 120;
const SPR_C = 14;
// 완료 때 남은 길을 미끄러져 가는 스프링(거의 임계감쇠)
const FIN_K = 150;
const FIN_C = 24;
const HANDLE_LABEL = '잠금 해제 손잡이 — 속도를 더해 판정하는 슬라이더';
let main: Lin;
let ghost: HTMLElement;
let rule: HTMLElement;
let note: HTMLElement;
let land = 0;
// 장면 02 는 고무줄이 없다 — 손가락 값을 0 과 1 사이로만 자른다
function mapDrag(x: number): number { return clamp(x, 0, 1); }
// 예상 도착점 — 위치 + 속도 × FLICK_T. 뒤로 가는 속도는 보태지 않는다
function landing(p: number, v: number): number { return p + Math.max(v, 0) * FLICK_T; }
function verdict(p: number, v: number): boolean { return landing(p, v) >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 빠르게 훑다 짧게 놓고, 홀수 바퀴는 같은 자리까지 천천히 끌고 가 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 0.6, x: ramp(t, [[0.2, 0], [0.6, 0.55]]) };
}
return { down: t >= 0.2 && t < 1.6, x: ramp(t, [[0.2, 0], [1.6, 0.55]]) };
}
function build(): void {
scene.textContent = '';
capAt(scene, '짧게 놓아도 속도를 더해 끝까지 보낸다', 11, '');
main = makeLin(scene, 'sl--flick', '밀어서 잠금 해제', 21, 1);
rule = mk('div', 'sl__mark', main.track);
rule.style.left = xOf(THRESH).toFixed(2) + 'cqw';
ghost = mk('div', 'ghost', main.track);
ghost.style.left = PAD_CQW + 'cqw';
ghost.style.top = PAD_CQW + 'cqw';
ghost.style.width = HANDLE_CQW + 'cqw';
ghost.style.height = HANDLE_CQW + 'cqw';
wireHandle(main.handle, HANDLE_LABEL);
note = capAt(scene, '', 42, 'cap--note');
}
function reset(): void { land = 0; }
function tick(h: number): void {
void h;
if (sl.down) { land = landing(sl.p, sl.vEma); }
}
function draw(): void {
placeHandle(main, sl.p);
// 점선 잔상 — 누르는 동안은 지금 속도로 잰 예상 도착점, 놓은 뒤에는 그 자리에 남았다가 1.2초에 걸쳐 옅어진다
const since = sl.relT < 0 ? 0 : sceneT - sl.relT;
const keep = sl.down ? 1 : (sl.relT < 0 ? 0 : clamp(1 - since / 1.2, 0, 1));
const shown = land;
const hit = shown >= THRESH;
ghost.style.transform = 'translateX(' + (clamp(shown, 0, 1.05) * travelCqw()).toFixed(3) + 'cqw)';
ghost.style.opacity = (keep * (shown > 0.05 ? 1 : 0)).toFixed(3);
ghost.classList.toggle(cls('is-hit'), hit);
const txt = shown <= 0.05 ? '' : (hit ? '예상 도착 {n}% — 임계를 넘어 끝까지 간다' : '예상 도착 {n}% — 임계에 못 미쳐 되돌아간다');
note.textContent = txt.replace('{n}', String(Math.round(shown * 100)));
note.style.opacity = txt ? '1' : '0';
}
function statusText(): string {
return '위치 {n}% · 속도 {v}'.replace('{n}', String(Math.round(sl.p * 100))).replace('{v}', (Math.max(sl.vEma, 0)).toFixed(1));
}
function finalText(): string { return '잠금이 해제되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'밀어서 잠금해제 트랙에서 놓은 위치에 속도를 더한 예상 도착점이 점선 잔상으로 보이고 임계를 넘으면 남은 거리를 미끄러져 완료되며 느리게 놓으면 같은 자리에서도 되돌아간다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'진행도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 · 짧게 놓아도 속도를 더해 끝까지 보내는 플릭 판정 — 측정 누적면적 14.492% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 장면 02 는 고무줄이 없다 — 손가락 값을 0 과 1 사이로만 자른다
function mapDrag(x) { return clamp(x, 0, 1); }
// 예상 도착점 — 위치 + 속도 × FLICK_T. 뒤로 가는 속도는 보태지 않는다
function landing(p, v) { return p + Math.max(v, 0) * FLICK_T; }
function verdict(p, v) { return landing(p, v) >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 빠르게 훑다 짧게 놓고, 홀수 바퀴는 같은 자리까지 천천히 끌고 가 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 0.6, x: ramp(t, [[0.2, 0], [0.6, 0.55]]) };
}
return { down: t >= 0.2 && t < 1.6, x: ramp(t, [[0.2, 0], [1.6, 0.55]]) };
}
function tick(h) {
void h;
if (sl.down) {
land = landing(sl.p, sl.vEma);
}
}
function draw() {
placeHandle(main, sl.p);
// 점선 잔상 — 누르는 동안은 지금 속도로 잰 예상 도착점, 놓은 뒤에는 그 자리에 남았다가 1.2초에 걸쳐 옅어진다
const since = sl.relT < 0 ? 0 : sceneT - sl.relT;
const keep = sl.down ? 1 : (sl.relT < 0 ? 0 : clamp(1 - since / 1.2, 0, 1));
const shown = land;
const hit = shown >= THRESH;
ghost.style.transform = 'translateX(' + (clamp(shown, 0, 1.05) * travelCqw()).toFixed(3) + 'cqw)';
ghost.style.opacity = (keep * (shown > 0.05 ? 1 : 0)).toFixed(3);
ghost.classList.toggle(cls('is-hit'), hit);
const txt = shown <= 0.05 ? '' : (hit ? '예상 도착 {n}% — 임계를 넘어 끝까지 간다' : '예상 도착 {n}% — 임계에 못 미쳐 되돌아간다');
note.textContent = txt.replace('{n}', String(Math.round(shown * 100)));
note.style.opacity = txt ? '1' : '0';
}
03못 미치면 오버슈트를 남기며 돌아오는 스프링 복귀
임계 0.80 에 못 미친 채 놓으면 v += (−SPR_K·p − SPR_C·v)·h 로 감쇠 스프링이 손잡이를 처음으로 돌려보내고, 손잡이는 출발점을 넘었다 되돌아온다. 시연에서 0.525 에 놓은 손잡이는 출발점을 두 번 지나며 최저 −0.117 까지 내려갔다가, 놓은 뒤 1.17초에 처음 자리에 멈춘다. 실수로 건드려도 안전하게 되돌아가야 하는 잠금 화면에 쓰며, 아래 대비판은 놓은 자리에 채움이 어정쩡하게 남는다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="밀어서 잠금해제 트랙에서 임계에 못 미치게 놓으면 손잡이가 감쇠 스프링으로 시작 위치를 한두 번 넘었다 돌아오고 채움 막대도 따라 줄며, 아래 작은 트랙은 되돌리지 않아 어정쩡한 채움이 남는 잘못된 방식을 보여 준다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">진행도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
// 못 미치게 놓았을 때 시작 위치로 돌아오는 감쇠 스프링 — 세기(K)와 감쇠(C)
const SPR_K = 110;
const SPR_C = 9;
const FIN_K = 160;
const FIN_C = 20;
const HANDLE_LABEL = '잠금 해제 손잡이 — 임계 전에 놓으면 스프링으로 돌아오는 슬라이더';
let main;
let mini;
let note;
let badP = 0;
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 반쯤에서 놓아 스프링으로 돌아오게 하고, 홀수 바퀴는 임계를 넘겨 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 0.95, x: ramp(t, [[0.2, 0], [0.95, 0.55]]) };
}
return { down: t >= 0.2 && t < 1.2, x: ramp(t, [[0.2, 0], [1.2, 0.92]]) };
}
function build() {
scene.textContent = '';
capAt(scene, '못 미치게 놓으면 스프링으로 돌아온다', 11, '');
main = makeLin(scene, 'sl--spring', '밀어서 잠금 해제', 21, 1);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '되돌리지 않는 슬라이더 — 어정쩡한 채움이 남는다', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
note = capAt(scene, '', 56, 'cap--note');
}
function reset() { badP = 0; }
function tick(h) {
void h;
// 나쁜 쪽은 누르는 동안만 따라가고 놓으면 그 자리에 그대로 선다
if (sl.down) {
badP = clamp(sl.p, 0, 1);
}
}
function draw() {
placeHandle(main, sl.p);
placeHandle(mini, badP);
// 스프링이 출발점을 넘어 간 만큼(진행도 0 미만)을 글자로 보여 준다
const under = Math.max(0, -sl.p);
const txt = under > 0.004 ? '출발점을 {n}% 넘어갔다 돌아온다' : '';
note.textContent = txt.replace('{n}', String(Math.round(under * 100)));
note.style.opacity = txt ? '1' : '0';
}
function statusText() {
if (sl.mode === M_BACK) {
return '돌아오는 중 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText() { return '잠금이 해제되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SpringReturnOvershoot - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SpringReturnOvershoot.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SpringReturnOvershoot() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
// 못 미치게 놓았을 때 시작 위치로 돌아오는 감쇠 스프링 — 세기(K)와 감쇠(C)
const SPR_K = 110;
const SPR_C = 9;
const FIN_K = 160;
const FIN_C = 20;
const HANDLE_LABEL = '잠금 해제 손잡이 — 임계 전에 놓으면 스프링으로 돌아오는 슬라이더';
let main: Lin;
let mini: Lin;
let note: HTMLElement;
let badP = 0;
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 반쯤에서 놓아 스프링으로 돌아오게 하고, 홀수 바퀴는 임계를 넘겨 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 0.95, x: ramp(t, [[0.2, 0], [0.95, 0.55]]) };
}
return { down: t >= 0.2 && t < 1.2, x: ramp(t, [[0.2, 0], [1.2, 0.92]]) };
}
function build(): void {
scene.textContent = '';
capAt(scene, '못 미치게 놓으면 스프링으로 돌아온다', 11, '');
main = makeLin(scene, 'sl--spring', '밀어서 잠금 해제', 21, 1);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '되돌리지 않는 슬라이더 — 어정쩡한 채움이 남는다', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
note = capAt(scene, '', 56, 'cap--note');
}
function reset(): void { badP = 0; }
function tick(h: number): void {
void h;
// 나쁜 쪽은 누르는 동안만 따라가고 놓으면 그 자리에 그대로 선다
if (sl.down) { badP = clamp(sl.p, 0, 1); }
}
function draw(): void {
placeHandle(main, sl.p);
placeHandle(mini, badP);
// 스프링이 출발점을 넘어 간 만큼(진행도 0 미만)을 글자로 보여 준다
const under = Math.max(0, -sl.p);
const txt = under > 0.004 ? '출발점을 {n}% 넘어갔다 돌아온다' : '';
note.textContent = txt.replace('{n}', String(Math.round(under * 100)));
note.style.opacity = txt ? '1' : '0';
}
function statusText(): string {
if (sl.mode === M_BACK) { return '돌아오는 중 {n}%'.replace('{n}', String(Math.round(sl.p * 100))); }
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText(): string { return '잠금이 해제되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'밀어서 잠금해제 트랙에서 임계에 못 미치게 놓으면 손잡이가 감쇠 스프링으로 시작 위치를 한두 번 넘었다 돌아오고 채움 막대도 따라 줄며, 아래 작은 트랙은 되돌리지 않아 어정쩡한 채움이 남는 잘못된 방식을 보여 준다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'진행도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}03 · 못 미치면 오버슈트를 남기며 돌아오는 스프링 복귀 — 측정 누적면적 12.287% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
function draw() {
placeHandle(main, sl.p);
placeHandle(mini, badP);
// 스프링이 출발점을 넘어 간 만큼(진행도 0 미만)을 글자로 보여 준다
const under = Math.max(0, -sl.p);
const txt = under > 0.004 ? '출발점을 {n}% 넘어갔다 돌아온다' : '';
note.textContent = txt.replace('{n}', String(Math.round(under * 100)));
note.style.opacity = txt ? '1' : '0';
}
04임계를 넘으면 남은 길을 스스로 가고 체크로 바뀌는 자동 완료
누르는 동안에도 진행도가 THRESH 0.6 에 닿으면 autoCross 가 M_FIN 으로 넘겨, 손을 떼지 않아도 남은 길을 스스로 간다. 시연에서는 1.03초에 0.60 에 닿아 자동 완료로 들어가 1.82초에 완료 상태가 되고, 그때부터 화살표가 체크로 바뀌며 둘레 링이 한 번 퍼지고 트랙이 옅어진다. 끝까지 쥐고 있게 하고 싶지 않은 확인 단계의 완료 피드백에 맞는다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="밀어서 잠금해제 트랙에서 진행도가 임계 비율을 넘는 순간 손을 떼지 않아도 손잡이가 끝까지 가고 화살표가 체크로 바뀌며 둘레로 링이 한 번 퍼지고 트랙이 옅어진다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">진행도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 임계 — 누르고 있는 중에도 진행도가 이 값에 닿으면 놓지 않아도 끝까지 간다
const THRESH = 0.6;
const SPR_K = 120;
const SPR_C = 14;
// 자동 완료 이징 — 감쇠비 1 에 가까운 스프링이라 넘치지 않고 끝에 닿는다
const FIN_K = 90;
const FIN_C = 19;
// 완료 뒤 체크가 켜지는 시간, 둘레 링이 퍼지는 시간, 트랙이 옅어지기 시작하는 시각(모두 초)
const CHECK_S = 0.3;
const RING_S = 0.7;
const FADE_AT_S = 0.5;
const HANDLE_LABEL = '잠금 해제 손잡이 — 임계를 넘으면 스스로 끝까지 가는 슬라이더';
let main;
let check;
let pulse;
let rule;
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 임계를 지나 계속 끌고(임계에서 자동 완료), 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.6, x: ramp(t, [[0.2, 0], [1.2, 0.75]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
function build() {
autoCross = true;
scene.textContent = '';
capAt(scene, '임계를 넘으면 남은 길을 스스로 간다', 11, '');
main = makeLin(scene, 'sl--auto', '밀어서 잠금 해제', 21, 1);
rule = mk('div', 'sl__mark', main.bg);
rule.style.left = xOf(THRESH).toFixed(2) + 'cqw';
pulse = mk('div', 'pulse', main.track);
pulse.style.left = PAD_CQW + 'cqw';
pulse.style.top = PAD_CQW + 'cqw';
pulse.style.width = HANDLE_CQW + 'cqw';
pulse.style.height = HANDLE_CQW + 'cqw';
main.track.insertBefore(pulse, main.handle);
check = icon('check-circle', main.handle);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '임계 {n}% 를 넘기는 순간 손을 떼지 않아도 완료'.replace('{n}', String(Math.round(THRESH * 100))), 42, 'cap--note');
}
function reset() { main.label.textContent = '밀어서 잠금 해제'; }
function tick(h) { void h; }
function draw() {
placeHandle(main, sl.p);
const t = sl.mode === M_DONE ? sl.doneT : -1;
const u = t < 0 ? 0 : clamp(t / CHECK_S, 0, 1);
const arrow = main.ico;
arrow.style.opacity = (1 - u).toFixed(3);
check.style.opacity = u.toFixed(3);
check.style.transform = 'scale(' + (0.6 + 0.4 * easeOut3(u)).toFixed(3) + ')';
// 둘레 링 — 손잡이 뒤에서 한 번 커지며 옅어진다
const r = t < 0 ? 0 : clamp(t / RING_S, 0, 1);
pulse.style.transform = 'translateX(' + (travelCqw()).toFixed(3) + 'cqw) scale(' + (1 + 1.3 * easeOut3(r)).toFixed(3) + ')';
pulse.style.opacity = t < 0 ? '0' : (0.7 * (1 - r)).toFixed(3);
// 완료 뒤에는 트랙 전체가 옅어진다
const f = t < 0 ? 0 : easeInOut((t - FADE_AT_S) / 0.6);
main.bg.style.opacity = (1 - 0.45 * f).toFixed(3);
const txt = t >= 0 ? '잠금 해제됨' : '밀어서 잠금 해제';
if (main.label.textContent !== txt) {
main.label.textContent = txt;
}
}
function statusText() {
if (sl.mode === M_FIN || sl.mode === M_DONE) {
return '임계를 넘어 자동 완료 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText() { return '잠금이 해제되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ThresholdAutoFinish - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ThresholdAutoFinish.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ThresholdAutoFinish() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 임계 — 누르고 있는 중에도 진행도가 이 값에 닿으면 놓지 않아도 끝까지 간다
const THRESH = 0.6;
const SPR_K = 120;
const SPR_C = 14;
// 자동 완료 이징 — 감쇠비 1 에 가까운 스프링이라 넘치지 않고 끝에 닿는다
const FIN_K = 90;
const FIN_C = 19;
// 완료 뒤 체크가 켜지는 시간, 둘레 링이 퍼지는 시간, 트랙이 옅어지기 시작하는 시각(모두 초)
const CHECK_S = 0.3;
const RING_S = 0.7;
const FADE_AT_S = 0.5;
const HANDLE_LABEL = '잠금 해제 손잡이 — 임계를 넘으면 스스로 끝까지 가는 슬라이더';
let main: Lin;
let check: HTMLElement;
let pulse: HTMLElement;
let rule: HTMLElement;
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 임계를 지나 계속 끌고(임계에서 자동 완료), 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.6, x: ramp(t, [[0.2, 0], [1.2, 0.75]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
function build(): void {
autoCross = true;
scene.textContent = '';
capAt(scene, '임계를 넘으면 남은 길을 스스로 간다', 11, '');
main = makeLin(scene, 'sl--auto', '밀어서 잠금 해제', 21, 1);
rule = mk('div', 'sl__mark', main.bg);
rule.style.left = xOf(THRESH).toFixed(2) + 'cqw';
pulse = mk('div', 'pulse', main.track);
pulse.style.left = PAD_CQW + 'cqw';
pulse.style.top = PAD_CQW + 'cqw';
pulse.style.width = HANDLE_CQW + 'cqw';
pulse.style.height = HANDLE_CQW + 'cqw';
main.track.insertBefore(pulse, main.handle);
check = icon('check-circle', main.handle);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '임계 {n}% 를 넘기는 순간 손을 떼지 않아도 완료'.replace('{n}', String(Math.round(THRESH * 100))), 42, 'cap--note');
}
function reset(): void { main.label.textContent = '밀어서 잠금 해제'; }
function tick(h: number): void { void h; }
function draw(): void {
placeHandle(main, sl.p);
const t = sl.mode === M_DONE ? sl.doneT : -1;
const u = t < 0 ? 0 : clamp(t / CHECK_S, 0, 1);
const arrow = main.ico;
arrow.style.opacity = (1 - u).toFixed(3);
check.style.opacity = u.toFixed(3);
check.style.transform = 'scale(' + (0.6 + 0.4 * easeOut3(u)).toFixed(3) + ')';
// 둘레 링 — 손잡이 뒤에서 한 번 커지며 옅어진다
const r = t < 0 ? 0 : clamp(t / RING_S, 0, 1);
pulse.style.transform = 'translateX(' + (travelCqw()).toFixed(3) + 'cqw) scale(' + (1 + 1.3 * easeOut3(r)).toFixed(3) + ')';
pulse.style.opacity = t < 0 ? '0' : (0.7 * (1 - r)).toFixed(3);
// 완료 뒤에는 트랙 전체가 옅어진다
const f = t < 0 ? 0 : easeInOut((t - FADE_AT_S) / 0.6);
main.bg.style.opacity = (1 - 0.45 * f).toFixed(3);
const txt = t >= 0 ? '잠금 해제됨' : '밀어서 잠금 해제';
if (main.label.textContent !== txt) { main.label.textContent = txt; }
}
function statusText(): string {
if (sl.mode === M_FIN || sl.mode === M_DONE) { return '임계를 넘어 자동 완료 {n}%'.replace('{n}', String(Math.round(sl.p * 100))); }
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText(): string { return '잠금이 해제되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'밀어서 잠금해제 트랙에서 진행도가 임계 비율을 넘는 순간 손을 떼지 않아도 손잡이가 끝까지 가고 화살표가 체크로 바뀌며 둘레로 링이 한 번 퍼지고 트랙이 옅어진다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'진행도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}04 · 임계를 넘으면 남은 길을 스스로 가고 체크로 바뀌는 자동 완료 — 측정 누적면적 18.602% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
function draw() {
placeHandle(main, sl.p);
const t = sl.mode === M_DONE ? sl.doneT : -1;
const u = t < 0 ? 0 : clamp(t / CHECK_S, 0, 1);
const arrow = main.ico;
arrow.style.opacity = (1 - u).toFixed(3);
check.style.opacity = u.toFixed(3);
check.style.transform = 'scale(' + (0.6 + 0.4 * easeOut3(u)).toFixed(3) + ')';
// 둘레 링 — 손잡이 뒤에서 한 번 커지며 옅어진다
const r = t < 0 ? 0 : clamp(t / RING_S, 0, 1);
pulse.style.transform = 'translateX(' + (travelCqw()).toFixed(3) + 'cqw) scale(' + (1 + 1.3 * easeOut3(r)).toFixed(3) + ')';
pulse.style.opacity = t < 0 ? '0' : (0.7 * (1 - r)).toFixed(3);
// 완료 뒤에는 트랙 전체가 옅어진다
const f = t < 0 ? 0 : easeInOut((t - FADE_AT_S) / 0.6);
main.bg.style.opacity = (1 - 0.45 * f).toFixed(3);
const txt = t >= 0 ? '잠금 해제됨' : '밀어서 잠금 해제';
if (main.label.textContent !== txt) {
main.label.textContent = txt;
}
}
05손잡이가 지나간 글자를 흐리게 지우고 광택이 훑는 안내 라벨
대기 중에는 광택 띠가 SHIM_PERIOD 1.6초마다 글자를 좌에서 우로 훑고, 손잡이가 지나간 글자는 (손잡이 오른쪽 끝 − 글자 중심)/FADE_W 만큼 투명해진다. 글자 9개의 가운데 위치는 measure() 가 getBoundingClientRect 로 한 번 재 두고 폭이 바뀔 때만 다시 재며, 손을 대면 idle = 1 − p/0.12 로 광택이 꺼진다. 버튼 안내 문구를 눈에 띄게 하면서 지나간 말은 지우고 싶을 때 쓴다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="밀어서 잠금해제 트랙의 안내 글자를 밝은 띠가 왼쪽에서 오른쪽으로 훑고 손잡이가 움직이면 지나간 글자부터 진행도에 비례해 투명해진다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">진행도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 광택 띠 — 한 번 훑는 시간(초), 띠의 폭(글줄 길이에 대한 비율), 지나간 글자가 투명해지는 폭(cqw)
const SHIM_PERIOD = 1.6;
const SHIM_W = 0.3;
const FADE_W = 12;
// 글자가 가만히 있을 때의 불투명도 — 광택이 닿으면 1 까지 올라간다
const BASE_ALPHA = 0.8;
const LABEL_TEXT = '밀어서 잠금 해제';
const HANDLE_LABEL = '잠금 해제 손잡이 — 지나간 글자가 흐려지는 슬라이더';
let main;
let sheen;
let chars = [];
// 글자마다 트랙 안 가운데 위치(cqw)와 글줄의 왼쪽 끝·길이(cqw) — 처음 그릴 때 한 번, 폭이 바뀌면 다시 잰다
let charX = [];
let lineL = 0;
let lineW = 1;
let measuredW = 0;
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 광택 띠가 훑는 모습을 보여 준 뒤 0.35초에 누르고, 짝수 바퀴는 끝까지, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.35 && t < 1.6, x: ramp(t, [[0.35, 0], [0.75, 0.12], [1.2, 0.75], [1.45, 0.9]]) };
}
return { down: t >= 0.35 && t < 1.4, x: ramp(t, [[0.35, 0], [1.4, 0.5]]) };
}
function build() {
scene.textContent = '';
capAt(scene, '광택이 훑고, 지나간 글자는 흐려진다', 11, '');
main = makeLin(scene, 'sl--shimmer', '', 21, 1);
chars = [];
for (const ch of LABEL_TEXT) {
const s = mk('span', 'ch', main.label);
s.textContent = ch;
chars.push(s);
}
sheen = mk('div', 'sheen', main.bg);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '훑는 속도 {s}초 · 사라지는 폭 {w}cqw'.replace('{s}', String(SHIM_PERIOD)).replace('{w}', String(FADE_W)), 42, 'cap--note');
}
function measure() {
const w = rootW();
if (w === measuredW) {
return;
}
measuredW = w;
const tr = main.track.getBoundingClientRect();
const k = 100 / w;
charX = [];
for (let i = 0; i < chars.length; i += 1) {
const r = chars[i].getBoundingClientRect();
charX.push((r.left + r.width / 2 - tr.left) * k);
}
const first = chars[0].getBoundingClientRect();
const last = chars[chars.length - 1].getBoundingClientRect();
lineL = (first.left - tr.left) * k;
lineW = Math.max(1, (last.right - first.left) * k);
}
function reset() { measuredW = 0; }
function tick(h) { void h; }
function draw() {
measure();
placeHandle(main, sl.p);
const xr = PAD_CQW + sl.p * travelCqw() + HANDLE_CQW;
// 광택 띠의 중심(글줄 길이에 대한 비율) — 한 번 훑고 쉬지 않고 이어진다. 손을 대면 서서히 꺼진다
const idle = 1 - clamp(sl.p / 0.12, 0, 1);
const s = ((sceneT / SHIM_PERIOD) % 1) * (1 + 2 * SHIM_W) - SHIM_W;
for (let i = 0; i < chars.length; i += 1) {
const f = (charX[i] - lineL) / lineW;
const gain = Math.max(0, 1 - Math.abs(f - s) / SHIM_W) * idle;
const gone = clamp((xr - charX[i]) / FADE_W, 0, 1);
chars[i].style.opacity = ((BASE_ALPHA + (1 - BASE_ALPHA) * gain) * (1 - gone)).toFixed(3);
chars[i].style.transform = 'scale(' + (1 + 0.1 * gain).toFixed(3) + ')';
}
const sx = lineL + s * lineW - 10;
sheen.style.transform = 'translateX(' + sx.toFixed(2) + 'cqw) skewX(-20deg)';
sheen.style.opacity = (0.6 * idle).toFixed(3);
}
function statusText() {
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText() { return '잠금이 해제되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ShimmerLabelMask - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ShimmerLabelMask.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ShimmerLabelMask() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 3.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 광택 띠 — 한 번 훑는 시간(초), 띠의 폭(글줄 길이에 대한 비율), 지나간 글자가 투명해지는 폭(cqw)
const SHIM_PERIOD = 1.6;
const SHIM_W = 0.3;
const FADE_W = 12;
// 글자가 가만히 있을 때의 불투명도 — 광택이 닿으면 1 까지 올라간다
const BASE_ALPHA = 0.8;
const LABEL_TEXT = '밀어서 잠금 해제';
const HANDLE_LABEL = '잠금 해제 손잡이 — 지나간 글자가 흐려지는 슬라이더';
let main: Lin;
let sheen: HTMLElement;
let chars: HTMLElement[] = [];
// 글자마다 트랙 안 가운데 위치(cqw)와 글줄의 왼쪽 끝·길이(cqw) — 처음 그릴 때 한 번, 폭이 바뀌면 다시 잰다
let charX: number[] = [];
let lineL = 0;
let lineW = 1;
let measuredW = 0;
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 광택 띠가 훑는 모습을 보여 준 뒤 0.35초에 누르고, 짝수 바퀴는 끝까지, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.35 && t < 1.6, x: ramp(t, [[0.35, 0], [0.75, 0.12], [1.2, 0.75], [1.45, 0.9]]) };
}
return { down: t >= 0.35 && t < 1.4, x: ramp(t, [[0.35, 0], [1.4, 0.5]]) };
}
function build(): void {
scene.textContent = '';
capAt(scene, '광택이 훑고, 지나간 글자는 흐려진다', 11, '');
main = makeLin(scene, 'sl--shimmer', '', 21, 1);
chars = [];
for (const ch of LABEL_TEXT) {
const s = mk('span', 'ch', main.label);
s.textContent = ch;
chars.push(s);
}
sheen = mk('div', 'sheen', main.bg);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '훑는 속도 {s}초 · 사라지는 폭 {w}cqw'.replace('{s}', String(SHIM_PERIOD)).replace('{w}', String(FADE_W)), 42, 'cap--note');
}
function measure(): void {
const w = rootW();
if (w === measuredW) { return; }
measuredW = w;
const tr = main.track.getBoundingClientRect();
const k = 100 / w;
charX = [];
for (let i = 0; i < chars.length; i += 1) {
const r = chars[i].getBoundingClientRect();
charX.push((r.left + r.width / 2 - tr.left) * k);
}
const first = chars[0].getBoundingClientRect();
const last = chars[chars.length - 1].getBoundingClientRect();
lineL = (first.left - tr.left) * k;
lineW = Math.max(1, (last.right - first.left) * k);
}
function reset(): void { measuredW = 0; }
function tick(h: number): void { void h; }
function draw(): void {
measure();
placeHandle(main, sl.p);
const xr = PAD_CQW + sl.p * travelCqw() + HANDLE_CQW;
// 광택 띠의 중심(글줄 길이에 대한 비율) — 한 번 훑고 쉬지 않고 이어진다. 손을 대면 서서히 꺼진다
const idle = 1 - clamp(sl.p / 0.12, 0, 1);
const s = ((sceneT / SHIM_PERIOD) % 1) * (1 + 2 * SHIM_W) - SHIM_W;
for (let i = 0; i < chars.length; i += 1) {
const f = (charX[i] - lineL) / lineW;
const gain = Math.max(0, 1 - Math.abs(f - s) / SHIM_W) * idle;
const gone = clamp((xr - charX[i]) / FADE_W, 0, 1);
chars[i].style.opacity = ((BASE_ALPHA + (1 - BASE_ALPHA) * gain) * (1 - gone)).toFixed(3);
chars[i].style.transform = 'scale(' + (1 + 0.1 * gain).toFixed(3) + ')';
}
const sx = lineL + s * lineW - 10;
sheen.style.transform = 'translateX(' + sx.toFixed(2) + 'cqw) skewX(-20deg)';
sheen.style.opacity = (0.6 * idle).toFixed(3);
}
function statusText(): string {
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText(): string { return '잠금이 해제되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'밀어서 잠금해제 트랙의 안내 글자를 밝은 띠가 왼쪽에서 오른쪽으로 훑고 손잡이가 움직이면 지나간 글자부터 진행도에 비례해 투명해진다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'진행도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 · 손잡이가 지나간 글자를 흐리게 지우고 광택이 훑는 안내 라벨 — 측정 누적면적 13.636% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function measure() {
const w = rootW();
if (w === measuredW) {
return;
}
measuredW = w;
const tr = main.track.getBoundingClientRect();
const k = 100 / w;
charX = [];
for (let i = 0; i < chars.length; i += 1) {
const r = chars[i].getBoundingClientRect();
charX.push((r.left + r.width / 2 - tr.left) * k);
}
const first = chars[0].getBoundingClientRect();
const last = chars[chars.length - 1].getBoundingClientRect();
lineL = (first.left - tr.left) * k;
lineW = Math.max(1, (last.right - first.left) * k);
}
function draw() {
measure();
placeHandle(main, sl.p);
const xr = PAD_CQW + sl.p * travelCqw() + HANDLE_CQW;
// 광택 띠의 중심(글줄 길이에 대한 비율) — 한 번 훑고 쉬지 않고 이어진다. 손을 대면 서서히 꺼진다
const idle = 1 - clamp(sl.p / 0.12, 0, 1);
const s = ((sceneT / SHIM_PERIOD) % 1) * (1 + 2 * SHIM_W) - SHIM_W;
for (let i = 0; i < chars.length; i += 1) {
const f = (charX[i] - lineL) / lineW;
const gain = Math.max(0, 1 - Math.abs(f - s) / SHIM_W) * idle;
const gone = clamp((xr - charX[i]) / FADE_W, 0, 1);
chars[i].style.opacity = ((BASE_ALPHA + (1 - BASE_ALPHA) * gain) * (1 - gone)).toFixed(3);
chars[i].style.transform = 'scale(' + (1 + 0.1 * gain).toFixed(3) + ')';
}
const sx = lineL + s * lineW - 10;
sheen.style.transform = 'translateX(' + sx.toFixed(2) + 'cqw) skewX(-20deg)';
sheen.style.opacity = (0.6 * idle).toFixed(3);
}
06밀수록 색과 금액 문구가 단계로 바뀌는 결제 확인 슬라이더
진행도가 STAGE_1 0.33 과 STAGE_2 0.7 을 지날 때 채움 색이 라일락에서 하늘색, 박하색으로 바뀌고 금액 문구가 「밀어서 확인」「조금 더」「놓으면 결제」 로 바뀌며, 색은 경계 BLEND_W 0.12 폭에서만 섞인다. 완료 뒤에는 로딩 링이 SPIN_S 0.9초 도는 동안 「결제 중」 이라고 쓰고 그다음 체크로 닫혀 「결제 완료」 가 된다. 송금·주문처럼 실수 결제를 막아야 하는 확인 단계에 쓴다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="결제 확인 슬라이더가 밀수록 채움 색이 옅은 라일락에서 하늘색과 박하색으로 바뀌고 금액 문구가 밀어서 확인과 조금 더와 놓으면 결제로 바뀌며 완료 뒤 로딩 링이 돌다 체크로 닫히고, 아래 작은 트랙은 손가락이 트랙 밖으로 나가 끌기가 끊기는 잘못된 방식을 보여 준다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">단계 1/3 · 진행도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 4.0;
const REST_T = 3.5;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 22;
// 단계 경계 — 진행도가 이 값을 지나면 채움 색과 안내 문구가 바뀐다
const STAGE_1 = 0.33;
const STAGE_2 = 0.7;
// 단계 사이 색이 섞이는 폭(진행도)
const BLEND_W = 0.12;
// 완료 뒤 로딩 링이 도는 시간(초), 링이 한 바퀴 도는 시간(초)
const SPIN_S = 0.9;
const SPIN_TURN_S = 0.8;
// 나쁜 쪽 — 손가락이 이 위치를 지나면 트랙 밖으로 나간 것으로 보고 끌기가 끊긴다
const LEAVE_X = 0.55;
const COLORS = [[185, 160, 255], [110, 200, 255], [76, 212, 166]];
const AMOUNT = '12,800원';
const HANDLE_LABEL = '결제 확인 손잡이 — 오른쪽으로 밀어 확인';
let main;
let mini;
let spinner;
let check;
let badP = 0;
let lost = false;
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝까지 밀어 결제 확인, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.35, x: ramp(t, [[0.2, 0], [1.2, 1]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
// 진행도 → 채움 색 — 세 단계 색을 경계 BLEND_W 폭에서만 섞는다
function stageColor(p) {
const a = easeInOut((p - STAGE_1 + BLEND_W / 2) / BLEND_W);
const b = easeInOut((p - STAGE_2 + BLEND_W / 2) / BLEND_W);
const c = [0, 0, 0];
for (let i = 0; i < 3; i += 1) {
const m1 = lerp(COLORS[0][i], COLORS[1][i], a);
c[i] = Math.round(lerp(m1, COLORS[2][i], b));
}
return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
}
function stageText(p) {
if (p >= STAGE_2) {
return '{a} 놓으면 결제'.replace('{a}', AMOUNT);
}
if (p >= STAGE_1) {
return '{a} 조금 더'.replace('{a}', AMOUNT);
}
return '{a} 밀어서 확인'.replace('{a}', AMOUNT);
}
function build() {
scene.textContent = '';
capAt(scene, '밀수록 색과 안내 문구가 단계로 바뀐다', 11, '');
main = makeLin(scene, 'sl--stages', stageText(0), 21, 1);
spinner = mk('div', 'spin', main.handle);
check = icon('check-circle', main.handle);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '손가락이 트랙 밖으로 나가면 끌기가 끊긴다', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
}
function reset() { badP = 0; lost = false; }
function tick(h) {
void h;
if (sl.down && !lost) {
if (sl.fx >= LEAVE_X) {
lost = true;
}
else {
badP = clamp(sl.fx, 0, 1);
}
}
}
function draw() {
placeHandle(main, sl.p);
placeHandle(mini, badP);
const p = clamp(sl.p, 0, 1);
const t = sl.mode === M_DONE ? sl.doneT : -1;
main.fill.style.background = stageColor(p);
// 완료 직후에는 로딩 링이 돌고, 다 돌면 체크로 닫힌다
const spin = t >= 0 && t < SPIN_S;
const ok = t >= SPIN_S;
spinner.style.opacity = spin ? '1' : '0';
spinner.style.transform = 'rotate(' + ((t / SPIN_TURN_S) * 360).toFixed(1) + 'deg)';
main.ico.style.opacity = t >= 0 ? '0' : '1';
check.style.opacity = ok ? '1' : '0';
check.style.transform = 'scale(' + (ok ? 0.6 + 0.4 * easeOut3((t - SPIN_S) / 0.25) : 0.6).toFixed(3) + ')';
const txt = ok ? '결제 완료' : (spin ? '결제 중' : stageText(p));
if (main.label.textContent !== txt) {
main.label.textContent = txt;
}
}
function statusText() {
const p = clamp(sl.p, 0, 1);
const st = p >= STAGE_2 ? 3 : (p >= STAGE_1 ? 2 : 1);
return '단계 {s}/3 · 진행도 {n}%'.replace('{s}', String(st)).replace('{n}', String(Math.round(p * 100)));
}
function finalText() { return '결제가 확인되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ConfirmPaymentStages - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ConfirmPaymentStages.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ConfirmPaymentStages() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 4.0;
const REST_T = 3.5;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 22;
// 단계 경계 — 진행도가 이 값을 지나면 채움 색과 안내 문구가 바뀐다
const STAGE_1 = 0.33;
const STAGE_2 = 0.7;
// 단계 사이 색이 섞이는 폭(진행도)
const BLEND_W = 0.12;
// 완료 뒤 로딩 링이 도는 시간(초), 링이 한 바퀴 도는 시간(초)
const SPIN_S = 0.9;
const SPIN_TURN_S = 0.8;
// 나쁜 쪽 — 손가락이 이 위치를 지나면 트랙 밖으로 나간 것으로 보고 끌기가 끊긴다
const LEAVE_X = 0.55;
const COLORS: number[][] = [[185, 160, 255], [110, 200, 255], [76, 212, 166]];
const AMOUNT = '12,800원';
const HANDLE_LABEL = '결제 확인 손잡이 — 오른쪽으로 밀어 확인';
let main: Lin;
let mini: Lin;
let spinner: HTMLElement;
let check: HTMLElement;
let badP = 0;
let lost = false;
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝까지 밀어 결제 확인, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.35, x: ramp(t, [[0.2, 0], [1.2, 1]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
// 진행도 → 채움 색 — 세 단계 색을 경계 BLEND_W 폭에서만 섞는다
function stageColor(p: number): string {
const a = easeInOut((p - STAGE_1 + BLEND_W / 2) / BLEND_W);
const b = easeInOut((p - STAGE_2 + BLEND_W / 2) / BLEND_W);
const c: number[] = [0, 0, 0];
for (let i = 0; i < 3; i += 1) {
const m1 = lerp(COLORS[0][i], COLORS[1][i], a);
c[i] = Math.round(lerp(m1, COLORS[2][i], b));
}
return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
}
function stageText(p: number): string {
if (p >= STAGE_2) { return '{a} 놓으면 결제'.replace('{a}', AMOUNT); }
if (p >= STAGE_1) { return '{a} 조금 더'.replace('{a}', AMOUNT); }
return '{a} 밀어서 확인'.replace('{a}', AMOUNT);
}
function build(): void {
scene.textContent = '';
capAt(scene, '밀수록 색과 안내 문구가 단계로 바뀐다', 11, '');
main = makeLin(scene, 'sl--stages', stageText(0), 21, 1);
spinner = mk('div', 'spin', main.handle);
check = icon('check-circle', main.handle);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '손가락이 트랙 밖으로 나가면 끌기가 끊긴다', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
}
function reset(): void { badP = 0; lost = false; }
function tick(h: number): void {
void h;
if (sl.down && !lost) {
if (sl.fx >= LEAVE_X) { lost = true; } else { badP = clamp(sl.fx, 0, 1); }
}
}
function draw(): void {
placeHandle(main, sl.p);
placeHandle(mini, badP);
const p = clamp(sl.p, 0, 1);
const t = sl.mode === M_DONE ? sl.doneT : -1;
main.fill.style.background = stageColor(p);
// 완료 직후에는 로딩 링이 돌고, 다 돌면 체크로 닫힌다
const spin = t >= 0 && t < SPIN_S;
const ok = t >= SPIN_S;
spinner.style.opacity = spin ? '1' : '0';
spinner.style.transform = 'rotate(' + ((t / SPIN_TURN_S) * 360).toFixed(1) + 'deg)';
main.ico.style.opacity = t >= 0 ? '0' : '1';
check.style.opacity = ok ? '1' : '0';
check.style.transform = 'scale(' + (ok ? 0.6 + 0.4 * easeOut3((t - SPIN_S) / 0.25) : 0.6).toFixed(3) + ')';
const txt = ok ? '결제 완료' : (spin ? '결제 중' : stageText(p));
if (main.label.textContent !== txt) { main.label.textContent = txt; }
}
function statusText(): string {
const p = clamp(sl.p, 0, 1);
const st = p >= STAGE_2 ? 3 : (p >= STAGE_1 ? 2 : 1);
return '단계 {s}/3 · 진행도 {n}%'.replace('{s}', String(st)).replace('{n}', String(Math.round(p * 100)));
}
function finalText(): string { return '결제가 확인되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'결제 확인 슬라이더가 밀수록 채움 색이 옅은 라일락에서 하늘색과 박하색으로 바뀌고 금액 문구가 밀어서 확인과 조금 더와 놓으면 결제로 바뀌며 완료 뒤 로딩 링이 돌다 체크로 닫히고, 아래 작은 트랙은 손가락이 트랙 밖으로 나가 끌기가 끊기는 잘못된 방식을 보여 준다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'단계 1/3 · 진행도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 · 밀수록 색과 금액 문구가 단계로 바뀌는 결제 확인 슬라이더 — 측정 누적면적 15.686% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 진행도 → 채움 색 — 세 단계 색을 경계 BLEND_W 폭에서만 섞는다
function stageColor(p) {
const a = easeInOut((p - STAGE_1 + BLEND_W / 2) / BLEND_W);
const b = easeInOut((p - STAGE_2 + BLEND_W / 2) / BLEND_W);
const c = [0, 0, 0];
for (let i = 0; i < 3; i += 1) {
const m1 = lerp(COLORS[0][i], COLORS[1][i], a);
c[i] = Math.round(lerp(m1, COLORS[2][i], b));
}
return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
}
function stageText(p) {
if (p >= STAGE_2) {
return '{a} 놓으면 결제'.replace('{a}', AMOUNT);
}
if (p >= STAGE_1) {
return '{a} 조금 더'.replace('{a}', AMOUNT);
}
return '{a} 밀어서 확인'.replace('{a}', AMOUNT);
}
function draw() {
placeHandle(main, sl.p);
placeHandle(mini, badP);
const p = clamp(sl.p, 0, 1);
const t = sl.mode === M_DONE ? sl.doneT : -1;
main.fill.style.background = stageColor(p);
// 완료 직후에는 로딩 링이 돌고, 다 돌면 체크로 닫힌다
const spin = t >= 0 && t < SPIN_S;
const ok = t >= SPIN_S;
spinner.style.opacity = spin ? '1' : '0';
spinner.style.transform = 'rotate(' + ((t / SPIN_TURN_S) * 360).toFixed(1) + 'deg)';
main.ico.style.opacity = t >= 0 ? '0' : '1';
check.style.opacity = ok ? '1' : '0';
check.style.transform = 'scale(' + (ok ? 0.6 + 0.4 * easeOut3((t - SPIN_S) / 0.25) : 0.6).toFixed(3) + ')';
const txt = ok ? '결제 완료' : (spin ? '결제 중' : stageText(p));
if (main.label.textContent !== txt) {
main.label.textContent = txt;
}
}
07가만히 두면 쉐브론이 물결로 일렁이며 방향을 알리는 힌트
손이 닿지 않는 동안 쉐브론 셋이 HINT_PHASE 0.16 의 위상 차로 차례로 밝아지고, 손잡이는 HINT_AMP·sin² 로 0.14 만큼 앞으로 나갔다 돌아오는 일렁임을 HINT_P 1.2초마다 되풀이한다. 누르면 mode 가 바뀌어 힌트가 즉시 꺼지고, 놓은 뒤에도 HINT_RESUME_S 0.6초가 지나야 다시 켜진다. 처음 쓰는 사람에게 밀 방향을 말없이 알려야 하는 온보딩에 쓴다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="밀어서 잠금해제 트랙을 가만히 두면 쉐브론 세 개가 차례로 밝아지며 물결치고 손잡이가 조금 앞으로 나갔다 돌아오며 방향을 알리다가 손이 닿으면 힌트가 바로 꺼진다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">진행도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 4.0;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 힌트 — 한 번 일렁이는 시간(초), 쉐브론 사이 위상 차(주기에 대한 비율), 손잡이가 앞으로 나갔다 오는 최대 진행도
const HINT_P = 1.2;
const HINT_PHASE = 0.16;
const HINT_AMP = 0.14;
// 손을 뗀 뒤 힌트가 다시 켜지기까지(초)
const HINT_RESUME_S = 0.6;
const CHEV_N = 3;
const HANDLE_LABEL = '잠금 해제 손잡이 — 가만히 두면 방향을 알려 주는 슬라이더';
let main;
let chevs = [];
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 힌트가 일렁이는 모습을 보여 준 뒤 1초에 누르고, 짝수 바퀴는 끝까지, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 1.0 && t < 1.8, x: ramp(t, [[1.0, 0], [1.7, 0.92]]) };
}
return { down: t >= 1.0 && t < 1.7, x: ramp(t, [[1.0, 0], [1.7, 0.5]]) };
}
function build() {
scene.textContent = '';
capAt(scene, '가만히 두면 쉐브론이 물결로 방향을 알린다', 11, '');
main = makeLin(scene, 'sl--hint', '밀어서 잠금 해제', 21, 1);
chevs = [];
for (let i = 0; i < CHEV_N; i += 1) {
const c = icon('caret-right', main.bg);
c.style.left = (HANDLE_CQW + 2 * PAD_CQW + 3 + i * 5.5) + 'cqw';
chevs.push(c);
}
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '손이 닿는 순간 힌트는 즉시 꺼진다', 42, 'cap--note');
}
function reset() { void 0; }
function tick(h) { void h; }
// 힌트가 켜진 상태 — 손잡이가 처음 자리에 있고 누르지도 놓은 직후도 아닐 때
function hintOn() {
return sl.mode === M_IDLE && sl.p < 0.001 && (sl.relT < 0 || sceneT - sl.relT > HINT_RESUME_S);
}
function draw() {
const on = hintOn();
const ph = (sceneT / HINT_P) % 1;
// 손잡이는 사인 곡선(sin 제곱)으로 조금 앞으로 나갔다 돌아온다
const hint = on ? HINT_AMP * Math.pow(Math.sin(Math.PI * ph), 2) : 0;
placeHandle(main, sl.p + hint);
for (let i = 0; i < chevs.length; i += 1) {
const w = Math.sin(2 * Math.PI * (sceneT / HINT_P - i * HINT_PHASE));
const a = on ? 0.22 + 0.78 * Math.pow(Math.max(0, w), 2) : 0.22;
chevs[i].style.opacity = a.toFixed(3);
}
}
function statusText() {
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText() { return '잠금이 해제되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HintChevronWave - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HintChevronWave.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HintChevronWave() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 4.0;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 힌트 — 한 번 일렁이는 시간(초), 쉐브론 사이 위상 차(주기에 대한 비율), 손잡이가 앞으로 나갔다 오는 최대 진행도
const HINT_P = 1.2;
const HINT_PHASE = 0.16;
const HINT_AMP = 0.14;
// 손을 뗀 뒤 힌트가 다시 켜지기까지(초)
const HINT_RESUME_S = 0.6;
const CHEV_N = 3;
const HANDLE_LABEL = '잠금 해제 손잡이 — 가만히 두면 방향을 알려 주는 슬라이더';
let main: Lin;
let chevs: HTMLElement[] = [];
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 힌트가 일렁이는 모습을 보여 준 뒤 1초에 누르고, 짝수 바퀴는 끝까지, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 1.0 && t < 1.8, x: ramp(t, [[1.0, 0], [1.7, 0.92]]) };
}
return { down: t >= 1.0 && t < 1.7, x: ramp(t, [[1.0, 0], [1.7, 0.5]]) };
}
function build(): void {
scene.textContent = '';
capAt(scene, '가만히 두면 쉐브론이 물결로 방향을 알린다', 11, '');
main = makeLin(scene, 'sl--hint', '밀어서 잠금 해제', 21, 1);
chevs = [];
for (let i = 0; i < CHEV_N; i += 1) {
const c = icon('caret-right', main.bg);
c.style.left = (HANDLE_CQW + 2 * PAD_CQW + 3 + i * 5.5) + 'cqw';
chevs.push(c);
}
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '손이 닿는 순간 힌트는 즉시 꺼진다', 42, 'cap--note');
}
function reset(): void { void 0; }
function tick(h: number): void { void h; }
// 힌트가 켜진 상태 — 손잡이가 처음 자리에 있고 누르지도 놓은 직후도 아닐 때
function hintOn(): boolean {
return sl.mode === M_IDLE && sl.p < 0.001 && (sl.relT < 0 || sceneT - sl.relT > HINT_RESUME_S);
}
function draw(): void {
const on = hintOn();
const ph = (sceneT / HINT_P) % 1;
// 손잡이는 사인 곡선(sin 제곱)으로 조금 앞으로 나갔다 돌아온다
const hint = on ? HINT_AMP * Math.pow(Math.sin(Math.PI * ph), 2) : 0;
placeHandle(main, sl.p + hint);
for (let i = 0; i < chevs.length; i += 1) {
const w = Math.sin(2 * Math.PI * (sceneT / HINT_P - i * HINT_PHASE));
const a = on ? 0.22 + 0.78 * Math.pow(Math.max(0, w), 2) : 0.22;
chevs[i].style.opacity = a.toFixed(3);
}
}
function statusText(): string {
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText(): string { return '잠금이 해제되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'밀어서 잠금해제 트랙을 가만히 두면 쉐브론 세 개가 차례로 밝아지며 물결치고 손잡이가 조금 앞으로 나갔다 돌아오며 방향을 알리다가 손이 닿으면 힌트가 바로 꺼진다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'진행도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 · 가만히 두면 쉐브론이 물결로 일렁이며 방향을 알리는 힌트 — 측정 누적면적 12.687% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 힌트가 켜진 상태 — 손잡이가 처음 자리에 있고 누르지도 놓은 직후도 아닐 때
function hintOn() {
return sl.mode === M_IDLE && sl.p < 0.001 && (sl.relT < 0 || sceneT - sl.relT > HINT_RESUME_S);
}
function draw() {
const on = hintOn();
const ph = (sceneT / HINT_P) % 1;
// 손잡이는 사인 곡선(sin 제곱)으로 조금 앞으로 나갔다 돌아온다
const hint = on ? HINT_AMP * Math.pow(Math.sin(Math.PI * ph), 2) : 0;
placeHandle(main, sl.p + hint);
for (let i = 0; i < chevs.length; i += 1) {
const w = Math.sin(2 * Math.PI * (sceneT / HINT_P - i * HINT_PHASE));
const a = on ? 0.22 + 0.78 * Math.pow(Math.max(0, w), 2) : 0.22;
chevs[i].style.opacity = a.toFixed(3);
}
}
08직선이 아니라 호 위를 각도로 재며 도는 전원 슬라이더
손잡이는 arcX·arcY 로 반원 호를 따라 움직이고, 진행도는 angleP 가 atan2 로 잰 중심각에서 1 − 각도/π 로 구한다. 임계는 THRESH_DEG 144° 이고, 넘겨 놓으면 호 전체가 FLASH_TAU 0.18초 감쇠로 한 번 번쩍이며 전원 아이콘이 옅어지고 문구는 「전원 꺼짐」 으로 바뀐다. 전원 끄기·긴급 중지처럼 위험한 조작 화면에 쓴다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="전원 끄기 슬라이더의 손잡이가 반원 호를 따라 움직이며 중심에서의 각도로 진행도를 재고 호 안쪽 채움이 같은 각도만큼 차오르며 임계 각도를 넘기면 호 전체가 한 번 번쩍이고 가운데 전원 아이콘이 꺼진다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">각도 0° (임계 144°)</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
const LOOP_S = 3.8;
const REST_T = 3.0;
// 호 — 무대 100 x 62.5 단위(cqw) 기준 중심, 반지름, 손잡이 지름
const CX = 50;
const CY = 53;
const R = 30;
const HANDLE_CQW = 12.5;
// 임계 각도 — 시작(왼쪽 끝)에서 이만큼 돌아가면 완료. 호는 반원(180도)
const THRESH_DEG = 144;
const THRESH = THRESH_DEG / 180;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 완료 순간 호 전체가 번쩍이는 시간 상수(초)
const FLASH_TAU = 0.18;
const HANDLE_LABEL = '전원 끄기 손잡이 — 호를 따라 오른쪽으로 돌려 끄기';
const SVG_NS = 'http://www.w3.org/2000/svg';
const ARC_LEN = Math.PI * R;
let handle;
let power;
let powerLabel;
let fillArc;
let flashArc;
let arrow;
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 호 위 한 점 — 진행도 p 는 왼쪽 끝(180도)에서 오른쪽 끝(0도)까지, 중심에서의 각도 θ = π(1 − p)
function arcX(p) { return CX + R * Math.cos(Math.PI * (1 - p)); }
function arcY(p) { return CY - R * Math.sin(Math.PI * (1 - p)); }
// 포인터 위치 → 진행도 — 중심에서 본 각도를 재서 극좌표로 바꾼다. 호 아래쪽은 가까운 끝으로 붙인다
function angleP(e) {
const r = scene.getBoundingClientRect();
const ctrX = r.left + r.width * CX / 100;
const ctrY = r.top + r.height * CY / 62.5;
let a = Math.atan2(ctrY - e.clientY, e.clientX - ctrX);
if (a < -Math.PI / 2) {
a = Math.PI;
}
else if (a < 0) {
a = 0;
}
return 1 - a / Math.PI;
}
// 시연 손가락 — 짝수 바퀴는 임계 각도를 넘겨 놓아 끄고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.92]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
function svgPath(parent, name) {
const e = document.createElementNS(SVG_NS, 'path');
e.setAttribute('class', cls(name));
e.setAttribute('d', 'M ' + (CX - R) + ' ' + CY + ' A ' + R + ' ' + R + ' 0 0 1 ' + (CX + R) + ' ' + CY);
parent.appendChild(e);
return e;
}
function build() {
scene.textContent = '';
rawFn = angleP;
scaleFn = function () { return 1; };
capAt(scene, '호 위에서 각도로 진행도를 잰다 (임계 {a}°)'.replace('{a}', String(THRESH_DEG)), 11, '');
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', cls('arc'));
svg.setAttribute('viewBox', '0 0 100 62.5');
svg.setAttribute('aria-hidden', 'true');
scene.appendChild(svg);
svgPath(svg, 'arc__track');
fillArc = svgPath(svg, 'arc__fill');
flashArc = svgPath(svg, 'arc__flash');
const mark = document.createElementNS(SVG_NS, 'line');
const tx = Math.cos(Math.PI * (1 - THRESH));
const ty = Math.sin(Math.PI * (1 - THRESH));
mark.setAttribute('class', cls('arc__mark'));
mark.setAttribute('x1', String(CX + (R - 6.5) * tx));
mark.setAttribute('y1', String(CY - (R - 6.5) * ty));
mark.setAttribute('x2', String(CX + (R + 6.5) * tx));
mark.setAttribute('y2', String(CY - (R + 6.5) * ty));
svg.appendChild(mark);
power = mk('div', 'pw', scene);
icon('power', power);
powerLabel = mk('div', 'pw__label', scene);
handle = mk('div', 'sl__handle sl__handle--arc', scene);
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
arrow = icon('caret-right', handle);
wireHandle(handle, HANDLE_LABEL);
}
function reset() { void 0; }
function tick(h) { void h; }
function draw() {
const p = sl.p;
handle.style.transform = 'translate(' + (arcX(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw,' + (arcY(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw)';
// 손잡이 안 화살표는 호의 접선 방향을 본다 — 왼쪽 끝에서는 위, 꼭대기에서는 오른쪽, 오른쪽 끝에서는 아래
arrow.style.transform = 'rotate(' + ((p - 0.5) * 180).toFixed(1) + 'deg)';
const shown = clamp(p, 0, 1);
fillArc.style.strokeDasharray = (ARC_LEN * shown).toFixed(2) + ' ' + ARC_LEN.toFixed(2);
const off = sl.mode === M_DONE;
const t = off ? sl.doneT : 0;
flashArc.style.opacity = off ? (0.9 * Math.exp(-t / FLASH_TAU)).toFixed(3) : '0';
fillArc.classList.toggle(cls('is-off'), off);
// 가운데 전원 아이콘은 진행도가 반을 넘기면서 옅어지고, 꺼진 뒤에는 가장 옅다
const dim = easeInOut((shown - 0.5) / 0.5);
power.style.opacity = (1 - 0.65 * dim).toFixed(3);
const txt = off ? '전원 꺼짐' : '전원 켜짐';
if (powerLabel.textContent !== txt) {
powerLabel.textContent = txt;
}
}
function statusText() {
return '각도 {a}° (임계 {t}°)'.replace('{a}', String(Math.round(clamp(sl.p, 0, 1) * 180))).replace('{t}', String(THRESH_DEG));
}
function finalText() { return '전원이 꺼졌습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ArcPowerSlider - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ArcPowerSlider.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ArcPowerSlider() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
const LOOP_S = 3.8;
const REST_T = 3.0;
// 호 — 무대 100 x 62.5 단위(cqw) 기준 중심, 반지름, 손잡이 지름
const CX = 50;
const CY = 53;
const R = 30;
const HANDLE_CQW = 12.5;
// 임계 각도 — 시작(왼쪽 끝)에서 이만큼 돌아가면 완료. 호는 반원(180도)
const THRESH_DEG = 144;
const THRESH = THRESH_DEG / 180;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 완료 순간 호 전체가 번쩍이는 시간 상수(초)
const FLASH_TAU = 0.18;
const HANDLE_LABEL = '전원 끄기 손잡이 — 호를 따라 오른쪽으로 돌려 끄기';
const SVG_NS = 'http://www.w3.org/2000/svg';
const ARC_LEN = Math.PI * R;
let handle: HTMLElement;
let power: HTMLElement;
let powerLabel: HTMLElement;
let fillArc: SVGPathElement;
let flashArc: SVGPathElement;
let arrow: HTMLElement;
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 호 위 한 점 — 진행도 p 는 왼쪽 끝(180도)에서 오른쪽 끝(0도)까지, 중심에서의 각도 θ = π(1 − p)
function arcX(p: number): number { return CX + R * Math.cos(Math.PI * (1 - p)); }
function arcY(p: number): number { return CY - R * Math.sin(Math.PI * (1 - p)); }
// 포인터 위치 → 진행도 — 중심에서 본 각도를 재서 극좌표로 바꾼다. 호 아래쪽은 가까운 끝으로 붙인다
function angleP(e: PointerEvent): number {
const r = scene.getBoundingClientRect();
const ctrX = r.left + r.width * CX / 100;
const ctrY = r.top + r.height * CY / 62.5;
let a = Math.atan2(ctrY - e.clientY, e.clientX - ctrX);
if (a < -Math.PI / 2) { a = Math.PI; } else if (a < 0) { a = 0; }
return 1 - a / Math.PI;
}
// 시연 손가락 — 짝수 바퀴는 임계 각도를 넘겨 놓아 끄고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.92]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
function svgPath(parent: Element, name: string): SVGPathElement {
const e = document.createElementNS(SVG_NS, 'path');
e.setAttribute('class', cls(name));
e.setAttribute('d', 'M ' + (CX - R) + ' ' + CY + ' A ' + R + ' ' + R + ' 0 0 1 ' + (CX + R) + ' ' + CY);
parent.appendChild(e);
return e;
}
function build(): void {
scene.textContent = '';
rawFn = angleP;
scaleFn = function (): number { return 1; };
capAt(scene, '호 위에서 각도로 진행도를 잰다 (임계 {a}°)'.replace('{a}', String(THRESH_DEG)), 11, '');
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', cls('arc'));
svg.setAttribute('viewBox', '0 0 100 62.5');
svg.setAttribute('aria-hidden', 'true');
scene.appendChild(svg);
svgPath(svg, 'arc__track');
fillArc = svgPath(svg, 'arc__fill');
flashArc = svgPath(svg, 'arc__flash');
const mark = document.createElementNS(SVG_NS, 'line');
const tx = Math.cos(Math.PI * (1 - THRESH));
const ty = Math.sin(Math.PI * (1 - THRESH));
mark.setAttribute('class', cls('arc__mark'));
mark.setAttribute('x1', String(CX + (R - 6.5) * tx));
mark.setAttribute('y1', String(CY - (R - 6.5) * ty));
mark.setAttribute('x2', String(CX + (R + 6.5) * tx));
mark.setAttribute('y2', String(CY - (R + 6.5) * ty));
svg.appendChild(mark);
power = mk('div', 'pw', scene);
icon('power', power);
powerLabel = mk('div', 'pw__label', scene);
handle = mk('div', 'sl__handle sl__handle--arc', scene);
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
arrow = icon('caret-right', handle);
wireHandle(handle, HANDLE_LABEL);
}
function reset(): void { void 0; }
function tick(h: number): void { void h; }
function draw(): void {
const p = sl.p;
handle.style.transform = 'translate(' + (arcX(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw,' + (arcY(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw)';
// 손잡이 안 화살표는 호의 접선 방향을 본다 — 왼쪽 끝에서는 위, 꼭대기에서는 오른쪽, 오른쪽 끝에서는 아래
arrow.style.transform = 'rotate(' + ((p - 0.5) * 180).toFixed(1) + 'deg)';
const shown = clamp(p, 0, 1);
fillArc.style.strokeDasharray = (ARC_LEN * shown).toFixed(2) + ' ' + ARC_LEN.toFixed(2);
const off = sl.mode === M_DONE;
const t = off ? sl.doneT : 0;
flashArc.style.opacity = off ? (0.9 * Math.exp(-t / FLASH_TAU)).toFixed(3) : '0';
fillArc.classList.toggle(cls('is-off'), off);
// 가운데 전원 아이콘은 진행도가 반을 넘기면서 옅어지고, 꺼진 뒤에는 가장 옅다
const dim = easeInOut((shown - 0.5) / 0.5);
power.style.opacity = (1 - 0.65 * dim).toFixed(3);
const txt = off ? '전원 꺼짐' : '전원 켜짐';
if (powerLabel.textContent !== txt) { powerLabel.textContent = txt; }
}
function statusText(): string {
return '각도 {a}° (임계 {t}°)'.replace('{a}', String(Math.round(clamp(sl.p, 0, 1) * 180))).replace('{t}', String(THRESH_DEG));
}
function finalText(): string { return '전원이 꺼졌습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'전원 끄기 슬라이더의 손잡이가 반원 호를 따라 움직이며 중심에서의 각도로 진행도를 재고 호 안쪽 채움이 같은 각도만큼 차오르며 임계 각도를 넘기면 호 전체가 한 번 번쩍이고 가운데 전원 아이콘이 꺼진다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'각도 0° (임계 144°)'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 · 직선이 아니라 호 위를 각도로 재며 도는 전원 슬라이더 — 측정 누적면적 19.829% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 호 위 한 점 — 진행도 p 는 왼쪽 끝(180도)에서 오른쪽 끝(0도)까지, 중심에서의 각도 θ = π(1 − p)
function arcX(p) { return CX + R * Math.cos(Math.PI * (1 - p)); }
function arcY(p) { return CY - R * Math.sin(Math.PI * (1 - p)); }
// 포인터 위치 → 진행도 — 중심에서 본 각도를 재서 극좌표로 바꾼다. 호 아래쪽은 가까운 끝으로 붙인다
function angleP(e) {
const r = scene.getBoundingClientRect();
const ctrX = r.left + r.width * CX / 100;
const ctrY = r.top + r.height * CY / 62.5;
let a = Math.atan2(ctrY - e.clientY, e.clientX - ctrX);
if (a < -Math.PI / 2) {
a = Math.PI;
}
else if (a < 0) {
a = 0;
}
return 1 - a / Math.PI;
}
function draw() {
const p = sl.p;
handle.style.transform = 'translate(' + (arcX(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw,' + (arcY(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw)';
// 손잡이 안 화살표는 호의 접선 방향을 본다 — 왼쪽 끝에서는 위, 꼭대기에서는 오른쪽, 오른쪽 끝에서는 아래
arrow.style.transform = 'rotate(' + ((p - 0.5) * 180).toFixed(1) + 'deg)';
const shown = clamp(p, 0, 1);
fillArc.style.strokeDasharray = (ARC_LEN * shown).toFixed(2) + ' ' + ARC_LEN.toFixed(2);
const off = sl.mode === M_DONE;
const t = off ? sl.doneT : 0;
flashArc.style.opacity = off ? (0.9 * Math.exp(-t / FLASH_TAU)).toFixed(3) : '0';
fillArc.classList.toggle(cls('is-off'), off);
// 가운데 전원 아이콘은 진행도가 반을 넘기면서 옅어지고, 꺼진 뒤에는 가장 옅다
const dim = easeInOut((shown - 0.5) / 0.5);
power.style.opacity = (1 - 0.65 * dim).toFixed(3);
const txt = off ? '전원 꺼짐' : '전원 켜짐';
if (powerLabel.textContent !== txt) {
powerLabel.textContent = txt;
}
}
09밀수록 배경이 선명해지고 시계가 물러나는 잠금 화면 히어로
값 하나 p 에서 blur(BLUR_MAX·(1−p)) 로 풍경의 흐림이 줄고, 시계는 CLOCK_RISE 9cqw 만큼 올라가며 CLOCK_MIN 0.5 배까지 작아지고, 알림 카드 둘은 CARD_FROM 0.3 부터 CARD_GAP 0.15 간격으로 아래에서 올라온다. 놓아 되돌리면 같은 식이 거꾸로 풀려 모든 값이 같은 길로 돌아간다. 앱·키오스크 첫 화면이나 서비스 랜딩의 히어로에 쓴다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="잠금 화면 슬라이더를 밀수록 흐린 풍경 배경이 선명해지고 큰 시계가 위로 올라가며 작아지고 알림 카드 두 장이 아래에서 올라오며 놓아 되돌리면 모든 값이 같은 길로 돌아간다"></div>
<p class="vj__status" data-role="status" aria-hidden="true">선명도 0%</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 4.0;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 배경 흐림 — 잠긴 상태의 최대 흐림(무대 폭 cqw), 시계가 작아지는 최소 배율, 시계가 올라가는 거리(cqw)
const BLUR_MAX = 2.4;
const CLOCK_MIN = 0.5;
const CLOCK_RISE = 9;
// 알림 카드 — 진행도가 이 값을 넘으면 첫 카드가 오르기 시작하고 카드마다 CARD_GAP 만큼 늦게 오른다
const CARD_FROM = 0.3;
const CARD_GAP = 0.15;
const CARD_SPAN = 0.4;
const CARD_N = 2;
const HANDLE_LABEL = '잠금 해제 손잡이 — 밀수록 배경이 선명해지는 슬라이더';
let main;
let img;
let clock;
let cards = [];
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝까지 밀고, 홀수 바퀴는 반쯤 밀다 놓아 모든 값이 같은 길로 돌아오게 한다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.5, x: ramp(t, [[0.2, 0], [1.4, 0.95]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.55]]) };
}
function build() {
scene.textContent = '';
const bg = mk('div', 'lock', scene);
img = mk('div', 'lock__img', bg);
mk('div', 'lock__sun', img);
mk('div', 'lock__hill lock__hill--far', img);
mk('div', 'lock__hill lock__hill--near', img);
clock = mk('div', 'lock__clock', scene);
clock.textContent = '09:41';
cards = [];
const msgs = ['오늘 일정 3건', '새 알림 2건'];
for (let i = 0; i < CARD_N; i += 1) {
const c = mk('div', 'lock__card', scene);
c.textContent = msgs[i];
c.style.top = (27 + i * 9) + 'cqw';
cards.push(c);
}
main = makeLin(scene, 'sl--lock', '밀어서 잠금 해제', 46, 1);
wireHandle(main.handle, HANDLE_LABEL);
}
function reset() { void 0; }
function tick(h) { void h; }
function draw() {
placeHandle(main, sl.p);
const p = clamp(sl.p, 0, 1);
// 풍경은 진행도에 반비례해 흐려지고, 시계는 위로 올라가며 작아지고 옅어진다 — 값 하나(p)에서 모두 나온다
img.style.filter = 'blur(' + (BLUR_MAX * (1 - p)).toFixed(3) + 'cqw)';
clock.style.transform = 'translateY(' + (-CLOCK_RISE * p).toFixed(3) + 'cqw) scale(' + lerp(1, CLOCK_MIN, p).toFixed(3) + ')';
clock.style.opacity = (1 - 0.3 * p).toFixed(3);
for (let i = 0; i < cards.length; i += 1) {
const u = easeOut3((p - CARD_FROM - i * CARD_GAP) / CARD_SPAN);
cards[i].style.transform = 'translateY(' + (16 * (1 - u)).toFixed(3) + 'cqw)';
cards[i].style.opacity = u.toFixed(3);
}
const txt = sl.mode === M_DONE ? '해제됨' : '밀어서 잠금 해제';
if (main.label.textContent !== txt) {
main.label.textContent = txt;
}
}
function statusText() {
return '선명도 {n}%'.replace('{n}', String(Math.round(clamp(sl.p, 0, 1) * 100)));
}
function finalText() { return '잠금이 해제되었습니다'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HeroLockScreenSharpen - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HeroLockScreenSharpen.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroLockScreenSharpen() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
const LOOP_S = 4.0;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 배경 흐림 — 잠긴 상태의 최대 흐림(무대 폭 cqw), 시계가 작아지는 최소 배율, 시계가 올라가는 거리(cqw)
const BLUR_MAX = 2.4;
const CLOCK_MIN = 0.5;
const CLOCK_RISE = 9;
// 알림 카드 — 진행도가 이 값을 넘으면 첫 카드가 오르기 시작하고 카드마다 CARD_GAP 만큼 늦게 오른다
const CARD_FROM = 0.3;
const CARD_GAP = 0.15;
const CARD_SPAN = 0.4;
const CARD_N = 2;
const HANDLE_LABEL = '잠금 해제 손잡이 — 밀수록 배경이 선명해지는 슬라이더';
let main: Lin;
let img: HTMLElement;
let clock: HTMLElement;
let cards: HTMLElement[] = [];
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝까지 밀고, 홀수 바퀴는 반쯤 밀다 놓아 모든 값이 같은 길로 돌아오게 한다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.5, x: ramp(t, [[0.2, 0], [1.4, 0.95]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.55]]) };
}
function build(): void {
scene.textContent = '';
const bg = mk('div', 'lock', scene);
img = mk('div', 'lock__img', bg);
mk('div', 'lock__sun', img);
mk('div', 'lock__hill lock__hill--far', img);
mk('div', 'lock__hill lock__hill--near', img);
clock = mk('div', 'lock__clock', scene);
clock.textContent = '09:41';
cards = [];
const msgs = ['오늘 일정 3건', '새 알림 2건'];
for (let i = 0; i < CARD_N; i += 1) {
const c = mk('div', 'lock__card', scene);
c.textContent = msgs[i];
c.style.top = (27 + i * 9) + 'cqw';
cards.push(c);
}
main = makeLin(scene, 'sl--lock', '밀어서 잠금 해제', 46, 1);
wireHandle(main.handle, HANDLE_LABEL);
}
function reset(): void { void 0; }
function tick(h: number): void { void h; }
function draw(): void {
placeHandle(main, sl.p);
const p = clamp(sl.p, 0, 1);
// 풍경은 진행도에 반비례해 흐려지고, 시계는 위로 올라가며 작아지고 옅어진다 — 값 하나(p)에서 모두 나온다
img.style.filter = 'blur(' + (BLUR_MAX * (1 - p)).toFixed(3) + 'cqw)';
clock.style.transform = 'translateY(' + (-CLOCK_RISE * p).toFixed(3) + 'cqw) scale(' + lerp(1, CLOCK_MIN, p).toFixed(3) + ')';
clock.style.opacity = (1 - 0.3 * p).toFixed(3);
for (let i = 0; i < cards.length; i += 1) {
const u = easeOut3((p - CARD_FROM - i * CARD_GAP) / CARD_SPAN);
cards[i].style.transform = 'translateY(' + (16 * (1 - u)).toFixed(3) + 'cqw)';
cards[i].style.opacity = u.toFixed(3);
}
const txt = sl.mode === M_DONE ? '해제됨' : '밀어서 잠금 해제';
if (main.label.textContent !== txt) { main.label.textContent = txt; }
}
function statusText(): string {
return '선명도 {n}%'.replace('{n}', String(Math.round(clamp(sl.p, 0, 1) * 100)));
}
function finalText(): string { return '잠금이 해제되었습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'잠금 화면 슬라이더를 밀수록 흐린 풍경 배경이 선명해지고 큰 시계가 위로 올라가며 작아지고 알림 카드 두 장이 아래에서 올라오며 놓아 되돌리면 모든 값이 같은 길로 돌아간다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'선명도 0%'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 밀수록 배경이 선명해지고 시계가 물러나는 잠금 화면 히어로 — 측정 누적면적 40.138% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw() {
placeHandle(main, sl.p);
const p = clamp(sl.p, 0, 1);
// 풍경은 진행도에 반비례해 흐려지고, 시계는 위로 올라가며 작아지고 옅어진다 — 값 하나(p)에서 모두 나온다
img.style.filter = 'blur(' + (BLUR_MAX * (1 - p)).toFixed(3) + 'cqw)';
clock.style.transform = 'translateY(' + (-CLOCK_RISE * p).toFixed(3) + 'cqw) scale(' + lerp(1, CLOCK_MIN, p).toFixed(3) + ')';
clock.style.opacity = (1 - 0.3 * p).toFixed(3);
for (let i = 0; i < cards.length; i += 1) {
const u = easeOut3((p - CARD_FROM - i * CARD_GAP) / CARD_SPAN);
cards[i].style.transform = 'translateY(' + (16 * (1 - u)).toFixed(3) + 'cqw)';
cards[i].style.opacity = u.toFixed(3);
}
const txt = sl.mode === M_DONE ? '해제됨' : '밀어서 잠금 해제';
if (main.label.textContent !== txt) {
main.label.textContent = txt;
}
}
밀어서 잠금해제 UI를 코드로 옮기면 손잡이는 몇 가지 상태를 가져야 할까?
다섯 상태면 충분하다
M_IDLE(가만히)·M_DRAG(끌기)·M_BACK(스프링으로 처음으로)·M_FIN(스프링으로 끝으로)·M_DONE(완료) 다섯 개가 전부이다. 끌기가 시작되면 M_DRAG, 놓으면 verdict 결과에 따라 M_BACK 이나 M_FIN, 스프링이 멈추면 M_IDLE 이나 M_DONE 으로 간다.
완료 뒤에는 되돌릴 길을 하나 남긴다
사람이 만진 장면은 완료 뒤 DONE_HOLD_S 1.8초가 지나면 처음으로 돌아가고, 키보드는 완료 뒤 Enter·Space·Home 으로 되돌린다. 자동 시연은 LOOP_S 가 돌 때마다 cycle 이 하나 올라 완료하는 손과 되돌아가는 손을 번갈아 보여 준다.
어디에 붙이나 — 결제·전원·첫 화면
송금·주문 확인에는 06 의 단계 색을, 전원 끄기·긴급 중지에는 08 의 호를, 앱이나 키오스크 첫 화면에는 09 를 붙인다. 밀어서 잠금해제 설정 화면을 따로 둘 때는 임계 비율과 스프링 세기 두 값만 바깥으로 빼 두면 된다.
트랙 폭을 재고, 손잡이 위치를 진행도로 바꾸고, 놓은 자리와 속도로 완료를 정하고, 못 미치면 되돌리기까지 — 밀어서 잠금해제 한 번이 처리되는 순서
트랙 폭은 cqw 로 재서 무대 폭에 비례시킨다
손잡이가 움직이는 거리는 TRACK_CQW − HANDLE_CQW − 2·PAD_CQW 이고, 72 이면 57cqw 로 480px 무대에서 273.6px 이다. 단위를 px 가 아니라 cqw 로 잡았으므로 무대가 줄어들면 같은 비율로 줄어든다.
손가락은 따라가고 속도는 걸러서 읽는다
끌기 중 위치는 p += (mapDrag(x) − p)·(1 − e^(−h·FOLLOW)), 속도는 vEma += (dp/h − vEma)·(1 − e^(−h·VEL_SMOOTH)) 로 FOLLOW 가 40, VEL_SMOOTH 가 20 이다. 위치를 손가락에 그대로 붙이지 않고 한 박자 늦게 따라가게 하므로 놓는 순간의 속도가 프레임마다 흔들리지 않는다.
한 프레임을 1/240초 조각으로 쪼갠다
update 는 ceil(dt·240) 조각으로 나눠 stepPhysics 를 돌리고, 한 프레임 시간은 DT_MAX 0.05초까지만 받는다. 그래서 프레임 간격이 달라도 스프링이 같은 곡선을 그린다.
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
밀어서 잠금해제가 깨지는 순간 — 끝을 넘어가는 손잡이·놓았는데 중간에 멈춘 채 남는 채움·터치가 트랙 밖으로 나가면 끊기는 끌기
한계가 없으면 손잡이가 끝을 넘어 튄다
01 의 아래 작은 트랙은 badX = clamp(fx, 0, 1.3) 로 손가락을 그대로 따라가서 손잡이가 끝을 넘어 튄다. 위의 큰 트랙은 같은 손에 1.078 에서 멈추므로 한 화면에서 두 방식을 나란히 비교할 수 있다.
되돌리지 않으면 채움이 중간에 남는다
03 의 대비판은 놓으면 그 자리에 선다. badP 가 누르는 동안만 clamp(p, 0, 1) 을 따라가기 때문에 0.525 에서 놓으면 반쯤 찬 채움이 그대로 남는다.
포인터가 트랙 밖으로 나가면 끌기가 끊긴다
06 의 대비판은 손가락이 LEAVE_X 0.55 를 지나면 lost = true 가 되어 손잡이가 그 자리에 멈추고 더는 손가락을 따라가지 않는다. 실제 조작에서는 손잡이에 setPointerCapture 를 걸고 touch-action: none 을 두므로 손가락이 트랙 밖으로 나가도 끌기가 이어진다. 압축 파일은 암호 smsvvdkv 로 열고 06 폴더의 index.html 에서 손잡이를 0.55 너머까지 끌어 보면 아래 작은 줄이 그 자리에서 끊기는 것을 볼 수 있다.
트랙 길이와 임계 비율과 스프링 세기를 바꾸면 완료 판정은 얼마나 달라질까 — 폭·임계·강성을 바꿔 본 차이
세 값을 하나씩만 바꿔 같은 시연 손에 돌려 본 결과이다. 수치는 렌더 측정이 아니라 60Hz 프레임·1/240초 조각으로 stepPhysics 를 옮겨 돌린 시뮬레이션 값이다.
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 트랙을 길게 | 02 TRACK_CQW 72 → 84 |
손잡이가 움직이는 거리가 57cqw 에서 69cqw 로(480px 무대에서 274px 에서 331px) 늘어난다. 같은 손(138px 지점에서 초속 369px 로 놓음)이 진행도 0.50·속도 1.35 에서 0.42·1.12 로 읽혀 예상 도착이 0.84 에서 0.69 로 내려가고, 임계 0.80 아래가 되어 완료가 복귀로 뒤집힌다 |
| 임계를 높게 | 04 THRESH 0.6 → 0.85 |
0.75 까지 끌었다 놓는 같은 손이 0.6 에서는 누른 채 1.03초에 자동 완료(완료 상태 1.82초)로 가지만, 0.85 에서는 1.62초에 놓일 때까지 임계에 닿지 못한다. 출발점을 5%쯤 넘었다가 2.47초에 처음 자리에서 멈춘다 |
| 스프링을 단단하게 | 03 SPR_K 110 → 240 |
감쇠비가 0.43 에서 0.29 로 내려가 출발점을 넘어가는 폭이 놓은 거리의 22.5%에서 38.5%로 늘고(0.525 에서 놓으면 −0.12 에서 −0.20), 출발점에 처음 닿는 시간이 0.20초에서 0.10초로 당겨진다. 진동수는 1.51Hz 에서 2.36Hz 로 빨라지고, SPR_C 9 가 그대로라 멈추는 시간은 둘 다 1.2초 안팎이다 |
값을 바꾸기 전에 볼 것
세 변형은 시연 손이 고정이라 값 하나가 판정을 뒤집는 지점을 보여 줄 뿐이고, 실제 사용자의 손은 매번 다르다. THRESH 는 낮출수록 가볍게 완료되고 높일수록 끝까지 밀어야 하므로, 결제 확인처럼 실수가 비싼 화면은 높은 쪽으로 잡고 가벼운 잠금은 낮은 쪽으로 잡는다.
움직임 줄이기에서는 잠금이 풀린 한 장 — 진행도를 문장으로 읽어 주고 키보드 화살표로 손잡이를 옮기는 접근성
움직임 줄이기와 풀린 한 장
prefers-reduced-motion: reduce 이면 미디어 쿼리 값을 읽은 reduceQuery 가 루프를 세우고, showRest() 가 트윈 없이 p = 1·M_DONE 인 잠금이 풀린 한 장을 그린다. 이 상태에서도 끌 수 있어서 pointermove 가 p 를 바로 바꾸고, 놓으면 settleNow() 가 속도를 0 으로 놓고 판정해 즉시 최종 상태를 고른다. 그래서 02 의 속도를 더하는 판정은 이 상태에서 적용되지 않는다. 페이지를 연 채 설정을 바꿔도 change 리스너가 다시 읽는다.
슬라이더 역할과 문장으로 읽는 진행도
손잡이는 slider 역할에 aria-valuemin·aria-valuemax(0~100) 와 aria-valuenow 를 갖고, aria-valuetext 에 그 순간의 상태 문장이 들어간다. 눈으로 보는 상태 줄은 aria-hidden 이며, 완료는 .vj__live 에 finalText() 한 문장을 한 번만 적는다. 시연 중에는 aria-live="off" 이고 사람이 처음 손대면 polite 로 켠다.
키보드와 일시정지
오른쪽·위 화살표는 KEY_STEP 0.1 만큼 올리고 왼쪽·아래 화살표는 내리며, Home 은 처음으로 End·Enter·Space 는 끝까지 보낸다. 화살표로 임계를 넘겨도 그대로 완료되고, 완료 뒤에는 Enter·Space·Home 이 처음으로 되돌린다. 일시정지 버튼은 aria-pressed 로 상태를 알리고 탭이 숨으면(visibilitychange) 루프가 멈춘다.
밀어서 잠금해제 FAQ — 라이브러리 없이 만들기·터치와 마우스 함께 받기·밀어서 잠금해제 없애기 대신 쓰는 확인 버튼
라이브러리 없이도 만들 수 있나요
아홉 장면 모두 라이브러리·WebGL·CDN 을 쓰지 않으며 Math.random·Date·fetch 도 없는 vanilla 코드입니다. 손잡이 하나를 pointerdown·pointermove·pointerup 으로 읽고 requestAnimationFrame 이 위치를 그리면 됩니다.
터치와 마우스를 함께 받으려면 어떻게 하나요
포인터 이벤트 하나가 마우스·터치·펜을 같이 받습니다. pointerdown 에서 setPointerCapture 를 걸고, 마우스는 e.button !== 0 이면 무시하며, pointercancel 도 놓는 것과 같이 처리합니다.
밀어서 잠금해제 없애기 대신 쓸 확인 버튼은 어떤 모양인가요
슬라이더를 빼고 싶다면 <button> 하나로 같은 확인을 받으면 됩니다. 이 편의 슬라이더도 Enter·Space 가 completeNow() 로 바로 끝까지 보내므로 키보드 사용자는 이미 버튼처럼 쓰고 있고, 실수 방지가 목적이라면 버튼을 한 번 더 눌러 확인하게 만드는 쪽이 같은 역할을 합니다.









