Non-Geographic Maps with Leaflet and Image Pyramiding
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.Simplecoordinates 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 theruby-vipsgem) 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.