年賀状を全力でおめでたくしたら動くメッセージができた (さわれるデモ付き)【HackDay 2021 最優秀賞作品 技術解説 #2】
はじめに
この記事はこちらの記事の続きです。
改めまして、ねりこです。
先日Hack Day 2021に参加し、「ネオネンガ」というプロダクトで最優秀賞ほか4冠を獲得しました。ありがとうございます!
作品の詳細については上記Part1をご覧ください。
さて、ネオネンガには「年賀状の上に手書きでメッセージを書き、それが書かれる様子まで含めて記録する機能」があります。
本記事では、Reactを用いてその機能を実装した方法を解説します。
手書きメッセージをアニメーションしよう
こいつ…動くぞ!
デモページ、リポジトリはこちら!
なお、この実装には、2012年のこの記事を大いに参考にしました。
やりたいこと
- canvas要素上にマウス(orタッチ)でお絵かきができる
- そのお絵かきの様子を記録して、JSONとしてexportする
- そのJSONから、お絵かきの様子をcanvas要素上にアニメーションとして再現する
実装の方針
- お絵かきの様子を、時系列で区切って(
Segment
と呼ぶ)記録する - マウスを離すか、一定時間止まったときにSegmentを切る
- 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]);
ざっくりとこんな感じの実装になります。
そしてこれを用いて「あけおめ」と書いたときの history
を JSON.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として記録し、他の人の端末でアニメーションとして再生できるようにする」方法について、自分の実装を書かせていただきました。
冒頭にも載せましたが、以下に全体の実装とデモがありますので、ぜひ触ってみてくれたら嬉しいです!
また、調べた限りではこのようなことができるnpm packageは存在しなかった(手書きはできるが、アニメーションとして記録するものはない)ので、後日コードを整理してnpm packageとしてpublishしようと思います。
お楽しみに!
Discussion