Carte hors ligne : conception du plan illustré, 3e fournisseur

Ni Google ni Mapbox ne donnent un fond hors ligne gratuit, et Mapbox
se facture au nombre de tuiles, pas à la surface. Pour un musée de
plein air comme le Fourneau Saint-Michel, la réponse est son propre
plan dessiné : les repères se posent sur l'image, le plan est une
Resource, donc embarqué par le pipeline hors ligne existant.

Conception seule, rien n'est implémenté. Mapbox est passé fournisseur
par défaut le 04/09.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-10 12:05:31 +02:00
parent 4206fde7f0
commit d36394cabd
3 changed files with 177 additions and 8 deletions

View File

@ -8,6 +8,8 @@ src: analyse du code le 2026-09-04 — MapProvider, flutter_map, mapbox_maps_flu
---
<p><strong>Aujourd'hui aucune carte ne fonctionne hors ligne</strong>, et ce n'est pas une question de données : <code>MapDTO</code> porte déjà <code>points</code>, <code>centerLatitude</code>, <code>zoom</code> et <code>iconResourceId</code>, et les icônes sont téléchargées avec la visite. C'est le <strong>fond</strong> qui manque — les tuiles sont chargées en réseau, sans cache.</p>
<p><strong>Le GPS, lui, n'est pas le problème</strong> : <code>geolocator ^13.0.0</code> lit le GNSS, qui est autonome. Seule réserve à annoncer au client : sans A-GPS, le premier point peut demander 30 à 60 s sous couvert forestier.</p>
<p><strong>Le chemin court existe déjà dans le pubspec</strong> : <code>mapbox_maps_flutter ^2.0.0</code> expose <code>OfflineManager</code> (style packs) et <code>TileStore</code> (tile regions) — on télécharge une emprise bornée au moment du téléchargement de la visite. Pour un domaine comme le Fourneau Saint-Michel, quelques dizaines de Mo. <code>MapProvider.MapBox</code> existe déjà côté modèle.</p>
<p><strong>Le chemin court existe déjà dans le pubspec</strong> : la version <strong>résolue est 2.8.0</strong> (pas 2.0.0 comme le laisse croire la contrainte), et le paquet installé contient bien <code>OfflineManager</code>, <code>TileStore</code>, <code>StylePackLoadOptions</code>, <code>TileRegionLoadOptions</code> et <code>TileRegion</code><strong>vérifié dans le cache pub, pas supposé</strong>. On télécharge une emprise bornée au moment du téléchargement de la visite ; pour un domaine comme le Fourneau, quelques dizaines de Mo.</p>
<p><strong>Mapbox est désormais le fournisseur par défaut</strong> (04/09) : le <code>null</code> retombait sur Google dans <code>map_page.dart</code> (deux endroits) et <code>map_config.dart</code>. ⚠️ Effet de bord à surveiller — les sections carte existantes de MDLF et Fort Saint-Héribert dont le <code>mapProvider</code> est <code>null</code> <strong>basculent silencieusement de Google à Mapbox</strong>. Le token est déjà en place, en dur dans <code>main.dart:60</code>.</p>
<p>⚠️ <strong>Ce n'est pas gratuit, et le compteur est le mauvais</strong> : ce qui coûte n'est pas la surface de l'emprise mais le <strong>nombre de téléchargements</strong> — un tile pack par visiteur. La facture monte donc avec la fréquentation, c'est-à-dire avec le succès du client. Grille à vérifier chez Mapbox, ligne « tile packs » et pas seulement « map loads ». C'est exactement ce calcul que supprime <code>v2/plan-illustre-plan.md</code>.</p>
<p><strong>Google est une impasse, et pas seulement techniquement.</strong> Le SDK Maps pour Android n'expose aucune API de tuiles hors ligne — les « zones hors connexion » sont une fonction de l'app Google Maps, pas du SDK intégrable. Et le code n'utilise même pas ce SDK pour les sections carte : il passe par <code>flutter_map</code> sur <code>https://mt1.google.com/vt/lyrs=m&amp;x={x}&amp;y={y}&amp;z={z}</code>, un endpoint non documenté dont la mise en cache est explicitement interdite. <strong>Conclusion : Mapbox devient le fournisseur du mode hors ligne, Google reste en ligne seulement.</strong></p>
<p>⚠️ Tant que ce chantier n'est pas fait, <code>SectionType.Map</code> reste volontairement hors de <code>offlineCapableSectionTypes</code> (<code>downloadConfiguration.dart</code>) : l'ajouter donnerait une carte grise, ce qui est pire que ne pas la proposer. Une fois les tuiles packagées, c'est <strong>une ligne à ajouter dans cette constante</strong>.</p>

