Guide de développement — Vue immersive Panoramax¶
Module GEO d'intégration des vues immersives Panoramax (photos géolocalisées à 360°,
solution open-source alternative à Google Street View). Il ne gère que Panoramax (pas de
bascule Google Street View). Pour une introduction générale au fonctionnement d'un module GEO,
voir README.md.
Approche retenue¶
Le module s'appuie sur l'API publique documentée du composant <pnx-viewer>
(https://docs.panoramax.fr/web-viewer/, paquet npm @panoramax/web-viewer, installé en
^5.2.0) plutôt que sur des appels internes non documentés, susceptibles de changer d'une
version à l'autre :
- attribut
endpoint→ URL de l'API STAC de l'instance Panoramax - attribut
metacatalog="false"→ n'interroge que l'instance configurée (pas de fédération avec d'autres instances, qui provoquerait des requêtes CORS parasites) - méthode
viewerEl.getAPI().getPicturesAroundCoordinates(lat, lon, radius, limit)→ photos les plus proches (Promise résolvant une FeatureCollection GeoJSON) - méthode
viewerEl.select(seqId, picId, force)→ affiche une photo donnée - événement
select(detail :{ seqId, picId, prevSeqId, prevPicId }) → l'utilisateur navigue dans le viewer - événement
ready→ le viewer est chargé et prêt
Comportement¶
- Onglet dans la barre latérale droite (libellé configurable,
toolName). Il n'y a pas de bouton flottant sur la carte : il ouvrait le même panneau que l'onglet, qui restait de toute façon affiché, et le masquer demanderait de manipuler le DOM de GEO. Une confirmation intégrée au panneau est possible (configconfirmBeforeOpen, « Ne plus demander » mémorisé danslocalStorage) : le viewer n'est créé qu'après « Oui ». Elle est dans le panneau car GEO ouvre l'onglet sans passer par le code du module. « Non » laisse le panneau ouvert sur un message : aucune API GEO de fermeture du panneau n'est connue. - Panneau élargi à 50 % de l'écran (message
EXTENSION_WIDGET_ACTION/setPanelSize, avec l'identifiant du widget), car le viewer a besoin de place. Un bouton plein écran le passe à 100 % et masque la toolbartoprightde la carte GEO, qui sinon chevauche la toolbartopleft. - Pas d'attribut
focussur<pnx-viewer>: le poser en statique déclencheUncaught Error: Map is not enabled(le composant réagit avant que sa carte interne soit prête). La valeur par défaut du composant s'applique, et la mini-carte interne de Panoramax est affichée. L'explication causale est une hypothèse, la correction a été validée en test. - À l'ouverture (
ready), recherche la photo la plus proche du centre de la carte GEO. Démarré sans photo, le viewer affiche sa carte en grand et y reste à la première sélection : le module posefocus="pic"au premierselectpour afficher la photo en grand (une seule fois, le bouton carte/photo reste ensuite au choix de l'utilisateur). - Tant que le panneau est ouvert, un clic sur la carte GEO
(
geoApplication.map.on('pointerClick', …)) recherche et affiche la photo la plus proche du point cliqué. Les coordonnées sont transformées en EPSG:4326 viageoApplication.transformsi le CRS de la carte est différent. Un message d'aide s'affiche tant qu'aucune photo n'est sélectionnée. - Marqueur sur la carte GEO à la position de la photo affichée. Quand l'utilisateur navigue
dans le viewer (
select), le marqueur suit (position lue via l'endpoint STAC/collections/{seqId}/items/{picId}) et la carte est recentrée de façon que le marqueur tombe au milieu de la zone visible (hors panneau), sans changer le zoom (map.centerOn, voir « Points à vérifier »). L'événementselectpouvant arriver deux fois pour une même photo, il est dédoublonné surpicId. - Utilisateur privilégié (config
preferredUser,preferredUserCandidates) :getPicturesAroundCoordinatesne filtrant pas par utilisateur, le module examine N candidats proches et retient le premier dontproviders[].name/idouproperties["geovisio:producer"]correspond (insensible à la casse). Sinon, il retombe sur le plus proche. Laisser vide pour désactiver. Aucune valeur par défaut (manifeste et code) : par défaut, la photo la plus proche est retenue, tous utilisateurs confondus. - Bouton rond « ouvrir dans Panoramax » superposé au viewer, visible quand une photo est
affichée : ouvre
{siteUrl}/?pic={picId}dans un nouvel onglet (voir « Points à vérifier »). - Tuto d'accueil et bouton d'aide (Driver.js, MIT ; design :
docs/designs/tuto-accueil.md, écrit pour la première version sous Shepherd.js, remplacé depuis par Driver.js, de licence MIT) : - Accroche au chargement de l'appli : une bulle d'une étape sur l'onglet Panoramax
(élément trouvé via l'icône
.icon_panoramax, 10 essais espacés de 500 ms, sinon pas d'accroche). Elle se ferme au clic sur l'onglet et, par sécurité, au début du contrôleur du panneau. - Visite du panneau au premier
readydu viewer (donc après la confirmation éventuelle), 9 étapes dont 3 interactives qui avancent sur le vrai geste via_tourNotify, appelé depuis les gestionnaires existants : clic sur la carte (pointerClickpuisselectd'une nouvelle photo : la photo du centre sélectionnée automatiquement à l'ouverture ne compte pas), rotation cumulée ≥ 30° (view-rotated), photo suivante (select). « Suivant » passe une étape dont le geste n'aboutit pas. - Visite non modale : Driver.js voile la page et bloque tout clic hors de l'élément visé
(
body.driver-active * { pointer-events: none }), ce qui empêcherait de cliquer sur la carte GEO, justement le geste à apprendre. Le module règleoverlayOpacity: 0, masque le SVG du voile en CSS (.driver-overlay) et retire la classedriver-activede<body>juste aprèsdrive()(_driveTour) ; la cible pulse (.driver-active-element). Si une mise à jour de Driver.js change ces noms de classes, c'est le seul endroit à revoir. - Clavier : les étapes ne déclarent que
showButtons: ['close'], donc Driver ignore ← → (qui feraient sauter les étapes interactives, la bulle prenant le focus). Les boutons natifs de Driver sont masqués, les nôtres (step.data.buttons) disent ce qui est mémorisé. La visite a deux boutons « ‹ Précédent » / « Suivant › » (movePrevious/moveNext) et une croix en haut à droite (closeButtonde Driver, étapesdata.closable) qui ferme et mémorisev1:done; l'accroche n'a pas de croix. Échap ferme sans rien mémoriser. Revenir en arrière sur « Cliquez sur la carte » repose le point d'ancrage : Driver résout l'élément de l'étape d'arrivée avant de désélectionner l'étape de départ, donc seule cette étape retire le point. - Mémorisation : clé
localStoragegeo-panoramax-tour=v1:done(Terminer, ou croix de fermeture) ouv1:never(Ne plus afficher). Seuls ces boutons écrivent. Fermer le panneau en pleine visite n'écrit rien : elle reprend au début à la prochaine ouverture. Passer_TOUR_VERSIONàv2réaffiche la visite une fois pour tout le monde. - Accroche ancrée sur l'icône elle-même (la plus à droite parmi les
.icon_panoramaxvisibles) : ancrée sur son ancêtre cliquable, elle se décalait vers le milieu de l'écran. - Infobulles : les boutons flottants portent
data-tip(CSS::after, à gauche du bouton, instantané) ; letitlenatif n'apparaissait pas dans GEO. L'infobulle est masquée sur la cible de la visite (.driver-active-element), dont la bulle occupe la même place. - Colonne de boutons, de haut en bas : plein écran, ouvrir dans Panoramax, pointage, nouveautés, aide (
?) ; la visite les présente dans cet ordre (pointage et nouveautés sans avance au clic : « Suivant » seulement, la modale des nouveautés a unzIndexsupérieur à celui de Driver). - Étapes « boutons » (plein écran, ouvrir dans Panoramax) : cliquer sur le bouton
désigné avance aussi (
advanceOnClick) ; sur la dernière, cliquer sur « ? » termine la visite. - Thème
.geo-pnx-tour(choisi le 14/10/2026 sur une planche de 3 variantes) : blanc, liseré et bouton principal rouge GEO#f04a4a, coins 4px, boutons secondaires en texte simple, compteur « Étape N sur M ». Tailles en px pour ne pas dépendre de GEO. - Réglage
showTourOnStart(défaut : oui) pour couper l'accroche et la visite automatiques dans une appli. Le bouton « ? » lance toujours la visite. -
Cône de vision sur le marqueur de la carte GEO, orienté selon la direction regardée dans le viewer. Le cap vient des événements du sous-composant photo
viewerEl.psv(ils ne remontent pas jusqu'à<pnx-viewer>) :picture-loaded(detail :{ x, y, z, picId, lon, lat, first }) donne le cap de référence à chaque nouvelle photo.view-rotatedn'est pas garanti au chargement, et s'il arrive avant les métadonnées il est calculé avec l'azimut de la photo précédente ;view-rotated(detail :{ x, y, z }) suit ensuite les rotations, en ignorant les variations de moins de 3° (l'événement est émis en continu pendant un glisser).
detail.xest le cap en degrés (0° = Nord), déjà corrigé deview:azimuthpar le viewer (cf.Photo.js, v5.2.0). GEO n'exposant pas de rotation de marqueur, le marqueur est supprimé puis recréé avec un SVG déjà tourné ; un seul cycleremoveMarkers/addMarkersest en vol à la fois, les demandes intermédiaires étant fusionnées. La carte n'est recentrée qu'au changement de photo, pas à la rotation. Limites : ouverture fixe de 60° (ne suit pas le zoom du viewer), carte GEO supposée orientée Nord en haut, et une photo sansview:azimuthest traitée comme orientée au Nord. Testé sur une instance GEO. 11. Groupe de couches GEO (configlayerGroupName, défautPanoramax, vide = désactivé) : coché à l'ouverture du panneau, décoché à sa fermeture ($destroy). Faute d'API GEO de couches connue, le module clique la case « œil » de la légende (.acf-thematic-layer-visibility-checkbox-label, reliée pararia-labelledbyau libellé#id-thematic-group-item-label-…dont le texte vaut le nom du groupe), seulement siaria-checkeddiffère de l'état voulu. Sans effet si la légende n'est pas rendue. -
Pointage d'un objet depuis la photo (issue #9, première version, non testée sur de vraies photos ni dans GEO). Bouton « cible » sous le bouton « ouvrir dans Panoramax » : en mode pointage, un clic sur le pied d'un objet au sol (grille, regard…) pose un point sur la carte GEO. Méthode A seule (projection sur le sol) :
- le clic vient de l'événement
clickdeviewerEl.psv(Photo Sphere Viewer 5.15,e.data:yaw/pitchen radians,objects,marker,rightclick) ; on ignore les clics sur les flèches de navigation (objects[].userData.tourLink) et les marqueurs, mais pas les autresobjects(un clic au sol en renvoie, constaté) ; - cap =
yaw+view:azimuth(commegetXY()), distance = hauteur de caméra / tan(angle sous l'horizon), position par approximation locale (m/° constants) ; - hauteur de caméra = réglage
cameraHeight(2,5 m par défaut), supposée, pas lue dans la photo ; visée refusée à moins de 2° sous l'horizon ou au-delà de 40 m ; - incertitude =
quality:horizontal_accuracy(5 m si absent) combinée à ±0,3 m sur la hauteur, affichée en « incertitude estimée ± m » dans la liste et l'infobulle (pas de cercle sur la carte) ; - fonctionnalité expérimentale : aucune mesure ne valide la précision. Bandeau d'avertissement
permanent dans le panneau de pointage, mention dans l'infobulle du bouton et la visite, colonne /
propriété
avertissementdans les exports (ajout,incertitude_minchangé) ; - points gardés en mémoire (perdus au rechargement de la page), exportables en GeoJSON et
CSV (
;, BOM UTF-8). Pas de couche GEO éditable, pas de triangulation, pas de rayon de visée : voir l'issue. Photos plates :sphereCorrection(inclinaison de la caméra) ignorée, à vérifier.
- le clic vient de l'événement
-
Séquences et photos sur la carte GEO (config
showCoverageLayer, défaut : non). Tant que le panneau est ouvert, le module ajoute à la carte GEO une couche OpenLayers alimentée par les tuiles vectorielles (MVT) de l'instance, retirée à la fermeture ($destroy) :- URL des tuiles lue dans le lien
rel="xyz"de la page d'accueil de l'API (GET {instance}), retenue seulement si elle est sur le même domaine que l'instance, sinon{instance}/map/{z}/{x}/{y}.mvt; tuiles servies jusqu'au zoom 15 ; - couches internes dessinées :
sequences(lignes violettes#54278f, comme le viewer) etpictures(points, en dessous de 2,4 m/px) ;grid(petits zooms) ignorée ; - L'API GEO ne permet pas d'ajouter une couche : la carte OpenLayers est obtenue par
acfMapService.getMapViewImmediate().getNativeMap()(service interne, le même que les autres modules cartographiques) ; classes prises danswindow.ol, celui de GEO (une autre copie d'OpenLayers ne marcherait pas avec sa carte). Si l'un manque : message en console, rien d'autre ; - carte en EPSG:3857 :
ol.layer.VectorTilenatif. Autre projection (ex. Lambert 93) : OpenLayers ne reprojette pas les tuiles vectorielles, le module charge donc les tuiles Web Mercator de l'emprise et reprojette leurs objets dans unol.layer.Vector, vidé et rechargé quand le niveau de tuiles change ; - testé hors GEO (Chromium, OpenLayers 6.15, cartes 3857 et 2154, tuiles simulées) et essayé
sur GEO 4.3.2 ; dépend de la structure interne de GEO, à revérifier à chaque montée de version.
Inutile si l'appli affiche déjà ces données par une couche du groupe
layerGroupName.
- URL des tuiles lue dans le lien
Points à vérifier avant mise en production¶
- Lien « ouvrir dans Panoramax » : l'URL de l'instance se termine par
/api(voir ci-dessous), elle ne convient donc pas pour ouvrir le site. Le lien utilise le réglagepanoramaxSiteUrl; s'il est vide, l'URL de l'API sans son suffixe/api. Le lien est de la forme{siteUrl}/?focus=pic&pic=…&seq=…. Le site doit être celui de la même instance que l'API :https://api.panoramax.xyz/apiest un méta-catalogue qui agrège plusieurs instances, donc mettrehttps://panoramax.ign.frdonne « La photo n'a pas pu être chargée » pour une photo hébergée ailleurs (constaté). Pour ce méta-catalogue, laisser vide ou mettrehttps://api.panoramax.xyz. Vérifier le lien obtenu avec chaque instance utilisée. - Format de
feature.properties.sequences: le code litproperties.sequences[0]pour obtenir l'ID de séquence. Utilisé en pratique, mais non confirmé par une trace. En cas de doute, logguerfc.features[0]en développement. - CORS : les
fetch()vers l'API Panoramax (item STAC) doivent être autorisés en CORS par l'instance. Affirmé pourhttps://api.panoramax.xyz/api, non retesté. - Version du composant : n'utiliser que la version installée (
npm ls @panoramax/web-viewer) comme référence pour les attributs et événements ci-dessus. - URL de l'instance = URL de l'API, avec
/api:panoramaxInstanceUrldoit pointer vers la racine de l'API STAC (ex.https://panoramax.ign.fr/api, PAShttps://panoramax.ign.frtout court) — sinon le viewer échoue à charger le catalogue (Viewer failed to communicate with API,Map is not enabled) et/ou déclenche des requêtes CORS parasites vers le domaine racine. Testé et confirmé fonctionnel avec une instance IGN (GeoVisio 2.15.1). endpointdoit être posé en JS, pas en{{interpolation}}: le custom element lit ses attributs dansconnectedCallback, avant que le digest Angular n'ait interpolé le template (voir_buildController).- Dépôt du module dans une zone du gabarit : le Générateur compare
compatibleZoneTagsauxtagsdes zones de typeExtensionZonedu gabarit Pro. Avec seulementglobal.pro, le module n'apparaît que dans la liste globale des modules de l'application. Tags relevés dans l'éditeur (Générateur 4.5.0, via la console :angular.element(e).scope().zone.tags) : - En-tête :
widget.header,widget.header.pro;widget.print,widget.print.pro - Carte :
map.pro,map - Bandeau de droite (zone « Accueil ») :
widget.side,widget.side.pro
Le manifeste déclare widget.side.pro et widget.side : le module peut être glissé dans le
bandeau de droite (testé). Il s'enregistre de toute façon par le code comme onglet de la barre
latérale droite.
- widgets="false" à éviter : il supprime aussi les flèches précédent/suivant. On garde les
widgets par défaut malgré la barre de recherche redondante avec la carte GEO.
- Recentrer la carte GEO sans dézoomer : centerOn, ni setExtent ni panTo. Mesuré en
console sur une instance GEO :
- map.setExtent(emprise courante, crs, { disablePadding: true }) ne restitue pas le zoom :
largeur ×1.333 panneau fermé, ×1.5 panneau ouvert. Appelé à chaque photo, il provoquait un
dézoom cumulatif (échelle doublée à chaque déplacement dans le viewer) ;
- map.panTo(direction) décale la carte d'un cran (nord, est…) : passé des coordonnées, il ne
fait rien et ne signale aucune erreur ;
- map.centerOn({ coordinates, crs }) déplace le centre exactement, zoom inchangé (ratio de
largeur 1.000). Le centre visé est décalé vers l'est de (0.5 - f) × largeur, avec
f = (100 - panelWidthPct) / 200, pour que le marqueur tombe au milieu de la zone visible.
Pour inspecter l'API carte en console : angular.element(document.body).injector().get('geoApplication').map
Tester la visite (à dérouler sur GEO avant chaque ZIP)¶
Pas de tests automatisés : le code dépend de GEO et du viewer. En console, vider la clé avec
localStorage.removeItem('geo-panoramax-tour') puis recharger l'appli.
Tuto
- [ ] Bulle d'accroche sur l'onglet ; « Plus tard » la ferme, elle revient au rechargement.
- [ ] Ouvrir l'onglet alors que la bulle est affichée : la bulle disparaît.
- [ ] Avec la confirmation : la visite ne démarre qu'après « Oui » ; « Non » → pas de visite.
- [ ] Étape « Cliquez sur la carte » : la photo du centre s'affiche sans faire avancer l'étape ;
un clic sur une rue → « ✓ Bien joué » → étape suivante. Un clic loin des photos → message
« Aucune photo… », l'étape attend.
- [ ] « Regardez autour » : glisser d'au moins 30° fait avancer l'étape ; le cône tourne.
- [ ] « Avancez » : une flèche du viewer fait avancer l'étape ; le marqueur suit.
- [ ] Les flèches ← → du clavier ne font pas avancer la visite.
- [ ] Étapes plein écran / ouvrir dans Panoramax : cliquer sur le bouton désigné avance ;
dernière étape : cliquer sur « ? » termine la visite (rien ne réapparaît ensuite).
- [ ] La bulle d'accroche pointe bien l'onglet Panoramax de la barre de droite.
- [ ] Terminer, ou fermer par la croix → recharger : ni accroche ni visite ; « ? » relance la visite.
- [ ] Ne plus afficher (accroche ou visite) → recharger : rien ne réapparaît.
- [ ] Fermer le panneau en pleine visite → rouvrir : la visite reprend au début.
- [ ] showTourOnStart décoché dans le Générateur : ni accroche ni visite auto ; « ? » marche.
- [ ] Console sans erreur, y compris en navigation privée (localStorage bloqué).
Non-régression (ne doit pas changer)
- [ ] Confirmation Oui / Non / Ne plus demander.
- [ ] Marqueur et recentrage centerOn sans dézoom à chaque photo.
- [ ] Cône orienté au chargement et pendant la rotation.
- [ ] Fermeture du panneau : marqueur retiré, plus de recherche au clic sur la carte.
- [ ] Groupe de couches coché à l'ouverture, décoché à la fermeture.
- [ ] Séquences (et photos en zoomant) affichées sur la carte GEO à l'ouverture, retirées à la fermeture ; pas d'erreur « carte OpenLayers de GEO introuvable » en console.
- [ ] Plein écran et « Ouvrir dans Panoramax ».
Piège webpack 4 : ne PAS utiliser @babel/preset-env sans réglage fin¶
@panoramax/web-viewer (3 Mo minifié) utilise de la syntaxe JS récente (??=, ?., champs de
classe, blocs statiques) que l'acorn embarqué dans webpack 4 ne parse pas. Un premier essai avec
presets: ["@babel/preset-env"] (aucune target → compile vers ES5 complet) a fonctionné à la
compilation mais cassé le viewer à l'exécution (TypeError: n is not a function,
ReferenceError: Pi is not defined, PhotoSphereViewer: Unknown option shouldGoFast) — Babel a
signalé un « code generator deoptimised » sur ce fichier de 3 Mo, symptôme d'une transpilation
peu fiable sur un aussi gros fichier.
Solution retenue : transpiler uniquement les constructions syntaxiques précises que webpack 4 ne parse pas, via des plugins Babel ciblés (pas de preset généraliste) :
plugins: [
"@babel/plugin-transform-optional-chaining",
"@babel/plugin-transform-nullish-coalescing-operator",
"@babel/plugin-transform-logical-assignment-operators",
"@babel/plugin-transform-class-static-block",
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
"@babel/plugin-transform-private-property-in-object"
]
Bundle plus petit (2.77 Mo vs 3.03 Mo) et pas de régression runtime constatée. Si une future
version de @panoramax/web-viewer utilise une autre construction syntaxique non supportée,
npm run build échouera avec Module parse failed: Unexpected token — ajouter le plugin
@babel/plugin-transform-* correspondant à cette liste plutôt que de revenir à un preset
généraliste.
Commandes¶
npm install
npm run watch # build webpack en continu
npm run build # build production → dist/main.js
npm run package # build + création du ZIP distribuable
# publication directe sur le serveur GEO : nécessite le plugin Business Geografic (voir lib/README.md)
GEO_ACCESS_TOKEN=xxx npm run publish
⚠️
npm run publishrenvoie une erreur 500 du serveur GEO avec ce module (constatée lors d'un test ; cause probable non confirmée : le bundle fait ~2,9 Mo, contre quelques Ko pour un module classique, et le plugin l'envoie en un seul champ de formulaire). Importer le ZIP à la main dans le Générateur est la méthode recommandée. Le plugin n'affiche l'échec que par unconsole.log("Error while publishing to GEO", …): le build se termine en succès même si la publication échoue.
Annexe : réassigner l'auteur des photos d'un compte Panoramax¶
Question hors périmètre du module (administration de l'instance Panoramax, pas de l'extension GEO), mais utile à garder ici pour référence.
- Pas d'endpoint API dédié : la spec OpenAPI complète de l'instance publique
(
https://api.panoramax.xyz/openapi.json) ne contient aucune route pour réassignerproviders[].id/.nameouproperties["geovisio:producer"]sur des items existants, ni de fusion/transfert de compte. - La CLI officielle (
panoramax, packagepanoramax_cli, dépôt https://gitlab.com/panoramax/clients/cli) le permet indirectement, via la commandemigration(transferexiste encore mais est dépréciée — elle affiche juste"This command is deprecated. Please use panoramax migration instead."et sort en erreur ; voirpanoramax_cli/main.py). migrationcopie des collections (séquences) d'une API source vers une API destination en s'authentifiant côté destination avec--to-token(POST {to_api.url}/api/transfers, voirpanoramax_cli/transfer.py) — c'est donc le compte authentifié en destination qui devient propriétaire des copies créées.- Rien n'empêche
--from-api-urlet--to-api-urld'être la même instance avec des comptes différents, ce qui permet de facto de réassigner l'auteur d'un compte A vers un compte B : - Attention : ce n'est ni testé ni documenté officiellement pour ce cas d'usage précis
(même instance, comptes différents) — les tests d'intégration du dépôt couvrent
vraisemblablement le cas inter-instances. Tester d'abord sur une seule séquence
(
--from-collectionplutôt que--from-user) avant de lancer sur l'ensemble d'un compte. - C'est une copie, pas un déplacement : les originaux restent sous le compte A. Pour un
vrai transfert, supprimer ensuite côté source
(
panoramax delete --api-url <src> --all-collections-of-user <A>) une fois le transfert vérifié. - Les items recréés ont de nouveaux UUID : tout ce qui référence les anciens identifiants
(permaliens,
?pic=dans les liens Panoramax) casse après la bascule.