Circuit Forgery est un planificateur de trajets moto, auto-hébergé et 100 % local, dédié à la France. Son réglage par défaut : ne proposer que des routes dont la limite de vitesse signalée ne dépasse pas 80 km/h — un seuil personnalisable ou désactivable depuis l'interface (voir Fonctionnalités). Le calcul d'itinéraire s'appuie sur les données routières OpenStreetMap via une instance GraphHopper auto-hébergée — aucun service tiers de routage n'est utilisé, aucune donnée ne sort de l'infrastructure locale.
Le déploiement est pensé pour tourner sur une seule machine (ou un NAS/mini serveur) du réseau domestique et être utilisé depuis n'importe quel appareil du même réseau local — pas d'exposition à Internet, pas de compte, pas de service cloud.
Code sous licence MIT. Voir LICENSE-DATA.md pour la licence des données OpenStreetMap utilisées et les conditions d'attribution — distincte de la licence du code.
- Fonctionnalités
- Architecture
- Démarrage
- Configuration
- Vérifier que le filtrage fonctionne
- Tests
- Maintenance des données OSM
- Points de vigilance
- État des vérifications
- Ajout de points : clic gauche sur la carte pour poser un point de départ, une étape ou une arrivée ; l'itinéraire (filtré > 80 km/h) est recalculé automatiquement à chaque changement.
- Édition des waypoints : liste réordonnable dans la sidebar (glisser-déposer ou boutons ▲▼, utilisables au clavier/tactile), suppression d'un point précis, sélection d'un marqueur sur la carte + touche Suppr, insertion d'un point en glissant directement sur le tracé affiché entre deux waypoints existants. Chaque point est aussi éditable finement (clic sur son libellé dans la liste) : renommage et coordonnées exactes (lat/lon), avec la distance depuis l'étape précédente affichée à côté.
- Annuler / rétablir : boutons dédiés ou Ctrl+Z / Ctrl+Maj+Z, sur un historique unique couvrant aussi bien les mutations de waypoints que l'ajout/retrait d'une zone à éviter.
- Fermer la boucle (ajoute le point de départ en fin de trajet) et Inverser le sens pour finaliser un trajet construit manuellement.
- Circuit en boucle : distance cible + un clic sur la carte comme point
de départ génère une boucle fermée (algorithme
round_tripde GraphHopper), reprise ensuite comme des waypoints normaux et donc éditable avec les outils ci-dessus. Bouton "Autre variante" pour obtenir une forme différente à distance équivalente. Le mode "clic pour générer" se quitte sans rien créer via Échap ou le lien "Annuler" affiché pendant l'attente. Quand le tracé généré est trop dense pour tenir dans la limite de waypoints, un bandeau signale que la boucle affichée est une version simplifiée du tracé réel calculé par GraphHopper. Un point de passage optionnel ("📍 Point de passage" puis clic sur la carte) force le circuit généré à traverser cet endroit — GraphHopper n'acceptant qu'un seul point pourround_trip, le point choisi est inséré après coup dans la séquence de waypoints (à l'emplacement qui minimise le détour), puis routé normalement ; la distance affichée n'est alors plus garantie de coller précisément à la distance cible. - Itinéraires alternatifs : pour un trajet à exactement 2 points (départ/arrivée), jusqu'à 3 tracés distincts proposés au choix. Désactivé tant qu'une zone à éviter ou une limite de vitesse personnalisée est active (voir Limitations connues) ; "Aucune limite" seule reste compatible.
- Zones à éviter : mode dédié pour dessiner un cercle (glisser sur la carte = centre puis rayon) que le calcul d'itinéraire doit contourner — le filtre anti-80 km/h reste actif en plus de cette contrainte. Un champ "Rayon (m)" optionnel permet de poser une zone d'un rayon précis par simple tap/clic, en complément du réglage visuel au glisser. Les zones actives sont listées, retirables individuellement, et persistées avec un trajet sauvegardé.
- Limite de vitesse personnalisable : le seuil de 80 km/h peut être abaissé (20 à 80 km/h) depuis le panneau "Limite de vitesse", ou entièrement désactivé via la case "Aucune limite". Recalcule automatiquement le trajet en cours, et se sauvegarde avec un trajet comme les zones à éviter. Voir Limitations connues pour ce qui n'est volontairement pas possible (relever le seuil au-dessus de 80 à une valeur précise).
- Sauvegarde / édition : un trajet calculé peut être nommé, annoté (champ description libre) et sauvegardé ; un trajet déjà sauvegardé se rouvre en édition ("Modifier" → mutation → "Enregistrer les modifications", distinct d'une nouvelle création) ou se duplique ("Dupliquer" charge une copie indépendante, nom pré-rempli "Copie de …", sans toucher à l'original).
- Brouillon persistant : le trajet en cours de construction est
automatiquement sauvegardé en local (
localStorage) et restauré si la page est rechargée par accident. - Import / export GPX : export d'un trajet sauvegardé au format GPX, import d'un fichier GPX externe — les waypoints sont extraits puis recalculés par le moteur de routage (pas de rejeu tel quel), pour que le filtre anti-80 km/h s'applique toujours, même à un trajet importé.
- Recherche d'adresse : géocodage via Nominatim (OpenStreetMap), un clic sur un résultat ajoute le point au trajet.
- Points d'intérêt : ajout par clic droit sur la carte (nom, catégorie, notes), icônes par catégorie, liste dédiée dans la sidebar, suppression avec confirmation.
Ces limites sont vérifiées et documentées, pas des oublis :
- Pas de profil altimétrique/dénivelé — l'instance GraphHopper n'a pas
l'élévation activée (
"elevation": false), ce qui nécessiterait un réimport complet avec données SRTM. - Alternatives limitées à 2 points — GraphHopper
alternative_routen'a de sens visuel clair que pour un trajet départ/arrivée simple, pas pour un trajet à étapes. Elles sont aussi désactivées dès qu'une zone à éviter ou un seuil de vitesse personnalisé est actif : vérifié empiriquement que GraphHopper ignore silencieusementalgorithm=alternative_routedès qu'uncustom_model(nécessaire pour exclure une zone ou resserrer le seuil) est présent dans la requête — combiner les deux renverrait un unique tracé présenté à tort comme "alternatives". "Aucune limite" reste compatible : c'est un simple changement de profil, pas uncustom_modelpar requête. - Le seuil de vitesse ne peut être qu'abaissé, pas relevé à une valeur
précise — un
custom_modelenvoyé par requête se fusionne avec celui du profil de base mais ne peut jamais l'annuler (multiply_by: 0reste à 0 quoi que la requête ajoute par-dessus ; vérifié empiriquement : un trajet forcé à emprunter le Pont de Normandie, exclu parmax_speed > 80, reste à 91 km de détour même avec un custom_model qui tente explicitement de neutraliser cette règle). Relever la limite au-delà de 80 nécessite donc un profil GraphHopper distinct, préparé à l'avance et sans cette règle (moto_no_limit, voir Architecture) — d'où le choix d'un simple bouton "Aucune limite" plutôt que d'accepter n'importe quelle valeur au-dessus de 80, ce qui aurait exigé un profil par palier. Avec "Aucune limite", les grands axes restent fortement pénalisés (même pondérationroad_classquemoto_no_fast) : ils redeviennent utilisables quand ils sont la seule option raisonnable, sans devenir l'itinéraire le plus direct pour autant. - Zones à éviter absentes du GPX — aucune représentation standard pour ça dans ce format ; seuls les waypoints et le tracé sont exportés/importés.
- Insertion sur le tracé sans équivalent tactile — glisser un point sur
le tracé affiché (
route-insert-interaction.js) n'a pas d'alternative tap/précise, contrairement aux zones à éviter ; aucun appareil tactile disponible pour concevoir et vérifier correctement une telle alternative.
Trois services Docker, orchestrés par docker-compose.yml :
┌──────────────┐ LAN :8000 ┌──────────────────────┐ réseau interne compose ┌──────────────┐
│ Navigateur │ ───────────────────────▶ │ backend (FastAPI) │ ─────────────────────────────────────▶ │ graphhopper │
│ (Leaflet/JS) │ ◀─────────────────────── │ + SQLite │ ◀───────────────────────────────────── │ (routage) │
└──────────────┘ └──────────────────────┘ └──────────────┘
graphhopper/— instance GraphHopper auto-hébergée, graphe construit à partir de l'extrait OSM France (data/osm/). Trois profils :car(référence, sans caractère moto, préparé en CH) ;moto_no_fast(custom_models/moto_no_fast.json), qui exclut les routes dont la vitesse maximale signalée dépasse 80 km/h — le cœur du filtrage, profil par défaut ;moto_no_limit(custom_models/moto_no_limit.json), identique àmoto_no_fastmais sans cette exclusion, utilisé quand l'utilisateur désactive la limite depuis l'UI.moto_no_fastetmoto_no_limitsont préparés en LM (pas CH), ce qui permet de leur envoyer uncustom_modelpar requête (zones à éviter, seuil de vitesse personnalisé) sans reconstruire le graphe. GraphHopper n'est jamais exposé au réseau local : le port publié (8989) est lié à127.0.0.1uniquement ; le backend l'atteint via le réseau interne docker-compose.backend/— API FastAPI qui sert de proxy/enrichissement vers GraphHopper (calcul d'itinéraire, alternatives, circuits en boucle, zones à éviter, seuil de vitesse personnalisé, import/export GPX, géocodage via Nominatim) et persiste trajets/POI dans SQLite. Sert aussi le frontend buildé (fichiers statiques) — un seul port exposé au LAN.frontend/— application vanille JS (pas de framework), carte Leaflet, buildée avec Vite et servie par le backend en production.
Prérequis : Docker + le plugin docker compose.
./scripts/download-osm-data.sh # télécharge data/osm/france-latest.osm.pbf (~4,7 Go)
docker compose build # construit l'image GraphHopper et l'image backend (inclut le build du frontend)
docker compose up -d # démarre les deux servicesdocker compose up -d suffit : un healthcheck Docker sur GraphHopper
(endpoint /healthcheck du port admin Dropwizard) fait attendre le
démarrage du backend jusqu'à ce que le graphe soit effectivement chargé
(depends_on: condition: service_healthy). Le tout premier import à froid
reste long (5 à 60 minutes selon la machine) ; suivre la progression avec
docker compose logs -f graphhopper ou docker compose ps (colonne
STATUS, passe de starting à healthy) reste possible mais n'est pas
nécessaire pour enchaîner les commandes.
L'application est alors accessible sur http://localhost:8000 et, depuis
toute autre machine du même réseau local, sur
http://<IP-LAN-de-la-machine>:8000 (trouver l'IP avec ip -4 addr show).
GraphHopper (port 8989) n'est publié que sur 127.0.0.1 : il n'est
joignable ni depuis le LAN ni depuis l'extérieur, seul le backend proxy
l'est.
.github/workflows/publish-docker.yml construit et publie automatiquement
les images backend et graphhopper sur GitHub Container Registry à
chaque mise à jour de main (tags latest et <sha du commit>) :
ghcr.io/vincent6785/circuit-forgery-backend:latest
ghcr.io/vincent6785/circuit-forgery-graphhopper:latest
docker pull sur ces images évite de construire depuis les sources, mais
ne dispense pas du reste de la configuration au runtime : les images
n'embarquent ni les données OSM, ni graphhopper/config.yml, ni les
custom models — ce sont toujours des volumes montés (voir
docker-compose.yml). docker-compose.images.yml surcharge uniquement
image: sur les deux services (hérite du reste — volumes, ports,
healthchecks — de docker-compose.yml) pour utiliser les images publiées
au lieu de builder localement :
git clone https://github.com/Vincent6785/Circuit-Forgery.git
cd Circuit-Forgery
./scripts/download-osm-data.sh # toujours nécessaire, cf. Démarrage
docker compose -f docker-compose.yml -f docker-compose.images.yml pull
docker compose -f docker-compose.yml -f docker-compose.images.yml up -dCloner le dépôt reste la façon la plus simple d'obtenir
graphhopper/config.yml et les custom models ; ce sont des fichiers texte
légers (pas de gros binaire versionné), le clone n'a rien de coûteux en soi
— seules les données OSM (téléchargées à part) sont volumineuses.
docker-compose.yml publie le port backend sur 0.0.0.0:8000 (toutes les
interfaces), donc quiconque atteint ce port en lecture/écriture accède à
tous les trajets et POI, sans compte ni mot de passe. Ne pas lancer cette
stack telle quelle sur une machine directement joignable depuis Internet
(VM cloud, port forwarding) sans ajouter sa propre authentification
(reverse proxy, VPN...) devant.
Le backend se configure par variables d'environnement, préfixées CF_
(voir backend/app/core/config.py pour la liste faisant foi). Les valeurs
par défaut conviennent à un usage local standard ; les plus utiles à
ajuster :
| Variable | Défaut | Rôle |
|---|---|---|
CF_GRAPHHOPPER_URL |
http://graphhopper:8989 |
URL interne de l'instance GraphHopper (déjà fixée par docker-compose.yml) |
CF_GRAPHHOPPER_NO_LIMIT_PROFILE |
moto_no_limit |
Profil GraphHopper utilisé pour "Aucune limite" (voir Architecture) |
CF_DATABASE_PATH |
/data/circuit-forgery.db |
Chemin du fichier SQLite (monté sur ./backend/data) |
CF_MAX_WAYPOINTS |
20 |
Nombre maximal de points par trajet (protège la complexité des requêtes GraphHopper) |
CF_MAX_ROUND_TRIP_DISTANCE_M |
500000 |
Distance cible maximale pour un circuit en boucle généré |
CF_MAX_AVOID_ZONE_RADIUS_M |
20000 |
Rayon maximal d'une zone à éviter |
CF_MAX_GPX_UPLOAD_BYTES |
5000000 |
Taille maximale d'un fichier GPX importé |
CF_NOMINATIM_URL |
https://nominatim.openstreetmap.org |
Serveur Nominatim utilisé pour la recherche d'adresse |
La heap JVM de GraphHopper se règle séparément via JAVA_OPTS dans
docker-compose.yml (service graphhopper) — voir
Dépannage import (RAM).
./scripts/smoke-test-routing.shCe script échoue si un trajet calculé par le profil moto_no_fast contient
un segment avec une limite de vitesse signalée > 80 km/h.
Nécessite la stack Docker démarrée (docker compose up -d, voir
Démarrage) — les tests s'exécutent contre l'application
réelle (frontend + backend + GraphHopper), sans mocks (à une exception
près, documentée ci-dessous).
cd frontend
npm install
npx playwright install --with-deps chromium # une seule fois
npm run test:e2e--with-deps installe aussi les bibliothèques système nécessaires au
navigateur headless ; le script est pensé pour Debian/Ubuntu et peut
échouer sur Arch Linux (ou toute distro non supportée) — dans ce cas,
retirer --with-deps, lancer npx playwright install chromium seul, puis
installer manuellement les dépendances système manquantes indiquées par
l'erreur au premier npm run test:e2e (sudo pacman -S ... ou équivalent).
Suites dans frontend/tests/e2e/ (une par fonctionnalité) : navigation et
sauvegarde d'un trajet, édition des waypoints, précision (renommage,
coordonnées), annuler/rétablir, circuits en boucle, zones à éviter, limite
de vitesse, alternatives, duplication, import/export GPX, points d'intérêt,
recherche d'adresse, brouillon persistant, gestion des erreurs de routage.
address-search.spec.js mocke /api/geocode — seule exception à la règle
"pas de mocks", Nominatim étant un service tiers à rate-limit strict.
cd frontend
npm run lintESLint (configuration plate minimale, eslint:recommended) sur
frontend/src.
Tests unitaires/intégration sur la logique pure (parsing/génération GPX,
validation des waypoints, calcul des leg_boundaries, throttling/cache du
client Nominatim) et quelques tests d'intégration via TestClient FastAPI
(base SQLite en mémoire, pas de dépendance à GraphHopper) — plus rapides et
plus ciblés que la suite Playwright pour ces cas-là.
cd backend
docker run --rm -v "$PWD":/app -w /app python:3.12-slim \
bash -c "pip install -q -r requirements-dev.txt && python -m pytest -q"Passer par Docker plutôt qu'un virtualenv local garantit la même version de
Python que l'image de production (backend/Dockerfile), indépendamment de
la version de python3 installée sur la machine hôte (pydantic-core n'a
par exemple pas encore de wheel pour Python 3.14 au moment de l'écriture).
Un virtualenv local fonctionne aussi tant que python3 pointe vers une
version ≤ 3.13 :
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/python -m pytest./scripts/update-osm-data.shPas d'automatisation en cron : l'import complet de la France consomme beaucoup de RAM et prend plusieurs minutes, mieux vaut le déclencher consciemment.
GraphHopper marque les sous-réseaux non connectés
(PrepareRoutingSubnetworks) et prépare les landmarks (LM) pour la
formule de pondération exacte du custom model au moment de l'import, et
persiste ce marquage dans graph-cache. Modifier
graphhopper/custom_models/moto_no_fast.json sans reconstruire le graphe
peut laisser GraphHopper utiliser un graphe dont la connectivité a été
calculée pour l'ancienne version du modèle.
Vérifié empiriquement en ajoutant le profil moto_no_limit : ce n'est
pas propre à la modification d'un profil existant — ajouter un profil à
config.yml sans reconstruire échoue tout aussi sec, GraphHopper refusant
carrément de démarrer :
java.lang.IllegalStateException: Profiles do not match:
Graphhopper config: car|...,moto_no_fast|...,moto_no_limit|...
Graph: car|...,moto_no_fast|...
Change configuration to match the graph or delete /graph-cache/
Le conteneur part alors en boucle de redémarrage jusqu'à ce que la configuration soit alignée avec le graphe déjà importé. Après toute modification ou ajout de profil :
docker compose down
docker volume rm circuit-forgery_graph-cache
docker compose up -d graphhopperDeux pièges rencontrés en développement, tous deux visibles dans les
logs PrepareRoutingSubnetworks à l'import (nombre de composantes
connectées) :
- Une exclusion dure (
multiply_by: 0) surroad_class == MOTORWAY || TRUNKfragmentait le graphe France en 969 composantes déconnectées (au lieu d'un seul réseau), car certaines routes nationales taguéesTRUNKsont l'unique liaison viable entre deux régions, même à vitesse ≤ 80 km/h.road_classne sert donc plus qu'à pénaliser fortement (jamais exclure) MOTORWAY/TRUNK/PRIMARY. - Après correction du point 1, la fragmentation persistait quasi à
l'identique (968 composantes). Cause réelle : GraphHopper encode un
max_speedabsent de tag OSM comme +infini (use_maximum_as_infinity) — très fréquent sur les petites routes rurales/communales françaises, qui n'ont souvent aucun tagmaxspeedexplicite. La conditionmax_speed > 80excluait donc aussi toutes les routes sans limite affichée. Le modèle final utilisemax_speed > 80 && max_speed < 1000pour ne cibler que les vitesses réellement connues et élevées ; les routes sans tag retombent sur les règlesroad_class(unclassified/residential/tertiary → priorisées). Après ce correctif : 69 composantes, la plus grande couvrant l'essentiel du réseau routable (comparable au profilcarde référence).
Le modèle final n'a donc qu'une seule exclusion dure : max_speed
connu et > 80 — exactement l'exigence de l'utilisateur, ni plus ni moins.
Cliquer un point sans route à proximité (en mer, zone très isolée) fait
répondre GraphHopper en HTTP 400 (PointNotFoundException ou
ConnectionNotFoundException). backend/app/services/graphhopper_client.py
distingue ce cas (entrée utilisateur, GraphHopper a bien répondu) d'une
vraie panne GraphHopper (erreur réseau/5xx) :
- point/connexion non trouvé →
GraphHopperRouteNotFoundError→ 422 côté API, message français affiché dans un bandeau inline (#route-error) côté frontend. - GraphHopper injoignable →
GraphHopperUnavailableError→ 503.
Avant ce correctif, les deux cas remontaient en 502 et le frontend
affichait une popup alert() bloquante — repéré via les logs
(docker compose logs backend, requêtes 502 Bad Gateway corrélées à des
PointNotFoundException GraphHopper au même timestamp). Couvert en
régression par frontend/tests/e2e/route-error-handling.spec.js.
JAVA_OPTS dans docker-compose.yml (service graphhopper) contrôle la
heap JVM (-Xmx). Prévoir 8-16 Go pour la France entière ; augmenter si
OutOfMemoryError pendant l'import.
Vérifié de bout en bout via l'API (curl), SQLite direct et Playwright
(navigateur headless réel) : import GraphHopper France entière, filtrage
moto_no_fast (smoke test), calcul d'itinéraire via le backend, CRUD
complet trajets et POI, persistance après redémarrage du conteneur
backend, accessibilité LAN du backend (ss -tlnp + requête depuis l'IP LAN
de la machine), non-exposition de GraphHopper hors loopback, et parcours
utilisateur complet dans un vrai navigateur (clic carte → calcul →
sauvegarde → rechargement, y compris les cas d'erreur de routage). Limite
de vitesse personnalisable vérifiée contre un cas réel concret (Pont de
Normandie, max_speed > 80 donc exclu par défaut) : 91 km de détour par
défaut, ~70 km avec "Aucune limite" une fois le profil moto_no_limit
importé et vérifié fonctionnel. Point de passage du circuit en boucle
vérifié après confirmation empirique que algorithm=round_trip rejette
tout appel avec plus d'un point ("For round trip calculation exactly one
point is required") — d'où l'insertion après coup dans la séquence de
waypoints plutôt qu'un envoi direct à GraphHopper.
Non vérifié dans cet environnement : l'accès depuis un second appareil physique du LAN (un seul appareil disponible pour les vérifications) — seule l'accessibilité réseau (port en écoute sur l'IP LAN) a été confirmée.