JavaScript アナログ時計

JavaScriptでアナログ時計を作成してみました。
[動作サンプル](https://akebi.jp/temp/analogclock/)

文字盤、各針それぞれにcanvasを振り分けて描画したものを重ね合わせて表示しています。

時刻、日付、ストップウォッチ各表示に切替ができます。

**HTML**

~~~html:index.html
<!doctype html>
<html lang='ja'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1' />
<title>Analog Clock</title>
</head>
<body>
<div id='clock'>
    <canvas id='face' width='350' height='350'></canvas>
    <div id='dh'><canvas id='hour' width='200' height='20'></canvas></div>
    <div id='dm'><canvas id='minute' width='200' height='20'></canvas></div>
    <div id='ds'><canvas id='second' width='200' height='20'></canvas></div>
</div>

<div id='button'>
    <input type='button' id='time' value='TIME'>
    <input type='button' id='date' value='DATE'>
    <input type='button' id='stopwatch' value='STOPWATCH'>
    <input type='button' id='start' value='START'>
    <input type='button' id='reset' value='RESET'>
</div>

<script src="./script.js"></script>
</body>
</html>
~~~

**JavaScript**

~~~js:script.js
"use strict";

document.getElementById('clock').style.position = 'relative';
document.getElementById('clock').style.height = '320px';
document.getElementById('button').style.position = 'relative';

const storage = getStorage();

const canvasHour = document.getElementById('hour');
const ctxHour = canvasHour.getContext('2d');

const canvasMinute = document.getElementById('minute');
const ctxMinute = canvasMinute.getContext('2d');

const canvasSecond = document.getElementById('second');
const ctxSecond = canvasSecond.getContext('2d');

const canvasFace = document.getElementById('face');
const ctxFace = canvasFace.getContext('2d');

canvasHour.style.position = 'absolute';
canvasMinute.style.position = 'absolute';
canvasSecond.style.position = 'absolute';

// 針回転中心の相対位置
canvasHour.style.left = '-8px';
canvasHour.style.top = '-4px';
canvasMinute.style.left = '-12px';
canvasMinute.style.top = '-4px';
canvasSecond.style.left = '-50px';
canvasSecond.style.top = '-4px';

const dh = document.getElementById('dh');
const dm = document.getElementById('dm');
const ds = document.getElementById('ds');

// 針位置
dh.style.position =
dm.style.position =
ds.style.position = 'absolute';
dh.style.left =
dm.style.left =
ds.style.left = '160px';
dh.style.top =
dm.style.top =
ds.style.top = '160px';

    // 各針初期角度
let hour = 0,
    minute = 0,
    second = 0,
    // 針移動アニメーション用
    hour_ = 0,
    minute_ = 0,
    second_ = 0;

//ストップウォッチ用
let swState     = storage.swState     ?? 0, // 動作状態
    swStartTime = storage.swStartTime ?? 0, // スタートタイム
    swStopTime  = storage.swStopTime  ?? 0, // ストップタイム
    swLapFlag   = 0;                        // ラップ動作フラグ

let t,
    mode = 0; // 動作モード

setButtonState();

// 起動1秒後、localStorageに記憶している動作モードに変更
setTimeout(function() {
    mode = storage.mode ?? 1;
}, 1000);

setInterval(setAngle, 15);

// 各針角度変更
function setAngle() {
    // 時刻
    if(mode === 1) {
        const n = (Date.now() / 1000 - new Date().getTimezoneOffset() * 60) % 86400;
        hour = (n / 120) % 360;
        minute = (n / 10) % 360;
        second = (Math.floor(n) * 6) % 360;
    }
    // 日付
    else if(mode === 2) {
        const d = new Date();
        // 時針 -> 月
        hour = ((d.getMonth() + 1) % 12) * 30;
        // 分針 -> 日
        minute = d.getDate() * 6;
        // 秒針 -> 年 下2桁
        second = (d.getFullYear() % 100) * 6;
    }
    // ストップウォッチ
    else if(mode === 3) {
        // ストップウォッチ動作中
        if(swState === 1) {
          const
                now = Date.now(),
                time = now - swStartTime;

            // ラップ動作中ではない
            if(!swLapFlag) {
                // 針位置 現在時間 - ストップウォッチ動作開始時間
                hour = (time / 120000) % 360;
                minute = (time / 10000) % 360;
                second = ((time / 1000) % 60) * 6;
            }
            // ストップウォッチ動作中は1秒周期でボタンを点滅
            document.getElementById('start').style.background =
                Math.floor((time / 500) % 2) === 0 ? 'cyan' : '#a0a0a0';
        }
        // ストップウォッチ停止中
        else {
            // 針位置 ストップウォッチ停止時の時間
            const time = swStopTime;
            hour = (time / 120000) % 360;
            minute = (time / 10000) % 360;
            second = ((time / 1000) % 60) * 6;
        }
    }

    // 前回針位置から最短距離で回転させる場合に0°を経由する場合の補正
    if(Math.abs(hour - hour_) > 180)
        hour_ += 360 * Math.sign(hour - hour_);

    if(Math.abs(minute - minute_) > 180)
        minute_ += 360 * Math.sign(minute - minute_);

    if(Math.abs(second - second_) > 180)
        second_ += 360 * Math.sign(second - second_);

    // 針回転方向及び上限速度設定
    t = (hour - hour_) / 4;
    if(Math.abs(t) > 2) t = 2 * Math.sign(t);
    hour_ += t;

    t = (minute - minute_) / 4;
    if(Math.abs(t) > 3) t = 3 * Math.sign(t);
    minute_ += t;

    t = (second - second_) / 3;
    if(Math.abs(t) > 3) t = 3 * Math.sign(t);
    second_ += t;

    // 針角度変更
    dh.style.transform = `rotate(${(270 + hour_) % 360}deg)`;
    dm.style.transform = `rotate(${(270 + minute_) % 360}deg)`;
    ds.style.transform = `rotate(${(270 + second_) % 360}deg)`;
}

// 時針描画
drawHands({
    ctx: ctxHour,
    draw: [{type: 'cline', color: '#444444', positions:[
        0,4, 6,0, 15,4-3, 85,4-2, 89,4, 85,4+2, 15,4+3, 6,8
    ]}],
});

// 分針描画
drawHands({
    ctx: ctxMinute,
    draw: [{type: 'cline', color: '#444444', positions:[
        0,4, 10,0, 30,4-2, 138,4-1.5, 145,4, 138,4+1.5, 30,4+2, 10,8
    ]}],
});

// 秒針描画
drawHands({
    ctx: ctxSecond,
    draw: [{type: 'cline', color: '#bbbb88', positions:[
        0,4, 4,1, 32,1, 36,3, 189,4, 36,5, 32,7, 4,7
    ]}],
});

// 針描写
function drawHands(params) {
    const c = params.ctx;
    const draw = params.draw;

    for(const d of draw) {
        if(d.type == 'cline') {
            c.beginPath();
            c.strokeStyle = d.color;
            c.moveTo(d.positions[0], d.positions[1]);
            const l = d.positions.length;
            for(let i = 0; i < l / 2; i++) {
                c.lineTo(d.positions[(2 + i * 2) % l], d.positions[(3 + i * 2) % l]);
            }
            c.stroke();
        }
    }
}

// 文字盤描画
canvasFace.style.position = 'absolute';
canvasFace.style.top = '0px';
canvasFace.style.left = '0px';

ctxFace.width = 350;
ctxFace.height = 350;

for(let i = 0; i < 60; i++){
    ctxFace.beginPath();
    ctxFace.strokeStyle = (i % 5 === 0) ? '#808080' : '#c0c0c0';
    const r = (Math.PI * 2) / 60 * i;
    const c = Math.cos(r);
    const s = Math.sin(r);
    const sr = (i % 15 === 0) ? 120 : (i % 5 === 0) ? 130 : 140;
    ctxFace.moveTo(160 + c * sr, 160 + s * sr);
    ctxFace.lineTo(160 + c * 150, 160 + s * 150);
    ctxFace.stroke();
}

const eventHandlerType =
    window.ontouchstart !== undefined ? 'touchstart' : 'mousedown';

// timeボタン押下時
document.getElementById('time').addEventListener(eventHandlerType, function() {
    mode = 1;
    swLapFlag = 0;
    setStorage();
    setButtonState(mode);
}, false);

// dateボタン押下時
document.getElementById('date').addEventListener(eventHandlerType, function() {
    mode = 2;
    swLapFlag = 0;
    setStorage();
    setButtonState(mode);
}, false);

// stopwatchボタン押下時
document.getElementById('stopwatch').addEventListener(eventHandlerType, function() {
    mode = 3;
    swLapFlag = 0;
    setStorage();
    setButtonState(mode);
}, false);

// startボタン押下時
document.getElementById('start').addEventListener(eventHandlerType, function() {
    // ストップウォッチ表示モードでなければ戻る
    if(mode !== 3) return;
    // ストップウォッチ停止中
    if(swState === 0) {
        // ストップウォッチ動作開始
        const now = Date.now();
        swStartTime = now - swStopTime;
        swState = 1;
        document.getElementById('start').value = 'STOP';
        document.getElementById('reset').value = 'LAP';
    }
    // ストップウォッチ停止中
    else {
        // ストップウォッチ動作停止
        const now = Date.now();
        swStopTime = now - swStartTime;
        swState = 0;
        swLapFlag = 0;
        document.getElementById('start').value = 'START';
        document.getElementById('reset').value = 'RESET';
    }
    setStorage();
    setButtonState(mode);
}, false);

// resetボタン押下時
document.getElementById('reset').addEventListener(eventHandlerType, function() {
    // ストップウォッチ表示モードでなければ戻る
    if(mode !== 3) return;
    // ストップウォッチ停止中
    if(swState === 0) {
        // ストップウォッチリセット
        swStartTime =
        swStopTime = 0;
        deleteStorage();
    }
    // ストップウォッチ停止中
    else {
        // ラップON/OFF
        swLapFlag ^= 1;
        setStorage();
        setButtonState(mode);
    }
}, false);

// localStorage保存
function setStorage() {
    localStorage.setItem('analogClock_params', JSON.stringify({
        swState:     swState,
        swStartTime: swStartTime,
        swStopTime:  swStopTime,
        mode:        mode,
    }));
}

// localStorage削除
function deleteStorage() {
    localStorage.removeItem('analogClock_params');
}

// localStorage取得
function getStorage() {
    const params = localStorage.getItem('analogClock_params');
    return params ? JSON.parse(params) : {};
}

// ボタン状態設定
function setButtonState(mode) {
    const _m = storage.mode ?? 1;
    mode = mode ?? _m;
    document.getElementById('time').style.background = mode == 1 ? '#ddffdd' : '#a0a0a0';
    document.getElementById('date').style.background = mode == 2 ? '#ddffdd' : '#a0a0a0';
    document.getElementById('stopwatch').style.background = mode == 3 ? '#ddffdd' : '#a0a0a0';
    document.getElementById('start').style.background = mode == 3 && swState ? 'cyan' : '#a0a0a0';
    document.getElementById('reset').style.background = swLapFlag ? 'yellow' : '#a0a0a0';
    document.getElementById('start').value = swState ? 'STOP' : 'START';
    document.getElementById('reset').value = swState ? 'LAP' : 'RESET';
}
~~~