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.
- Features
- Quick start
- Architecture
- Network CLI
- Simulator API
- WebSocket events
- Adapter plugins
- Configuration
- Testing
- Docker
- Contributing
| 🗺 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 |
| 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 |
- Node.js ≥ 24, npm ≥ 9 (workspace root)
- Yarn (UI package)
- Docker (optional)
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 :5011To prepare a road network for a new city:
cd apps/network
npm run dev -- prepare nairobi # or any region in regions.jsonflowchart 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.
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]
apps/network is a standalone CLI that turns raw OpenStreetMap data into a simulator-ready GeoJSON road network. It requires Docker (for osmium) and runs entirely offline after the initial Geofabrik download.
cd apps/network
npm run dev -- prepare nairobi # interactive wizard if region omitted
npm run dev -- prepare --output apps/simulator/data/network.geojsonThe prepare command runs the full pipeline: download → extract → filter → export → validate.
| 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 (Docker) |
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.
Base URL:
http://localhost:5010
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Method | Path | Description |
|---|---|---|
GET |
/health |
Uptime and subsystem status |
| 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 |
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 |
Base URL:
http://localhost:5011
| 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 |
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 |
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"}'| 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) |
| 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 |
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 CLISimulator 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.
curl -O https://raw.githubusercontent.com/ivannovazzi/moveet/main/docker-compose.ghcr.yml
docker compose -f docker-compose.ghcr.yml upOpen 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
cd apps/simulator && docker compose up| Package | Path | Tech | Port |
|---|---|---|---|
| network | apps/network/ |
Node.js 24 · Commander · osmium (Docker) | 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.
Please read CONTRIBUTING.md before opening a PR.
See SECURITY.md for the vulnerability disclosure policy.
MIT © Ivan Novazzi