@geomotion/gm
GM is the GeoMotion SDK — a tiny, dependency-free fluent builder that produces an
AnimationConfig, the JSON contract the GeoMotion
render engine consumes to produce cinematic map animations (Remotion + MapLibre) on real
GIS data.
npm install @geomotion/gm
Quick start
import { GM } from '@geomotion/gm'
const config = GM.animation({ title: 'Nairobi → Mombasa', aspect: '9:16', duration: 12 })
.basemap('dark')
.view({ center: [38, -2.6], zoom: 6, pitch: 45 })
.source('route', GM.lineString([[36.82, -1.29], [39.66, -4.05]]))
.camera([
GM.key(0, { center: [36.82, -1.29], zoom: 6 }),
GM.key(1, { center: [39.66, -4.05], zoom: 8 }),
], { follow: 'route' })
.trimPath('route', { glow: true })
.travelingMarker('route', { icon: 'plane' })
.title('Nairobi → Mombasa')
.build()
// POST { base_config: config } to the GeoMotion render API
Concepts
- Coordinates are always
[longitude, latitude]. - Timing: every track has a
from/towindow as a fraction of the clip (0–1), so animations stay in sync at any duration. - Real data: sources can be live PostGIS layers (
GM.postgis) or Google Earth Engine rasters (GM.gee), resolved server-side at render time.
Builder methods
Setup: GM.animation(opts) · .basemap(id|url) (21-map catalog: satellite, dark, light, voyager, esri-topo, osm, ofm-liberty… or a style URL / {z}/{x}/{y} template) · .view({center,zoom,pitch,bearing}) · .source(id, def) · .title(text, opts) · .counter(opts) · .post({grain,vignette,colorGrade,filter,adjust}) · .filter(name) · .terrain(opts) · .sky(on?) · .globe(on?) · .projection('globe') · .compass(opts) · .legend(items, opts) · .build().
Camera
| Method | Purpose |
|---|---|
.camera(keyframes, {follow}) |
Animate across GM.key(t, …) keyframes (one camera/clip). |
.whipPan(a, b, opts) / .quickPan |
Fast snap-pan between two places. |
.pan(a, b, opts) |
Steady pan. |
.zoomIn(center, opts) / .pushIn |
Push down into a point (+tilt). |
.zoomOut(center, opts) / .pullOut |
Pull back to reveal context. |
.orbit(center, opts) |
Rotate around a pivot (degrees). |
.dollyZoom(center, opts) |
Vertigo (zoom vs pitch). |
.flyTo(a, b, opts) |
Cinematic hop between two framings. |
.reveal · .spiral · .craneUp · .craneDown · .sCurve · .multiStage |
More named moves. |
.cameraSequence([{move, t:[from,to], …}]) |
Compose several moves into one continuous shot. |
Every direct move is configurable (zoom, by, pitch, bearing, degrees) and accepts an optional { from, to } time window. The same generators are also available standalone as GM.whipPan(...), GM.orbit(...), etc. (pass to .camera()).
flyTo is a smooth Van Wijk optimal path (zoom-out → pan along the great circle → zoom-in), with the zoom-out depth derived from the jump distance — long flights feel like Google Earth Studio, short hops stay tight.
Continuous camera tours — GM.tour(...)
Compile a continuous multi-stop tour into ONE clip (single scene, no cuts): one camera flies place→place and holds at each stop while that stop's effects play, then flies on. Render once → finish voice-over / sound in Premiere or After Effects. This is the GeoBytes / Altivex map-storytelling look, scripted.
import { GM } from '@geomotion/gm'
const config = GM.tour({
title: 'AFRICA', aspect: '9:16', basemap: 'satellite', look: 'cinematic',
intro: { establish: true }, // wide fly-in to the first stop
outro: { pullOut: true }, // end on a pull-back
stops: [
{ feature: congoGeo, flag: 'https://flagcdn.com/w1280/cd.png',
beat: 'orbitReveal', dwell: 6, color: '#ffd166',
effects: ['boundary', 'flagWave', 'label'], label: 'DR CONGO',
overlays: [{ kind: 'counter', toValue: 102, suffix: 'M people', font: { family: 'Impact' } }] },
{ feature: kenyaGeo, beat: 'spotlight', dwell: 6,
effects: ['highlight', 'label'], label: 'KENYA',
overlays: [{ kind: 'emoji', emoji: '🦁' }] },
{ feature: egyptGeo, flag: 'https://flagcdn.com/w1280/eg.png',
beat: 'pushIn', dwell: 6, effects: ['boundary', 'flagPaint', 'label'], label: 'EGYPT',
overlays: [{ kind: 'text', text: 'Gift of the Nile', font: { family: 'Georgia' }, position: 'bottom' }] },
],
}).build()
Per stop: beat — the full camera-move catalog: orbitReveal · orbit · spotlight/hold · pushIn · pullOut · dropIn · reveal · spiral · craneUp · craneDown · dollyZoom (vertigo) · whipPan · sCurve · creepPan · multiStage · routeFly (follow a route path) — plus dwell seconds, effects (highlight · boundary · flagWave|flagPaint|flagPour|flagFlat · spotlight · region/fill · label · lowerThird · pulse · radio · pin), and overlays (counter · text · emoji/icon · image). Text & overlays take a per-stop font {family,size}, color, and position. Tour effects auto-fade-out so a stop's outline never lingers as the camera flies on.
Everything is configurable per stop: camera start→end zoom / tilt / rotation (startZoom/endZoom, startPitch/endPitch, startBearing/endBearing — the camera zooms in/out while it holds, single-style parity), ease (easeInOut default = smooth), highlight/stroke style (strokeColor, strokeWidth, glow, glowWidth, dash, fillOpacity, spotlightOpacity, spotlightFeather, spotlightRing), and a big title (titlePosition, titleFont, titleColor, titleTyping).
Spotlight is fully configurable: opacity (push it high so the rest dims right out and the focus pops), feather (0–1 soft edge on the cutout), and ring (a glowing outline on the lit feature). flagFlat paints a static flag (no wobble); flagWave/flagPaint/flagPour animate it.
Captions / lower-third readout: the lowerThird and label effects type out on screen (typewriter, typing — on by default) and auto-fill a lat/long readout (coords, on by default) so you have an on-screen cue and coordinates to time voice-over / SFX. Text and field colours are independent — textColor for the caption text, fieldColor for the side bar (so they never collide). Anchor it with lowerThirdPosition (any of the 9 anchors, adapts to the aspect) and dismiss (seconds) to auto-remove it after the type-out.
Tour-wide film look: look (grade preset), filter (named CSS look — vivid, noir, sepia, warm, cool…), and adjust ({ hue, saturate, brightness, contrast }). All applied identically in the live preview and the MP4 render.
Smoothness & flow: smoothness (0–1, default 0.5) keeps a gentle continuous drift at each stop so the camera never freezes. flow (0–1, default 0.5) is a graph-editor smoothing pass over the whole camera track — it low-pass-filters the path so the stop→stop transitions glide instead of stopping dead at each keyframe (which reads blocky/robotic). flow: 0 plays the exact keyframes; higher = more glide. flyTo between stops is already a Van Wijk optimal path.
Atmosphere, globe & map elements: by default an atmosphere/sky is drawn at the horizon so tilting never exposes the flat map's rectangular edge (pass sky: false to turn it off). Set globe: true to wrap the whole tour onto a 3D globe (MapLibre globe projection). Add a true-north compass (true or { position, color }) that rotates with the camera, and a legend key ({ items: [{ color, label }], position, title }) — all rendered identically in the live preview and the MP4 render. These are also available as builder methods on a plain animation: .sky(on?) · .globe(on?) · .projection('globe') · .compass({position,color}) · .legend(items,{position,title}).
Effects & overlays
.trimPath · .travelingMarker · .arrowLine · .dashedLine · .arc · .boundaryStroke · .regionFill · .spotlight · .zoomToFeature · .highlightFeature · .choropleth · .extrusion3d · .heatmap · .proportionalSymbols · .bars · .geeLayer (reveal a real GEE/XYZ raster — NDVI, land cover, Sentinel-2 — over a window) · .radioWaves · .pulseMarker · .blink · .pinDrop · .flagMarker (circular flag-disc map pins that drop in — the "find-your-team" look) · .flag · .flagFill · .mapLabel · .lowerThird · .photo · .infographic (floating stat panel: title + animated counter + mini bar chart) · .school (the "fishy areas" swimming markers).
Source factories
GM.lineString, GM.point, GM.polygon, GM.geojson, GM.url, GM.postgis, GM.gee, GM.key.
Reusable styles, effectors & easings (v0.18+)
Reusable styles — design a look once, reference it from any track via styleRef:
GM.animation({ title: 'World Cup', aspect: '9:16', duration: 10 })
.basemap('satellite')
.defineStyle('gold', { color: '#E8A33D', style: { glow: true, glowWidth: 3 } })
.build()
// then any track: { type: 'pinDrop', at: [...], styleRef: 'gold' }
Effectors — one composable procedural-motion mechanism on any track (parity with
GEOlayers' valueEffectors). Types: sin · wiggle · pulse · breathe · ramp ·
blink · random · loop/marching · easeIn · easeOut. Frame-driven (speed =
cycles/sec) so they're independent of clip duration:
{ type: 'pinDrop', at: [2.35, 48.86], from: 0.1, to: 1,
effectors: [{ type: 'pulse', prop: 'scale', amp: 0.25, speed: 1.2 }] }
Easings (24) — pass any to a camera keyframe for smooth, non-robotic motion:
linear, easeIn/Out/InOut (+ …Quad/Cubic/Quart/Sine/Expo), easeIn/Out/InOutBack,
easeOutElastic, easeOutBounce. e.g. GM.key(0, { center, zoom }, 'easeOutBack').
Self-drawing borders, fog & blend (v0.25+)
Trace many borders at once — each in its own colour, direction and glow, auto-staggered.
direction: forward · reverse · center (out from the middle) · ends (both ends → middle):
.traceBorders([
{ geojson: france, color: '#ff2d78', direction: 'forward', glowWidth: 3, width: 5 },
{ geojson: germany, color: '#2dd4ff', direction: 'reverse' },
{ geojson: spain, color: '#ffd166', direction: 'center' },
], { stagger: 0.06 })
// or a single self-drawing border: .traceBorder(countryPolygon, { color, direction, glow, glowWidth })
// (trimPath now also draws EVERY line/ring in a source at once, with direction + glowWidth)
Atmospheric fog — a soft horizon haze that thickens as the camera pitches; keyframeable:
.fog(0.35, { color: 'rgb(210,222,232)', height: 46,
keys: [{ t: 0, intensity: 0.1 }, { t: 1, intensity: 0.5 }] })
mix-blend-mode on overlays (the GEOlayers "layer blends into the map" look) — on
.photo(), .video(), .infographic() (and mapLabel/title via config): blend: multiply ·
screen · overlay · soft-light · difference …
.infographic({ title: 'STATS', countTo: 88966, blend: 'screen' })
.photo(url, { anchor: 'map', at: [...], blend: 'soft-light' })
The "poppy" look — textures, stroke, palettes (v0.23+)
Port of the GEOlayers visual-system pillars onto our Remotion engine (zero shaders).
Procedural fill textures — hatch/dots/sketch generated on a 16px canvas at render time
(no bitmap assets) and tiled over a region as a MapLibre fill-pattern, on top of the solid fill:
.regionFill('country', { color: '#1f6f3a', opacity: 0.5,
pattern: 'hatch', patternColor: '#ffd166', patternScale: 1.2, patternOpacity: 0.55 })
// pattern: 'hatch' | 'cross' | 'dots' | 'grid' | 'sketch' | 'diag'
Double-render text stroke — keeps labels legible over a busy map (-webkit-text-stroke +
paint-order:stroke, so the fill is never eaten):
.mapLabel([2.35, 48.86], 'PARIS', { stroke: '#0b0e14', strokeWidth: 4 })
.title('WORLD CUP', { strokeWidth: 6, stroke: '#000' })
Curated palettes — GM.palette(name) / GM.PALETTES: 8 deliberate sets (poppy, sunset,
cobalt, neon, mono, gold, emerald, crimson), each { accent, ink, fill, stroke, bg }.
Keeps a clip on-palette; lets the AI name a palette instead of inventing hex codes:
const p = GM.palette('gold')
.regionFill('c', { color: p.fill, pattern: 'hatch', patternColor: p.accent })
.mapLabel(at, 'LUSAIL', { color: p.ink, stroke: p.stroke, strokeWidth: 4 })
.infographic({ title: 'FANS', countTo: 88966, accent: p.accent, bg: p.bg, position: 'bottom-left' })
Configurable infographic motion — the floating stat panel is fully configurable:
.infographic({ title: 'FANS', countTo: 88966, subtitle: 'Lusail 2022',
position: 'bottom-left', // any of the 9 anchors
motion: 'slide-up', // slide-up | slide-down | slide-left | slide-right | fade | pop
drift: 1.4, // px of gentle continuous "life"
margin: 54 }) // fixed edge spacing px — cards never crowd the frame
Video & GIF overlays that PLAY as the map moves (v0.24+)
Drop a playing video or GIF onto the map — pinned to a location (rides the camera) or as a
screen card / full-frame. mp4/webm render through Remotion's OffthreadVideo; .gif through
@remotion/gif. For a YouTube clip, download it first (yt-dlp) and point src at the file.
// a clip pinned over Berlin that plays + rides the camera, trimmed to 5s–15s of the source
.video('clips/zidane.mp4', { anchor: 'map', at: [13.24, 52.51], width: 0.3,
from: 0.45, to: 0.6, startFrom: 5, endAt: 15, caption: 'BERLIN 2006' })
// a looping GIF as a bottom-right screen card
.gif('https://example.com/octopus.gif', { position: 'bottom-right', width: 0.18, from: 0.6, to: 0.9 })
// full-frame B-roll
.video('https://cdn.example.com/broll.mp4', { position: 'full', from: 0, to: 1, muted: true })
Options: anchor ('map' | 'screen') · at (when map-pinned) · position (9 anchors or
'full') · width (fraction of frame) · shape (rounded|square|circle) · border /
borderColor / borderWidth · caption · startFrom / endAt (trim seconds) · muted
(default true) · volume · playbackRate. The overlay only mounts during its from→to window
(one clip decodes at a time), and stays glued to its pin frame-to-frame.
Geo-pinned photos
.photo(url, { anchor: 'map', at: [lng,lat], width, shape:'rounded'|'square'|'circle', border, caption }) pins a sized, styled photo card onto a map location that rides with the
camera. In the playground, the + Photo button uploads to Cloudinary and pins it to a stop.
AI authoring (hybrid)
Describe an animation in plain language and the server-side Claude director returns a full, validated config. The frontend playground ( Generate) and timeline editor both expose it, with an instant offline rule-based parser as the fallback — see the GeoMotion docs.
License
MIT Lawrence Kimutai