
January 16, 2026
Testing GIS animations in Ruby (exploratory work)
Today, early in the morning, after releasing GIF and animation support in ruby-libgd, together with updated documentation, versioning, and examples, I decided to do something very concrete:
spend the entire day stress-testing the alpha version of libgd-gis.
And what better way to test animations than to play a bit with real cities, casually moving around familiar maps… perhaps with a Pac-Man wandering through them.
The outcome was surprisingly solid. Not because everything is finished, but because the foundations are already strong enough that next week this can realistically evolve into a beta, or at least something very close to production from an architectural point of view.

The real challenge: animating maps
GIS animation is not just about “moving an icon”.
Each frame requires:
- Reprojecting coordinates
- Rendering complex vector layers
- Preserving visual consistency
- Repeating all of the above for every single frame
In practice:
The complexity of a map is multiplied by the number of frames.
This is precisely why v0.2.7.pre.alpha.1 exists. It is not a feature release — it is a testing ground.
Technical approach
The experiment was built around a very clear idea:
- Render the base map once
- Cache it
- Render only dynamic layers per frame
This approach made it possible to generate smooth animations even over dense urban maps such as Manhattan or Buenos Aires.
Core structure
The entire experiment relies on three simple concepts:
- A cached static base
- Mutable dynamic layers
- A normalized time value (t from 0 to 1)
require "json"require "gd/gis"
Simple UI overlay (legend)

Each frame includes a small legend rendered directly onto the image. No HTML, no canvas, no external rendering engine.
def draw_legend(img) x = 24 y = 24 w = 520 h = 90 pad = 18 bg = GD::Color.rgba(0, 0, 0, 190) border = GD::Color.rgb(255, 140, 0) title_col = GD::Color.rgb(255, 160, 40) sub_col = GD::Color.rgb(255, 210, 160) img.filled_rectangle(x, y, x + w, y + h, bg) img.rectangle(x, y, x + w, y + h, border) img.text( "libgd-gis v0.2.7.pre.alpha.1", x: x + pad, y: y + 38, size: 26, color: title_col, font: "fonts/GoogleSans-Bold.ttf" ) img.text( "Animated GIS engine for Ruby", x: x + pad, y: y + 68, size: 16, color: sub_col, font: "fonts/GoogleSans-SemiBold.ttf" )endThis alone demonstrates something important: libgd-gis does not depend on a frontend to produce presentable results.
Animation configuration
MANHATTAN = [-74.02, 40.70, -73.97, 40.78]FRAMES = 40DELAY = 8PELLET_T_OFFSET = 0.01
All animation logic is driven by a normalized t, which makes it trivial to:
- Interpolate positions
- Synchronize multiple layers
- Avoid visual tunneling issues

Rendering the base map (once)
This is arguably the most important optimization in the entire experiment.
map = GD::GIS::Map.new( bbox: MANHATTAN, zoom: 14, basemap: :osm)map.style = GD::GIS::Style.load("dark")map.add_geojson "nyc_roads.geojson"map.render_base
From this point on, the base map is reused for every frame.
Defining the Pac-Man path
The movement is not arbitrary. It is sampled from a real GeoJSON path.
path = GD::GIS::PathSampler.from_geojson("bicicle.json")
This opens the door to real GIS use cases: routes, tracks, trajectories, fleets, and timelines.
Pellets as mutable state
Each pellet carries its own progress value.
pellets = (0...PELLET_COUNT).map do |i| t = i.to_f / (PELLET_COUNT - 1) lon, lat = path.point_at(t) { "lon" => lon, "lat" => lat, "t" => t }end
As Pac-Man advances, pellets are removed in a robust and deterministic way.
Pac-Man as a dynamic layer
pacman_layer = map.add_points( [{ "lon" => path.point_at(0)[0], "lat" => path.point_at(0)[1] }], lon: ->(f){ f["lon"] }, lat: ->(f){ f["lat"] }, icon: "pacman.png")
Nothing special here — just another point layer synchronized to global time.

Animation loop
This is where everything comes together.
gif = GD::Gif.new("pacman_manhattan.gif")FRAMES.times do |i| t = i.to_f / (FRAMES - 1) px, py = path.point_at(t) pacman_layer.data = [{ "lon" => px, "lat" => py }] pellets.reject! { |p| p["t"] <= t + PELLET_T_OFFSET } pellet_layer.data = pellets img = map.render_with_base draw_legend(img) gif.add_frame(img, delay: DELAY)endgif.close
The key detail is simple but powerful:
Only dynamic layers are rendered per frame, on top of a cached base image.
Final thoughts
This release is intentionally labeled pre-alpha.
The goal was not API stability or performance tuning, but to answer a single question:
Can Ruby realistically drive GIS animations without external engines?
Based on these experiments, the answer is yes.
The next step is to harden the API, formalize the animation layer model, and move toward a proper beta.
More to come soon.
