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

스톱워치 9종 — 연속으로 도는 초침부터 랩 기록과 기록 비교까지

스톱워치는 시간을 어떻게 숫자와 바늘로 바꿀까 — 01번은 정수 센티초 하나로 초침 각도를 정하고, 03번은 랩과 누적 기록을 한 표에서 계산하며, 08번은 이전 기록의 그림자 바늘과 지금 바늘의 차이를 읽는다.

스톱워치는 시계를 읽는 일이 아니라 흐른 시간을 정수 하나로 세고 그 정수에서 숫자와 바늘 각도를 모두 계산하는 장치다 — 스톱워치 만들기는 시간을 세는 단위를 하나로 정하는 데서 시작한다

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

아홉 장면이 시간을 세고 읽는 방식은 하나로 같다. 경과 시간은 센티초(100분의 1초) 정수 하나이고, 숫자판의 분·초·센티초도 문자반 바늘의 각도도 랩 구간도 이 정수에서만 계산된다. 시간 원천은 장면 시계 sceneT 하나이며, 프레임마다 rAF 타임스탬프 차이 dt 를 쌓되 한 프레임이 DT_MAX 0.05초를 넘지 않게 자른다.

순서는 바늘 하나에서 시작해 기록으로 넓어진다. 01·02 는 바늘과 숫자판을, 03·04 는 스톱워치 랩타임을 표와 막대로, 05·06 은 정지 때의 튕김과 시간이 넘어가는 보조 문자반을, 07·08·09 는 랩이 쌓여 만드는 고리와 그림자 바늘과 전광판을 다룬다.

스톱워치 9종 — 초침이 연속으로 도는 문자반·분 초 센티초로 나누는 숫자판·랩과 누적을 함께 보이는 기록표·랩 길이를 비교하는 막대·멈출 때 바늘이 튕기는 스프링·분과 시간을 세는 보조 문자반·랩마다 호를 남기는 궤적 링·이전 기록과 겨루는 그림자 바늘·전광판 히어로

아홉 장면의 면적과 움직임

장면 누적면적 움직인 프레임 강도 평균
01 연속 초침 9.9% 23/23 43.9
02 센티초 숫자판 12.3% 23/23 68.1
03 랩 분할표 11.1% 23/23 56.9
04 랩 막대 8.9% 23/23 49.6
05 스프링 정지 21.0% 23/23 95.7
06 보조 문자반 11.1% 23/23 39.7
07 궤적 링 9.2% 23/23 102.4
08 그림자 바늘 18.3% 23/23 90.6
09 전광판 20.6% 23/23 95.7

바늘이 크게 튕기고 두 판이 함께 번쩍이는 05 가 21.0% 로 가장 넓고, 막대만 자라는 04 가 8.9% 로 가장 좁다. 강도 평균은 07 이 102.4 로 가장 높고 06 이 39.7 로 가장 낮으며, 움직인 프레임은 아홉 장면 전부 23/23 이어서 정지한 구간 없이 계속 바뀐다.

01초침이 끊김 없이 도는 문자반

초침 각도는 센티초 정수 하나에서 HAND_ANGLE(cs) 로 나오고 TURN_CS 6000 에 한 바퀴를 돈다. 눈금은 TICK_COUNT 60 개에 MAJOR_EVERY 5 마다 굵고, 오른쪽 판은 초 단위 값 stepCs() 로만 바늘을 세워 툭툭 끊기며 초가 바뀔 때마다 허브에서 가장자리로 파문이 퍼진다. 운동 기록·실험·요리 시간을 재는 문자반 스톱워치에 쓴다.

초침이 끊김 없이 도는 문자반
새 창에서 열기

