← All posts

Non-Geographic Maps with Leaflet and Image Pyramiding

April 25, 2024 rails stimulus

Leaflet isn’t just for street maps. Its CRS.Simple coordinate system lets you use any image as a zoomable, pannable map — floor plans, blueprints, satellite photos, historical maps, game worlds. The catch is that large images need to be sliced into tiles at multiple zoom levels, or the browser chokes trying to load a single massive file.

This post builds on the Leaflet + Stimulus setup from the previous post. We’ll create a Ruby script that generates an image tile pyramid, then a Stimulus controller that displays it.

What Is an Image Pyramid?

An image pyramid is the same image at multiple resolutions, each sliced into 256×256 pixel tiles. At zoom level 0, the entire image fits in one tile. At zoom level 1, it’s split into 4 tiles (2×2). At zoom level 2, 16 tiles (4×4). Each level doubles the resolution.

The directory structure looks like this:

tiles/
  0/
    0/
      0.png    # entire image, 256x256
  1/
    0/
      0.png    # top-left quarter
      1.png    # bottom-left quarter
    1/
      0.png    # top-right quarter
      1.png    # bottom-right quarter
  2/
    0/
      0.png
      1.png
      2.png
      3.png
    ...

The path pattern is {z}/{x}/{y}.png — the same format Leaflet uses for geographic tiles.

The Tile Generator Script

This Ruby script uses ImageMagick (via the mini_magick gem) to generate a tile pyramid from any source image:

scripts/generate_tiles.rb:

require "mini_magick"
require "fileutils"

SOURCE = ARGV[0]
OUTPUT_DIR = ARGV[1] || "tiles"
TILE_SIZE = 256

abort "Usage: ruby scripts/generate_tiles.rb <image_path> [output_dir]" unless SOURCE

image = MiniMagick::Image.open(SOURCE)
width = image.width
height = image.height
max_dimension = [width, height].max

# Calculate the number of zoom levels needed
max_zoom = (Math.log2(max_dimension.to_f / TILE_SIZE)).ceil
max_zoom = [max_zoom, 0].max

puts "Image: #{width}x#{height}"
puts "Zoom levels: 0..#{max_zoom}"
puts "Output: #{OUTPUT_DIR}/"

(0..max_zoom).each do |zoom|
    scale = 2**zoom
    scaled_width = (width * scale.to_f * TILE_SIZE / max_dimension).ceil
    scaled_height = (height * scale.to_f * TILE_SIZE / max_dimension).ceil

    # Resize the image for this zoom level
    resized = MiniMagick::Image.open(SOURCE)
    resized.resize "#{scaled_width}x#{scaled_height}!"

    tiles_x = (scaled_width.to_f / TILE_SIZE).ceil
    tiles_y = (scaled_height.to_f / TILE_SIZE).ceil

    puts "  Zoom #{zoom}: #{scaled_width}x#{scaled_height} (#{tiles_x}x#{tiles_y} tiles)"

    (0...tiles_x).each do |x|
        (0...tiles_y).each do |y|
            tile_dir = File.join(OUTPUT_DIR, zoom.to_s, x.to_s)
            FileUtils.mkdir_p(tile_dir)

            crop_x = x * TILE_SIZE
            crop_y = y * TILE_SIZE
            crop_w = [TILE_SIZE, scaled_width - crop_x].min
            crop_h = [TILE_SIZE, scaled_height - crop_y].min

            tile = MiniMagick::Image.open(resized.path)
            tile.crop "#{crop_w}x#{crop_h}+#{crop_x}+#{crop_y}"

            # Pad to full tile size if at the edge
            if crop_w < TILE_SIZE || crop_h < TILE_SIZE
                tile.combine_options do |c|
                    c.background "transparent"
                    c.gravity "NorthWest"
                    c.extent "#{TILE_SIZE}x#{TILE_SIZE}"
                end
            end

            tile.write File.join(tile_dir, "#{y}.png")
        end
    end
end

puts "Done. #{OUTPUT_DIR}/ ready for Leaflet."

Run it:

gem install mini_magick
ruby scripts/generate_tiles.rb path/to/large_image.png public/tiles/floorplan

This produces public/tiles/floorplan/{z}/{x}/{y}.png — the tile URL Leaflet needs.

The Stimulus Controller

app/javascript/controllers/image_map_controller.js:

