Write a story. Scroll it. Publish it anywhere.
Waymark is a self-hosted editor for scroll-driven map stories and the maps inside them.
Build maps with live government data and analysis, reuse them across stories, then share a link or export a website.
Quick start · Features · Saved maps · Export · Self-hosting · Story format
⚡ Start in a minute
You only need Node.js 22.13 or newer. There's nothing to npm install.
git clone https://github.com/chrispy90/waymark.git
cd waymark
npm startThen open http://localhost:5180.
- Create your account. The first visit makes you the owner.
- Answer a few questions. Pick what you're making and a look, add a title and your places, and Waymark sets up the story for you.
- Click anything to edit it. When you're happy with it, press Publish.
Tip
Windows: if PowerShell says "running scripts is disabled", run node server.js instead. It does the same thing.
Docker: docker compose up -d also works, and keeps your data in a named volume.
✨ What you can make
A map that moves with the story
The map stays put while story cards scroll past, and it glides to each card's place. You can tilt it, rotate it, switch layers, or swap it for a photo or video on any card. Select words in a card and press Map link to make them send the map somewhere else; click a link in the text to change it, move it to other words, or remove it.
Scroll effects, built in
Effects from the best long-form sites, available as blocks. Add one and it works on phones too. Each effect switches off for readers who ask their device to reduce motion.
You also get chapters that recolor the page, big numbers that count up, and parallax, zoom and wipe-in photos and covers.
Live data from NOAA, NASA, USGS and more
Open Browse public data and click Add. Every layer arrives styled, with popups, a legend and credit to its source. No keys and no sign-ups.
| Source | Layers |
|---|---|
| USGS | Earthquakes (live) · stream gauges by state |
| NOAA | Weather warnings (live) · radar (live) · tide stations · tsunamis · significant earthquakes · volcanoes · plate boundaries |
| NASA | Natural events (EONET) · daily satellite imagery · Earth at night |
| Census · NPS · USFS · EPA · NIFC | States and counties · park units · national forests · Superfund sites · active wildfires and perimeters |
| Natural Earth | Countries · cities · rivers |
| ArcGIS Online | Search thousands more public layers |
Live layers refresh every time someone opens the story, so a published map stays current.
3D Terrain, Elevation HUD & 3D City Buildings
Turn on realistic 3D elevation with a single click in any story map, moving map or saved map:
- Free global DEM terrain & hillshading: powered by Mapzen Terrarium elevation tiles with dynamic vertical exaggeration (1.0× to 3.0×) and atmospheric sky horizon.
- 3D city building footprints: toggle extruded 3D structures (
fill-extrusion) from OpenFreeMap vector building footprints with true-to-height scaling and ambient shadows. - Camera Tilt & Pitch HUD: live interactive tilt angle indicator in the map status bar with real-time pitch readouts (up to 85°).
3D Polygon & Hexbin Extrusions + Bivariate Mapping
Display complex multivariate geospatial relationships directly on the map:
- 3D Attribute Extrusions: extrude any 2D polygon layer or hexbin summary into 3D height prisms scaled proportionally to any numeric attribute (e.g. population, total sales, earthquake count).
- 3×3 Bivariate Choropleth: map two continuous variables simultaneously (e.g. household income vs climate flood risk) with quantile breaks and an interactive 3×3 matrix legend.
Remote Sensing, STAC Satellite Finder & Time Travel
Professional Earth Observation and spectral analytics without leaving the browser:
- Spectral Index Catalog: one-click live overlays for NASA MODIS NDVI Vegetation Health, False-Color Infrared 7-2-1 (burn scar & flood detection), Sea Surface Temperature (GHRSST), Aerosol Optical Depth (wildfire smoke & dust), Snow Cover NDSI, and USGS 3DEP Shaded Relief & National Hydrography.
- Live STAC Satellite Scene Finder: search Copernicus Sentinel-2 L2A scenes around your map viewport using Element84 Earth Search STAC API. Filter by cloud cover %, inspect scene metadata (MGRS tile, sun elevation, telemetry), and stream 10-meter Cloud-Optimized GeoTIFFs (COG) directly into the map.
- Satellite Time-Travel Before & After: compare multi-date satellite imagery side by side with a draggable swipe divider to detect wildfire burn scars, urban sprawl, drought depletion, or glacial retreat.
Universal GIS Ingest: KML, GPX, Photos, GeoJSON & CSV
Drop any spatial file directly onto the map or Data panel:
- KML & KMZ: parses Google Earth placemarks, folders, lines, polygons, and ExtendedData attributes.
- GPX: reads GPS waypoints, tracks, and routes with automatic distance, elevation gain/loss, and timestamp stats.
- Geotagged JPEG Photos: extracts camera EXIF GPS coordinates and timestamps; multiple photos automatically merge into an interactive photo-points layer with popup image previews.
- Elevation Profile & Slope Analysis Tool: sample polylines or points against the global DEM in the Analyze toolbox to compute total distance, elevation gain/loss, and slope grade percentages (%).
$ELEVATION_Min Field Calculator: access elevation directly in attribute tables and calculate derived metrics.
Filter it, then analyze it
Filters narrow a layer with plain rules, like magnitude ≥ 5, in the last 7 days or state = Texas. They apply to the map, the insights, the analysis and the export.
Analyze previews every result on a map before you keep it. Results become ordinary layers, and you can re-run them on fresh data. The 23 tools are grouped the way desktop GIS toolboxes group them:
| Group | Tools |
|---|---|
| Summarize | Hex bins (count, sum or average) · Spatial density clusters (DBSCAN) · Count in areas · Dissolve (by a field, e.g. counties into states) |
| Proximity | Buffer · Multi-ring trade areas (concentric catchment bands) · Hub & spoke desire lines · Distance to nearest · Within distance · Voronoi (Thiessen) areas |
| Overlay | Clip · Erase · Intersect · Join by location · Select by location (touch, inside, contain, stay clear) · Merge |
| Terrain | Elevation profile & slope (samples polylines along global DEM for distance, gain/loss, and grade %) |
| Manage data | Join attributes by field · Center points · Outline (convex hull) · Simplify · Add geometry attributes (area, length, coordinates) · Filter |
You can also show points as a heatmap or clusters, or use proportional symbols: size points, areas or lines by a number field (circle area grows with the value, and it works together with color). Give a layer a time field and its maps get a time slider with play, showing everything up to a moment or a trailing day, week or month. Readers can scrub it too, and bookmarks and story cards remember where it was set. Bring your own data: GeoJSON, KML, GPX, geotagged photos, CSV, Hosted Feature Layers, or any public ArcGIS layer.
Hosted feature layers & editable attribute tables
Create reusable, server-backed GeoJSON feature layers (like ArcGIS Online Hosted Feature Layers) from the Layers tab on the home screen, from inside the map or story editor, or by promoting any drawn, uploaded, or analyzed layer in one click.
- Editable attribute tables & schema studio: click any cell to edit values inline, or add, rename, and delete columns (
string,number,date,boolean). Add or delete feature rows and see live column statistics (Sum,Avg,Min,Max) in the header. - Field Calculator: compute values across all features using column math (
+,-,*,/,% of,% change), automatic spatial geometry measurements ($AREA_KM2,$AREA_ACRES,$LEN_KM,$LAT,$LON), rankings (1..N), or text templates. - Source attribution & live service URLs: attach source attribution/credits to a hosted layer so it automatically appears on map legends and credits, copy its live
/api/layers/:id/geojsonendpoint, or export any table to GeoJSON or CSV.
Interactive charts & executive tables
Add a Chart & table block to any story — or click Add chart to story directly from a layer's Insights histogram — to present data alongside your narrative:
- Four views: horizontal Bars, vertical Columns, SVG Donut rings with total callouts, or sortable Executive Tables.
- Live layer aggregation or custom rows: group and aggregate (
count,sum,avg,min,max) any layer in your story on the fly, or enter custom rows with prefixes and suffixes (such as$or%).
Make a map once, use it in every story
Maps on the home screen are maps you build on their own, in a full-screen map editor, and then use in as many stories as you like. Edit the map once and every story that uses it updates, published stories included.
- Layers: live public data, hosted feature layers, your files, or links. Reorder them by dragging, switch them on and off, and zoom to each.
- Per-layer settings: color, proportional symbols, a time field for the time slider, labels, popups, source attribution, filters, visible zoom range and insights, plus an editable attribute table & schema studio you can sort, search, calculate and edit. Click a row number to zoom to that feature.
- Tools: 22 analysis tools (multi-ring trade areas, hub & spoke desire lines, spatial density clusters, attribute table joins, clip, erase, intersect, dissolve, spatial join, buffer, hex bins, Voronoi and more), a time slider, drawing, measuring, place search, a live coordinate & zoom HUD, a basemap gallery and the legend.
- Bookmarks: named views that also remember which layers are on, with a note for each.
Then walk readers through it: one click turns the bookmarks into moving-map cards, so a story can show the raw data, then the analysis, then the result, one card at a time. Any map in a story can also use a saved map, showing one of its bookmarks or a view you frame.
Draw right on the map
Press Draw on any map in the editor and sketch points, lines, freehand routes, areas, boxes, circles and text labels right on it. Give each shape its own colors, fill, line width, dashes or dots, and an arrowhead, plus a name and a description that readers see when they click it. Distances and areas are measured as you draw. Select a shape to move, restyle or duplicate it, trace over any other layer, and reopen a saved drawing or Hosted Feature Layer whenever you like.
Tours from your phone photos
Stop by stop tours have their own tour builder: every stop numbered on a big map, a list with photo thumbnails, and the selected stop's photo, name and text side by side. Drop a batch of photos and each becomes a stop where it was taken, in the order you took it, named after the file. Photos without a location are flagged and placed with a click. Add stops by searching or clicking the map, drag markers to move them, and drag the list to reorder.
An editor that gets out of the way
Click anything in the live preview to edit it. You also get drag to reorder, undo and redo, a command palette (Ctrl/⌘+K), a phone preview and autosave. Drafts stay private until you publish again.
Make it loud: the Brutalist look
Switch any story to Brutalist for raw monospace and heavy display type, hard black edges and offset shadows, acid-yellow stickers, a ticker headline, outlined words that fill in as you scroll, and numbered chapters. Or start from the Brutalist zine template.
Every story works on a phone
Start with a few questions, not a demo to delete
New story asks what you're making, how it should look, its title and cover, and where it goes. Then it builds the story around your places and chapters, with short prompts where your words go. A checklist in the editor walks you through the rest: cover photo, your own text, photos, a phone check and publishing.
| Kind of story | What you get |
|---|---|
| A journey | A moving map with one card per place, plus an overview of the whole route |
| A walking tour | Numbered stops with room for a photo and a description each. Build it in the tour builder: drop your phone photos and each one lands on the map where it was taken |
| An illustrated essay | Your chapters as full-screen titles, with space to write and a photo |
| A data story | One big map ready for live or uploaded data, with numbers and context |
| Start from scratch | A title and an empty page |
Every kind comes in eight looks. Each one sets the type, colors, chapter colors, map style and scroll animation. You can switch later under Story → Look & feel, and restyle existing chapters and maps to match in one click.
| Look | Style |
|---|---|
| Classic | Warm paper and serif type. The original Waymark look |
| Night | Dark and cinematic, so photos and maps glow |
| Brutalist | Raw type, hard edges, loud color and a ticker headline |
| Atlas | An old atlas: parchment, engraved type and small caps, sepia maps in a double frame with a compass rose, and chapters numbered as plates |
| Aurora | Dark glass and drifting light, glowing gradient type, frosted cards and bento-style numbers. Points and lines on its maps glow like fireflies |
| Editorial | Magazine journalism: warm newsprint ivory, deep espresso ink, crimson double-rules, high-contrast Playfair Display headlines, drop-cap intros and pull-quotes |
| Blueprint | Architectural CAD & telemetry: deep Prussian navy with a dual-scale coordinate grid, electric cyan and amber accents, Space Mono headers, corner reticle brackets and glowing map features |
| Fog | A thick fog storm: banks of fog drift behind and in front of the story, gusts tear across the screen and lightning flickers inside the murk. The reader's pointer carries a lantern that clears the fog around it, headings and photos come into focus as they scroll in, statement words sharpen as they light up, and markers glow like lanterns. A weather readout in the top bar counts visibility up from 40 m to 10 km as the fog lifts toward the end of the story |
Prefer to learn from something finished? Explore an example opens a complete story to take apart: the moving map, walk, essay, Brutalist zine and Down the Potomac samples. Any story can also use Before & after, a block with two maps in one view and a draggable divider, or Chart & table, an interactive breakdown of any layer or dataset.
🚀 Publish it anywhere
Publish gives a story a public link like /s/your-story, with a preview image when the link is shared.
Export as a website turns a story into plain files that run on any web server, with no Node, no database and no Waymark.
| Format | Use it for |
|---|---|
| Website folder (.zip) | Apache, nginx, IIS, shared hosting, S3, GitHub Pages, Netlify, or just double-click index.html |
| Tomcat web app (.war) | Drop it in Tomcat's webapps/ folder (Tomcat 8.5 to 11) |
The export includes the page, your images, a snapshot of your map data (saved maps included) and, optionally, the map library. Every path is relative, so it works in any folder. You can also export from the command line:
node server.js export my-story --war --out /opt/tomcat/webapps/my-story.warAll export options
node server.js stories # find the story's link or id node server.js export my-story # published version, as a .zip node server.js export my-story --draft # the unpublished draft
Options: --war, --draft, --out <file>, --no-images, --no-data and --no-vendor. The last three keep images, data or the map library as links instead of copying them. Only the map tiles and web fonts load from the internet.
🏠 Host it yourself
Your stories, saved maps, accounts and images all live in one data/ folder. Waymark runs anywhere that has Node 22.13+ or Docker and a persistent disk: a VPS, a home server, Fly.io, Railway, Render and so on.
- Put it behind HTTPS. Caddy is the easiest:
caddy reverse-proxy --from stories.example.com --to localhost:5180 - In
.env, setAPP_URL=https://stories.example.comandCOOKIE_SECURE=1. - Once your accounts exist, set
ALLOW_SIGNUP=0to close sign-ups. - Back up
data/. It holds everything.
Settings (.env)
Copy .env.example to .env and edit it. Everything is optional.
| Setting | Default | What it does |
|---|---|---|
APP_NAME, APP_TAGLINE |
Waymark | Rename the app (interface, emails, link previews) |
PORT |
5180 |
Port to listen on |
HOST |
this computer only | Set to 0.0.0.0 to let other devices on your network connect |
APP_URL |
http://localhost:PORT |
Your site's public address, used in password reset emails |
DATA_DIR |
./data |
Where the database (waymark.db) and uploaded images are stored |
COOKIE_SECURE |
off | Set to 1 when the site is served over HTTPS |
ALLOW_SIGNUP |
on | Set to 0 to stop new sign-ups once your accounts exist |
SMTP_* |
off | Email for password resets. See below |
Password reset and email
People can reset a forgotten password from the sign-in page. To deliver the emails, set the SMTP_* settings in .env. Any SMTP provider works, for example Postmark, Resend, SendGrid, Mailgun, or a Gmail app password.
Without email set up, the reset link prints in the terminal where the server runs. That's fine for personal or local use. You can also reset passwords yourself from the command line:
npm run users # list accounts npm run reset-link -- someone@example.com # print a reset link npm run set-password -- someone@example.com # type a new password directly
Other ways to run it
npm run dev |
Restarts automatically when you edit server code |
npm test |
Runs the test suite against a temporary database |
docker compose up -d |
Runs in Docker, with data kept in a named volume |
📚 Under the hood
Waymark is plain JavaScript with no build step and no dependencies. The server uses only Node's built-in modules, including node:sqlite. The browser app is ordinary ES modules. Maps are drawn with MapLibre GL JS on free OpenFreeMap and Sentinel-2 basemaps.
Project layout
server.js entry point + startup checks
server/index.js HTTP server: API routes, uploads, published pages, static files
server/db.js SQLite schema (built-in node:sqlite)
server/auth.js password hashing, sessions, reset tokens, rate limiting
server/mail.js minimal SMTP client
server/cli.js admin commands
server/brand.js app name and tagline
public/ the web app (plain ES modules, no build step)
js/render.js story → page; all block renderers
js/effects.js scroll-driven effects engine (one rAF loop, on-screen elements only)
js/fog.js weather for the Fog look (fog layers, lantern, gusts, lightning, visibility readout)
js/ui.js icons, logo, toasts, menus, dialogs
js/maps.js MapLibre helpers: basemaps, views, data layers (incl. heatmap/clusters), legend, popups
js/analysis.js geospatial tools and layer statistics (Turf.js, loaded only in the editor)
js/workbench.js Add data, Analyze and Draw workspaces
js/mapeditor.js the saved-map editor (map.html)
js/webmap.js saved maps in stories: builds them into a story before it's shown or exported
js/table.js hosted feature layers, editable attribute tables, schema studio and field calculator
js/layer-form.js layer settings, shared by the story editor and the map editor
js/catalog.js public data catalog (government sources + ArcGIS Online search)
js/export-core.js static-site / .war exporter (shared by the editor and `node server.js export`)
js/export.js the export dialog
js/zip.js minimal zip writer
js/builder.js the story editor
js/wizard.js the New story wizard; story-kit.js builds stories from its answers and holds the looks
js/tour.js the tour builder for Stop by stop; exif.js reads where and when photos were taken
js/templates.js finished examples
map.html the saved-map editor page
welcome.html landing page for signed-out visitors (itself a story)
stories/, data/ sample story and sample GeoJSON
docs/SCHEMA.md the story and saved-map file formats
test/ API tests plus unit tests for saved maps, hosted layers, the wizard, photo locations and story formats (node:test)
API
Every request that changes data must send the header X-Waymark: 1. This guards against cross-site request forgery: another site can't add a custom header without a CORS preflight, and the server never allows one.
POST /api/auth/signup {email, password, name} · POST /api/auth/login · POST /api/auth/logout · GET /api/me |
Accounts |
POST /api/auth/forgot {email} · GET /api/auth/reset/:token · POST /api/auth/reset {token, password} · POST /api/auth/password {current, password} |
Password reset and change |
GET /api/stories · POST /api/stories {story} |
List and create your stories |
GET /api/stories/:id · PUT /api/stories/:id {story, version} · DELETE /api/stories/:id |
Read, save and delete a draft. A save with an outdated version returns 409 |
POST /api/stories/:id/publish {slug?} · POST /api/stories/:id/unpublish |
Publish copies the draft to the public copy |
GET /api/public/:slug |
A published story's JSON |
GET /api/maps · POST /api/maps {map} · GET PUT {map, version} DELETE /api/maps/:id · GET /api/maps/:id/usage · GET /api/public/maps/:id |
Saved maps: list, create, read, save, delete, which stories use one, and the copy readers load once a published story uses it |
GET /api/layers · POST /api/layers · GET PUT DELETE /api/layers/:id · GET /api/layers/:id/geojson · GET /api/layers/:id/usage · GET /api/public/layers/:id |
Hosted feature layers: list, create, edit schema/attributes/features, serve as GeoJSON, check story/map usage, and public GeoJSON service endpoint |
POST /api/uploads (raw image body) · GET /api/uploads · DELETE /api/uploads/:id |
Images: JPEG, PNG, WebP or GIF, checked by their bytes. 12 MB limit |
Security
- Passwords and sessions: passwords are hashed with scrypt. Sessions are random tokens stored hashed, in an
HttpOnly; SameSite=Laxcookie. Sign-in, sign-up and reset requests are rate-limited. - Password reset: reset links are single-use and expire after an hour, and using one signs out every existing session. A reset request gets the same answer whether or not the email has an account. Links are always built from
APP_URL, never from the request's Host header. - Story content: story text is cleaned against an allow-list of HTML tags. Every page sends a Content-Security-Policy that only runs scripts from this site and unpkg, with no inline scripts.
- Uploads: uploaded files are checked by their contents, which rules out SVG, and served with
nosniffand adefault-src 'none'policy.
Story file format
Stories are plain JSON. See docs/SCHEMA.md.
🤝 Contributing
Bug reports, ideas and pull requests are welcome. Start with CONTRIBUTING.md. Please report security problems privately, as described in SECURITY.md. Each release is listed in the changelog.
Background
This project started as a rebuild of the ArcGIS StoryMaps reading and authoring experience. It was written from scratch using only public behavior and open formats. Esri's open-source classic templates (Map Journal, Map Series, Apache-2.0) and mapbox/storytelling (BSD-3) were studied for data models and UX patterns only. No code was copied, and the current closed-source ArcGIS StoryMaps product was not used.
License
Waymark is released under the MIT License.
It loads these open-source libraries from a CDN (an exported story can bundle MapLibre, along with its license): MapLibre GL JS (BSD-3-Clause) and, in the editor only, Turf.js (MIT).
Map tiles and data come from their providers, which are credited on every map: OpenStreetMap contributors and OpenFreeMap (ODbL), Sentinel-2 cloudless by EOX (CC-BY 4.0), and USGS, NOAA, NASA, US Census Bureau, NPS, USDA Forest Service, EPA, NIFC and Natural Earth (public domain). If you host Waymark for others, keep those credits visible.