01 · 초침이 끊김 없이 도는 문자반 — 측정 누적면적 9.893% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TURN_CSHAND_ANGLETICK_COUNT
    const TURN_CS = 6000;
    const TICK_COUNT = 60;
    const MAJOR_EVERY = 5;
    const WINDOW_S = 4;
    const LAP_S = 15.0;
    const START_S = 3.7;
    const REST_S = 3.7;
    const RESULT_S = 13.0;
    let hist = [];
    function build() {
        swReset();
        hist = [];
    }
    function update(dt) {
        if (dt < 0) {
            return;
        }
        hist.push({ t: sceneT, cs: swCs(sceneT) });
        while (hist.length > 1 && hist[0].t < sceneT - WINDOW_S) {
            hist.shift();
        }
    }
    function handCs() { return swCs(sceneT); }
    function stepCs() { return Math.floor(handCs() / CS_PER_SEC) * CS_PER_SEC; }
    function HAND_ANGLE(cs) { return dialAng(cs, TURN_CS); }

02분 초 센티초로 나누어 찍는 숫자판

splitCs 는 센티초를 6000 으로 나눈 몫을 분, 나머지를 100 으로 나눈 몫을 초, 남은 값을 센티초로 정수 나눗셈만으로 나눈다. 오른쪽 판은 0.01 을 센티초 수만큼 더한 합을 읽는데 0.01 을 11번 더하면 0.10999999999999999 라 11 이 아니라 10 으로 읽히고, 1700 센티초 중 1498칸이 어긋난다. 기록이 소수점 아래에서 흔들리면 안 되는 대회·측정 화면의 숫자는 정수 센티초 방식으로 만든다.

분 초 센티초로 나누어 찍는 숫자판
새 창에서 열기

02 · 분 초 센티초로 나누어 찍는 숫자판 — 측정 누적면적 12.269% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CS_PER_MINsplitCsSTEP_F
    function build() {
        swReset();
        errs = [0];
        sums = ['0'];
        bad = [0];
        let acc = 0;
        for (let n = 1; n <= MAX_CS; n += 1) {
            acc += STEP_F;
            errs.push(n - Math.floor(acc * CS_PER_SEC));
            sums.push(acc.toPrecision(17));
            bad.push(bad[n - 1] + (errs[n] !== 0 ? 1 : 0));
        }
    }
    function update(dt) { if (dt < 0) {
        return;
    } }
    function nowCs() { return Math.min(swCs(sceneT), MAX_CS); }
    function floatCs() { return nowCs() - errs[nowCs()]; }

03랩과 누적 기록을 한 표에서 계산하는 기록표

러닝 구간 기록이나 발표 시간 배분 표에서는 lapSpan(i) 가 지금 누적에서 직전 누적을 빼 구간 시간을 얻고, 누적은 옆 열에 그대로 둔다. 첫 판 누적 200 452 714 936 1208 1410 1712 센티초에서 구간은 200 252 262 222 272 202 302 센티초가 된다. 오른쪽 판은 누른 시각을 구간 열에 쓰고 그 합을 누적 열에 써서 누적이 6632 센티초까지 불어난다.

랩과 누적 기록을 한 표에서 계산하는 기록표
새 창에서 열기

03 · 랩과 누적 기록을 한 표에서 계산하는 기록표 — 측정 누적면적 11.107% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PRESS_ATlapSpanTABLE_ROWS
    let pressT = [];
    function build() {
        swReset();
        pressT = [];
        for (let i = 0; i < PRESS_AT.length; i += 1) {
            const t = PRESS_AT[i] + ((lap * 7 + i * 3) % 5) * 0.04;
            pressT.push(t);
            script.push({ t: t, f: function () { pressLap(t); } });
        }
    }
    function update(dt) { if (dt < 0) {
        return;
    } }
    function lastPress() { return marks.length > 0 ? pressT[marks.length - 1] : -9; }
    function liveSpan() { return swCs(sceneT) - (marks.length > 0 ? marks[marks.length - 1] : 0); }

04랩 길이를 막대로 겨루고 최고와 최악을 표시하는 비교

막대 길이는 BAR_RANGE(i) 가 (구간 − 기준) ÷ (최댓값 − 기준) 으로 정하고, 기준 baseOf() 는 최솟값에서 범위의 BASE_PAD 0.25 만큼 아래에 둔다. 첫 판 구간은 324 247 320 264 298 239 279 센티초이고(첫 랩 324 는 시작 0.4초 뒤부터 센 값이라 길다), 최솟값 239 줄에 민트 최고 띠, 최댓값 324 줄에 분홍 최악 띠가 깔리며 점선은 평균 281.57 이다. 기준을 최솟값에 두면 가장 짧은 랩의 막대가 0 이 되어 비어 보이므로, 구간별 속도 비교와 페이스 점검 화면은 이 여유를 남긴다.

