potisanのプログラミングメモ

趣味のプログラマーがプログラミング関係で気になったことや調べたことをいつでも忘れられるようにメモするブログです。はてなブログ無料版なので記事の上の方はたぶん広告です。記事中にも広告挿入されるみたいです。

JavaScript Canvas APIでアナログ時計を描画する(カスタム要素仕様)

JavaScriptCanvas APIでアナログ時計を描画するコードです。カスタム要素も使用しています。

本来は類似名前空間やむき出しのオブジェクトよりもモジュールを使った方が適切ですが、ローカルでのデバッグが面倒なので通常のスクリプトとして分割しています。

エンコーディングUTF-8で決め打ちしています。保存形式に合わせてMETA要素とSCRIPT要素のcharset属性を変更してください。

analog_clock.htm

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>アナログ時計</title>
<script src="RadianAngle.js" charset="utf-8"></script>
<script src="NumberXY.js" charset="utf-8"></script>
<script>
// キャンバスにアナログ時計を描画するカスタム要素クラス
class AnalogClockElement extends HTMLElement
{
  #canvas;
  #timerId;

  static
  {
    const customAttributes = ["width","height","disabled","interval"];
    const floatAttributesWithRedraw = [
      "secondHandWidth","minuteHandWidth","hourHandWidth",
      "secondHandLengthCoeff","minuteHandLengthCoeff","hourHandLengthCoeff","hourStringDistanceCoeff",
      "borderWidth"
    ];
    const stringAttributesWithRedraw = [
      "strokeStyle", "fillStyle"
    ];
    const observedAttributes = [
      ...customAttributes, ...floatAttributesWithRedraw, ...stringAttributesWithRedraw];
    Object.defineProperties(this, {
        customAttributes: {value: customAttributes, writable: false},
        floatAttributesWithRedraw: {value: floatAttributesWithRedraw, writable: false},
        stringAttributesWithRedraw: {value: stringAttributesWithRedraw, writable: false},
        observedAttributes: {value: observedAttributes, writable: false}
      }
    );
  }

