✍️

年賀状を全力でおめでたくしたら動くメッセージができた (さわれるデモ付き)【HackDay 2021 最優秀賞作品 技術解説 #2】

12 min read

はじめに

この記事はこちらの記事の続きです。

https://zenn.dev/nerikosans/articles/23f94063e29875

改めまして、ねりこです。
先日Hack Day 2021に参加し、「ネオネンガ」というプロダクトで最優秀賞ほか4冠を獲得しました。ありがとうございます!
作品の詳細については上記Part1をご覧ください。

さて、ネオネンガには「年賀状の上に手書きでメッセージを書き、それが書かれる様子まで含めて記録する機能」があります。
本記事では、Reactを用いてその機能を実装した方法を解説します。

手書きメッセージをアニメーションしよう

こいつ…動くぞ!

デモページ、リポジトリはこちら!

https://react-recordable-handwriting-demo.vercel.app/
https://github.com/nerikosans/react-recordable-handwriting-demo

なお、この実装には、2012年のこの記事を大いに参考にしました。

http://ramkulkarni.com/blog/record-and-playback-drawing-in-html5-canvas/

やりたいこと

  1. canvas要素上にマウス(orタッチ)でお絵かきができる
  2. そのお絵かきの様子を記録して、JSONとしてexportする
  3. そのJSONから、お絵かきの様子をcanvas要素上にアニメーションとして再現する

実装の方針

  1. お絵かきの様子を、時系列で区切って(Segment と呼ぶ)記録する
  2. マウスを離すか、一定時間止まったときにSegmentを切る
  3. Segmentには、マウスの座標列と、直前Segmentからの待機時間を記録する

つまりこういうことになります。

type Position = [number, number];
type Segment = {
  anchors: Position[];
  interval: number;
  
  // 直前Segmentとつながっているか? (マウスを離さずに止まっただけならtrue)
  continuous: boolean; 
};
export type DrawingData = Segment[];

時間設定としては、マウスが動いたとき、

  • 前回の記録より 100ms 以上経っていたら、新しいSegmentを追加
  • 10ms 以上経っていたら、同一Segment内の新しい点として追加
  • 10ms 経っていなければ無視

として記録します。

実装

  • マウスの動きについてはcanvas要素上の onMouseDown/Move/Up で取得
  • お絵かきの描画については、canvas contextに対して moveTo, lineTo, stroke 等を呼び出す

準備

const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [history, setHistory] = React.useState<Segment[]>([]); // 手書きデータ
const [, setCurrentSegment] = React.useState<Segment | null>(null); // いま書いてるSegment
const [lastAnchorTime, setLastAnchorTime] = React.useState(0); // 前回描いた時刻
const ANCHOR_INTERVAL = 10;
const SEGMENT_THRES = 100;

canvas上でのマウス/タッチの座標を取得する

マウス座標
const getCanvasMousePosition = React.useCallback(
  (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>): Position | null => {
    if (canvasRef.current === null) return null;
    const rect = canvasRef.current.getBoundingClientRect();
    const x = Math.floor(e.clientX - rect.left);
    const y = Math.floor(e.clientY - rect.top);
    return [x, y];
  },
  []
);
タッチ座標
const getCanvasTouchPosition = React.useCallback(
  (e: React.TouchEvent<HTMLCanvasElement>): Position | null => {
    if (canvasRef.current === null) return null;
    const rect = canvasRef.current.getBoundingClientRect();
    const x = Math.floor(e.touches[0].clientX - rect.left);
    const y = Math.floor(e.touches[0].clientY - rect.top);
    return [x, y];
  },
  []
);

canvas上で描画を行うための関数群

線を引く
const beginPath = React.useCallback((startPos: Position) => {
  const ctx = canvasRef.current?.getContext('2d');
  if (!ctx) return;
  ctx.strokeStyle = drawingColor;
  ctx.lineWidth = currentLineWidth;
  ctx.beginPath();
  ctx.moveTo(startPos[0], startPos[1]);
}, []);

const lineTo = React.useCallback((pos: Position) => {
  const ctx = canvasRef.current?.getContext('2d');
  if (!ctx) return;
  ctx.lineTo(pos[0], pos[1]);
  ctx.stroke();
}, []);
クリアする
const clearCanvas = React.useCallback(() => {
  setLastAnchorTime(0);
  const canvas = canvasRef.current;
  const ctx = canvas?.getContext('2d');
  if (!ctx || !canvas) return;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
}, []);

Segmentを処理する関数を書く

Segmentの開始・終了
const startSegment = React.useCallback(
  (initPos: Position, interval: number, continuous: boolean) => {
    setCurrentSegment((prev) => {
      if (prev !== null) return prev;
      return {
        anchors: [initPos],
        interval,
        continuous,
      };
    });
    setLastAnchorTime(getNow());
  },
  []
);