랩 길이를 막대로 겨루고 최고와 최악을 표시하는 비교
새 창에서 열기

04 · 랩 길이를 막대로 겨루고 최고와 최악을 표시하는 비교 — 측정 누적면적 8.885% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BASE_PADBAR_RANGESPANS
    function baseOf() { return minOf() - (maxOf() - minOf()) * BASE_PAD; }
    // 막대 길이 비율 0~1 — 랩이 하나뿐이면 범위가 0 이라 고정값
    function BAR_RANGE(i) {
        const hi = maxOf();
        const lo = baseOf();
        return hi - lo > 0 ? (lapSpan(i) - lo) / (hi - lo) : 0.6;
    }
    function update(dt) {
        const k = 1 - Math.exp(-EASE_RATE * dt);
        for (let i = 0; i < marks.length; i += 1) {
            if (i >= disp.length) {
                disp.push(0);
            }
            disp[i] += (BAR_RANGE(i) - disp[i]) * k;
        }
    }

05멈출 때 바늘이 한 번 튕겨 서는 스프링 정지

정지를 누르면 숫자는 그 값에서 멈추고 vel 에 KICK_V 3.6 이 들어가, 바늘은 목표 각도에 OVERSHOOT() 를 더한 채 SPRING_K 420·DAMPING_C 14 의 스프링을 따라 돌아온다. 첫 정점은 6.18도(0.067초)이고 반대쪽으로 −1.93도(0.217초)까지 갔다가 0.417초에 0.5도 안으로 들어와 서며, 오른쪽 판은 바늘이 그 자리에서 곧바로 서서 정지가 끊겨 보인다. 정지·재개 버튼의 조작감이나 계기판 바늘 정지에 쓴다.

멈출 때 바늘이 한 번 튕겨 서는 스프링 정지
새 창에서 열기

05 · 멈출 때 바늘이 한 번 튕겨 서는 스프링 정지 — 측정 누적면적 20.954% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_KDAMPING_COVERSHOOT
    function build() {
        swReset();
        stopT = 4.0 + 0.43 * (lap % 3);
        ov = 0;
        vel = 0;
        trace = [];
        script.push({ t: stopT, f: function () { swStop = stopT; vel = KICK_V; } });
    }
    // 한 프레임을 0.004초 이하 조각으로 쪼개 적분 — 프레임이 길어도 스프링이 폭주하지 않는다
    function update(dt) {
        const n = Math.max(1, Math.ceil(dt / 0.004));
        const h = dt / n;
        for (let i = 0; i < n; i += 1) {
            vel += (-SPRING_K * ov - DAMPING_C * vel) * h;
            ov += vel * h;
        }
        if (sceneT >= stopT - 0.3) {
            trace.push({ t: sceneT, o: ov });
        }
        while (trace.length > 1 && trace[0].t < sceneT - TRACE_S - 0.3) {
            trace.shift();
        }
    }

06분과 시간을 세는 보조 문자반

vcs() 는 장면 시간에 SCALE 300 을 곱해 같은 swCs 에서 센티초를 얻고, MIN_QUOTIENT() 는 그 값을 6000 으로 나눈 몫, HOUR_QUOTIENT() 는 분 몫을 60 으로 나눈 몫이다. 세 바늘 각은 모두 한 값의 몫과 나머지에서 나오고 몫이 늘 때 해당 보조 문자반 테두리가 번쩍이는데, 12.00초에 360000 센티초라 시 몫이 1 이 되고 17.0초에는 510000 센티초로 1시간 25분까지 간다. 마라톤·장시간 작업·긴 실험처럼 한 시간을 넘는 측정에 쓴다.

분과 시간을 세는 보조 문자반
새 창에서 열기