  constructor()
  {
    super();

    const shadow = this.attachShadow({mode: "closed"});
    this.#canvas = document.createElement("canvas");
    shadow.appendChild(this.#canvas);

    // 浮動小数点getterと代入・再描画setterだけのプロパティ追加
    function makeFloatAttribute(name) {
      return {
        get: function() { return Number.parseFloat(this.getAttribute(name)); },
        set: function(value) {
          this.setAttribute(name, value);
          this.redraw();
        }
      }
    }
    this.constructor.floatAttributesWithRedraw.forEach(
      name => Object.defineProperty(this, name, makeFloatAttribute(name)));

    // 文字列getterと代入・再描画setterだけのプロパティ追加
    function makeStringAttribute(name) {
      return {
        get: function() { return this.getAttribute(name); },
        set: function(value) {
          this.setAttribute(name, value);
          this.redraw();
        }
      }
    }
    this.constructor.stringAttributesWithRedraw.forEach(
      name => Object.defineProperty(this, name, makeStringAttribute(name)));
  }

  get canvasElement() { return this.#canvas; }
  get timerId() { return this.#timerId; }

  attributeChangedCallback(name, oldValue, newValue)
  {
    this[name] = newValue;
  }

  connectedCallback()
  {
    // 属性の既定値を設定する。
    this.width                   = Number.parseInt(this.width)  || 100;
    this.height                  = Number.parseInt(this.height) || 100;
    this.disabled                = Boolean(this.disabled);
    this.interval                = Number.parseInt(this.interval) || 100;
    this.secondHandWidth         = Number.parseFloat(this.secondHandWidth) || 1;
    this.minuteHandWidth         = Number.parseFloat(this.minuteHandWidth) || 3;
    this.hourHandWidth           = Number.parseFloat(this.hourHandWidth)   || 6;
    this.secondHandLengthCoeff   = Number.parseFloat(this.secondHandLengthCoeff)   || 0.8;
    this.minuteHandLengthCoeff   = Number.parseFloat(this.minuteHandLengthCoeff)   || 0.6;
    this.hourHandLengthCoeff     = Number.parseFloat(this.hourHandLengthCoeff)     || 0.4;
    this.hourStringDistanceCoeff = Number.parseFloat(this.hourStringDistanceCoeff) || 0.8;
    this.borderWidth             = Number.parseFloat(this.borderWidth) || 1;
    this.strokeStyle             ||= "black";
    this.fillStyle               ||= "white";

    if (!this.disabled)
      this.#startInterval();
  }

  disconnectedCallback()
  {
    this.#stopInterval();
  }

  #startInterval()
  {
    this.#stopInterval();
    this.#timerId = setInterval(function(caller) { caller.redraw(); }, this.interval, this);
  }

  #stopInterval()
  {
    clearInterval(this.#timerId);
    this.#timerId = undefined;
  }

  get width() { return this.#canvas.width; }
  set width(value)
  {
    this.#canvas.width = value;
    this.redraw();
  }
  get height() { return this.#canvas.width; }
  set height(value)
  {
    this.#canvas.height = value;
    this.redraw();
  }

  get disabled() { return this.hasAttribute("disabled"); }
  set disabled(value)
  {
    if (value) {
      this.setAttribute("disabled", "");
      this.#stopInterval();
    } else {
      this.removeAttribute("disabled");
      this.#startInterval();
    }
  }

  get interval() { return Number.parseInt(this.getAttribute("interval")); }
  set interval(value)
  {
    value = Number.parseInt(value);
    var oldValue = this.interval;
    if (value !== oldValue)
    {
      this.setAttribute("interval", value);
      this.#startInterval();
    }
  }

  redraw()
  {
    const ctx = this.#canvas.getContext("2d");
    const now = new Date();

    const wh = new NumberXY(this.width, this.height);
    const center = wh.divided2(2, 2);
    const radius = Math.min(center.x, center.y) * 0.8;
    const secondHandLength   = radius * this.secondHandLengthCoeff;
    const minuteHandLength   = radius * this.minuteHandLengthCoeff;
    const hourHandLength     = radius * this.hourHandLengthCoeff;
    const hourStringDistance = radius * this.hourStringDistanceCoeff;
    const secondRad  = RadianAngle.fromTimeSecond(now.getSeconds());
    const minuteRad  = RadianAngle.fromTimeMinute(now.getMinutes());
    const hourRad    = RadianAngle.fromTimeHour(now.getHours());
    const secondHand = center.added(NumberXY.getCosSinMultiplied(secondRad, secondHandLength));
    const minuteHand = center.added(NumberXY.getCosSinMultiplied(minuteRad, minuteHandLength));
    const hourHand   = center.added(NumberXY.getCosSinMultiplied(hourRad, hourHandLength));
    const strokeStyle = this.strokeStyle;
    const fillStyle   = this.fillStyle;
    const borderWidth = this.borderWidth;

    // 背景の塗りつぶし
    ctx.fillStyle = fillStyle;
    ctx.fillRect(0, 0, wh.x, wh.y);

    // 丸枠の描画
    ctx.beginPath();
    ctx.strokeStyle = strokeStyle;
    ctx.lineWidth   = borderWidth;
    ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
    ctx.stroke();

    // 針の描画
    const drawTimeHand = (lineWidth, handXY) => {
      ctx.beginPath();
      ctx.strokeStyle = strokeStyle;
      ctx.lineWidth = lineWidth;
      ctx.moveTo(center.x, center.y);
      ctx.lineTo(handXY.x, handXY.y);
      ctx.stroke();
    };
    drawTimeHand(this.secondHandWidth, secondHand); // 秒針
    drawTimeHand(this.minuteHandWidth, minuteHand); // 分針
    drawTimeHand(this.hourHandWidth,   hourHand);   // 時針

    // 時間の描画
    const hourStrings =  ["12","1","2","3","4","5","6","7","8","9","10","11"];
    hourStrings.forEach((hourStr, i) => {
      const iHourRad = RadianAngle.fromTimeHour(i);
      const xy = center.added(NumberXY.getCosSinMultiplied(iHourRad, hourStringDistance));
      ctx.fillStyle    = strokeStyle;
      ctx.textAlign    = "center";
      ctx.textBaseline = "middle";
      ctx.fillText(hourStr, xy.x, xy.y);
    });
  }
}
customElements.define("analog-clock", AnalogClockElement);
</script>
</head>
<body>
<analog-clock id="clock1" width="250" height="250" borderWidth="3"></analog-clock>
</body>
</html>

RadianAngle.js

"use strict;"
// ラジアン単位の角度
window.RadianAngle = {
  ...window.RadianAngle,
  fromTimeSecond(second)
  {
    if (!Number.isInteger(second)) throw new TypeError("整数を指定してください。");
    if (!(0 <= second && second <= 60))
      throw new RangeError("0~60の範囲内で指定してください。");
    return second / 60.0 * Math.PI * 2.0 - Math.PI / 2.0;
  },
  fromTimeMinute(minute)
  {
    if (!Number.isInteger(minute)) throw new TypeError("整数を指定してください。");
    if (!(0 <= minute && minute <= 60))
      throw new RangeError("0~60の範囲内で指定してください。");
    return minute / 60.0 * Math.PI * 2.0 - Math.PI / 2.0;
  },
  fromTimeHour(hour)
  {
    if (!Number.isInteger(hour)) throw new TypeError("整数を指定してください。");
    if (0 <= hour && hour < 12)
      return hour / 12.0 * Math.PI * 2.0 - Math.PI / 2.0;
    else if (12 <= hour && hour <= 24)
      return (hour - 12) / 12.0 * Math.PI * 2.0 - Math.PI / 2.0;
    else
      throw new RangeError("0~24の範囲内で指定してください。");
  }
}

NumberXY.js

// 整数型の変数X、Yをまとめて操作するクラス
class NumberXY
{
  #x;
  #y;

  constructor(x, y)
  {
    NumberXY.#throwIfNotNumber2(x, y);
    this.x = x;
    this.y = y;
  }

  get x() { return this.#x; }
  get y() { return this.#y; }
  set x(value)
  {
    NumberXY.#throwIfNotNumber(value);
    return this.#x = value;
  }
  set y(value)
  {
    NumberXY.#throwIfNotNumber(value);
    return this.#y = value;
  }

  added(xy)
  {
    NumberXY.#throwIfNotNumberXY(xy);
    return new NumberXY(this.#x + xy.x, this.#y + xy.y);
  }
  subtraced(xy)
  {
    NumberXY.#throwIfNotNumberXY(xy);
    return new NumberXY(this.#x - xy.x, this.#y - xy.y);
  }
  multiplied(xy)
  {
    NumberXY.#throwIfNotNumberXY(xy);
    return new NumberXY(this.#x * xy.x, this.#y * xy.y);
  }
  divided(xy)
  {
    NumberXY.#throwIfNotNumberXY(xy);
    return new NumberXY(this.#x / xy.x, this.#y / xy.y);
  }
  added2(x, y)
  {
    NumberXY.#throwIfNotNumber2(x, y);
    return new NumberXY(this.#x + x, this.#y + y);
  }
  subtracted2(x, y)
  {
    NumberXY.#throwIfNotNumber2(x, y);
    return new NumberXY(this.#x - x, this.#y - y);
  }
  multiplied2(x, y)
  {
    NumberXY.#throwIfNotNumber2(x, y);
    return new NumberXY(this.#x * x, this.#y * y);
  }
  divided2(x, y)
  {
    NumberXY.#throwIfNotNumber2(x, y);
    return new NumberXY(this.#x / x, this.#y / y);
  }

  static getCosSin(rad)
  {
    NumberXY.#throwIfNotNumber(rad);
    return new NumberXY(Math.cos(rad), Math.sin(rad));
  }

  static getCosSinMultiplied(rad, coefficient)
  {
    NumberXY.#throwIfNotNumber2(rad, coefficient);
    return new NumberXY(Math.cos(rad) * coefficient, Math.sin(rad) * coefficient);
  }

  static #throwIfNotNumber(x)
  {
    if (typeof x !== "number")
      throw new TypeError("数値型を指定してください。");
  }

  static #throwIfNotNumber2(x, y)
  {
    if (typeof x !== "number" || typeof y !== "number")
      throw new TypeError("数値型を指定してください。");
  }

  static #throwIfNotNumberXY(xy)
  {
    if (!(xy instanceof NumberXY))
      throw new TypeError("NumberXY型を指定してください。");
  }
}

関連リンク