// 終了時には全体データに追加
const endSegment = React.useCallback(() => {
  setCurrentSegment((seg) => {
    if (seg !== null) {
      setHistory((prevHistory) => [...prevHistory, seg]);
    }
    return null;
  });
}, []);
Segmentに座標を追加する
const pushPosition = React.useCallback((pos: Position) => {
  setCurrentSegment((prev) => {
    if (prev === null) return null;
    setLastAnchorTime(getNow());

    return {
      ...prev,
      anchors: [...prev.anchors, pos],
    };
  });
}, []);

マウスが動いたときにSegmentの情報を更新する

動き始め
const onDrawStart = React.useCallback(
  (pos: Position) => {
    setMouseDown(true);
    
    // 直前Segmentからの経過時間
    const interval = lastAnchorTime === 0 ? 0 : getNow() - lastAnchorTime;

    if (canvasRef.current === null) return;
    startSegment(pos, interval, false);

    beginPath(pos);
  },
  [beginPath, lastAnchorTime, startSegment]
);
動き途中
const onDrawMove = React.useCallback(
  (pos: Position) => {
    if (canvasRef.current === null) return;
    if (!mouseDown) return;

    const now = getNow();
    if (now < lastAnchorTime + ANCHOR_INTERVAL) return;
    lineTo(pos);
    if (now < lastAnchorTime + SEGMENT_THRES) {
      pushPosition(pos);
      return;
    }
    endSegment();
    startSegment(pos, now - lastAnchorTime, true);
  },
  [
    endSegment,
    lastAnchorTime,
    lineTo,
    mouseDown,
    pushPosition,
    startSegment,
  ]
);
動き終わり
const onDrawEnd = React.useCallback(() => {
  if (canvasRef.current === null) return;
  setMouseDown(false);
  endSegment();
}, [endSegment]);

ざっくりとこんな感じの実装になります。
そしてこれを用いて「あけおめ」と書いたときの historyJSON.stringify すれば、この通り!

[{"anchors":[[38,410]],"interval":0,"continuous":false},{"anchors":[[39,410],[46,410],[52,410],[58,410],[63,409],[69,409],[75,408],[80,407],[82,407],[84,407],[85,407],[85,407]],"interval":263,"continuous":true},{"anchors":[[61,393]],"interval":487,"continuous":false},{"anchors":[[61,394],[61,398],[61,401],[61,406],[61,410],[61,413],[61,418],[61,424],[61,429],[61,434],[62,438],[63,441],[65,445],[67,448],[71,454],[74,457],[75,459]],"interval":186,"continuous":true},{"anchors":[[86,428]],"interval":482,"continuous":false},{"anchors":[[86,429],[86,433],[86,435],[86,438],[85,441],[84,443],[83,445],[82,448],[80,450],[77,452],[74,454],[70,455],[66,456],[60,458],[56,458],[54,459],[51,459],[49,459],[47,459],[45,458],[44,457],[43,456],[42,455],[42,453],[41,451],[41,448],[41,446],[41,444],[41,443],[41,441],[42,440],[43,439],[45,438],[47,437],[49,436],[50,435],[52,435],[53,435],[54,435],[58,435],[62,435],[67,435],[73,435],[77,436],[81,437],[82,437],[84,438],[86,439],[87,439],[89,440],[92,442],[94,444],[100,447],[103,450],[106,452],[108,454],[110,456],[110,457],[111,458],[111,459],[111,459],[111,460],[111,460]],"interval":166,"continuous":true},{"anchors":[[161,419]],"interval":585,"continuous":false},{"anchors":[[161,419],[161,421],[161,424],[161,427],[161,430],[161,434],[161,438],[161,444],[161,447],[161,450],[162,452],[163,453]],"interval":188,"continuous":true},{"anchors":[[187,428]],"interval":524,"continuous":false},{"anchors":[[189,428],[191,428],[195,428],[202,428],[207,428],[214,428],[219,428],[223,428],[225,428],[226,428]],"interval":181,"continuous":true},{"anchors":[[211,411]],"interval":444,"continuous":false},{"anchors":[[211,412],[211,415],[211,417],[211,419],[211,421],[211,423],[211,425],[211,427],[211,429],[211,432],[211,434],[211,438],[211,440],[211,443],[211,446],[211,448],[211,450],[211,452],[211,453],[211,455],[210,456],[209,458],[209,460],[208,462],[206,464]],"interval":181,"continuous":true},{"anchors":[[83,529]],"interval":647,"continuous":false},{"anchors":[[84,529],[88,529],[95,529],[107,529],[125,529],[130,529],[136,529],[139,529],[139,529],[139,529]],"interval":153,"continuous":true},{"anchors":[[119,515]],"interval":389,"continuous":false},{"anchors":[[119,517],[119,521],[119,525],[119,527],[119,532],[119,536],[119,539],[119,543],[119,546],[119,548],[119,550],[119,553],[119,557],[118,560],[117,562],[117,564],[116,565],[115,566],[114,566],[114,566],[113,566],[112,566],[111,564],[109,563],[107,560],[104,557],[104,555],[103,554],[103,553],[103,552],[103,551],[103,549],[104,548],[104,547],[105,547],[106,547],[107,547],[108,547],[111,547],[116,547],[119,547],[124,547],[128,547],[131,547],[133,547],[136,547],[139,548],[141,549],[143,550],[145,552],[148,554],[151,558],[153,561],[155,563],[156,565],[156,567],[156,568],[156,571],[156,572],[156,575],[155,576],[154,578],[152,579],[151,580],[150,581],[149,581],[148,581]],"interval":189,"continuous":true},{"anchors":[[148,530]],"interval":492,"continuous":false},{"anchors":[[148,531],[149,533],[149,532]],"interval":165,"continuous":true},{"anchors":[[197,525]],"interval":659,"continuous":false},{"anchors":[[197,525],[197,529],[197,533],[197,537],[197,542],[197,546],[197,549],[199,554],[200,557],[203,561],[206,563],[207,564],[212,565]],"interval":156,"continuous":true},{"anchors":[[228,525]],"interval":469,"continuous":false},{"anchors":[[228,526],[228,527],[228,529],[228,531],[228,532],[228,534],[228,536],[228,540],[227,544],[226,548],[224,552],[222,558],[220,560],[219,562],[217,564],[216,565],[215,567],[213,568],[211,569],[208,571],[205,573],[201,574],[198,575],[196,576],[195,576],[193,576],[192,576],[191,576],[190,574],[188,572],[187,571],[186,569],[185,567],[184,566],[184,564],[184,563],[184,562],[184,561],[184,560],[184,559],[185,558],[185,556],[186,555],[187,554],[188,553],[188,552],[189,551],[190,551],[191,550],[192,549],[194,549],[196,548],[198,547],[201,546],[205,546],[209,545],[212,544],[215,544],[217,544],[219,543],[221,543],[223,543],[225,543],[230,543],[234,543],[239,544],[244,546],[248,547],[251,548],[253,549],[255,551],[256,552],[257,553],[259,554],[260,556],[261,557],[262,559],[262,560],[262,562],[263,563],[263,564],[263,565],[263,566],[263,567],[263,568],[263,569],[263,570],[262,571],[262,573],[261,574],[260,576],[259,577],[258,578],[257,579],[256,580],[256,581],[255,582],[254,582],[254,583],[253,584],[253,584],[253,584]],"interval":169,"continuous":true}]