06 · 분과 시간을 세는 보조 문자반 — 측정 누적면적 11.069% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MIN_QUOTIENTHOUR_QUOTIENTSUBDIAL_ANGLE
    function vcs() { return swCs(sceneT * SCALE); }
    function MIN_QUOTIENT() { return Math.floor(vcs() / CS_PER_MIN); }
    function HOUR_QUOTIENT() { return Math.floor(MIN_QUOTIENT() / 60); }
    function update(dt) {
        if (dt < 0) {
            return;
        }
        if (MIN_QUOTIENT() !== prevMin) {
            prevMin = MIN_QUOTIENT();
            flashMin = sceneT;
        }
        if (HOUR_QUOTIENT() !== prevHour) {
            prevHour = HOUR_QUOTIENT();
            flashHour = sceneT;
        }
    }
    function SUBDIAL_ANGLE(frac) { return frac * TAU - PI / 2; }

07랩마다 색 호를 남기는 궤적 링

ARC_SPAN(c0, c1) 은 직전 랩 지점에서 지금까지의 센티초 구간을 한 바퀴 TURN_CS 1200(12초) 경계에서 잘라 (고리 순번, 시작각, 끝각) 으로 돌려주고, 고리 반지름은 RING_R0 100 에서 순번마다 RING_STEP 17 씩 줄어든다. 11랩 누적 250 515 735 1050 1220 1465 1755 1990 2300 2505 2765 센티초는 고리 3까지 쌓이고 5번째와 10번째 랩이 경계에서 둘로 잘리며, 랩을 누른 순간 문자반 바탕이 그 랩 색으로 번쩍인다. 구간 비율을 한눈에 보여 주는 요약 링이나 운동 앱 기록 화면에 쓴다.

랩마다 색 호를 남기는 궤적 링
새 창에서 열기

07 · 랩마다 색 호를 남기는 궤적 링 — 측정 누적면적 9.233% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ARC_SPANRING_STEPTURN_CS
    function ARC_SPAN(c0, c1) {
        const out = [];
        let s = c0;
        while (s < c1) {
            const ring = Math.floor(s / TURN_CS);
            const e = Math.min(c1, (ring + 1) * TURN_CS);
            out.push({ ring: ring, a0: dialAng(s, TURN_CS), a1: ((e - ring * TURN_CS) / TURN_CS) * TAU - PI / 2 });
            s = e;
        }
        return out;
    }
    function paintSpan(c0, c1, col, a) {
        const arcs = ARC_SPAN(c0, c1);
        for (let i = 0; i < arcs.length; i += 1) {
            arcBand(CX, CY, RING_R0 - arcs[i].ring * RING_STEP, arcs[i].a0, arcs[i].a1, ARC_W, col, a);
        }
    }
    function ringOf(cs) { return Math.floor((cs - 1) / TURN_CS) + 1; }

08이전 기록의 그림자 바늘과 겨루는 비교

progress(m, t) 는 시각 열 m 에서 t 초까지 몇 번째 지점 사이 어디쯤인지를 돌려주고, reachAt 은 그 거꾸로 진행도 p 에 닿은 시각이다. 두 함수로 GAP_SEC(t) (같은 진행도에 이전 기록이 닿은 시각 − 지금 시각)를 얻어 양수는 민트 음수는 분홍으로 SIGN_COLOR 가 칠하고, 첫 판의 지점별 차는 −0.18 +0.54 −0.14 +0.98 +1.20 초라 시간에 따른 차의 부호가 3번 바뀐다. 개인 최고 기록 도전이나 반복 작업의 시간 단축 확인에 쓴다.

이전 기록의 그림자 바늘과 겨루는 비교
새 창에서 열기

