Leaflet は地図を扱うための JavaScript のライブラリです。この Leaflet の地図の上にマウスで手書きの様に任意の線を引いて、その線が入った画像をダウンロードする方法を紹介します。
実際にこれを実装したデモとソースコードが次です。
cpt-sugiura/freehand-map-demo: Leafletの上に手書きで色々書いてダウンロード
ソースコード上では React を用いていますが、処理の本体は Leaflet 内の addEventListener、React に無関係な SVG を生成する関数、HTML要素を画像化する処理になっていますので素の JavaScript 上でも同じことができます。この重要な部分はそれぞれ次です。
クリックして手書き可能地図ソースコードを展開
import { TileLayer, MapContainer, MapContainerProps } from 'react-leaflet'; import * as React from 'react'; import { select, Selection } from 'd3-selection'; import { line, curveMonotoneX } from 'd3-shape'; import { LeafletMouseEvent, Point } from 'leaflet'; type MapChartProps = { center: MapContainerProps['center']; }; /** * Leaflet の Map */ export const FreeHandMap: React.FC<MapChartProps> = (props) => { // SVG の path 要素を一続きにして伸ばし続ける関数の置き場変数 let pathStretcher: (toPoint: Point) => void; // Map コンポーネントを配置。手書き中に地図が不意に動かない様に以下の props を定義。不意に動くと思った通りの書き込みをし難いです。 // dragging={false} // zoomControl={false} // scrollWheelZoom={false} // doubleClickZoom={false} return ( <MapContainer id={'map'} center={props.center} zoom={17} dragging={false} zoomControl={false} scrollWheelZoom={false} doubleClickZoom={false} whenCreated={(map) => { // Map が用意されたところでイベントをまとめて登録 // 書き始め、書き終わりを制御するための状態 let mouseState: 'WAIT' | 'WRITING' = 'WAIT'; map.addEventListener('mousedown', (event: LeafletMouseEvent) => { // マウスをクリックしたら書き始める。=> 書き込み待ち状態から書き込み中状態になる mouseState = 'WRITING'; // SVG 要素を用意して地図要素の末尾に追加。この SVG 要素の中の path で任意に動かされたマウスの軌跡を表現する const svg = select(map.getContainer()) .append('svg') .attr('width', '100%') .attr('height', '100%') .style('pointer-events', 'none') .style('z-index', '1001') .style('position', 'absolute'); // マウスが動かされたら、SVG 要素中に線を追加する関数を用意 pathStretcher = makePathCreator(svg, map.latLngToContainerPoint(event.latlng)); }); map.addEventListener('mousemove', (event: LeafletMouseEvent) => { // マウスが動かされた時、書き込み中でないのならなにもしない if (mouseState !== 'WRITING') { return; } // 書き込み中ならば線を追加 const point = map.mouseEventToContainerPoint(event.originalEvent); pathStretcher(new Point(point.x, point.y)); }); map.addEventListener('mouseup', () => { // マウスが離されたら書き込みを終えて再び待機状態へ移行 mouseState = 'WAIT'; }); }} > <TileLayer url="https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg" /> </MapContainer> ); }; /** * 点を与えるたびに、引数の SVG 要素に、引数の点から始まる一続きのパスを SVG 要素に追加する関数を返す。 * @param svg パスが追加され続ける SVG 要素 * @param initPoint パスを追加する始点 */ function makePathCreator(svg: Selection<SVGSVGElement, unknown, null, undefined>, initPoint: Point) { /** * leaflet の Point 二点から SVG のパス定義である d 属性を生成する関数 * @see https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/d */ const pointsToPathD = line<Point>() .curve(curveMonotoneX) .x((d) => d.x) .y((d) => d.y); /** 始点 */ let fromPoint: Point = initPoint; // 現在の始点から与えられた点へ続く線を描画する関数を返す return (toPoint: Point) => { // 始点から与えられた点への線を追加 svg .append('path') .attr('d', pointsToPathD([fromPoint, toPoint])) // ユーザーに書く線を決めさせたい場合はここに色々とデザインを決定する属性を渡せるようにする .attr('stroke', 'red') .attr('stroke-width', '2px'); // 今回の終点を次回の始点化 fromPoint = toPoint; }; }
クリックして手書き入り地図画像ダウンロードソースコードを展開
import React from 'react'; // 9割9分、このライブラリに頼ってダウンロードしています。 // @see https://www.npmjs.com/package/html-to-image // @sse https://github.com/bubkoo/html-to-image import * as htmlToImage from 'html-to-image'; // 要素からダウンロード const downloadElAsPng = (el: HTMLElement, filename: string) => { return htmlToImage.toPng(el).then((dataUrl) => { const aEl = document.createElement('a'); aEl.href = dataUrl; aEl.download = filename; aEl.click(); }); }; // クエリセレクタからダウンロード const downloadElAsPngFromQuery = (query: string, filename: string) => { const el = document.querySelector(query); if (!el) { console.error(`要素が見つかりませんでした。query: ${query}`); return new Promise(() => null); } return downloadElAsPng(el as HTMLElement, filename); }; // ダウンロードボタンコンポーネント export const DownloadBtn: React.FC<{ query: string }> = ({ query }) => { const filename = '手書き付き地図_' + new Date().toLocaleString().replace(':', '_') + '.png'; return ( <button onClick={() => downloadElAsPngFromQuery(query, filename)}>手書き入りの地図を画像化してダウンロード</button> ); };
実装の細部はソースコード内のコメントにまかせるとして、大まかな部分を説明します。まず手書き機能付き地図についての実装として以下の二つを満たせば実装できます。
- マウスを押したら書き込み開始、押している間は書き込み中、離したら書き込み終了
- マウスの位置を追って、イベントの度に SVG のパスを伸ばす様に書き込み続ける
前者はシンプルです。書き込み中状態と書き込み待ち状態の二種類を定義し、マウスを押したら書き込み中、マウスを離したら書き込みを終了、と状態変化をすることと書き込み中状態の時のみマウスを動かした際の処理を実行する様にするのみです。状態が関わるコードは次です。
// 書き始め、書き終わりを制御するための状態 let mouseState: 'WAIT' | 'WRITING' = 'WAIT'; map.addEventListener('mousedown', (event: LeafletMouseEvent) => { // マウスをクリックしたら書き始める。=> 書き込み待ち状態から書き込み中状態になる mouseState = 'WRITING'; }); map.addEventListener('mousemove', (event: LeafletMouseEvent) => { // マウスが動かされた時、書き込み中でないのならなにもしない if (mouseState !== 'WRITING') { return; } // 書き込み中ならば線を追加 /** ここに線の書き込み処理本体 */ }); map.addEventListener('mouseup', () => { // マウスが離されたら書き込みを終えて再び待機状態へ移行 mouseState = 'WAIT'; });
これで不意に線が増えたり、線を途切れなかったりすることはなくなります。
後者は次コードです。マウスイベントはイベントが起きた時の位置を渡してくれるため、その位置と前回イベントが起きた時の位置を線で結び続けることで好きに線を伸ばせます。
/** * 点を与えるたびに、引数の SVG 要素に、引数の点から始まる一続きのパスを SVG 要素に追加する関数を返す。 * この関数をで得られた関数をマウスが動かされる度に実行することでマウスをいい感じに追う軌跡を画面に追加する * * @param svg パスが追加され続ける SVG 要素 * @param initPoint パスを追加する始点 */ function makePathCreator(svg: Selection<SVGSVGElement, unknown, null, undefined>, initPoint: Point) { /** * leaflet の Point 二点から SVG のパス定義である d 属性を生成する関数 * @see https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/d */ const pointsToPathD = line<Point>() .curve(curveMonotoneX) .x((d) => d.x) .y((d) => d.y); /** 始点 */ let fromPoint: Point = initPoint; // 現在の始点から与えられた点へ続く線を描画する関数を返す return (toPoint: Point) => { // 始点から与えられた点への線を追加 svg .append('path') .attr('d', pointsToPathD([fromPoint, toPoint])) // ユーザーに書く線を決めさせたい場合はここに色々とデザインを決定する属性を渡せるようにする .attr('stroke', 'red') .attr('stroke-width', '2px'); // 今回の終点を次回の始点化 fromPoint = toPoint; }; } // マウスが押された時、↑の関数を起動して初期値を与えて次の点を与える関数を準備する map.addEventListener('mousedown', (event: LeafletMouseEvent) => { // SVG 要素を用意して地図要素の末尾に追加。この SVG 要素の中の path で任意に動かされたマウスの軌跡を表現する const svg = select(map.getContainer()) .append('svg') .attr('width', '100%') .attr('height', '100%') .style('pointer-events', 'none') .style('z-index', '1001') .style('position', 'absolute'); // マウスが動かされたら、SVG 要素中に線を追加する関数を用意 pathStretcher = makePathCreator(svg, map.latLngToContainerPoint(event.latlng)); }); // マウスが動くたびに↑の関数で得られる SVG の path をいい感じに接いで伸ばし続ける関数を実行する map.addEventListener('mousemove', (event: LeafletMouseEvent) => { // マウスが動かされたら線を追加 const point = map.mouseEventToContainerPoint(event.originalEvent); pathStretcher(new Point(point.x, point.y)); });
こんな感じでマウスで手書きができます。注意点として、Leaflet のズームや中心位置の移動があります。マウス操作はこれらの挙動が先んじて使っているので Leaflet のオプションを介してあらかじめ地図が不意に動かない様にする必要があります。デモでは次の様に React の props でオプションを渡しています(dragging 以下の false)。業務で用いる際には書き込みモード、地図操作モードの二種類を切り替えることになるでしょう(もっというと Undo、Redo、リセットあたりは少なくとも欲しいです)。
return ( <MapContainer id={'map'} center={props.center} zoom={17} dragging={false} zoomControl={false} scrollWheelZoom={false} doubleClickZoom={false}
画像のダウンロードについてはいい感じに HTML 要素を画像化してくれるライブラリがありますのでそれを頼りましょう。これは img 要素も問題なく画像化してしてくれます。
bubkoo/html-to-image: ✂️ Generates an image from a DOM node using HTML5 canvas and SVG.
これで画像データを生成すればよくある blob のダウンロードの流れ同様に次の方法でダウンロードができます。
// 9割9分、このライブラリに頼ってダウンロードしています。 // @see https://www.npmjs.com/package/html-to-image // @sse https://github.com/bubkoo/html-to-image import * as htmlToImage from 'html-to-image'; // 要素からダウンロード const downloadElAsPng = (el: HTMLElement, filename: string) => { return htmlToImage.toPng(el).then((dataUrl) => { const aEl = document.createElement('a'); aEl.href = dataUrl; aEl.download = filename; aEl.click(); }); };