MapView
Carte géographique : marqueurs, cadrage, surcouches.
Statut : brouillon. Rupture nette avec la v1 ; aucune donnée en production, aucun enjeu de migration.
Description
Le composant MapView affiche des données géographiques sur une carte interactive. Le format de transport s'organise autour d'une pile de couches nommées — marqueurs, formes, cartes de chaleur, sources de tuiles personnalisées ou GeoJSON brut — que le moteur de rendu compose en une seule vue, avec un panneau d'affichage des couches optionnel.
Philosophie de conception
- Le backend décrit les données et l'intention. Le backend précise ce qu'il faut afficher (marqueurs, formes, ordre des couches, visibilité initiale, actions au clic) et quel cadrage adopter au départ. Le moteur de rendu maîtrise l'apparence (icônes, animations, gestes) et traduit la spécification en widgets natifs (
flutter_mapMarker/Polyline/Polygon/CircleMarkersur mobile, couchesmapbox-gl/leafletsur le web). - Une seule convention de coordonnées, partout. Tous les points géographiques sont des objets
{ lat: number, lon: number }. Les coordonnées sous forme de tableau ([lon, lat]ou[lat, lon]) ne sont acceptées dans aucun champ, y compris à l'intérieur des charges utiles des couches GeoJSON — voir Convention de coordonnées. - Les couches sont un concept de premier plan — et l'unique chemin de données sur le fil. Même une carte à marqueur unique se décrit sous la forme
layers: [{ type: 'markers', markers: [...] }]. Le SDK fournit des méthodes de commodité (addMarker,addPolygon, …) qui composent ces couches en coulisses, afin que le code du backend reste concis. - GeoJSON est une porte de sortie, pas le cas nominal. Un backend qui produit déjà du GeoJSON (PostGIS, Overpass, etc.) peut le brancher via le type de couche
geojson, mais les types typésmarkers/shapesexistent pour éviter aux auteurs backend toute expertise SIG dans les cas courants.
Démarrage rapide
1import { YeriaApp } from '@numerum-tech/yeriasdk';
2
3const yeriaApp = new YeriaApp({ appId: 'my-app' });
4
5// `addMarker` is a convenience method — under the hood it appends to a
6// default markers layer, creating it on first call. The wire payload that
7// reaches the renderer is always shaped as `{ layers: [...] }`.
8const map = yeriaApp.createMapView('stores-map', 'Partner Stores')
9 .setIntro('Find our partner stores')
10 .setViewport({ center: { lat: 48.8566, lon: 2.3522 }, zoom: 12 })
11 .setControls({ zoom: true, userLocation: true, layerToggle: true })
12 .addMarker({
13 id: 'paris',
14 location: { lat: 48.8566, lon: 2.3522 },
15 title: 'Paris Centre',
16 description: 'Open 9–19',
17 icon: 'store',
18 action: { method: 'GET', url: '/api/stores/paris' }
19 });
20
21return yeriaApp.serve(map);Modèle de premier niveau
1interface MapView {
2 id: string;
3 type: 'Map';
4 content: MapContent;
5 metadata?: ViewMetadata;
6 process?: ProcessContext;
7}
8
9interface MapContent {
10 // Header
11 title: string;
12 intro?: string;
13
14 // Camera
15 viewport?: MapViewport;
16 basemap?: 'streets' | 'satellite' | 'terrain' | 'dark' | 'auto'; // default 'auto'
17
18 // Data — always layered
19 layers: MapLayer[];
20
21 // UI
22 controls?: MapControls;
23 emptyMessage?: string; // shown if no layer ends up drawable
24
25 // Input mode (location picker)
26 mode?: 'view' | 'pick'; // default 'view'
27 pick?: MapPickConfig; // required when mode === 'pick'
28}layers[] est l'unique chemin de données — même une carte à marqueur unique se décrit comme une couche markers comportant une seule entrée. Les méthodes de commodité du SDK (addMarker, addPolygon, …) créent ou complètent des couches par défaut afin que le code du backend reste concis. Une vue qui ne produit aucune couche dessinable en mode: 'view' exige emptyMessage ; en mode: 'pick', le marqueur placé par l'utilisateur constitue la donnée.
Convention de coordonnées
Tout point géographique de cette spécification s'écrit :
1interface GeoPoint {
2 lat: number; // -90 to 90
3 lon: number; // -180 to 180
4 altitude?: number; // meters above sea level
5 precision?: number;// horizontal accuracy in meters (sensor data)
6}- Toujours un objet, jamais un tableau.
[lat, lon]comme[lon, lat]sont rejetés par le validateur du SDK. - La conversion côté moteur de rendu tient en une ligne :
- Dart /
latlong2:LatLng(p.lat as double, p.lon as double) - Mapbox GL / Leaflet :
[p.lon, p.lat](longitude en premier, convention GeoJSON) flutter_map_geojson: le type de couchegeojsondu SDK émet du GeoJSON déjà converti ; les moteurs de rendu qui empruntent ce chemin ne manipulent[lon, lat]qu'à l'intérieur des charges utiles GeoJSON — le reste de la spécification demeure en{lat, lon}.
Marqueurs
1interface MapMarker {
2 id: string;
3 location: GeoPoint;
4
5 // Display
6 title?: string;
7 description?: string;
8 icon?: string; // catalog name OR full URL OR data: URI
9 color?: string; // hex string, e.g. '#1A73E8'; falls back to renderer default
10 size?: 'sm' | 'md' | 'lg'; // default 'md'
11 selected?: boolean; // renderer should highlight; default false
12
13 // Interaction
14 action?: ActionRef; // see "Actions"
15 popup?: MarkerPopup; // overrides the default title+description popup
16
17 // Free-form
18 meta?: Record<string, unknown>;
19}
20
21interface MarkerPopup {
22 title?: string; // defaults to marker.title
23 body?: string; // markdown allowed
24 image?: string; // URL
25 actions?: ActionRef[]; // buttons inside the popup
26}Les moteurs de rendu doivent interpréter icon ainsi : (a) un nom du catalogue (p. ex. 'store', 'home', 'pin') s'il correspond à une entrée de leur catalogue ; (b) une URL si la valeur commence par http(s):// ou data: ; (c) à défaut, le repère par défaut du moteur de rendu, les valeurs inconnues étant ignorées.
Formes (géométrie typée)
shapes remplace le chemin polygone-par-overlays de la v1. Les formes sont dessinées au-dessus du fond de carte et sous les marqueurs (sauf indication contraire du zIndex de la couche).
1type MapShape = PolygonShape | CircleShape | PolylineShape | RectangleShape;
2
3interface ShapeBase {
4 id: string;
5 config?: ShapeStyle;
6 action?: ActionRef; // optional click handler
7 meta?: Record<string, unknown>;
8}
9
10interface PolygonShape extends ShapeBase {
11 type: 'Polygon';
12 points: GeoPoint[]; // 3+ points; the polygon auto-closes
13}
14
15interface CircleShape extends ShapeBase {
16 type: 'Circle';
17 center: GeoPoint;
18 radius: number; // meters
19}
20
21interface PolylineShape extends ShapeBase {
22 type: 'Polyline';
23 points: GeoPoint[]; // 2+ points
24}
25
26interface RectangleShape extends ShapeBase {
27 type: 'Rectangle';
28 sw: GeoPoint; // south-west corner
29 ne: GeoPoint; // north-east corner
30}
31
32interface ShapeStyle {
33 fillColor?: string;
34 fillOpacity?: number; // 0–1
35 strokeColor?: string;
36 strokeOpacity?: number; // 0–1
37 strokeWidth?: number; // px
38 dashed?: boolean;
39}Cadrage
1interface MapViewport {
2 // Pick ONE of (center+zoom) or (bounds) or (fitMarkers)
3 center?: GeoPoint;
4 zoom?: number; // typical 0–22
5 bounds?: { sw: GeoPoint; ne: GeoPoint };
6 fitMarkers?: boolean; // if true, renderer fits viewport to all visible markers + shapes; takes precedence
7
8 // Constraints
9 minZoom?: number;
10 maxZoom?: number;
11
12 // 3D — renderer may ignore if unsupported (must NOT throw)
13 bearing?: number; // 0–360, default 0
14 pitch?: number; // 0–60, default 0
15}Ordre de résolution au moment du rendu : fitMarkers → bounds → center+zoom. Si rien n'est fourni et qu'il existe au moins un marqueur ou une forme, le moteur de rendu ajuste le cadrage aux données ; sinon il affiche une région par défaut raisonnable, à sa discrétion.
Contrôles
1interface MapControls {
2 zoom?: boolean; // zoom +/- buttons; default true
3 compass?: boolean; // shown when bearing != 0; default true
4 userLocation?: boolean; // "locate me" button; default false
5 layerToggle?: boolean; // legend / show-hide panel; default true if any layer is `toggleable`
6 scale?: boolean; // distance scale; default false
7 fullscreen?: boolean; // default false
8 attribution?: string; // override; renderer must always show some attribution
9}Couches (composition multicouche)
Lorsque plusieurs jeux de données logiques partagent une même carte (p. ex. Boutiques, Zones de couverture, Trafic), utilisez layers[] :
1type MapLayer =
2 | MarkersLayer
3 | ShapesLayer
4 | HeatmapLayer
5 | TilesLayer
6 | GeoJsonLayer;
7
8interface LayerBase {
9 id: string; // stable id; clients persist toggle state by id
10 name?: string; // shown in legend / toggle UI
11 legendIcon?: string; // icon shown next to `name`
12 visible?: boolean; // default true
13 toggleable?: boolean; // user can show/hide; default true
14 zIndex?: number; // higher = on top; default by insertion order
15 minZoom?: number; // hide below this zoom
16 maxZoom?: number; // hide above this zoom
17}
18
19interface MarkersLayer extends LayerBase {
20 type: 'markers';
21 markers: MapMarker[];
22 cluster?: boolean; // default true when markers.length > 50
23 clusterRadius?: number; // px; default 50
24}
25
26interface ShapesLayer extends LayerBase {
27 type: 'shapes';
28 shapes: MapShape[];
29}
30
31interface HeatmapLayer extends LayerBase {
32 type: 'heatmap';
33 points: Array<{ lat: number; lon: number; intensity?: number }>;
34 radius?: number; // px; default 25
35 intensityMax?: number; // default 1
36 colorRamp?: string[]; // hex stops, low-to-high; renderer default if omitted
37}
38
39interface TilesLayer extends LayerBase {
40 type: 'tiles';
41 url: string; // {z}/{x}/{y} template
42 attribution: string; // required by most providers
43 maxNativeZoom?: number;
44 opacity?: number; // 0–1
45}
46
47interface GeoJsonLayer extends LayerBase {
48 type: 'geojson';
49 data: object; // RFC 7946 FeatureCollection | Feature | Geometry
50 // Defaults applied to features that lack `properties.style`:
51 defaultMarkerIcon?: string;
52 defaultShapeStyle?: ShapeStyle;
53}Où addMarker / addShape et consorts ajoutent-ils leurs éléments ?
Les méthodes de commodité acceptent toutes un argument final layerId optionnel qui désigne la couche cible :
| Appel | Cible |
|---|---|
addMarker(m) (sans layerId) | Couche de marqueurs par défaut _default_markers. Créée automatiquement au premier appel avec name: undefined, toggleable: false. |
addMarker(m, 'stores') | Couche d'id: 'stores'. Doit déjà exister (créée via addLayer({...})). Lève LayerNotFoundError dans le cas contraire. |
addPolygon(id, points, config?) (sans layerId) | Couche de formes par défaut _default_shapes. |
addPolygon(id, points, config?, 'zones') | Couche nommée 'zones'. Même règle de préexistence. |
Même schéma pour addMarkers, addCircle, addPolyline, addRectangle, addShape. |
Une incompatibilité de type lève LayerTypeMismatchError au moment même de l'appel (p. ex. ajouter un marqueur à une couche de type shapes). Les erreurs se déclenchent sur le constructeur du SDK, non à serve() : les auteurs backend obtiennent ainsi un retour immédiat en développement.
Il n'existe pas de curseur caché de « couche courante » — chaque appel utilise la couche par défaut ou nomme explicitement sa cible.
1// Simple case — no layer ceremony
2map.addMarker({ id: 'home', location: { lat: 6.13, lon: 1.22 } });
3// → goes into _default_markers (auto-created)
4
5// Multi-layer case — declare, then target
6map
7 .addLayer({ id: 'stores', type: 'markers', name: 'Boutiques', cluster: true })
8 .addLayer({ id: 'events', type: 'markers', name: 'Événements' });
9
10map.addMarker({ id: 'paris', location: { lat: 48.85, lon: 2.35 } }, 'stores');
11map.addMarker({ id: 'fest', location: { lat: 43.60, lon: 1.44 } }, 'events');Mode sélection (saisie d'une position)
Pour les parcours qui exigent une position choisie par l'utilisateur :
1interface MapPickConfig {
2 prompt?: string; // helper text above the map; e.g. "Tap to choose your delivery point"
3 initialLocation?: GeoPoint;
4 submitUrl: string; // URL the picked location is POSTed to
5 submitMethod?: 'POST' | 'PUT'; // default POST
6 submitLabel?: string; // confirm button label; default "Confirm"
7 payloadKey?: string; // key under which the location is sent; default 'location'
8 bounds?: { sw: GeoPoint; ne: GeoPoint }; // restrict pickable area
9 snapToMarkers?: boolean; // default false; if true, picking snaps to the nearest existing marker
10}Lorsque mode === 'pick', le moteur de rendu :
- Affiche la carte avec les marqueurs et formes existants en guise de contexte.
- Laisse l'utilisateur poser ou déplacer un unique marqueur de sélection (initialisé sur
pick.initialLocation, ou sur la position courante de l'utilisateur sicontrols.userLocationest actif). - À la confirmation, envoie
POST pick.submitUrlavec le corps{ [pick.payloadKey]: { lat, lon }, processContext? }.
La primitive mobile de sélection existe déjà dans yeria-app/lib/presentation/shared/widgets/map_picker/gps_map_picker_widget.dart — la spécification ne fait que lui donner un format de transport.
Actions
ActionRef a la même forme que dans toutes les autres vues Yeria : le répartiteur d'actions du JsonRenderer achemine donc les clics sur la carte par un chemin unique :
1interface ActionRef {
2 url: string;
3 method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; // default GET
4 body?: Record<string, unknown>;
5 confirm?: { title: string; message: string; submitLabel?: string };
6}L'action d'un marqueur et l'action d'une forme sont toutes deux prises en charge. En leur absence, toucher le marqueur affiche sa bulle (et rien ne se produit pour une forme).
Méthodes du SDK (JavaScript)
setIntro(text)setViewport(viewport)viewport est un MapViewport ; passez { center, zoom } dans le cas simple.setBasemap(name)'streets' | 'satellite' | 'terrain' | 'dark' | 'auto'.setControls(controls)MapControls partiel ; fusionné avec les valeurs par défaut.setEmptyMessage(text)layerId optionneladdMarker(marker, layerId?) / addMarkers(markers, layerId?)layerId (qui doit exister) ou dans _default_markers (créée automatiquement au premier appel).clearMarkers(layerId?)addPolygon(id, points, config?, layerId?)Polygon dans layerId ou _default_shapes.addCircle(id, center, radius, config?, layerId?)Circle.addPolyline(id, points, config?, layerId?)Polyline.addRectangle(id, sw, ne, config?, layerId?)Rectangle.addShape(shape, layerId?)clearShapes(layerId?)addLayer(layer)setLayers(layers)clearLayers()getLayer(id)undefined.setPickMode(config)mode='pick' et pick=config.getContent() / serve() / toJSON() / setProcess(...) etc.BaseView.Matrice de conformité des moteurs de rendu
Les moteurs de rendu SHOULD publier leur niveau de conformité à cette matrice dans leur documentation. Un moteur de rendu est conforme s'il implémente chaque MUST, se dégrade proprement sur chaque MAY (une valeur inconnue reste sans effet et ne provoque jamais de plantage) et documente chaque SHOULD qu'il laisse de côté.
| Fonctionnalité | Niveau |
|---|---|
title, intro | MUST |
raccourci markers | MUST |
raccourci shapes (Polygon, Circle, Polyline) | MUST |
viewport.{center, zoom} | MUST |
viewport.fitMarkers | MUST |
viewport.bounds | SHOULD |
viewport.{minZoom, maxZoom} | SHOULD |
viewport.{bearing, pitch} | MAY |
basemap | SHOULD (doit accepter la valeur, rendu au mieux) |
controls.zoom | MUST |
controls.userLocation | SHOULD |
controls.compass | SHOULD |
controls.layerToggle | SHOULD |
controls.{scale, fullscreen, attribution} | MAY |
marker.{icon, color, size, selected} | SHOULD (catalogue d'icônes) ; NE MUST PAS planter sur une valeur inconnue |
marker.action | MUST |
marker.popup | SHOULD |
shape.action | SHOULD |
forme Rectangle | SHOULD |
layers[] (markers, shapes) | MUST |
layers[] heatmap | SHOULD |
layers[] tiles | SHOULD |
layers[] geojson | SHOULD |
layer.{visible, toggleable, zIndex, minZoom, maxZoom} | MUST, pour les types de couches pris en charge |
layer.cluster (markers) | SHOULD |
mode: 'pick' | SHOULD |
emptyMessage | MUST |
Règles de validation (côté SDK)
lat ∈ [-90, 90],lon ∈ [-180, 180]. Rejet dans le cas contraire.- Un
Polygonexigepoints.length ≥ 3. UnePolylineexigepoints.length ≥ 2. Circle.radius > 0.Rectangle.sw.lat ≤ ne.lat,sw.lon ≤ ne.lon.viewport.zoom ∈ [0, 22].bearing ∈ [0, 360).pitch ∈ [0, 60].- Une
MarkersLayerexigemarkers.length ≥ 1; uneShapesLayerexigeshapes.length ≥ 1; uneHeatmapLayerexigepoints.length ≥ 1. - Les valeurs d'
idde couche doivent être uniques au sein delayers[]. mode === 'pick'exigepick.submitUrl.- Une vue en
mode: 'view'dontlayers[]est vide (ou ne contient que des couches sans rien à dessiner) exigeemptyMessage. Enmode: 'pick', les couches ne servent que de contexte facultatif : le marqueur placé par l'utilisateur constitue la donnée.
Exemple complet
1{
2 "id": "logistics",
3 "type": "Map",
4 "content": {
5 "title": "Logistique – Région du Centre",
6 "intro": "Aperçu en temps réel des points de livraison et zones de couverture.",
7 "basemap": "auto",
8 "viewport": { "fitMarkers": true, "minZoom": 6, "maxZoom": 18 },
9 "controls": {
10 "zoom": true, "compass": true, "userLocation": true,
11 "layerToggle": true, "scale": true
12 },
13 "layers": [
14 {
15 "id": "stores",
16 "type": "markers",
17 "name": "Boutiques",
18 "legendIcon": "store",
19 "cluster": true,
20 "markers": [
21 {
22 "id": "store-lome",
23 "location": { "lat": 6.1319, "lon": 1.2228 },
24 "title": "Boutique Lomé Centre",
25 "description": "Ouvert 8h–20h",
26 "icon": "store",
27 "color": "#1A73E8",
28 "action": { "method": "GET", "url": "/api/stores/lome" }
29 }
30 ]
31 },
32 {
33 "id": "coverage",
34 "type": "shapes",
35 "name": "Zone de livraison",
36 "visible": true,
37 "toggleable": true,
38 "zIndex": -1,
39 "shapes": [
40 {
41 "id": "delivery-zone",
42 "type": "Polygon",
43 "points": [
44 { "lat": 6.20, "lon": 1.18 },
45 { "lat": 6.20, "lon": 1.30 },
46 { "lat": 6.05, "lon": 1.30 },
47 { "lat": 6.05, "lon": 1.18 }
48 ],
49 "config": { "fillColor": "#34A853", "fillOpacity": 0.15, "strokeColor": "#34A853", "strokeWidth": 2 }
50 }
51 ]
52 },
53 {
54 "id": "traffic",
55 "type": "heatmap",
56 "name": "Trafic",
57 "visible": false,
58 "points": [
59 { "lat": 6.13, "lon": 1.22, "intensity": 0.9 },
60 { "lat": 6.14, "lon": 1.23, "intensity": 0.6 }
61 ]
62 }
63 ]
64 },
65 "metadata": { "version": "2.0.0", "createdAt": "2026-04-26T08:00:00.000Z" }
66}Journal des modifications
Voir map-view-changelog.md — les différences majeures avec la v1 et la justification de chaque changement.