import { Controller } from "@hotwired/stimulus"
import L from "leaflet"

export default class extends Controller {
  static targets = ["container"]
  static values = {
    tileUrl: String,
    width: Number,
    height: Number,
    maxZoom: Number,
    minZoom: { type: Number, default: 0 }
  }

  connect() {
    this.map = L.map(this.containerTarget, {
      crs: L.CRS.Simple,
      minZoom: this.minZoomValue,
      maxZoom: this.maxZoomValue
    })

    const bounds = [
      [0, 0],
      [this.heightValue, this.widthValue]
    ]

    L.tileLayer(this.tileUrlValue, {
      minZoom: this.minZoomValue,
      maxZoom: this.maxZoomValue,
      bounds: bounds,
      noWrap: true
    }).addTo(this.map)

    this.map.fitBounds(bounds)
    this.map.setMaxBounds(bounds.map(([y, x]) => [y - 100, x - 100]))
  }

  disconnect() {
    this.map.remove()
    this.map = null
  }
}

The key difference from a geographic map is crs: L.CRS.Simple. This tells Leaflet to use pixel coordinates instead of latitude/longitude. The bounds are set to the image dimensions so the map frames the full image.

noWrap: true prevents the tiles from repeating. setMaxBounds with padding prevents the user from panning too far away from the image.

The Partial

app/views/components/_image_map.html.erb:

<div data-controller="image-map"
     data-image-map-tile-url-value="<%= tile_url %>"
     data-image-map-width-value="<%= width %>"
     data-image-map-height-value="<%= height %>"
     data-image-map-max-zoom-value="<%= max_zoom %>"
     style="height: <%= local_assigns[:height_css] || '600px' %>;">
  <div data-image-map-target="container" style="height: 100%;"></div>
</div>

Usage

<%= render partial: "components/image_map", locals: {
  tile_url: "/tiles/floorplan/{z}/{x}/{y}.png",
  width: 4096,
  height: 3072,
  max_zoom: 4,
  height_css: "500px"
} %>

Adding Markers to the Image Map

Since CRS.Simple uses pixel coordinates, markers work the same way — just use pixel positions instead of lat/lng:

// In the controller
static values = {
  // ...existing values
  markers: { type: Array, default: [] }
}

connect() {
  // ...existing setup
  this.markersValue.forEach(marker => {
    L.marker([marker.y, marker.x])
      .addTo(this.map)
      .bindPopup(marker.popup || "")
  })
}
<%= render partial: "components/image_map", locals: {
  tile_url: "/tiles/floorplan/{z}/{x}/{y}.png",
  width: 4096,
  height: 3072,
  max_zoom: 4,
  markers: @rooms.map { |r| { x: r.x_pos, y: r.y_pos, popup: r.name } }
} %>

This is useful for interactive floor plans — clickable rooms, equipment locations, or points of interest on any large image.

Using a Single Image (No Tiling)

For smaller images that don’t need tiling, use L.imageOverlay instead:

connect() {
  this.map = L.map(this.containerTarget, {
    crs: L.CRS.Simple,
    minZoom: -2,
    maxZoom: 3
  })

  const bounds = [[0, 0], [this.heightValue, this.widthValue]]
  L.imageOverlay(this.imageUrlValue, bounds).addTo(this.map)
  this.map.fitBounds(bounds)
}

This loads the entire image at once. Fine for images under ~5000px. Above that, tiling is worth the setup cost.

Tips

  • CRS.Simple coordinates are [y, x]. Leaflet’s coordinate system puts latitude (y) first. In pixel coordinates, [0, 0] is the top-left corner, [height, width] is the bottom-right.
  • Transparent padding. Edge tiles that don’t fill a full 256×256 square get padded with transparency. This prevents visual gaps at the image boundaries.
  • Pregenerate tiles. Don’t generate tiles on the fly. The script runs once per source image — put the output in public/ and serve it as static files.
  • ImageMagick memory. Very large images (50,000px+) can consume significant memory during processing. For those, consider using vips (via the ruby-vips gem) instead of ImageMagick — it processes images in streaming fashion with constant memory usage.
  • Max zoom. The script calculates zoom levels automatically based on image size. A 4096px image gets ~4 zoom levels. You can cap it lower if you don’t need pixel-level zoom.
Let’s work together! Tell Me About Your Project