08 · 이전 기록의 그림자 바늘과 겨루는 비교 — 측정 누적면적 18.317% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GAP_SECSIGN_COLORGHOST
    // 시각 열 m 에서 진행도 — 지점 사이는 직선으로 잇고 마지막 지점 뒤에는 기다린다
    function progress(m, t) {
        if (t <= 0) {
            return 0;
        }
        for (let k = 0; k < m.length - 1; k += 1) {
            if (t < m[k + 1]) {
                return k + (t - m[k]) / (m[k + 1] - m[k]);
            }
        }
        return LEGS;
    }
    // 진행도 p 에 닿은 시각 — progress 의 거꾸로
    function reachAt(m, p) {
        const k = Math.min(Math.floor(p), LEGS - 1);
        return m[k] + (p - k) * (m[k + 1] - m[k]);
    }
    function GAP_SEC(t) {
        const tn = Math.min(t, nowMarks[LEGS]);
        return reachAt(GHOST, progress(nowMarks, tn)) - tn;
    }
    function gapCs(t) { return Math.round(GAP_SEC(t) * CS_PER_SEC); }
    function SIGN_COLOR(g) { return g >= 0 ? MINT : ROSE; }
    function signed(cs) { return (cs >= 0 ? '+' : '−') + (Math.abs(cs) / CS_PER_SEC).toFixed(2); }

09큰 숫자판과 랩 목록이 어우러진 전광판 히어로

update 는 매 프레임 fmtCs 문자열을 이전 글자와 비교해 바뀐 자리의 시각 chAt 을 적고, 그 시각부터 FLIP_S 0.07초 동안 위 반쪽이 접히고 아래 반쪽이 펴진다(맨 끝 센티초 자리는 너무 빨리 바뀌어 접지 않고 바로 바뀐다). 새 랩은 목록 맨 위로 들어오며 나머지가 SLIDE_S 0.25초 동안 한 줄씩 내려가고, 왼쪽은 LIST_MAX 5줄을 넘으면 가장 오래된 줄이 아래로 사라진다. 줄 수 제한이 없는 오른쪽 판은 8랩부터 숫자판 아래쪽이 잘리고 10랩부터 패널 밖으로 밀려 나가며, 스포츠 중계나 이벤트 랜딩의 시간 표시에 쓴다.

큰 숫자판과 랩 목록이 어우러진 전광판 히어로
새 창에서 열기

09 · 큰 숫자판과 랩 목록이 어우러진 전광판 히어로 — 측정 누적면적 20.579% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIST_MAXFLIP_SSLIDE_S
    function update(dt) {
        if (dt < 0) {
            return;
        }
        const s = fmtCs(swCs(sceneT));
        for (let i = 0; i < s.length; i += 1) {
            if (s[i] !== curCh[i]) {
                prevCh[i] = curCh[i];
                curCh[i] = s[i];
                chAt[i] = sceneT;
            }
        }
    }

스톱워치 타이머를 코드로 짜면 숫자 하나와 바늘 하나는 어떤 값 몇 개로 정해질까?

숫자 하나는 센티초 정수 하나에서 나온다

센티초 정수 cs 하나가 있으면 splitCs 가 분·초·센티초를 정수 나눗셈으로 나눈다. 시작 전이면 0 이고 정지 뒤에는 정지 순간의 값에 멈추는 것도 swCs(t) 안의 swStop 한 값이 맡는다. 스톱워치 타이머의 숫자는 이 두 함수 말고는 시간을 건드리는 곳이 없다.

    const CS_PER_SEC = 100;
    const CS_PER_MIN = 6000;
    let swStart = 0;
    let swStop = -1;
    // 장면 시각 t 에서의 경과 센티초 — 시작 전이면 0, 정지 뒤에는 정지 순간 값에 멈춘다
    function swCs(t) {
        const e = Math.min(t, swStop >= 0 ? swStop : 1e9) - swStart;
        return e <= 0 ? 0 : Math.floor(e * CS_PER_SEC + 1e-6);
    }
    // 랩 — 누른 순간의 누적 센티초 목록. 값은 장면 시각 t 로 계산하므로 프레임 길이와 상관없다
    let marks = [];
    function pressLap(t) { marks.push(swCs(t)); }
    // 랩 구간 = 이번 누적 − 직전 누적
    function lapSpan(i) { return marks[i] - (i > 0 ? marks[i - 1] : 0); }
    function pad2(n) { return n < 10 ? '0' + n : String(n); }

바늘 하나는 센티초와 한 바퀴 길이 두 값에서 나온다

