# Moveet
[](https://github.com/ivannovazzi/moveet/actions/workflows/ci.yml)
[](LICENSE)
[](https://nodejs.org/)
[](https://www.typescriptlang.org/)
[](docker-compose.yml)
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 WebGL map rendering engine — no map tile provider required.
---
## Contents
- [Features](#features)
- [Quick start](#quick-start)
- [Architecture](#architecture)
- [Network CLI](#network-cli)
- [Simulator API](#simulator-api)
- [WebSocket events](#websocket-events)
- [Adapter plugins](#adapter-plugins)
- [Configuration](#configuration)
- [Testing](#testing)
- [Docker](#docker)
- [Contributing](#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** | deck.gl + luma.gl WebGL scene (Web Mercator viewport, pan/zoom, fly-to); GPU 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 |
| 📊 **Observability** | Simulator and adapter each expose a Prometheus `/metrics` endpoint (prom-client); an `x-request-id` correlation id flows end to end (simulator → adapter → telemetry envelope `correlation_id` / `trace_id`) |
| 📈 **Optional scale-out** | WebSocket fan-out runs in-process by default, or moves to a standalone `ws-gateway` process over a Redis pub/sub bus via `WS_TRANSPORT=redis` (compose `scale` profile, off by default) |
---
## Quick Start
### Prerequisites
- **Node.js** ≥ 26, npm ≥ 9 (workspace root)
- **Docker** (optional)
### Run locally
```bash
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:
```bash
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:
```bash
cd apps/network
npm run dev -- prepare nairobi # or any region in regions.json
```
---
## Architecture
```mermaid
flowchart TD
NET["apps/network
OSM CLI pipeline
(offline, one-time)"]
UI["apps/ui
React 19 · deck.gl · Vite
:5012"]
SIM["apps/simulator
Express · ws · Turf.js
:5010"]
ADP["apps/adapter
Express · plugin manager
:5011"]
EXT["External system
GraphQL · Kafka · REST · …"]
NET -- "GeoJSON road network" --> SIM
UI -- "REST + WebSocket" --> SIM
SIM -- "GET /vehicles
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 on a WebGL canvas using deck.gl + luma.gl over a Web Mercator viewport. 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
```mermaid
flowchart LR
GJ[GeoJSON
road network] --> RN[RoadNetwork
graph + A*]
RN --> VM[VehicleManager
movement · routing · types]
VM --> SC[SimulationController
start · stop · options]
SC --> RM[RecordingManager]
SC --> RP[ReplayManager]
SC --> IM[IncidentManager
rerouting]
SC --> FM[FleetManager]
SC --> GF[GeoFenceManager
enter / exit events]
SC --> TM[TrafficManager
BPR · time-of-day]
SC --> WS[WebSocketBroadcaster
buffer + flush]
WS --> TR{WS_TRANSPORT}
TR -- inprocess (default) --> CF[ClientFanout
per-client fan-out]
TR -- redis --> RB[(Redis pub/sub)]
RB --> GW[ws-gateway
standalone process]
GW --> CF
```
The `WebSocketBroadcaster` keeps the de-duping buffer and 10 Hz flush timer, then delegates egress to a `BroadcastTransport`. By default (`WS_TRANSPORT=inprocess`) it fans out to clients on the simulation thread. Setting `WS_TRANSPORT=redis` publishes serialized envelopes onto a Redis bus that a standalone `ws-gateway` process consumes, running the same `ClientFanout` engine against its own WS server so client count scales independently of the simulator. See `apps/simulator/CLAUDE.md` for the transport seam details.
### Shared packages
Cross-app code lives in `packages/`:
- **`@moveet/shared-types`**: the single source of truth for the cross-app contracts. It owns the WebSocket message union (`WsMessageMap`, the derived `WebSocketMessage`, and `WsDataMessageType`) and the REST request/response DTOs. The simulator's broadcaster is typed against the union (`broadcast(type, data)`), so producer and consumer derive from one definition and a payload-shape change fails to compile on the other side.
- **`@moveet/server-kit`**: shared server runtime infra used by both Node services, namely the `correlationId` and `errorHandler` Express middleware, a pino `logger` factory with secret redaction, and a retrying `httpClient`.
---
## 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](https://osmcode.org/osmium-tool/) (≥ 1.14) and runs entirely offline after the initial Geofabrik download.
### One-command setup
```bash
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 ` | 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 ` 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 |
| `GET` | `/metrics` | Prometheus scrape endpoint (prom-client) |
### 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 |
| `GET` | `/metrics` | Prometheus scrape endpoint (prom-client) |
### Source plugins
```mermaid
flowchart LR
SRC["Source plugin"] --> MGR["Plugin Manager"]
subgraph Sources
static["static
synthetic vehicles"]
graphql_s["graphql
GraphQL query"]
rest_s["rest
HTTP GET"]
mysql["mysql"]
postgres["postgres"]
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
```mermaid
flowchart LR
MGR["Plugin Manager"] --> SINK["Sink plugin(s)"]
subgraph Sinks
console["console
stdout"]
graphql_k["graphql
GraphQL mutation"]
rest_k["rest
HTTP POST"]
redpanda["redpanda
Kafka / Redpanda"]
redis["redis"]
webhook["webhook
HTTP fire-and-forget"]
end
SINK --> Sinks
```
Multiple sinks run simultaneously. Configure via env vars or the runtime API:
```bash
# 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) |
| `WS_TRANSPORT` | `inprocess` | WebSocket fan-out transport: `inprocess` or `redis` |
| `REDIS_URL` | _(empty)_ | Redis bus URL; required when `WS_TRANSPORT=redis` |
### 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__CONFIG` | `{}` | JSON config per sink, e.g. `SINK_REDPANDA_CONFIG` |
---
## Testing
Tests use [Vitest](https://vitest.dev/) across all four packages. CI enforces 50 % coverage thresholds.
```bash
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)
```bash
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](#network-cli)) or edit the volume in the compose file.
Open [http://localhost:5012](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
All three images build from the single workspace-aware root `Dockerfile` (targets:
`simulator`, `adapter`, `ui`). From the repo root:
```bash
docker compose up --build
```
For the Flare dev environment (real fleet roster + dev Redpanda), layer the override:
```bash
docker compose -f docker-compose.yml -f docker-compose.flare-dev.yml up --build
```
To scale the WebSocket fan-out onto a standalone `ws-gateway` process backed by Redis, enable the optional `scale` profile (off by default):
```bash
WS_TRANSPORT=redis REDIS_URL=redis://redis:6379 docker compose --profile scale up --build
```
---
## Project Structure
| Package | Path | Tech | Port |
| ------------- | ------------------------------------ | -------------------------------------------------------------- | ---- |
| **network** | [`apps/network/`](apps/network/) | Node.js 26 · Commander · osmium-tool (local install) | CLI |
| **simulator** | [`apps/simulator/`](apps/simulator/) | Node.js 26 · Express 4 · ws 8 · Turf.js 7 | 5010 |
| **adapter** | [`apps/adapter/`](apps/adapter/) | Node.js 26 · Express 4 | 5011 |
| **ui** | [`apps/ui/`](apps/ui/) | React 19 · deck.gl 9 · Vite · TypeScript 6.0 · Tailwind CSS v4 | 5012 |
Shared workspace packages consumed by the apps:
| Package | Path | Role |
| ------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| **@moveet/shared-types** | [`packages/shared-types/`](packages/shared-types/) | Cross-app contracts: WebSocket message union + REST request/response DTOs |
| **@moveet/server-kit** | [`packages/server-kit/`](packages/server-kit/) | Shared server runtime: correlation-id + error middleware, pino logger, retrying HTTP client |
Each package has its own README with deeper architecture notes.
---
## Contributing
Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR.
## Security
See [SECURITY.md](SECURITY.md) for the vulnerability disclosure policy.
## License
[MIT](LICENSE) © Ivan Novazzi