Moveet
June 10, 2026 · View on GitHub
A real-time vehicle fleet simulator that runs vehicles on actual road networks with A* pathfinding, realistic motion physics, BPR traffic congestion, time-of-day patterns, geofencing, incident-based rerouting, session recording, and a custom browser-side map rendering engine — no map tile provider required.
Contents
- Features
- Quick start
- Architecture
- Network CLI
- Simulator API
- WebSocket events
- Adapter plugins
- Configuration
- Testing
- Docker
- Contributing
Features
| 🗺 Road-network agnostic | Ingests any GeoJSON/OSM-derived road graph — swap the file to simulate a different city |
| 🌐 Network CLI | apps/network pipeline: download OSM data from Geofabrik, extract a bbox, filter road classes, export GeoJSON, validate topology, and diff versions — one prepare command does it all |
| 🔀 A* pathfinding | Haversine heuristic over bidirectional road segments; respects turn restrictions, roundabouts, and road-class access rules; incident-aware route cache |
| 🚗 Vehicle types | Five types (car, truck, motorcycle, ambulance, bus) with distinct speed profiles, acceleration curves, road restrictions, and special behaviours (e.g. ambulances ignore heat-zone penalties) |
| 🚦 Traffic realism | BPR congestion model (flow/capacity), time-of-day rush-hour/night demand multipliers, traffic-signal intersection delays, surface-smoothness speed factors |
| 🎨 Custom map renderer | D3 SVG scene with a Mercator projection (1×–15× zoom, pan); dedicated layers for roads, vehicles, POIs, heat-zone contours, incident markers, geofences, breadcrumb trails, and dispatch routes — no Leaflet or Mapbox |
| 📡 Real-time WebSocket | 100 ms batched broadcast with backpressure handling; streams vehicle positions, routes, heat zones, incidents, geofence events, fleet events, and replay frames |
| 🔥 Heat zones | Contour density map (green → red, 50 thresholds) derived from road-network intersection density |
| 🔲 Geofencing | Draw custom polygons on the map; monitor vehicles crossing zone boundaries; enter/exit events broadcast in real time |
| ⚠️ Incidents & rerouting | Operator-created road incidents trigger live A* rerouting for all affected vehicles |
| 🎬 Recording & replay | NDJSON session recording; replay with pause, seek, and 1×/2×/4× speed controls and interpolated progress bar |
| 🚘 Breadcrumb trails | Per-vehicle position history rendered as fading path overlays on the map |
| 🚦 Fleet management | Group vehicles into named, colour-coded fleets; assign/unassign at runtime |
| 🔍 POI + road search | Typeahead combining road names and points of interest; dispatches selected vehicles to result |
| 🖥 Operator UI | Icon-rail sidebar (Vehicles · Fleets · Incidents · Geofences · Recordings · Visibility · Speed · Adapter) + bottom dock with live and replay controls |
| 🔌 Adapter plugins | Hot-swappable source and sink plugins; configure via env vars or REST API at runtime |
Quick Start
Prerequisites
- Node.js ≥ 24, npm ≥ 9 (workspace root)
- Docker (optional)
Run locally
git clone https://github.com/ivannovazzi/moveet.git
cd moveet
npm install
npm run dev # starts all three services via Turborepo
| Service | URL |
|---|---|
| Dashboard | http://localhost:5012 |
| Simulator API | http://localhost:5010 |
| Adapter API | http://localhost:5011 |
Or start services individually:
npm run dev:sim # simulator only :5010
npm run dev:ui # UI only :5012
npm run dev:adapter # adapter only :5011
To prepare a road network for a new city:
cd apps/network
npm run dev -- prepare nairobi # or any region in regions.json
Architecture
flowchart TD
NET["<b>apps/network</b><br/>OSM CLI pipeline<br/>(offline, one-time)"]
UI["<b>apps/ui</b><br/>React 19 · D3 · Vite<br/>:5012"]
SIM["<b>apps/simulator</b><br/>Express · ws · Turf.js<br/>:5010"]
ADP["<b>apps/adapter</b><br/>Express · plugin manager<br/>:5011"]
EXT["External system<br/><i>GraphQL · Kafka · REST · …</i>"]
NET -- "GeoJSON road network" --> SIM
UI -- "REST + WebSocket" --> SIM
SIM -- "GET /vehicles<br/>POST /sync" --> ADP
ADP -- "source / sink plugins" --> EXT
Network is an offline CLI that turns raw OpenStreetMap data into a simulator-ready GeoJSON road network. Run it once per city; the output drops straight into apps/simulator/data/.
Simulator is the core — it builds a routable graph from GeoJSON, runs vehicles with per-vehicle interval timers, and serves a REST API + WebSocket feed. It works completely standalone.
UI is a React app that renders everything in an SVG canvas using D3 with a Mercator projection. It has no map-tile dependency — roads, routes, heat-zone contours, POIs, incidents, geofences, breadcrumb trails, and vehicles are all drawn from GeoJSON/API data.
Adapter is optional — only needed when you want to push data to an external fleet management system. It hot-swaps source and sink plugins at runtime via its own REST API.
Simulator internals
flowchart LR
GJ[GeoJSON<br/>road network] --> RN[RoadNetwork<br/>graph + A*]
RN --> VM[VehicleManager<br/>movement · routing · types]
VM --> SC[SimulationController<br/>start · stop · options]
SC --> RM[RecordingManager]
SC --> RP[ReplayManager]
SC --> IM[IncidentManager<br/>rerouting]
SC --> FM[FleetManager]
SC --> GF[GeoFenceManager<br/>enter / exit events]
SC --> TM[TrafficManager<br/>BPR · time-of-day]
SC --> WS[WebSocket<br/>broadcaster]
Network CLI
apps/network is a standalone CLI that turns raw OpenStreetMap data into a simulator-ready GeoJSON road network. It requires a locally installed osmium-tool (≥ 1.14) and runs entirely offline after the initial Geofabrik download.
One-command setup
cd apps/network
npm run dev -- prepare nairobi # interactive wizard if region omitted
npm run dev -- prepare --output apps/simulator/data/network.geojson
The prepare command runs the full pipeline: download → extract → filter → export → validate.
Individual commands
| Command | Description |
|---|---|
network download | Download country PBF from Geofabrik (cached after first run) |
network extract | Clip a bounding box from the country PBF using osmium |
network filter | Keep only drivable road classes from the extracted PBF |
network export | Convert filtered PBF to GeoJSON via osmium |
network validate | Run topology checks: orphan nodes, duplicate edges, disconnected components |
network diff <old> <new> | Compare two network GeoJSON files and report changes |
network prepare [region] | Full pipeline in one step |
Regions are defined in regions.json (covers major cities globally). Pass --bbox w,s,e,n for a custom area or --geofabrik <path> for a Geofabrik sub-path.
Simulator API
Base URL:
http://localhost:5010
Simulation control
| Method | Path | Description |
|---|---|---|
GET | /status | Simulation state (running, ready, interval) |
POST | /start | Start simulation (accepts options body) |
POST | /stop | Stop simulation |
POST | /reset | Reset to initial state |
GET | /options | Get current simulation options |
POST | /options | Update simulation options |
Vehicles & routing
| Method | Path | Description |
|---|---|---|
GET | /vehicles | List all vehicle DTOs |
POST | /direction | Dispatch one or more vehicles to a destination |
GET | /directions | Get active direction assignments |
POST | /find-node | Snap a lat/lng to the nearest graph node |
POST | /find-road | Snap a lat/lng to the nearest road edge |
POST | /search | Full-text POI search |
Map data
| Method | Path | Description |
|---|---|---|
GET | /network | Full road-network GeoJSON |
GET | /roads | Road segments GeoJSON |
GET | /pois | Points of interest |
GET | /heatzones | Current heat zone features |
POST | /heatzones | Regenerate heat zones |
Fleets
| Method | Path | Description |
|---|---|---|
GET | /fleets | List all fleets |
POST | /fleets | Create a fleet |
DELETE | /fleets/:id | Delete a fleet |
POST | /fleets/:id/assign | Assign vehicles to a fleet |
POST | /fleets/:id/unassign | Unassign vehicles from a fleet |
Incidents
| Method | Path | Description |
|---|---|---|
GET | /incidents | List active incidents |
POST | /incidents | Create an incident (triggers rerouting) |
DELETE | /incidents/:id | Clear an incident |
POST | /incidents/random | Create a random incident |
Geofences
| Method | Path | Description |
|---|---|---|
GET | /geofences | List all geofence zones |
POST | /geofences | Create a geofence (GeoJSON polygon + metadata) |
GET | /geofences/:id | Get a geofence |
PUT | /geofences/:id | Update a geofence |
DELETE | /geofences/:id | Delete a geofence |
PATCH | /geofences/:id/toggle | Enable / disable a geofence |
Health
| Method | Path | Description |
|---|---|---|
GET | /health | Uptime and subsystem status |
Recording & replay
| Method | Path | Description |
|---|---|---|
POST | /recording/start | Start recording the session |
POST | /recording/stop | Stop recording and save NDJSON file |
GET | /recordings | List saved recordings |
POST | /replay/start | Load and start a recording replay |
POST | /replay/pause | Pause replay |
POST | /replay/resume | Resume replay |
POST | /replay/stop | Stop replay, return to live mode |
POST | /replay/seek | Seek to a timestamp (ms) |
POST | /replay/speed | Set playback speed multiplier |
GET | /replay/status | Current replay state |
WebSocket Events
Connect to ws://localhost:5010. On connect the server sends a status and options snapshot.
| Event | Direction | Payload |
|---|---|---|
vehicles | server → client | Array of VehicleDTO (position, speed, heading, fleetId) |
status | server → client | SimulationStatus (running, ready, interval) |
options | server → client | Current StartOptions |
heatzones | server → client | HeatZoneFeature[] |
direction | server → client | Active dispatch assignment |
waypoint:reached | server → client | Vehicle reached a waypoint |
route:completed | server → client | Vehicle completed its full route |
reset | server → client | Simulation was reset |
fleet:created | server → client | New fleet |
fleet:deleted | server → client | Fleet removed |
fleet:assigned | server → client | Vehicles assigned to fleet |
incident:created | server → client | New incident + affected vehicles |
incident:cleared | server → client | Incident resolved |
vehicle:rerouted | server → client | Vehicle rerouted around incident |
geofence:event | server → client | Vehicle entered or exited a geofence zone |
Adapter Plugins
Base URL:
http://localhost:5011
Runtime configuration API
| Method | Path | Description |
|---|---|---|
GET | /config | Current source + sinks config |
POST | /config/source | Swap the active source plugin |
POST | /config/sinks | Replace the active sink list |
DELETE | /config/sinks/:type | Remove one sink |
GET | /vehicles | Vehicles from the current source |
GET | /fleets | Fleets from the current source |
POST | /sync | Push a position update through all sinks |
GET | /health | Health check |
Source plugins
flowchart LR
SRC["Source plugin"] --> MGR["Plugin Manager"]
subgraph Sources
static["<b>static</b><br/>synthetic vehicles"]
graphql_s["<b>graphql</b><br/>GraphQL query"]
rest_s["<b>rest</b><br/>HTTP GET"]
mysql["<b>mysql</b>"]
postgres["<b>postgres</b>"]
end
Sources --> SRC
| Plugin | Key config fields |
|---|---|
static | count (default 20) |
graphql | url, query, token, headers, vehiclePath, maxVehicles |
rest | url, token, headers, vehiclePath, maxVehicles |
mysql | host, port, user, password, database, query |
postgres | host, port, user, password, database, query |
Sink plugins
flowchart LR
MGR["Plugin Manager"] --> SINK["Sink plugin(s)"]
subgraph Sinks
console["<b>console</b><br/>stdout"]
graphql_k["<b>graphql</b><br/>GraphQL mutation"]
rest_k["<b>rest</b><br/>HTTP POST"]
redpanda["<b>redpanda</b><br/>Kafka / Redpanda"]
redis["<b>redis</b>"]
webhook["<b>webhook</b><br/>HTTP fire-and-forget"]
end
SINK --> Sinks
Multiple sinks run simultaneously. Configure via env vars or the runtime API:
# Env-var example: Redpanda + webhook
SOURCE_TYPE=graphql
SOURCE_CONFIG='{"url":"https://api.example.com/graphql","token":"..."}'
SINK_TYPES=redpanda,webhook
SINK_REDPANDA_CONFIG='{"brokers":"localhost:9092","topic":"fleet-updates"}'
SINK_WEBHOOK_CONFIG='{"url":"https://hooks.example.com/fleet"}'
Configuration
Simulator (apps/simulator/.env)
| Variable | Default | Description |
|---|---|---|
PORT | 5010 | HTTP / WebSocket port |
GEOJSON_PATH | ./data/network.geojson | Path to the road-network GeoJSON file |
VEHICLE_COUNT | 70 | Number of vehicles to spawn |
UPDATE_INTERVAL | 500 | Position broadcast interval (ms) |
MIN_SPEED | 20 | Minimum vehicle speed (km/h) |
MAX_SPEED | 60 | Maximum vehicle speed (km/h) |
ACCELERATION | 5 | Acceleration rate (km/h per tick) |
DECELERATION | 7 | Deceleration rate (km/h per tick) |
TURN_THRESHOLD | 30 | Bearing change (°) that triggers slowdown |
SPEED_VARIATION | 0.1 | Random speed jitter factor [0, 1] |
HEATZONE_SPEED_FACTOR | 0.5 | Speed multiplier inside heat zones |
ADAPTER_URL | (empty) | Enable adapter sync (e.g. http://localhost:5011) |
SYNC_ADAPTER_TIMEOUT | 5000 | Adapter sync timeout (ms) |
Adapter (apps/adapter/.env)
| Variable | Default | Description |
|---|---|---|
PORT | 5011 | HTTP port |
SOURCE_TYPE | static | Active source plugin |
SOURCE_CONFIG | {} | JSON config for the source plugin |
SINK_TYPES | (empty) | Comma-separated sink plugin names |
SINK_<TYPE>_CONFIG | {} | JSON config per sink, e.g. SINK_REDPANDA_CONFIG |
Testing
Tests use Vitest across all four packages. CI enforces 50 % coverage thresholds.
npm test # all packages via Turborepo
cd apps/simulator && npm test # simulator
cd apps/ui && npm test # UI
cd apps/adapter && npm test # adapter
cd apps/network && npm test # network CLI
Simulator test coverage includes: road-network graph, A* pathfinding, vehicle types and profiles, turn restrictions, BPR traffic manager, time-of-day clock, geofence manager, heat zones, fleet management, incident rerouting, recording/replay lifecycle, rate limiter, geospatial helpers, serializer, config validation, and SimulationController lifecycle.
Docker
Pull and run (no build needed)
curl -O https://raw.githubusercontent.com/ivannovazzi/moveet/main/docker-compose.ghcr.yml
docker compose -f docker-compose.ghcr.yml up
The simulator image does not bundle a road network: place a simulator-ready GeoJSON at ./apps/simulator/data/network.geojson (see Network CLI) or edit the volume in the compose file.
Open http://localhost:5012.
Images (published on every release via GitHub Container Registry):
ghcr.io/ivannovazzi/moveet-simulator
ghcr.io/ivannovazzi/moveet-adapter
ghcr.io/ivannovazzi/moveet-ui
Build from source
cd apps/simulator && docker compose up
Project Structure
| Package | Path | Tech | Port |
|---|---|---|---|
| network | apps/network/ | Node.js 24 · Commander · osmium-tool (local install) | CLI |
| simulator | apps/simulator/ | Node.js 24 · Express 4 · ws 8 · Turf.js 7 | 5010 |
| adapter | apps/adapter/ | Node.js 24 · Express 4 | 5011 |
| ui | apps/ui/ | React 19 · D3 7 · Vite · TypeScript 5.8 · CSS Modules | 5012 |
Each package has its own README with deeper architecture notes.
Contributing
Please read CONTRIBUTING.md before opening a PR.
Security
See SECURITY.md for the vulnerability disclosure policy.
License
MIT © Ivan Novazzi