바늘 각도는 cs 와 한 바퀴에 해당하는 센티초 turnCs 두 값으로 dialAng 가 계산한다. 01·05 는 turnCs 가 6000(60초), 07 은 1200(12초)이라 같은 함수가 다른 문자반을 그린다. 랩이 붙으면 직전 누적 marks[i−1] 이 하나 더 필요하고, 구간은 lapSpan 이 이 두 값을 빼서 얻는다.

    // 센티초 → [분, 초, 센티초] — 정수 나눗셈만 쓴다
    function splitCs(cs) {
        const m = Math.floor(cs / CS_PER_MIN);
        const r = cs - m * CS_PER_MIN;
        const s = Math.floor(r / CS_PER_SEC);
        return [m, s, r - s * CS_PER_SEC];
    }
    function fmtCs(cs) {
        const p = splitCs(cs);
        return pad2(p[0]) + ':' + pad2(p[1]) + '.' + pad2(p[2]);
    }
    // 문자반 각도(라디안, 12시가 -π/2) — turnCs 센티초에 한 바퀴
    function dialAng(cs, turnCs) { return ((cs % turnCs) / turnCs) * TAU - PI / 2; }

흐른 시간을 센티초 정수로 세고, 분과 초와 센티초로 나누고, 초침 각도로 바꾸고, 랩이면 직전 값과 빼기까지 — 스톱워치 한 프레임이 계산되는 순서

한 프레임의 계산 순서

한 프레임은 네 단계로 끝난다.

  1. frame(ts) 가 이번과 지난 타임스탬프의 차를 구해 DT_MAX 0.05초로 자르고 advance(dt) 에 넘긴다.
  2. advance 가 sceneT 에 dt 를 더하고, 시간표에서 이 시각이 지난 일(랩 누르기·정지)을 실행한다.
  3. draw() 가 swCs(sceneT) 로 센티초 정수를 얻어 splitCs 로 숫자판을, dialAng 로 바늘 각도를 그린다.
  4. 랩이 눌렸으면 그 시각 t 로 계산해 둔 marks 에서 lapSpan 이 직전 값을 뺀다.
    const DT_MAX = 0.05;
    function showNow() {
        draw();
        pushStatus(statusText());
        checkDone();
    }
    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);
        }
        showNow();
        rafId = window.requestAnimationFrame(frame);
    }

랩 값이 프레임 길이와 무관한 이유

pressLap(t) 는 렌더된 프레임이 아니라 누른 시각 t 로 swCs 를 부르므로, 프레임이 길어져도 랩 값은 같은 센티초 정수로 기록된다. 03 의 랩 시각이 2.00·4.52·7.14 초로 프레임 간격과 맞물리지 않는 것도 이 때문이다.

스톱워치 사이트에서 정확도를 지키는 방법 — 라이브러리 없이 만들기·탭이 숨었다 돌아올 때 시간이 어긋나는 이유·랩 기록을 몇 개까지 둘지

실제 앱에서는 performance.now 를 시간 원천으로 쓴다

이 데모는 Date 도 performance 도 쓰지 않고 rAF 타임스탬프 차이만 쌓는다. 같은 시연이 어느 기기에서나 같은 순서로 재생되도록 한 선택이다. 실제 스톱워치는 시작 순간의 performance.now() 를 저장해 두고 매 프레임 performance.now() − 시작 으로 경과를 다시 계산해야 한다. setInterval 로 10ms 씩 더하면 타이머가 밀려 오차가 쌓이고 탭이 숨은 동안 멈춘다.

그 경과 밀리초를 Math.floor(ms / 10) 으로 센티초 정수로 바꾼 뒤부터는 이 소스의 splitCs·dialAng·lapSpan 이 그대로 쓰인다. 정지할 때는 그 순간의 값을 따로 저장해 두는데, 데모에서는 swStop 이 그 일을 한다. 규격은 MDN 의 requestAnimationFrame 문서와 Page Visibility API 문서에 있다.

탭이 숨었다 돌아올 때

데모는 탭이 숨으면 루프를 멈췄다가 돌아오면 첫 프레임의 dt 를 버린다. 멈춘 동안의 시간을 한 프레임에 몰아 넣으면 바늘이 순간 이동하기 때문이다. 시작 시각을 기준으로 뺄셈하는 실제 앱에서는 숨은 시간도 경과에 그대로 들어가므로, 이 처리가 필요 없는 대신 정지와 재개 때 저장하는 값을 정확히 두어야 한다.

