Skip to content

Instantly share code, notes, and snippets.

@veltman
Last active September 27, 2019 22:12
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save veltman/013e1fcd756c0c66184346d1d4784116 to your computer and use it in GitHub Desktop.
Save veltman/013e1fcd756c0c66184346d1d4784116 to your computer and use it in GitHub Desktop.
Making a big image zoomable

Making a big image zoomable

When you have a giant image and you want to make it easy to pan and zoom without downloading the whole 50MB image into someone's browser, a nice workaround is to cut that image into tiles at different zoom levels and view it as it were a map. An example where I've used this technique is The "Snowpiercer" Scenario.

One way to cut your big image into the requisite tiles is with gdal2tiles.py.

Alternatively, this Node script will do the cutting after you install node-canvas and mkdirp:

const fs = require("fs"),
  path = require("path"),
  mkdirp = require("mkdirp"),
  { createCanvas, loadImage } = require("canvas");

const minZoom = 0,
  maxZoom = 6,
  tileDirectory = "tiles";

const tile = createCanvas(256, 256).getContext("2d");

loadImage("original-image.png").then(function(img) {
  // Center the image in a square
  const size = Math.max(img.width, img.height);
  const centered = createCanvas(size, size);
  centered
    .getContext("2d")
    .drawImage(
      img,
      Math.round((size - img.width) / 2),
      Math.round((size - img.height) / 2)
    );

  // Make each zoom level
  for (let z = minZoom; z <= maxZoom; z++) {
    const dim = 256 * Math.pow(2, z);
    const numTiles = dim / 256;
    const rescaled = createCanvas(dim, dim);
    rescaled.getContext("2d").drawImage(centered, 0, 0, dim, dim);

    // Render each tile
    for (let x = 0; x < numTiles; x++) {
      const dir = path.join(tileDirectory, z.toString(), x.toString());
      mkdirp.sync(dir);
      for (let y = 0; y < numTiles; y++) {
        console.warn(z, x, y);
        tile.clearRect(0, 0, 256, 256);
        tile.drawImage(rescaled, x * 256, y * 256, 256, 256, 0, 0, 256, 256);
        fs.writeFileSync(path.join(dir, y + ".png"), tile.canvas.toBuffer());
      }
    }
  }
});

This will create a tiles directory will all of your tiles in the file structure that something like Leaflet expects.

Now, you can wrap the image in a viewer like this:

<!DOCTYPE html>
<meta charset="utf-8" />
<link
  rel="stylesheet"
  href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css"
  integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA=="
  crossorigin=""
/>
<style>
  body {
    margin: 0;
    padding: 0;
    background-color: #fff;
  }

  #map {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
  }
</style>

<body>
  <div id="map"></div>
  <script
    src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"
    integrity="sha512-QVftwZFqvtRNi0ZyCtsznlKSWOStnDORoefr1enyq5mVL4tmKB3S/EnC3rRJcxCPavG10IcrVGSmPh6Qw5lwrg=="
    crossorigin=""
  ></script>
  <script>
    var map = L.map("map").setView([0, 0], 2);
    L.tileLayer("tiles/{z}/{x}/{y}.png", {
      minZoom: 0,
      maxZoom: 6,
      noWrap: true
    }).addTo(map);

    map.setMaxBounds([[-90, -180], [90, 180]]);
  </script>
</body>

Enhancements

  • You could speed this up by adding some concurrency
  • You could also skip tiles that are completely outside the original image
  • You could generate 512x512 retina tiles
  • You could use fs.mkdirSync with recursive: true instead of mkdirp on recent versions of Node.
@cpietsch
Copy link

this is golden! very simple and clean. before I used https://github.com/VoidVolker/MagickSlicer for that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment