iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🗺️

Implementing a Minimap in React

に公開

I have researched various ways to implement a Minimap in React. By the way, a Minimap is that feature seen in Miro or VSCode that allows you to see your relative position.

miro minimap

vscode minimap

Through my research, I found that there are three main methods to implement a Minimap:

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

Canvas to Canvas

The simplest method is Canvas to Canvas. It feels like an image scaling algorithm where you shrink pixels by a certain scaling factor.

http://fabricjs.com/build-minimap

HTML to Canvas

Unfortunately, there are times when you want to create a Minimap for something built with HTML rather than Canvas. In those cases, you would use method 2 or 3.

For method 2, HTML to Canvas, libraries are available.

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

However, the unpacked size on npm is 2.47MB at the time of writing, so it’s not something you can easily include in the frontend ^^; There might be ways to reduce the size, but I haven't been able to research that fully.

By the way, I believe VSCode also uses this method to create its Minimap, but the source code was too complex for me to decipher. I look forward to hearing from anyone who has managed to decode it.

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

HTML to HTML

Then there is the third option: HTML to HTML. A library for this is provided for jQuery.

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

If you look at the source code, you'll see it's incredibly short.

Referenced from the page:
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 dimensions
			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 );

I also created the same thing using pure JavaScript:

There are also volunteers who have ported this to React. The internal logic is essentially the jQuery minimap library rewritten for React.
https://www.npmjs.com/package/react-minimap

However, there are drawbacks to this approach. First, let me explain how the Minimap is implemented.

  1. Obtain the original Node (DOM) for which you want to create a Minimap. Let's call this "parent."
  2. Obtain the Node (DOM) where the Minimap will be displayed. Let's call this "minimap."
  3. Calculate the horizontal and vertical scaling ratios based on the width and height of both the parent and the minimap.
  4. Use the children API of the Node (DOM) to get the direct child Nodes (DOM) of the parent. Let's call these "children."
  5. Get the width and height of each child, multiply them by the respective scaling ratios, and calculate the width and height of that element within the minimap.
  6. Create a new Node (DOM) using document.createElement and assign it the width and height calculated in the previous step. It is best to assign the same classes as those held by the parent's children.
  7. Finally, append the Node (DOM) created in step 6 into the minimap.

What do you think? It feels like it's brute-forcing the copy-paste using DOM APIs lol.

The drawback is that it uses the children API when implementing the Minimap in steps 5–7. This means only the DOM elements one level deep are reflected. Text written inside them is also not reflected. It cannot accurately copy complex structures, resulting in something featureless. Since the role of a Minimap is to know your relative position within the whole, even a featureless one should fulfill that purpose. If you can accept this, please try implementing a Minimap this way.

I actually wrote a version from scratch in React. Please use it for reference.

Discussion