어디에 붙이나

운동 기록은 03 의 구간 표와 04 의 막대가 맞는 자리이고, 발표 시간 배분은 03 의 누적 열과 08 의 그림자 바늘로 지난 연습과의 차를 읽을 수 있다. 공부 타이머는 06 의 보조 문자반이 한 시간을 넘는 기록을 받쳐 주고, 첫 화면의 시간 표시는 09 의 전광판이 맡는다. 라이브러리·WebGL·CDN 은 아홉 장면 소스에 0건이라 복사한 파일 하나로 돌아간다.

랩 기록을 몇 개까지 둘지

03 의 표는 TABLE_ROWS 6줄, 09 의 목록은 LIST_MAX 5줄에서 오래된 줄이 사라진다. 줄 수 제한이 없는 09 의 오른쪽 판은 랩이 쌓일수록 숫자판을 밀어내므로, 화면에 보일 줄 수를 정하고 나머지는 스크롤이나 별도 기록 화면으로 보내는 편이 낫다.

스톱워치가 깨지는 순간 — 소수를 더하다 쌓이는 오차로 흔들리는 숫자·랩 누른 시각과 누적이 섞여 틀리는 기록·멈출 때 바늘이 한 번에 서서 끊겨 보이는 정지

소수를 더하다 쌓이는 오차

0.01 을 센티초 수만큼 더하는 방식은 곧 어긋난다. 11번 더한 합이 0.10999999999999999 라 센티초가 10 으로 읽히고, 첫 500 센티초 중 298칸, 1700 센티초 중 1498칸이 어긋난다. 어긋남의 크기는 늘 1 센티초라 눈에 덜 띄지만, 100번 더한 합 1.0000000000000007 은 바닥을 취하면 우연히 100 으로 맞아서 어느 칸이 틀리는지 예측하기가 어렵다. 02 의 시연 시작점은 경과 6.55초이고 이때 정수 판이 00:06.55 이며 합 판은 00:06.54 로 읽힌다.

랩 누른 시각과 구간이 섞이는 기록

누른 시각 marks[i] 를 구간 열에 그대로 쓰면 줄마다 값이 커지기만 한다. 03 의 틀린 판은 구간 열에 200 452 714 를 쓰고 그 합을 누적 열에 써서, 누적이 7번째 줄에서 6632 센티초가 된다. 정답은 같은 누른 시각에서 직전 누적을 뺀 200 252 262 222 272 202 302 센티초다.

멈출 때 바늘이 한 번에 서는 정지

숫자는 정지 순간의 값에서 멈추는데 바늘이 그 자리에서 곧바로 서면, 눈에는 정지가 끊겨 보인다. 05 의 오른쪽 판이 그 모양이고, 왼쪽은 KICK_V 3.6 으로 첫 정점 6.18도까지 지나쳤다가 0.417초에 0.5도 안으로 들어와 선다. 정지 순간 두 판이 한 번 번쩍이는 것도 이 차이를 눈에 띄게 해 준다.

아홉 장면의 HTML 과 React 판 파일은 아래 zip 에 들어 있고 압축을 푸는 비밀번호는 vj4wavrb 이다.

랩 막대 기준과 정지 튕김과 한 바퀴 길이를 바꾸면 느낌은 얼마나 달라질까 — BASE_PAD·KICK_V·TURN_CS 를 바꿔 본 차이

이름 바꾼 값 느낌
기준을 최솟값에 두기 04 BASE_PAD 0.25 → 0 기준이 최솟값 239 에 놓여 가장 짧은 랩의 막대 길이가 0.200 에서 0 이 되어 막대가 사라지고, 두 번째로 짧은 랩(247)도 0.275 에서 0.094 로 줄어 짧은 랩끼리의 차이는 크게 보이지만 가장 짧은 랩이 비어 보인다
정지 때 더 약하게 밀기 05 KICK_V 3.6 → 2.0 첫 정점이 6.18도에서 3.43도로 낮아지고(정점 시각 0.067초는 같다), 0.5도 안으로 들어와 서기까지 걸리는 시간이 0.417초에서 0.283초로 줄어 튕김이 작고 빨리 가라앉는다
한 바퀴를 더 짧게 07 TURN_CS 1200 → 600 한 바퀴가 12초에서 6초가 되면 같은 11랩에서 가장 안쪽 고리 번호가 3 에서 5 로 늘고 바퀴 경계를 넘어 둘로 잘리는 랩이 2개에서 4개가 되어 호가 더 자주 안쪽 고리로 이어진다