ぜひ デモページ で再生してみてください!

メッセージのアニメーション再生機能

さて、あとは再生機能があれば完成です。これは次のように作っていきます。

  • 1つのSegmentに含まれるanchorについては、10msごとにたどって描画
  • 各Segmentのintervalに応じて、setTimeout でSegmentの描画をスケジュール

録画の時点で beginPath, lineTo を定義してあるので、これらを使って次のように書けます。

Segmentを描く

continuous なSegmentかどうかに応じて、beginPath するかを決定しています。

const playSegment = React.useCallback(
  (seg: Segment) => {
    if (seg.continuous) {
      seg.anchors.forEach((pos, i) => {
        setTimeout(() => {
          lineTo(pos);
        }, i * ANCHOR_INTERVAL);
      });
    } else {
      beginPath(seg.anchors[0]);

      seg.anchors.slice(1).forEach((pos, i) => {
        setTimeout(() => {
          lineTo(pos);
        }, (i + 1) * ANCHOR_INTERVAL);
      });
    }
  },
  [beginPath, lineTo]
);
全体を描画する

textareaValue にJSONが入っているとします。

const playData = React.useCallback(() => {
  const data = JSON.parse(textareaValue) as DrawingData;
  clearCanvas();

  let totalDelay = 0;
  for (const seg of data) {
    totalDelay += seg.interval;
    setTimeout(() => {
      playSegment(seg);
    }, totalDelay);
    totalDelay += seg.anchors.length * ANCHOR_INTERVAL;
  }
}, [clearCanvas, playSegment, textareaValue]);

これで再生できました!完成です!

まとめ

ということで、「HTMLのcanvas上にマウスで手書きして、それをJSONとして記録し、他の人の端末でアニメーションとして再生できるようにする」方法について、自分の実装を書かせていただきました。

冒頭にも載せましたが、以下に全体の実装とデモがありますので、ぜひ触ってみてくれたら嬉しいです!

https://react-recordable-handwriting-demo.vercel.app/
https://github.com/nerikosans/react-recordable-handwriting-demo

また、調べた限りではこのようなことができるnpm packageは存在しなかった(手書きはできるが、アニメーションとして記録するものはない)ので、後日コードを整理してnpm packageとしてpublishしようと思います。
お楽しみに!