🗺️

ReactでMinimapを実現する

2021/08/21に公開

ReactでMinimapを実現する方法を一通り調べて見ました。ちなみにMinimapとはMiroやVSCodeで表示される自分の相対位置がわかるあれです。

miro minimap

vscode minimap

調べたところMinimapの実装方法は大きく以下の3つあることがわかりました。

  1. Canvas to Canvas
  2. HTML to Canvas
  3. HTML to HTML

Canvas to Canvas

一番簡単なのは Canvas to Canvas です。画像の縮小アルゴリズムのように一定単位のピクセルを縮小比ぶん小さくしていく感じですね。

http://fabricjs.com/build-minimap

HTML to Canvas

残念ながらCanvasではなくHTMLで作られたもののMinimapを作りたい場合があります。そこで2か3の方法を取ることになります。

2の HTML to Canvas はライブラリが提供されています。

https://html2canvas.hertzen.com/
https://www.npmjs.com/package/html2canvas

ただnpmの unpacked size が執筆時で2.47MBあり、あまり気軽にフロントエンドに載せられないんですよね^^; もしかしたらもっとサイズを小さくする方法もあるかもしれないですが、調べきれていないです。

ちなみにVSCodeもこの手法でMinimapを作っていると思うのですがソースコードみても複雑すぎて解読できませんでした。解読できた方の共有をお待ちしております。

https://github.com/microsoft/vscode/blob/main/src/vs/editor/browser/viewParts/minimap/minimap.ts

HTML to HTML

そこで3つの目の選択肢 HTML to HTML です。こちらはJQueryでライブラリが提供されています。

https://github.com/john-bai/jquery-minimap

これソースコードみたらわかるのですが、めちゃくちゃ短いです。

https://github.com/john-bai/jquery-minimap/blob/master/jquery-minimap.js
のページから参照

(function( $ ) {
	$.fn.minimap = function( $mapSource ) {
		var x, y, l, t, w, h;
		var $window = $( window );
		var $minimap = this;
		var minimapWidth = $minimap.width();
		var minimapHeight = $minimap.height();
		var $viewport = $( "<div></div>" ).addClass( "minimap-viewport" );
		$minimap.append( $viewport );
		synchronize();

		$window.on( "resize", synchronize );
		$mapSource.on( "scroll", synchronize );
		$mapSource.on( "drag", init );
		$minimap.on( "mousedown touchstart", down );

		function down( e ) {
			var moveEvent, upEvent;
			var pos = $minimap.position();

			x = Math.round( pos.left + l + w / 2 );
			y = Math.round( pos.top + t + h / 2 );
			move( e );

			if ( e.type === "touchstart" ) {
				moveEvent = "touchmove.minimapDown";
				upEvent = "touchend";
			} else {
				moveEvent = "mousemove.minimapDown";
				upEvent = "mouseup";
			}
			$window.on( moveEvent, move );
			$window.one( upEvent, up );
		}

		function move( e ) {
			e.preventDefault();

			if ( e.type.match( /touch/ ) ) {
				if ( e.touches.length > 1 ) {
					return;
				}
				var event = e.touches[ 0 ];
			} else {
				var event = e;
			}

			var dx = event.clientX - x;
			var dy = event.clientY - y;
			if ( l + dx < 0 ) {
				dx = -l;
			}
			if ( t + dy < 0 ) {
				dy = -t;
			}
			if ( l + w + dx > minimapWidth ) {
				dx = minimapWidth - l - w;
			}
			if ( t + h + dy > minimapHeight ) {
				dy = minimapHeight - t - h;
			}

			x += dx;
			y += dy;

			l += dx;
			t += dy;

			var coefX = minimapWidth / $mapSource[ 0 ].scrollWidth;
			var coefY = minimapHeight / $mapSource[ 0 ].scrollHeight;
			var left = l / coefX;
			var top = t / coefY;

			$mapSource[ 0 ].scrollLeft = Math.round( left );
			$mapSource[ 0 ].scrollTop = Math.round( top );

			redraw();
		}

		function up() {
			$window.off( ".minimapDown" );
		}

		function synchronize() {
			var dims = [ $mapSource.width(), $mapSource.height() ];
			var scroll = [ $mapSource.scrollLeft(), $mapSource.scrollTop() ];
			var scaleX = minimapWidth / $mapSource[ 0 ].scrollWidth;
			var scaleY = minimapHeight / $mapSource[ 0 ].scrollHeight;

			var lW = dims[ 0 ] * scaleX;
			var lH = dims[ 1 ] * scaleY;
			var lX = scroll[ 0 ] * scaleX;
			var lY = scroll[ 1 ] * scaleY;

			w = Math.round( lW );
			h = Math.round( lH );
			l = Math.round( lX );
			t = Math.round( lY );
			//set the mini viewport dimesions
			redraw();
		}

		function redraw() {
			$viewport.css( {
				width : w,
				height : h,
				left : l,
				top : t
			} );
		}

		function init() {
			$minimap.find( ".minimap-node" ).remove();
			//creating mini version of the supplied children
			$mapSource.children().each( function() {
				var $child = $( this );
				var mini = $( "<div></div>" ).addClass( "minimap-node" );
				$minimap.append( mini );
				var ratioX = minimapWidth / $mapSource[ 0 ].scrollWidth;
				var ratioY = minimapHeight / $mapSource[ 0 ].scrollHeight;

				var wM = $child.width() * ratioX;
				var hM = $child.height() * ratioY;
				var xM = ($child.position().left + $mapSource.scrollLeft()) * ratioX;
				var yM = ($child.position().top + $mapSource.scrollTop()) * ratioY;

				mini.css( {
					width : Math.round( wM ),
					height : Math.round( hM ),
					left : Math.round( xM ),
					top : Math.round( yM )
				} );
			} );
		}

		init();

		return this;
	}
})( jQuery );

