Upload an Excel file with stops, plot them on a map, and compute the order in which they should be delivered by solving the TSP with OSRM. Includes per-stop ETAs, time-window alerts, and Excel export.
The Vite/React frontend and the FastAPI API run separately. The routing engine stays independent of the UI and of the HTTP transport.
Excel/CSV ─▶ normalize ─▶ geocode (missing) ─▶ OSRM /trip ─▶ itinerary ─▶ map + panel
cd web
npm ci
npm run dev
# http://localhost:5173Click Cargar datos de ejemplo (Load sample data) or drag in
web/public/samples/paradas-ejemplo.xlsx. The frontend uses npm packages;
the map tiles still need internet access.
Headers are auto-detected, in Spanish or English, with or without accents. Every column is optional except that you need an address or lat/lon:
| Field | Recognized headers |
|---|---|
| Reference | Referencia, Ref, ID, Orden, BL, Contenedor, Guía, Tracking |
| Name | Cliente, Nombre, Consignatario, Destinatario, Customer |
| Address | Direccion, Dirección, Address, Calle, Domicilio |
| City / State / ZIP / Country | Ciudad, Municipio, Departamento, CP, País |
| Coordinates | Latitud/Lat/Y, Longitud/Lon/Lng/X |
| Service time | Servicio, Tiempo_servicio, Dwell (minutes) |
| Time window | Ventana_inicio/Desde, Ventana_fin/Hasta (HH:MM) |
| Notes | Notas, Observaciones, Remarks |
Rules: rows with valid lat/lon are used as-is; otherwise the app geocodes
address + city + state + zip + country. Accepts decimal commas (10,9878)
and discards 0,0. Rows that can't be located are listed separately and
excluded from the route.
- Depot / origin — any row; the first one by default. Fixed as the
starting point (
source=firstin OSRM). - Return to depot —
roundtrip=true; adds the return leg to the totals. - Fixed final destination — the last row in the file closes the route
(
destination=last). - Departure time and service time per stop — used to compute ETAs; service time can be overridden per row.
- Strategy:
OSRM /trip(default) — OSRM solves the TSP. Fast and good./table + NN + 2-opt— time matrix + a custom heuristic. Slower, but it's the extension point for priorities, hard time windows, or multi-vehicle routing (web/src/lib/core/strategies/nn-two-opt.js,tourCostfunction).
web/
src/
App.jsx composition root (React mount point)
store/
useConfigStore.js Zustand + persist: server mode, OSRM/geocoder/plan config
usePlannerStore.js Zustand: stops, ingestion, calculation, alerts, selection
hooks/
useTheme.js light/dark theme
components/ TopBar, FilePanel, RouteParamsPanel, ResultsPanel,
RouteSequence, StopsTable, PlannerMap (Leaflet)
lib/
config.js DEFAULT_CONFIG + deepMerge, consumed by useConfigStore
ports.js contracts: Geocoder, RoutingEngine, StopSource
core/ ← pure logic, no DOM or HTTP (testable in Node)
normalize.js raw Excel -> Stops
planner.js geocoding -> optimization -> itinerary with ETAs
strategies/ osrm-trip.js · nn-two-opt.js
adapters/ ← swappable implementations
xlsx-source.js SheetJS (read + export)
nominatim-geocoder.js Nominatim with rate-limit + cache
osrm-engine.js OSRM /trip, /table, /route
http-backend.js the same ports, against YOUR API
factory.js the single decision point: which adapter goes in
api/ optional FastAPI layer (same contracts)
osrm/ reproducible OSRM data build
docker-compose.yml local OSRM infrastructure
The rule: core/ never imports from adapters/, store/, or the React
tree. That's why switching from "everything in the browser" to "behind my
own API" is just changing cfg.mode — and why the tests run in Node,
without a browser.
Each one is additive; none of them require touching core/.
| Layer | What it solves | Where |
|---|---|---|
| 1. Your own API | hides OSRM/geocoder URLs, shared geocoding cache, global rate limiting, auth and per-client quotas | api/app/main.py + flip server mode in the UI |
| 2. Your own OSRM | removes the ~100-stop limit and the dependency on an SLA-free demo | osrm/build.py + docker-compose.yml |
| 3. Your own Nominatim | geocoding without the 1 req/s limit (the real bottleneck on large files) | commented-out service in the compose file |
| 4. Persistence | save plans, compare planned vs. executed, per-driver history | new RouteStore adapter + tables |
| 5. Real VRP | multiple vehicles, capacities, hard time windows, shifts | swap the strategy for VROOM/OR-Tools behind the same port |
cd api
uv sync --all-extras --dev
OSRM_URL=https://router.project-osrm.org uv run uvicorn app.main:app --port 8000
# API at http://localhost:8080; Vite serves the frontend on :5173Then in the UI: Servidores → Modo servidor (Servers → Server mode). The
frontend switches to calling /api/geocode, /api/trip, /api/table
instead of reaching out to the internet.
uv run --project osrm python osrm/build.py build florida
OSRM_REGION=florida docker compose up -d --wait osrmosrm-routed comes up with --max-trip-size 1000 --max-table-size 2000:
that's where the public demo's ceiling disappears. Start the API with
OSRM_URL=http://localhost:9090 and turn on server mode in the UI.
The builder supports only the current Colombia and Florida markets:
uv run --project osrm python osrm/build.py build colombia
OSRM_REGION=colombia docker compose up -d --wait osrmcd web
npm test # core: normalization, OSRM parsing, 2-opt, ETAs
npm run build # Vite production build
cd ../api
uv run pytest -q # API tests, once they exist- Public OSRM demo: ~100 coordinates per request,
carprofile only, no SLA. Don't use it with client data in production. - Public Nominatim: 1 req/s and bans bulk use. 200 addresses without coordinates ≈ 4 minutes the first time (cached afterward).
/tripoptimizes distance/time, not time-window compliance: the UI flags stops that arrive late, but doesn't reorder around windows. That's layer 5.- One vehicle per file.
Leaflet (BSD-2) and SheetJS (Apache-2.0) are installed from npm. OSM data is ODbL: map attribution must be kept.