View File

@ -1,12 +1,15 @@
---
title: Plan illustré géoréférencé — un 3<sup>e</sup> fournisseur de carte
title: Plan illustré — un 3<sup>e</sup> fournisseur de carte, sans tuiles
area: manager backend visitapp
horizon: v2
tags: carto, offline, manager-app
flag: good | hors ligne par construction, et c'est ce qu'un musée dessine déjà
src: analyse du code le 2026-09-04 — MapProvider n'a que Google et MapBox
flag: good | le seul chemin vers une carte hors ligne sans coût tiers
src: v2/plan-illustre-plan.md — conçu le 2026-09-04
---
<p><strong>Un musée de plein air ne distribue pas un fond OpenStreetMap, il distribue son plan dessiné.</strong> Aujourd'hui <code>MapProvider</code> ne connaît que <code>Google</code> et <code>MapBox</code> : il n'existe aucun moyen de téléverser une image de plan et d'y placer les points.</p>
<p><strong>Ce qu'il faut</strong> : une troisième valeur de <code>MapProvider</code>, une ressource image portée par la section, et un calage — deux points d'ancrage suffisent (coin haut-gauche / bas-droit en coordonnées réelles) pour projeter un <code>GeoPoint</code> sur l'image et y afficher la position du visiteur.</p>
<p><strong>Pourquoi c'est le meilleur des trois</strong> : aucune tuile, donc <strong>hors ligne par construction</strong> — l'image part avec la visite comme n'importe quelle ressource, et le mécanisme de téléchargement n'a rien de nouveau à apprendre. Le rendu est aussi plus lisible qu'un fond routier sur un site de plusieurs dizaines de bâtiments.</p>
<p><strong>Périmètre réel</strong> : c'est le plus gros des trois chemins carto — backend (modèle + migration), manager-app (téléversement du plan et pose des ancres), <code>mymuseum-visitapp</code> et <code>visitapp-web</code> (rendu). À faire après <em>Carte hors ligne — tile packs Mapbox</em>, qui débloque le besoin immédiat avec un fond classique.</p>
<p><strong>Un musée de plein air ne distribue pas un fond OpenStreetMap, il distribue son plan dessiné.</strong> Conception complète, modèle de données et périmètre par repo : <strong><code>DOCS/v2/plan-illustre-plan.md</code></strong>. Rien n'est implémenté.</p>
<p><strong>La décision qui structure tout</strong> : les repères se posent <strong>directement sur l'image</strong>, à la main, et c'est cette position qui fait foi — un plan dessiné n'étant ni à l'échelle ni orienté au nord, projeter des <code>GeoPoint</code> depuis leurs coordonnées les ferait tomber à côté des bâtiments. Le calage GPS ne sert qu'à afficher <strong>le visiteur</strong>, et devient donc <strong>facultatif</strong>.</p>
<p>⚠️ <strong>Deux affirmations de la première version de cette carte étaient fausses</strong>, corrigées le 04/09 : on ne projette pas les repères, et <strong>deux points d'ancrage ne suffisent pas</strong> — il en faut trois pour une transformation affine qui absorbe rotation et étirement, et pour pouvoir vérifier le calage au lieu de diluer l'erreur.</p>
<p><strong>Le calage se fait assis</strong> : cliquer le même angle de bâtiment sur le plan puis sur une carte réelle en regard. <code>GeolocInputContainer</code> ouvre déjà un <code>FlutterLocationPicker</code> (vraie carte, recherche d'adresse) — le composant est écrit, il faut le mettre à côté du plan. Plus besoin de prestation d'installation.</p>
<p><strong>Hors ligne gratuit</strong> : le plan est une <code>Resource</code>, une ligne dans <code>GetReferencedResourceIds()</code> et le pipeline existant l'embarque. Aucun tiers, aucun quota — contrairement aux tile packs Mapbox, facturés <strong>par visiteur</strong>.</p>
<p>⚠️ <strong>Conséquence web à trancher</strong> : <code>visitapp-web</code> reste sur Leaflet (lot E, W1), et <code>mapProviderMobileOnlyNote</code> le dit déjà au client. Pour un plan illustré c'est bloquant — le plan <em>est</em> le produit. Soit on porte le rendu en CSS dans la foulée, soit le <strong>plan Essentiel, web-only, n'y a pas droit</strong>.</p>
<p><strong>Livrable qui se vend seul</strong> : le plan <em>sans</em> calage — téléversement, pose des repères, rendu, hors ligne. C'est le produit que les audioguides vendent depuis trente ans.</p>

164
v2/plan-illustre-plan.md Normal file
View File

@ -0,0 +1,164 @@
# Plan illustré — un 3ᵉ fournisseur de carte, sans tuiles
> **Contexte** : conception du 2026-09-04, déclenchée par la demande du **Fourneau Saint-Michel**
> (musée de plein air, plusieurs dizaines de bâtiments, réseau quasi absent). Fait suite à l'analyse
> carto du même jour, qui a établi que ni Google ni Mapbox ne donnent une carte hors ligne gratuite.
>
> Maquette et diagramme : <https://claude.ai/code/artifact/6e8b681e-1697-48e8-b010-d77569c6b9d7>
>
> ⚠️ **Rien n'est implémenté.** Ce document est la conception, pas un état d'avancement.
---
## Pourquoi ce troisième fournisseur existe
`MapProvider` ne connaît que `Google` et `MapBox`. Les deux chargent leurs tuiles en réseau, et
aucun des deux ne donne un fond hors ligne gratuit :
| Fournisseur | Hors ligne | Coût |
|---|---|---|
| Google | ❌ Aucune API de tuiles hors ligne dans le SDK Android. Les « zones hors connexion » sont une fonction de l'app grand public, pas du SDK. De plus le code passe par `flutter_map` sur `https://mt1.google.com/vt/…`, un endpoint non documenté dont la mise en cache est interdite | — |
| Mapbox | ✅ `OfflineManager` + `TileStore`, présents dans `mapbox_maps_flutter 2.8.0` (version résolue) | ⚠️ **Un tile pack par visiteur** : le compteur monte avec la fréquentation, donc avec le succès du client. Grille à vérifier chez Mapbox |
| Plan illustré | ✅ **Par construction** — l'image est une `Resource`, elle part avec la visite | ✅ Aucun tiers. Le coût se déplace sur notre propre stockage : quelques Mo par visite téléchargée, prévisible |
Le plan illustré est aussi **ce qu'un musée de plein air possède déjà** : un plan dessiné, plus
lisible qu'un fond routier sur cinquante bâtiments.
---
## La décision qui structure tout le reste
Un plan de musée est stylisé, pas à l'échelle, rarement orienté au nord. **Ne pas projeter les
repères depuis leurs coordonnées GPS** — ils tomberaient à côté des bâtiments dessinés.
> **Les repères se posent directement sur l'image, à la main, et c'est cette position qui fait foi.**
> Le calage GPS ne sert qu'à afficher **le visiteur** — la seule donnée qui traverse la frontière
> entre le terrain et le dessin.
⚠️ **Correction par rapport à la première note du 2026-09-04** : celle-ci disait « deux points
d'ancrage suffisent pour projeter un `GeoPoint` sur l'image ». Les deux moitiés étaient fausses —
on ne projette pas les repères, et deux points ne suffisent pas (voir plus bas).
Conséquence directe : **le calage est facultatif**. Un lieu qui ne veut pas de position visiteur
téléverse son plan, pose ses repères, et c'est fini. Deux niveaux d'effort, deux produits vendables.
### Trois points de calage, pas deux
Deux points donnent une échelle et une translation — suffisant seulement si le plan est à l'échelle
et orienté au nord, ce qu'un plan dessiné n'est presque jamais. Trois points donnent une
transformation affine, qui absorbe la rotation et l'étirement. Le quatrième n'apporte rien tant
qu'on ne veut pas corriger de perspective.
Raison de terrain en plus : trois croix bien réparties permettent de **vérifier** le calage. Avec
deux, toute erreur de relevé se répartit silencieusement sur toute l'image.
---
## Modèle de données — additif, aucune colonne existante modifiée
| Champ | Où | Note |
|---|---|---|
| `MapProvider.IllustratedPlan` | `DTOs/SubSection/MapDTO.cs` | Troisième valeur, **position 2**. Les lignes existantes gardent 0 et 1, rien à migrer. ⚠️ À répercuter **à la main** dans `manager_api_new/lib/model/map_provider.dart` — la règle « pas de regénération du client » s'applique |
| `PlanResourceId` / `PlanResource` | `Data/SubSection/SectionMap.cs` | Exactement sur le modèle de `IconResourceId` / `IconResource`, déjà présents dans la même classe. C'est ce parallèle qui rend le hors ligne gratuit |
| `PlanX`, `PlanY` | `GeoPoint` (dans `SectionMap.cs`) | `double?` **normalisés 0→1**, pas des pixels : le plan peut être ré-encodé ou redimensionné sans invalider un repère |
| `PlanAnchors` | `Data/SubSection/SectionMap.cs` | `[{planX, planY, latitude, longitude}]` en `jsonb`, comme `MapCategories` l'est déjà. Nul si le lieu ne veut pas de position visiteur |
---
## Le geste dans manager-app
Écran concerné : `lib/Screens/Configurations/Section/SubSection/Map/map_config.dart` (481 lignes).
1. **Choisir « Plan illustré »** — une valeur de plus dans `map_providers`, servie par le
`SingleSelectContainer` existant. Le choix masque les réglages Google/Mapbox et fait apparaître
la zone de plan.
2. **Téléverser le plan**`ResourceInputContainer`, déjà utilisé juste à côté pour l'icône.
Passe par la médiathèque, le quota et la compression 2560 px livrée en C4.
3. **Poser les repères** — le plan s'affiche, on clique, un repère apparaît ; la liste des
`GeoPoint` existants est à côté et on y fait glisser un point déjà rempli. **Seul écran vraiment
neuf du chantier.**
4. **Caler** (facultatif) — voir ci-dessous.
### Le calage se fait assis, en deux clics par ancre
**Personne ne va relever de coordonnées sur le terrain.** Le geste est : *cliquer le même angle de
bâtiment deux fois* — une fois sur le plan dessiné, une fois sur une carte réelle affichée juste à
côté. L'outil déduit les coordonnées du second clic. Aucun chiffre n'est jamais tapé.
**Le composant existe déjà** : `GeolocInputContainer` (`lib/Components/geoloc_input_container.dart`)
ouvre un `FlutterLocationPicker` — vraie carte, recherche d'adresse, centrée sur Namur
(50.429333, 4.891434) par défaut. C'est déjà lui qui sert à poser le point central d'une section
carte. Le calage n'invente rien : il appelle deux fois le même composant dans un écran qui les met
en regard.
⚠️ **i18n obligatoire** : tout texte de cet écran passe par `AppLocalizations`, FR/EN/NL.
---
## Le rendu — aucun SDK de carte
Une image, des repères positionnés en pourcentage par-dessus, un conteneur qui zoome.
- **`mymuseum-visitapp`** : une `PlanView` à côté de `GoogleMapView` et `MapBoxView`
(`lib/Screens/Sections/Map/`). `InteractiveViewer` enveloppant un `Stack` donne le pincer-zoomer
et le déplacement sans écrire une ligne.
- **`visitapp-web`** : le même rendu en CSS (`transform`). À écrire **après** le Flutter, qui reste
la référence de comportement.
Ni `flutter_map`, ni `google_maps_flutter`, ni `mapbox_maps_flutter` : donc ni jeton, ni quota, ni
compte à surveiller.
La position du visiteur est un repère de plus, calculé à l'affichage par la transformation affine,
et **masqué dès qu'il sort du cadre du plan** — un visiteur au parking ne doit pas voir son point
collé au bord de l'image.
Le GPS lui-même ne pose pas de problème : `geolocator ^13.0.0` lit le GNSS, autonome sans réseau.
Seule réserve à annoncer au client : sans A-GPS, le premier point peut demander 30 à 60 s sous
couvert forestier.
### Ce que le hors ligne gagne
Le plan est une `Resource`. Une ligne dans `SectionMap.GetReferencedResourceIds()`, à côté de
`ResourceId(IconResourceId)`, et le pipeline de téléchargement existant l'embarque — **aucun
mécanisme neuf**. `SectionType.Map` peut alors entrer dans `offlineCapableSectionTypes`
(`mymuseum-visitapp/lib/Services/downloadConfiguration.dart`) pour ce fournisseur.
---
## ⚠️ La conséquence côté web
`mapProviderMobileOnlyNote` prévient déjà le client que le fournisseur de carte ne vaut que pour le
mobile et la borne, parce que **`visitapp-web` reste sur Leaflet** (décision du lot E, W1, le
2026-08-11). Pour un plan illustré cette note devient gênante : le plan **est** le produit, pas un
réglage de fond.
Deux issues, à trancher : porter le rendu en web dans la foulée (c'est du CSS, quelques heures), ou
assumer que le plan est une fonctionnalité d'app mobile et le dire à la vente. ⚠️ Le plan
**Essentiel étant web-only**, la seconde option lui interdit le plan illustré.
---
## Ordre d'exécution
`manager-service``manager_api_new``manager-app``mymuseum-visitapp``visitapp-web`.
Rien ne peut être configuré tant que le modèle n'existe pas.
| Repo | Travail | Ampleur |
|---|---|---|
| `manager-service` | Enum, 4 champs, une migration, une ligne dans `GetReferencedResourceIds` | petit |
| `manager_api_new` | Enum et champs répercutés **à la main** | petit |
| `manager-app` | Écran de pose des repères et du calage (le picker de carte est déjà écrit) | **le gros morceau** |
| `mymuseum-visitapp` | `PlanView` + projection GPS | moyen |
| `visitapp-web` | Le même rendu en CSS | moyen |
### Un raccourci pour valider vite
Une `PlanView` dans `mymuseum-visitapp` avec un plan et des repères **en dur** valide le rendu et le
pincer-zoomer en une heure, avant d'engager la migration. À faire si on veut voir quelque chose
bouger avant de toucher au schéma.
### Ce qui peut être livré en premier et se vendre seul
**Le plan sans calage** : téléversement, pose des repères, rendu, hors ligne. Ni transformation
affine, ni position visiteur — et un musée de plein air a déjà le produit complet que les
audioguides vendent depuis trente ans. Le calage GPS se greffe après sans rien casser.