값을 바꾸기 전에 볼 것

이 세 값은 각각 04·05·07 의 상수이니 장면 하나에서 값 하나만 바꿔야 비교가 맞는다. 04 는 랩을 누르는 시각이 판마다 달라지므로 첫 판(lap 0)의 구간을 기준으로 삼아야 위 수치와 맞는다. 05 의 감쇠 DAMPING_C 14 와 스프링 SPRING_K 420 은 그대로 두고 KICK_V 만 바꾸면 정점 시각 0.067초가 변하지 않는 것도 확인된다.

움직임 줄이기에서는 멈춘 한 장의 문자반 — 장면 이름과 현재 기록을 문장으로 읽어 주는 접근성

Enter·Space·R 로 다시 시작하고 P·N 으로 다룬다

무대 상자는 아홉 장면 모두 role="application" 이며 그 aria-label 에 쓸 수 있는 조작이 적혀 있다. Enter·Space·R·클릭은 처음부터 다시 시작, P 는 일시정지, N 은 시간표의 다음 일까지 한 걸음이다. 시간표가 빈 01·02·06 에서는 곧바로 처음부터 다시가 된다. 랩 버튼과 초기화 버튼은 없다. 랩 누르기와 정지는 시간표가 자동으로 시연한다.

상태 줄은 시연 중에는 조용하다

상태 줄 data-role="status" 는 시연 중 aria-live="off" 라 매 프레임 갱신이 읽히지 않고, 사람이 처음 손대는 순간 polite 로 바뀐다. 결과 안내는 data-role="done" (role="status") 한 곳에 RESULT_S 초가 되면 한 번만 쓴다. 캔버스는 role="img" 이고 aria-label 에 장면 설명이 들어 있다.

움직임 줄이기와 숨은 탭

prefers-reduced-motion 이 켜져 있으면 루프를 돌리지 않고 REST_S 초 지점의 장면 한 장만 그린다. 아홉 항목 모두 visibilitychange 를 받아 탭이 숨으면 루프를 멈춘다.

스톱워치 FAQ — 라이브러리 없이 만들기·탭이 숨었다 돌아올 때 시간이 어긋나는 이유·웹에 올릴 때 필요한 것

스톱워치 사이트에 올릴 때 이 데모의 시간 원천을 그대로 써도 되나요

그대로 쓰면 안 된다. 데모의 sceneT 는 시연이 재현되도록 rAF 타임스탬프 차이를 쌓은 값이라, 실제 사이트에서는 시작 순간의 performance.now() 를 저장하고 performance.now() − 시작 에서 센티초 정수를 만든 뒤 이 소스의 계산을 이어 붙여야 한다.

스톱워치에 라이브러리가 필요한가요

필요하지 않다. 캔버스 2D 만으로 그려지고 아홉 장면의 소스에 라이브러리·WebGL·CDN 이 0건이며, 난수와 Date 호출도 0건이라 같은 시연은 같은 순서로 재생된다.

랩 기록은 몇 개까지 두면 되나요

화면에 보일 줄 수를 먼저 정하면 된다. 03 은 6줄, 09 는 5줄에서 오래된 줄이 빠지는데, 줄 수 제한이 없으면 목록이 커져 숫자판이 밀려 나가므로 그 이상은 따로 저장해 두고 화면에는 최근 줄만 보인다.

더 읽을거리로는 시계 문자반 디자인을 다룬 아날로그 시계 9종, 카운트다운 쪽의 타이머 링과 모래시계, 숫자가 굴러가는 숫자 롤링 이 이웃하고, 캔버스 호는 MDN 의 arc 문서를 본다.

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

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