純粋なjsで同じものを作ってみた

こちらをReactに移植している有志の方もいます。中身はjQueryのminimapライブラリをReactように作り変えた感じです。
https://www.npmjs.com/package/react-minimap

ただしこの作り方だと欠点があります。まず、どのようにMinimapを実現しているのかを紹介します。

  1. Minimapを作りたい大本のNode(dom)を取得する。これを仮にparentと名付ける。
  2. Minimapを表示するNode(dom)を取得する。これを仮にminimapと名付ける。
  3. parentとminimapのそれぞれの横と縦の長さから、横の縮小比と縦の縮小比を計算する
  4. Node(dom)のAPIのchildrenを利用して、parentの直接の子供Node(dom)たちを取得する。これを仮にchildrenと名付ける。
  5. childrenの一つ一つの横と縦の長さを取得し、これにそれぞれの縮小比をかけてminimapないにおける、その要素の横と縦の長さを計算する。
  6. 新たなNode(dom)をdocument.createElelementで生成し、先程計算したminimap内における要素の横と縦の長さを割り当てる。このときclassはparentのchildrenがもつものと同様のものを割り当てると良い。
  7. あとは6で作ったNode(dom)をminimapないにappendしていく

どうでしょうか?結構domのapi使ってゴリゴリ力技でコピペしていってる感じがします笑。

何が欠点かというと、5~7でMinimapを実現する際にchildrenというAPIを使っていることです。これだと1階層したのdomのみしか反映できません。そこに書かれてある文章も反映できません。複雑に作り込んだものを正確にコピーできず、のっぺらぼうみたいなものができます。Minimapの役割としては全体における自分の相対位置を知ることなので、のっぺらぼうでもその目的は果たせると思います。これを許容できる方はこの方法でMinimapを実現してみてください。

実際にReactでスクラッチで書いてみました。参考にしてください。

Discussion