PageSourceSearch

https://www.visorando.com/logiciel-randonnee.html

html visorando.com collected 2026-09-24 09:13:40 UTC 327,982 bytes, 7,169 lines download raw bytes

1<!DOCTYPE html>
2<html lang="fr" style="height: 100%">
3    <head>
4        <meta http-equiv="content-language" content="fr" />
5<meta charset="UTF-8"/>
6<meta name="viewport" content="width=device-width,initial-scale=1.0">        
7    <title>Logiciel de randonnée gratuit</title>
8    <meta name="description" content="Notre logiciel gratuit permet de créer un circuit de randonnée sur une carte au 1:25000 de l&#039;IGN et de voir son diagramme de dénivellation."/>
9    <link rel="canonical" href="https://www.visorando.com/logiciel-randonnee.html"/>
10    <link rel="alternate" hreflang="fr" href="https://www.visorando.com/logiciel-randonnee.html"/>
11    <link rel="alternate" hreflang="en" href="https://www.visorando.com/en/walk-planner.html"/>
12    <link rel="alternate" hreflang="de" href="https://www.visorando.com/de/tourenplaner-wandern.html"/>
13    <link rel="alternate" hreflang="x-default" href="https://www.visorando.com/en/walk-planner.html"/>
14        <link href="https://www.visorando.com/css.css?v=1790147326" rel="stylesheet"/>
15            <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" />
16    <link href="https://www.visorando.com/templatesR/visorando/css/visorandomap.css?v=1790147288" rel="stylesheet"/>
17    <link href="https://www.visorando.com/templatesR/visorando/css/libs/izitoast.css?v=1790147288" rel="stylesheet"/>
18    <link href="https://www.visorando.com/templatesR/visorando/css/modal.css?v=1790147288" rel="stylesheet"/>
19    <link href="https://www.visorando.com/templatesR/visorando/css/mapbox-gl-draw.css?v=1790147288" rel="stylesheet"/>
20    <link href="https://www.visorando.com/templatesR/visorando/css/swiper.min.css?v=1790147288" rel="stylesheet"/>
21    <link href="https://www.visorando.com/templatesR/visorando/css/pages/mapSoftware.css?v=1790147288" rel="stylesheet"/>
22    <link href="https://www.visorando.com/templatesR/visorando/css/pages/softwareToolbar.css?v=1790147288" rel="stylesheet"/>
23                
23<script type="text/javascript">
24    var _paq = window._paq = window._paq || [];
25    _paq.push(['setCustomDimension', 1, 'Free']);
26    _paq.push(['setCustomDimension', 2, 'Private_Individual']);
27    _paq.push(['setCustomDimension', 3, 'Visitor']);
28    _paq.push(['setCustomDimension', 4, 'No']);
29    _paq.push(['setCustomDimension', 5, 'rando/newCircuitV2']);
30        (function() {
31        var mtmKeys = ['mtm_campaign', 'mtm_source', 'mtm_medium', 'mtm_content'];
32        var params = new URLSearchParams(window.location.search);
33        var mtmParams = [];
34        for (var i = 0; i < mtmKeys.length; i++) {
35            var val = params.get(mtmKeys[i]);
36            if (val) {
37                mtmParams.push(mtmKeys[i] + '=' + encodeURIComponent(val));
38            }
39        }
40        if (mtmParams.length > 0) {
41            for (var j = 0; j < _paq.length; j++) {
42                if (_paq[j][0] === 'setCustomUrl') {
43                    var url = _paq[j][1];
44                    url += (url.indexOf('?') === -1 ? '?' : '&') + mtmParams.join('&');
45                    _paq[j][1] = url;
46                    break;
47                }
48            }
49        }
50    })();
51    _paq.push(['trackPageView']);
52        _paq.push(['enableLinkTracking']);
53    _paq.push(['enableHeartBeatTimer']);
54    (function () {
55        var u = "//analytics.visorando.com/";
56        _paq.push(['setTrackerUrl', u + 'matomo.php']);
57        _paq.push(['setSiteId', '1']);
58        var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
59        g.type = 'text/javascript';
60        g.async = true;
61        g.src = u + 'matomo.js';
62        s.parentNode.insertBefore(g, s);
63    })();
64</script>
64
65
66        <meta name="apple-itunes-app" content="app-id=969793415"/>                
67<link rel="shortcut icon" href="https://www.visorando.com/images/favicons/favicon.ico"/>
68<link rel="icon" href="https://www.visorando.com/images/favicons/favicon-16.png" sizes="16x16" type="image/png">
69<link rel="icon" href="https://www.visorando.com/images/favicons/favicon-32.png" sizes="32x32" type="image/png">
70<link rel="icon" type="image/svg+xml" href="https://www.visorando.com/images/favicons/favicon.svg">
71<!--Android-->
72<meta name="theme-color" content="#986F57">
73<meta name="application-name" content="Visorando">
74<meta name="mobile-web-app-capable" content="yes">
75<!--iOS-->
76<link rel="apple-touch-icon" href="https://www.visorando.com/images/favicons/icon-180.png" sizes="180x180" type="image/png">
77<meta name="apple-mobile-web-app-capable" content="yes">
78<meta name="apple-mobile-web-app-status-bar-style" content="default">
79<meta name="apple-mobile-web-app-title" content="Visorando">
80
81<meta name="google-signin-client_id" content="564074217767-ofteei0v4sv04vjev17eeipnqaf17tsv.apps.googleusercontent.com">
82<meta name="verify-v1" content="SrkH0+upkPGLTLLX6rJcrWue1uHFh3XVtz1ye3wlosg="/>
83
84<!--[if lt IE 9]>
85<script src="https://www.visorando.com/templatesR/visorando/js/html5shiv.js"></script>
vendor: 1 bytes, line 85
85
86<script src="https://www.visorando.com/templatesR/visorando/js/respond.min.js"></script>
86
87<![endif]-->    </head>
88    <body style="height: 100%">
89                
90
91
92    <div class='top-bar clearfix'>
93        <div class='group-actions' >
94            <div class='nav'>
95                <a href='https://www.visorando.com/' target='_blank' title="Retour sur l'accueil du site" class="tippy">
96                    <i style="margin-right:0px" class="vr-icon vr-icon-home"></i>
97                </a><a href='javascript:;' id="undo" title="Annuler la dernière action" disabled="disabled" class="tippy">
98                    <i class="vr-icon vr-icon-arrow-back"></i>
99                    <span class="xs:hidden">Annuler</span>
100                </a><a href='javascript:;' id="redo" title="Restaurer la dernière action" disabled="disabled" class="tippy">
101                    <i class="vr-icon vr-icon-arrow-forward"></i>
102                    <span class="xs:hidden">Restaurer</span>
103                </a><a href='javascript:;' id="draw" title="Commencer un nouveau tracé" class="tippy mode draw_line_string advanced hide">
104                    <i class="vr-icon vr-icon-trace-vector"></i>
105                    <span>Tracer</span>
106                </a><a href='javascript:;' title="Continuer un tracé" class='tippy advanced hide' id="continue">
107                    <i class="vr-icon vr-icon-edit"></i>
108                    <span>Continuer</span>
109                </a><!--<a href='javascript:;' class='tippy advanced' id="go-to" title="">
110                    <i class="vr-icon vr-icon-search"></i>
111                    <span></span>
112                </a>--><a href='javascript:;' id='save' class='tippy' title="Sauvegarder le(s) tracé(s)">
113                    <i class="vr-icon vr-icon-save"></i>
114                    <span>Enregistrer</span>
115                </a><a href='javascript:;' id='print' class='tippy' title="Imprimer au format PDF">
116                    <i class="vr-icon vr-icon-print"></i>
117                    <span>Imprimer/PDF</span>
118                </a><a href='javascript:;' class='tippy' id='export-gpx' title="Obtenir un tracé au format GPX">
119                    <i class="vr-icon vr-icon-upload"></i>
120                    <span>Export GPX</span>
121                </a><a href='javascript:;' class='tippy advanced hide' id='import-gpx' title="Importer un ou plusieurs tracé(s) au format GPX">
122                    <i class="vr-icon vr-icon-download"></i>
123                    <span>Import GPX</span>
124                </a><a href='javascript:;' class='tippy advanced hide' title="Activez cette option pour automatiquement centrer la carte sur le dernier point tracé" data-toggle-text="Désactivez cette option pour ne plus automatiquement centrer la carte sur le dernier point tracé" id="center-last-point">
125                    <i class="vr-icon vr-icon-trace-center-point"></i>
126                    <span>Centrage automatique</span>
127                </a><a href='javascript:;' title="Continuer un tracé" class='tippy not-advanced' id="continue">
128                    <i class="vr-icon vr-icon-edit"></i>
129                    <span>Continuer</span>
130                </a><a href='javascript:;' class='tippy' title="Prendre le même itinéraire pour tracer le retour d'un ou plusieurs tracé(s) sélectionné(s)" id="draw-back-path">
131                    <i class="vr-icon vr-icon-trace-loop-end"></i>
132                    <span>Rebrousser chemin</span>
133                </a><a href='javascript:;' class='tippy' id="reset" title="Supprimer tous les points et tracés">
134                    <i class="vr-icon vr-icon-trash-suppr"></i>
135                    <span>Tout supprimer</span>
136                </a><a href='javascript:;' id="delete" class='tippy advanced hide' title="Supprimer la sélection">
137                    <i class="vr-icon vr-icon-close"></i>
138                    <span>Supprimer la sélection</span>
139                </a><a href='javascript:;' class='tippy advanced hide' title="Supprimer le dernier point d'un ou plusieurs tracé(s) sélectionné(s)" id="delete-last-point">
140                    <i class="vr-icon vr-icon-trace-delete-point"></i>
141                    <span>Supprimer le dernier point</span>
142                </a><a href='javascript:;' id="cut" class='tippy advanced hide' title="Couper un tracé en deux">
143                    <i class="vr-icon vr-icon-cut"></i>
144                    <span>Couper</span>
145                </a><a href='javascript:;' class='tippy advanced hide' title="Fusionner plusieurs tracés" id="merge">
146                    <i class="vr-icon vr-icon-union"></i>
147                    <span>Fusionner</span>
148                </a><a href='javascript:;' class='tippy advanced hide' title="Tracer le retour d'un ou plusieurs tracé(s) sélectionné(s)" id="draw-close-path">
149                    <i class="vr-icon vr-icon-trace-distance-line"></i>
150                    <span>Tracer le retour</span>
151                </a><a href='javascript:;' class='tippy advanced hide' title="Inverser le sens d'un ou de plusieurs tracé(s) sélectionné(s)" id="reverse-direction">
152                    <i class="vr-icon vr-icon-arrow-switch-horizontal"></i>
153                    <span>Inverser le sens</span>
154                </a><a href='javascript:;' class='tippy advanced hide' title="Mesurer une distance entre des points" id="measure-rule">
155                    <i class="vr-icon vr-icon-measure-distance"></i>
156                    <span>Distance en ligne droite</span>
157                </a><a href='javascript:;' class='tippy advanced hide' title="Afficher les coordonnées du point sélectionné, dans le format choisi dans le menu "Réglages des cartes et du logiciel" de votre compte" id="point-coordinates">
158                    <i class="vr-icon vr-icon-geoloc-enabled"></i>
159                    <span>Coordonnées du point</span>
160                </a><a href='javascript:;' class='tippy advanced hide' title="Royaume-Unis uniquement : obtenir les coordonnées OS grid du point sélectionné" id="os-grid-ref">
161                    <i class="vr-icon vr-icon-geoloc-enabled"></i>
162                    <span>OS grid (UK)</span>
163                </a><a href='javascript:;' class='tippy advanced hide' title="Choisir un nouveau point de départ" id="move-start">
164                    <i class="vr-icon vr-icon-place"></i>
165                    <span>Modifier départ</span>
166                </a><a href='javascript:;' class='tippy active advanced hide' data-toggle-text="Départs affichés" data-toggle-text-tooltip="Activez cette option pour afficher les repères de départ et arrivée" title="Désactivez cette option pour cacher les repères de départ et arrivée" id="display-start">
167                    <i class="vr-icon vr-icon-eye-closed"></i>
168                    <span>Départs affichés</span>
169                </a><!--<a href='javascript:;' class='tippy advanced' data-toggle-text="" title="" id="tape-measure">
170                    <i class="vr-icon vr-icon-trace-distance"></i>
171                    <span></span>
172                </a>--><a href='javascript:;' class='tippy' id='help' data-toggle-text="Désactivez cette option pour cacher l'aide contextuelle" title="Activez cette option pour afficher l'aide contextuelle">
173                    <i class="vr-icon vr-icon-help-center"></i>
174                    <span>Aide contextuelle</span>
175                </a><a href='javascript:;' class='tippy advanced active hide' data-toggle-text="Textes affichés" data-toggle-text-tooltip="Activez cette option pour afficher le texte des boutons" title="Désactivez cette option pour cacher le texte des boutons" id="hide-txt-button">
176                    <i class="vr-icon vr-icon-text"></i>
177                    <span>Textes affichés</span>
178                </a></a><a href='javascript:;' id='switch-version' data-toggle-text-tooltip="Désactivez cette option pour accéder au logiciel simple (non premium)" data-toggle-text="Logiciel avancé" class='tippy' title="Activez cette option pour accéder au logiciel avancé (premium)">
179                    <i class="vr-icon vr-icon-crown"></i>
180                    <span>Logiciel avancé</span>
181                </a>
182            </div>
183        </div>
184        <div class="mouse-coordinates-indicator mod" >
185            n.c
186        </div>
187    </div>
188
189    <div class="responsive-toolbar auto-trace-toolbar-enabled" id="responsive-toolbar">
190
191        <a href="javascript:;" class="btn btn--ghost btn--square drag-handle" title="Déplacer la barre d'outils">
192            <i class="vr-icon vr-icon-grab-dots-vertical"></i>
193            <span class="sr-only">Déplacer la barre d'outils</span>
194        </a>
195
196        <div class="toolbar-separator"></div>
197
198        
199<div class="form-switch">
200    <div class="form-switch--input">
201        <input type="hidden" name="auto-trace-toggle" value="0"/>
202        <input value="1" type="checkbox" autocomplete="off" id="auto-trace-toggle" name="auto-trace-toggle" checked  >
203    </div>
204    <div class="form-switch--label">
205        <label for="auto-trace-toggle">
206            Tracé automatique                    </label>
207            </div>
208</div>
209        
210        <div class="toolbar-separator"></div>
211
212        <a href="javascript:;" class="btn btn--ghost btn--square tippy" id="center" title="Centrer la carte sur le(s) tracé(s)">
213            <i class="vr-icon vr-icon-trace-fit-screen"></i>
214            <span class="sr-only">Centrer la carte sur le(s) tracé(s)</span>
215        </a>
216
217        <div class="dropdown drop--right" id="color-picker-dropdown">
218            <a class="dropdown-toggle btn btn--ghost btn--square tippy" title="Couleur du tracé" data-toggle="dropdown" href="javascript:;">
219                <div class="color-circle" style="background-color:#FF0000"></div>
220            </a>
221            <ul class="dropdown-menu">
222                                    <li class="active">
223                        <a href="javascript:;" class="dropdown-item color-item" data-color="#FF0000">
224                            <span class="color-name">Rouge</span>
225                            <div class="color-circle" style="background-color:#FF0000"></div>
226                        </a>
227                    </li>
228                                    <li >
229                        <a href="javascript:;" class="dropdown-item color-item" data-color="#FF9900">
230                            <span class="color-name">Orange</span>
231                            <div class="color-circle" style="background-color:#FF9900"></div>
232                        </a>
233                    </li>
234                                    <li >
235                        <a href="javascript:;" class="dropdown-item color-item" data-color="#FFFF00">
236                            <span class="color-name">Jaune</span>
237                            <div class="color-circle" style="background-color:#FFFF00"></div>
238                        </a>
239                    </li>
240                                    <li >
241                        <a href="javascript:;" class="dropdown-item color-item" data-color="#00FF00">
242                            <span class="color-name">Vert</span>
243                            <div class="color-circle" style="background-color:#00FF00"></div>
244                        </a>
245                    </li>
246                                    <li >
247                        <a href="javascript:;" class="dropdown-item color-item" data-color="#0000FF">
248                            <span class="color-name">Bleu</span>
249                            <div class="color-circle" style="background-color:#0000FF"></div>
250                        </a>
251                    </li>
252                                    <li >
253                        <a href="javascript:;" class="dropdown-item color-item" data-color="#9900FF">
254                            <span class="color-name">Violet</span>
255                            <div class="color-circle" style="background-color:#9900FF"></div>
256                        </a>
257                    </li>
258                                    <li >
259                        <a href="javascript:;" class="dropdown-item color-item" data-color="#FF00FF">
260                            <span class="color-name">Rose</span>
261                            <div class="color-circle" style="background-color:#FF00FF"></div>
262                        </a>
263                    </li>
264                                    <li >
265                        <a href="javascript:;" class="dropdown-item color-item" data-color="#646464">
266                            <span class="color-name">Gris</span>
267                            <div class="color-circle" style="background-color:#646464"></div>
268                        </a>
269                    </li>
270                                    <li >
271                        <a href="javascript:;" class="dropdown-item color-item" data-color="#000000">
272                            <span class="color-name">Noir</span>
273                            <div class="color-circle" style="background-color:#000000"></div>
274                        </a>
275                    </li>
276                            </ul>
277        </div>
278
279        <div class="toolbar-separator"></div>
280
281        <a href="javascript:;" class="btn btn--ghost btn--square btn--negative tippy" id="reset" title="Tout supprimer">
282            <i class="vr-icon vr-icon-trash-suppr"></i>
283            <span class="sr-only">Tout supprimer</span>
284        </a>
285    </div>
286
287<div class="cd-panel cd-panel--from-right js-cd-panel-main">
288    <header class="cd-panel__header">
289        <h1>Aide</h1>
290        <a href="#0" class="cd-panel__close js-cd-close" data-sw="help-panel-close"><span class="sr-only">Fermer</span></a>
291    </header>
292    <div class="cd-panel__container">
293        <div class="cd-panel__content">
294            <div class="xs:p-4">
295                <h2>Guide des fonctionnalités</h2>
296                <div>
297                    <a href="https://youtu.be/41xD8jgQPV8" target="_blank" rel="noopener noreferrer" class="btn btn-secondary w-full">Vidéo de présentation<i class="vr-icon vr-icon-media-video"></i></a>
298                </div>
299                <br/>
300                <div class="cd-panel__legend">
301                    <div class="flex align-center">
302                        <div class="icon"><i class="vr-icon vr-icon-trace-vector"></i></div>
303                        <div class="explanations">Activer/désactiver le mode traçage pour créer ou terminer votre itinéraire.
304                        </div>
305                    </div>
306                    <div class="flex align-center">
307                        <div class="icon"><i class="vr-icon vr-icon-edit"></i></div>
308                        <div class="explanations">Continuer le tracé sélectionné.</div>
309                    </div>
310                    <div class="flex align-center">
311                        <div class="icon"><i class="vr-icon vr-icon-search"></i></div>
312                        <div class="explanations">Centrer la carte sur le lieu de départ/arrivée souhaité de votre randonnée.
313                        </div>
314                    </div>
315                    <div class="flex align-center">
316                        <div class="icon"><i class="vr-icon vr-icon-save"></i></div>
317                        <div class="explanations">Enregistrer les(s) tracé(s) dans "Mon compte > Mes Randonnées".</div>
318                    </div>
319                    <div class="flex align-center">
320                        <div class="icon"><i class="vr-icon vr-icon-print"></i></div>
321                        <div class="explanations">Sauvegarder et exporter votre tracé dans une fiche PDF.</div>
322                    </div>
323                    <div class="flex align-center">
324                        <div class="icon"><i class="vr-icon vr-icon-download"></i></div>
325                        <div class="explanations">Importer des traces avec des fichiers GPX.</div>
326                    </div>
327                    <div class="flex align-center">
328                        <div class="icon"><i class="vr-icon vr-icon-upload"></i></div>
329                        <div class="explanations">Exporter les tracés sélectionnées en fichier GPX. Si la sélection est vide, tous les tracés valides sont exportés.
330                        </div>
331                    </div>
332                    <div class="flex align-center">
333                        <div class="icon"><i class="vr-icon vr-icon-trace-fit-screen"></i></div>
334                        <div class="explanations">Recentrer la carte sur vos/votre tracé(s).</div>
335                    </div>
336                    <div class="flex align-center">
337                        <div class="icon"><i class="vr-icon vr-icon-trace-loop"></i></div>
338                        <div class="explanations">Fermer votre tracé en repassant par tous les points existants dans le sens inverse.
339                        </div>
340                    </div>
341                    <div class="flex align-center">
342                        <div class="icon"><i class="vr-icon vr-icon-trash-suppr"></i></div>
343                        <div class="explanations">Supprimer tous les tracés existants sur la carte.</div>
344                    </div>
345                    <div class="flex align-center">
346                        <div class="icon"><i class="vr-icon vr-icon-close"></i></div>
347                        <div class="explanations">Supprimer les points de votre sélection.</div>
348                    </div>
349                    <div class="flex align-center">
350                        <div class="icon"><i class="vr-icon vr-icon-trace-delete-point"></i></div>
351                        <div class="explanations">Supprimer le dernier point de chacun des tracés sélectionnés (cette action est également valable en cours de traçage).
352                        </div>
353                    </div>
354                    <div class="flex align-center">
355                        <div class="icon"><i class="vr-icon vr-icon-cut"></i></div>
356                        <div class="explanations">Scinder le tracé sélectionné en deux tracés au niveau du point sélectionné.
357                        </div>
358                    </div>
359                    <div class="flex align-center">
360                        <div class="icon"><i class="vr-icon vr-icon-union"></i></div>
361                        <div class="explanations">Fusionner les tracés sélectionnés (l'ordre dans lequel vous sélectionnez vos tracés influe la fusion).
362                        </div>
363                    </div>
364                    <div class="flex align-center">
365                        <div class="icon"><i class="vr-icon vr-icon-trace-distance-line"></i></div>
366                        <div class="explanations">Fermer le tracé sélectionné en ajoutant un point aux mêmes coordonnées que celui du départ.
367                        </div>
368                    </div>
369                    <div class="flex align-center">
370                        <div class="icon"><i class="vr-icon vr-icon-arrow-switch-horizontal"></i></div>
371                        <div class="explanations">Inverser le sens du tracé.</div>
372                    </div>
373                    <div class="flex align-center">
374                        <div class="icon"><i class="vr-icon vr-icon-eye-closed"></i></div>
375                        <div class="explanations">Afficher/cacher les points de départ de tous les tracés.</div>
376                    </div>
377                    <div class="flex align-center">
378                        <div class="icon"><i class="vr-icon vr-icon-measure-distance"></i></div>
379                        <div class="explanations">Mesurer la distance à vol d'oiseau entre au moins deux points.</div>
380                    </div>
381                    <div class="flex align-center">
382                        <div class="icon"><i class="vr-icon vr-icon-geoloc-enabled"></i></div>
383                        <div class="explanations">Obtenir les coordonnées du point sélectionné (selon le format sélectionné dans votre profil : Mon compte > Réglages du Logiciel > Format des coordonnées géographiques).
384                        </div>
385                    </div>
386                    <div class="flex align-center">
387                        <div class="icon" style="font-size:23px"><i class="vr-icon vr-icon-text"></i></div>
388                        <div class="explanations">Afficher/cacher les textes des icônes de la barre des fonctionnalités en haut de l'écran.
389                        </div>
390                    </div>
391                    <div class="flex align-center">
392                        <div class="icon svg"><img src="/templatesR/visorando/images/software/click-move.svg"/></div>
393                        <div class="explanations">Se déplacer sur la carte.</div>
394                    </div>
395                    <div class="flex align-center">
396                        <div class="icon svg"><img src="/templatesR/visorando/images/software/mouse-wheel.svg"/></div>
397                        <div class="explanations">Zoomer/Dézoomer la carte.</div>
398                    </div>
399                    <div class="flex align-center">
400                        <div class="icon svg"><img src="/templatesR/visorando/images/software/click-twice.svg"/></div>
401                        <div class="explanations">Zoomer sur la carte.</div>
402                    </div>
403                    <div class="flex align-center">
404                        <div class="icon svg"><img src="/templatesR/visorando/images/software/enter-key.svg"/></div>
405                        <div class="explanations">Terminer votre tracé lorsque vous êtes en mode traçage.</div>
406                    </div>
407                    <div class="flex align-center">
408                        <div class="icon svg biggest"><img src="/templatesR/visorando/images/software/shift-click.svg"/></div>
409                        <div class="explanations">Sélectionner plusieurs points ou tracés.
410                        </div>
411                    </div>
412                    <div class="flex align-center">
413                        <div class="icon svg biggest"><img src="/templatesR/visorando/images/software/shift-click-move.svg"/>
414                        </div>
415                        <div class="explanations">Créér une zone de sélection de points ou tracés.</div>
416                    </div>
417                    <div class="flex align-center">
418                        <div class="icon svg biggest"><img src="/templatesR/visorando/images/software/ctrl-z.svg"/></div>
419                        <div class="explanations">Annuler la dernière action.</div>
420                    </div>
421                    <div class="flex align-center">
422                        <div class="icon svg biggest"><img src="/templatesR/visorando/images/software/ctrl-shift-z.svg"/></div>
423                        <div class="explanations">Restaurer la dernière action.</div>
424                    </div>
425                </div>
426            </div>
427        </div>
428    </div>
429</div>
430    <div class="bottom-right-bar">
431
432        
433            <div class="mapboxgl-ctrl mapboxgl-ctrl-group btn--contribution">
434            <a id="goToDonationIcon" target="_blank" data-tippy-content="Contribuer à Visorando"  href="https://www.visorando.com/coup-de-pouce.html"><i class="vr-icon vr-icon-heart-like"></i><span class="xs:ml-4">Contribuer</span></a>
435        </div>
436    
437        <a href="javascript:void(0);" class="switch-handler" ><i class="vr-icon vr-icon-chevron-s-down" ></i></a>
438        <a href="javascript:void(0);
438" title="Lien vers la fiche du tracé (dernière version enregistrée)" class="hike-link tippy" ><i class="vr-icon vr-icon-link-external"></i></a>
439        <div class='tabs'>
440            <a href='javascript:;' class='tab active'><i class='vr-icon vr-icon-information-sign'></i> <span>Informations</span></a>
441            <a href='javascript:;' class='tab'><i class='vr-icon vr-icon-graph-line'></i> <span>Diagramme</span></a>
442        </div>
443        <div class="swiper-container swiper-tabs">
444            <div class='swiper-wrapper'>
445                <div class='swiper-slide infos'>
446                    <div class="swiper-container swiper-infos">
447                        <div class='swiper-wrapper'>
448                            <div class='swiper-slide'>
449                                <div class='duration'>
450                                    <img src="https://www.visorando.com/img/fiches/datasheet/duration.svg" alt="◔" title="Durée" height="25" width="25" role="presentation" />                                    Durée&nbsp;<span>0</span>
451                                </div>
452                                <div class='distance'>
453                                    <img src="https://www.visorando.com/img/fiches/datasheet/distance.svg" alt="↔" title="Distance" height="25" width="25" role="presentation" />                                    Distance&nbsp;<span>0</span> km                                </div>
454                                <div class='pos-elevation'>
455                                    <img src="https://www.visorando.com/img/fiches/datasheet/elevation-up.svg" alt="↗" title="Dénivelé positif" height="25" width="25" role="presentation" />                                    Dénivelé positif&nbsp;<span>0</span> m                                </div>
456                                <div class='neg-elevation'>
457                                    <img src="https://www.visorando.com/img/fiches/datasheet/elevation-down.svg" alt="↘" title="Dénivelé négatif" height="25" width="25" role="presentation" />                                    Dénivelé négatif&nbsp;<span>0</span> m                                </div>
458                                <div class='high-point'>
459                                    <img src="https://www.visorando.com/img/fiches/datasheet/point-high.svg" alt="▲" title="Point haut" height="25" width="25" role="presentation" />                                    Point haut&nbsp;<span>0</span> m                                </div>
460                                <div class='low-point'>
461                                    <img src="https://www.visorando.com/img/fiches/datasheet/point-low.svg" alt="▼" title="Point bas" height="25" width="25" role="presentation" />                                    Point bas&nbsp;<span>0</span> m                                </div>
462                                <div class='count-point'>
463                                    <img src="https://www.visorando.com/img/fiches/datasheet/points.svg" alt="•••" title="Nombre de points" height="25" width="25" role="presentation" />                                    Nombre de points&nbsp;<span>0</span>
464                                </div>
465                            </div>
466                        </div>
467                        <div class="swiper-pagination"></div>
468                    </div>
469                </div>
470                <div class='swiper-slide diagrams'>
471                    <div class="swiper-container swiper-diagrams">
472                        <div class='swiper-wrapper'>
473                            <div class='swiper-slide'>
474                                <div id='placeholder-diagram' >Le diagramme de dénivelé se mettra automatiquement à jour lorsque vous aurez créé votre itinéraire.</div>
475                            </div>
476                        </div>
477                        <div class="swiper-pagination"></div>
478                    </div>
479                </div>
480            </div>
481        </div>
482    </div>
483
484<div id='boot-el-software' class='map-container loading' >
485    <div class="go-to" >
486        <form method="GET" class="form-inline">
487                <label for="loc" class="sr-only">Rechercher </label>
488                <div class="inputs-group">
489                    <input id="loc" type="search" class="ui-autocomplete-input input" aria-autocomplete="list" aria-expanded="false" spellcheck="false" autocorrect="off" name="loc" autocomplete="off" placeholder="Lieu, sommet, région…">
490                    <div class="btns-group">
491                    <button type="submit" class="btn btn--square sr-only"><i class="vr-icon vr-icon-search"></i> Rechercher</button>
492                    <button title="Ma position" type="button" class="btn btn--square btn-secondary btn-geoloc tippy"><i class="vr-icon vr-icon-geoloc-disabled"></i><span class="sr-only">Utiliser ma localisation</span></button>
493                    </div>
494                </div>
495            <input type="hidden" id='meta-data' name="metaData" value='' />
496        </form>
497    </div>
498
499    <div class='loader viso-loader'>
500        <div class="vr-loader" role="presentation"></div>
501        <span class="text-white">Chargement…</span>
502    </div>
503
504
505</div>
506
507<div id="not-logged-popup" style="display:none;width:925px;max-width:96%" class="radius--lg xs:p-0 bg-vr-brown-1">
508    <div class='container container--fluid'>
509        <div class="row no-gutters">
510            <div class='col xs:12 sm:4 xs:p-4 sm:p-8 bg-neutral-0'>
511                <div class="text-center">
512                    <a class='inbl' href="https://www.visorando.com/"><img src="https://www.visorando.com/templatesR/visorando/images/logo_visorando.png" width="150" class="xs:my-4 sm:my-8" alt="Visorando" title="Visorando"/></a>
513                </div>
514                <form action="" method="post" id="quick-entry" role="form" class="xs:spacey-4">
515                    <div id="div_email">
516                        <label for="email">Adresse email</label>
517                        <input type="email" name="email" id="email" class="input">
518                    </div>
519                    <div id="div_password">
520                        <label for="password">Mot de passe</label>
521                        <div class="input-password">
522                            <input type="password" name="password" id="password" class="input">
523                            <button type="button" class="input-password--toggle">
524                                <i class="vr-icon vr-icon-eye-open"></i>
525                            </button>
526                        </div>
527                        <div id="progress_password" style="display: none"></div>
528                    </div>
529                    <div id="div_type">
530                        <label for="type">Type de compte</label>
531                        <select id="type" name="type" class="input">
532                                                            <option value="0">Visorandonneur</option>
533                                                            <option value="2">Association / Club / AMM</option>
534                                                            <option value="3">Office de tourisme</option>
535                                                            <option value="7">Collectivité territoriale</option>
536                                                            <option value="10">Parc Naturel Régional</option>
537                                                            <option value="9">Parc National</option>
538                                                            <option value="11">Professionnel du tourisme</option>
539                                                            <option value="13">Structure d'enseignement</option>
540                                                    </select>
541                    </div>
542                    <input type="hidden" name="regJS" id="regJS" value="vtreg02">
543                    <input type="hidden" name="from" id="from" value="null">
544                    <input type="hidden" name="idRandonnee" id="idRandonnee" value="0">
545                    <input type="hidden" name="idPDFRU" id="idPDFRU" value="0">
546                    <input type="hidden" name="source" id="source" value="Formulaire inscription_logiciel">
547                    <input type="hidden" name="urlRedirect" id="urlRedirect" value="">
548                    <input type="hidden" name="deviceLocale" class="js-device-locale" value=""/>
549<script>
550    (function () {
551        var inputs = document.getElementsByClassName('js-device-locale');
552        for (var i = 0; i < inputs.length; i++) {
553            inputs[i].value = navigator.language || '';
554        }
555    })();
556</script>
556
557                    <button id="btnSubmit" class="btn btn--success w-full" type="submit">Je m'inscris</button>
558                    <p>Si vous avez déjà un compte, <a href="https://www.visorando.com/index.php?component=user&task=loginForm&urlRedirect=https%3A%2F%2Fwww.visorando.com%2Flogiciel-randonnee.html">cliquez ici pour vous connecter</a></p>
559                </form>
560            </div>
561            <div class='col xs:12 sm:8 disp-fl xs:flex-middle xs:p-6 xs:pb-12 sm:p-16'>
562                <div>
563                    <h1 class="jumbo-4 xs:mb-4 sm:mb-8">Testez le logiciel de randonnée <strong class="text-vr-brown-5">GRATUIT</strong></h1>
564                    <ul class="list-unstyled sm:text--lg xs:spacey-2 sm:spacey-4 md:spacey-6 lh--sm">
565                        <li style="display:flex;gap:10px;align-items:center;"><i class="vr-icon vr-icon-valid"></i> Simple, intuitif et convivial, tracer vos randonnées devient un jeu d'enfant</li>
566                        <li style="display:flex;gap:10px;align-items:center;"><i class="vr-icon vr-icon-valid"></i> Logiciel disponible sur l'application mobile</li>
567                        <li style="display:flex;gap:10px;align-items:center;"><i class="vr-icon vr-icon-valid"></i> Tracez toutes vos envies sur le fond de carte de votre choix (Topo & Satellite IGN, OSM, Swisstopo, IGN Espagne + Belgique)</li>
568                        <li style="display:flex;gap:10px;align-items:center;"><i class="vr-icon vr-icon-valid"></i> Exportez, imprimez et partagez en un clic vos tracés avec vos amis&nbsp;!</li>
569                        <li style="display:flex;gap:10px;align-items:center;"><i class="vr-icon vr-icon-valid"></i> Retrouvez tous vos tracés sur votre compte Visorando</li>
570                    </ul>
571                </div>
572            </div>
573        </div>
574    </div>        
575                
575<script src="https://www.visorando.com/js.js?v=1790147326" type="application/javascript"></script>
575
576            
576<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js" type="application/javascript"></script>
576
577    
577<script type="application/javascript" src="https://www.visorando.com/libs/js/highcharts/highcharts.stock.min.js?v=1790147288"></script>
577
578    
578<script type="application/javascript" src="https://www.visorando.com/libs/js/visorando-map.umd.js?v=1790147288"></script>
578
579    
579<script type="application/javascript" src="https://www.visorando.com/software.js?v=1790147326"></script>
579
580    
580<script type="application/javascript" src="https://www.visorando.com/libs/js/mapbox-gl-draw.js?v1.3"></script>
580
581    
581<script type="application/javascript" src="https://www.visorando.com/libs/js/jquery/jquery.validate.fr.js"></script>
581
582    
582<script type="application/javascript" src="https://www.visorando.com/libs/js/mapbox-polyline/1.2.1/polyline.min.js"></script>
582
583    
583<script type="application/javascript" src="https://www.visorando.com/libs/js/sprintf/1.1.2/sprintf.min.js"></script>
583
584    <link rel="preload" href="https://www.visorando.com/libs/js/i18n.js?v=1790147288" as="script">
585    
585<script src="https://www.visorando.com/libs/js/i18n.js?v=1790147288"></script>
585
586        
586<script type="application/javascript">
587            var baseUrl = 'https://www.visorando.com/';
588
589            $(document).ready(function () {
590                
591(function () {
592    const i18nJsonFile = "https:\/\/www.visorando.com\/i18n\/fr\/web-software.json?v=1790241218";
593
594    if (!i18nJsonFile) return;
595
596    $.ajax(i18nJsonFile)
597        .done(function (text) {
598            try {
599                const data = typeof text === "string" ? JSON.parse(text) : text;
600                i18n.translator.add({values: data});
601            } catch (e) {
602                console.error("i18n parsing error:", e);
603            }
604        })
605        .fail(function (jqXHR, textStatus, errorThrown) {
606            console.error("i18n loading error:", {
607                status: jqXHR.status,
608                textStatus: textStatus,
609                errorThrown: errorThrown
610            });
611        });
612})();
613
614// UMD
615(function (root, factory) {
616if (typeof define === "function" && define.amd) {
617    define(["jquery"], factory);
618} else if (typeof exports === "object") {
619    module.exports = factory(require("jquery"));
620} else {
621    root.VISORANDO_SOFTWARE = factory(root.$);
622}
623}(window, function ($) {
624
625let _module = (function(){
626    let settings = {
627        mapEl: $('#boot-el-software'), // Element DOM qui va accueilir la map
628        groupActions: $('.group-actions'),
629        topBar: $('.top-bar'),
630        bottomRightBar: $('.bottom-right-bar'),
631        searchLocForm: $('.go-to > form'),
632        hikeUrlEl : $('.hike-link'),
633        rasterOpacityEl : $('#raster-opacity-handler'),
634        isAdvanced: false,
635        baseUrl : 'https://www.visorando.com/',
636        departureThreshold : 100,
637        hashCode : '52b51633f87aeb835332e1e883849420',
638        clubUrl : 'https://www.visorando.com/premium.html',
639        isCompatible: true,
640        coordinateFormat : 0,
641        idRando : 0,
642        titreRando : null,
643        idRandoHash : '0',
644        carteType : 'IGNTOPO',
645        colorPalette: ["#FF9900","#00FF00","#0000FF","#9900FF","#FF00FF","#646464","#000000","#d49867","#513027","#850d24","#007e77","#03d9ff","#008efc"],
646        hexColor : '#FF0000',
647        softwareOpts: false,
648        deviceType: 'desktop',
649        maxDistanceForAutoPlot: 200000,
650        heightDifferenceLimit: 10,
651                    bounds: [
652                [-5.152361, 41.285270],
653                [9.736311, 51.158016]
654            ],
655                currentUnits: {
656            altitude: 'm',
657            distance: 'km',
658        },
659        localStorageExpiration : 1 * 24 * 60 * 60 * 1000, // 1 jour
660        elevationLayer : {"source":{"type":"raster","tiles":["https:\/\/tiles.visomap.com\/wmts\/slopes\/mercator\/{z}\/{x}\/{y}.png"],"tileSize":256},"layer":{"id":"elevation-layer","type":"raster","source":"elevation-layer","minzoom":9,"maxzoom":16},"name":"ALTIMETRIEMOUNTAIN"},
661        user: {
662            uGid: 0,
663            hasPremiumAccess : false,
664            hasIGNAccess : false,
665            showPub : true,
666            isLoggedIn: false        },
667    },
668    booted = false,
669        tippySingleton = null;
670
671    let _this = {
672        mapGl: null,                    // Instance MapLibre GL brute (via mapController.getMapInstance())
673        mapController: null,            // Instance MapController de VisorandoMap
674        drawGl: null,                   // Drawer GL instance
675        cookieHandler: null,            // Cookies instance
676        swiper: {                       // Swiper instances
677            tabs: null,
678            infos: null,
679            layers: null,
680            diagrams: null
681        },
682        helperTootlip: null,            // Tippy instance (infobulle d'aide)
683        asyncTasks: [],                 // Tableau pour gérer tout type de traitement asynchrone
684        store: {},                      // Objet pour gérer les points de chaque tracés
685        userPoints: {},                 // Les points d'ancrage (clics utilisateur)
686        autoTracingEnabled: false,      // Indique si le tracé automatique est activé
687        autoTraceOnGoing: false,        // Flag pour indiquer si un traçage automatique est en cours
688        descriptionWaypointOriginalCount: 0,
689        vertexDeleteOnGoing: false,     // Prevents a second Delete key press from targeting the whole selected trace.
690        blockingOperationCount: 0,      // Prevents user inputs from interleaving with async path mutations.
691        blockingCursorStyle: null,
692
693        commonConfirmOpts: {
694            zindex: 500,
695            titleSize:'18px',
696            color: 'white',
697            closeOnClick: false,
698            overlayClose: true,
699            progressBar: false,
700            drag: false,
701            timeout: false,
702            close: false,
703            overlay: true,
704            position: 'center'
705        },
706
707        getMap: function() {
708          return _this.mapGl;
709        },
710        setInitialDescriptionWaypointNumbers: function () {
711            if (!settings.idRando) return;
712
713            const pathId = _this.path.getAll({saved: true})[0];
714            const points = pathId ? _this.store[pathId] : null;
715            if (!Array.isArray(points)) return;
716
717            let waypointNumber = 0;
718            let originalCount = 0;
719
720            // The planner does not load structured-description content. It only tags existing intermediate
721            // waypoints with their initial description block number so missing numbers can be sent on save.
722            points.forEach(function (point, index) {
723                if (index === 0 || index === points.length - 1 || +point.wpt !== 1) return;
724
725                waypointNumber++;
726                if (!point.descriptionWaypointInitialNumber) {
727                    point.descriptionWaypointInitialNumber = waypointNumber;
728                }
729                originalCount = Math.max(originalCount, +point.descriptionWaypointInitialNumber || 0);
730            });
731
732            _this.descriptionWaypointOriginalCount = Math.max(_this.descriptionWaypointOriginalCount, originalCount);
733        },
734        getDeletedDescriptionWaypointIndexes: function () {
735            if (!settings.idRando || !_this.descriptionWaypointOriginalCount) return [];
736
737            const pathId = _this.path.getAll({saved: true})[0];
738            const points = pathId ? _this.store[pathId] : null;
739            if (!Array.isArray(points)) return [];
740
741            const remaining = {};
742            points.forEach(function (point, index) {
743                if (index !== 0 && index !== points.length - 1 && +point.wpt === 1 && point.descriptionWaypointInitialNumber) {
744                    remaining[String(point.descriptionWaypointInitialNumber)] = true;
745                }
746            });
747
748            const deletedIndexes = [];
749            // Undo is handled naturally: if a tagged waypoint exists again at save time, its block is not deleted.
750            for (let index = 1; index <= _this.descriptionWaypointOriginalCount; index++) {
751                if (!remaining[String(index)]) {
752                    deletedIndexes.push(index);
753                }
754            }
755
756            return deletedIndexes;
757        },
758        rebaseDescriptionWaypointNumbers: function () {
759            if (!settings.idRando) return;
760
761            const pathId = _this.path.getAll({saved: true})[0];
762            const points = pathId ? _this.store[pathId] : null;
763            if (!Array.isArray(points)) return;
764
765            let waypointNumber = 0;
766            // After a successful save, backend blocks are renumbered. Rebase client tags to that new canonical order.
767            points.forEach(function (point, index) {
768                if (index === 0 || index === points.length - 1 || +point.wpt !== 1) {
769                    delete point.descriptionWaypointInitialNumber;
770                    return;
771                }
772
773                waypointNumber++;
774                point.descriptionWaypointInitialNumber = waypointNumber;
775            });
776
777            _this.descriptionWaypointOriginalCount = waypointNumber;
778        },
779        isBlockingOperationOnGoing: function() {
780            return this.blockingOperationCount > 0;
781        },
782        beginBlockingOperation: function() {
783            this.blockingOperationCount++;
784            if (this.blockingOperationCount !== 1 || !this.mapGl) return;
785
786            const canvas = this.mapGl.getCanvas();
787            const container = this.mapGl.getCanvasContainer();
788            this.blockingCursorStyle = canvas.style.cursor;
789            canvas.style.cursor = 'wait';
790            $(container).addClass('software-wait-cursor');
791        },
792        endBlockingOperation: function() {
793            if (this.blockingOperationCount === 0) return;
794
795            this.blockingOperationCount--;
796            if (this.blockingOperationCount !== 0 || !this.mapGl) return;
797
798            const canvas = this.mapGl.getCanvas();
799            const container = this.mapGl.getCanvasContainer();
800            canvas.style.cursor = this.blockingCursorStyle || '';
801            $(container).removeClass('software-wait-cursor');
802            this.blockingCursorStyle = null;
803        },
804        runBlockingOperation: async function(callback) {
805            this.beginBlockingOperation();
806            try {
807                return await callback();
808            } finally {
809                this.endBlockingOperation();
810            }
811        },
812        setupBlockingInputGuard: function() {
813            const blockWhenBusy = function(e) {
814                if (!_this.isBlockingOperationOnGoing()) return;
815
816                e.preventDefault();
817                e.stopImmediatePropagation();
818            };
819
820            const container = this.mapGl.getCanvasContainer();
821            ['pointerdown', 'mousedown', 'dblclick', 'touchstart'].forEach(function(eventName) {
822                container.addEventListener(eventName, blockWhenBusy, true);
823            });
824
825            document.addEventListener('keydown', blockWhenBusy, true);
826        },
827        /**
828         * Auto-trace un segment de chemin entre deux points.
829         * @param {string} idPath - L'identifiant du tracé
830         * @param startIndex
831         * @param endIndex
832         * @param viaIndex
833         * @param preserveStart
834         * @param preserveEnd
835         * @param {Object|null} options.swiperInstance - Instance Swiper (optionnelle)
836         * @param {Object|null} options.swiperTabs - Instance Swiper Tabs (optionnelle)
837         */
838        autoTraceSegment: async function (idPath, startIndex, endIndex, viaIndex = null, preserveStart = false, preserveEnd = false) {
839            const originalCursorStyle = _this.mapGl.getCanvas().style.cursor;
840            const routingMode = getSelectedMode();
841
842            try {
843                const feature = _this.drawGl.get(idPath);
844                if (!feature) {
845                    throw new Error(sprintf(
846                        i18n("error_trace_not_found"),
847                        idPath
848                    ));
849                }
850
851                const points = _this.store[idPath];
852                if (!points || points.length < 2) throw new Error(i18n("error_autotrace_too_few_points"));
853
854                const startPoint = [points[startIndex].lng, points[startIndex].lat];
855                const endPoint = [points[endIndex].lng, points[endIndex].lat];
856
857
858                let locations = [{ lat: startPoint[1], lon: startPoint[0] }];
859
860                let fallbackLegs = [];
861                if (viaIndex != null) {
862                    const viaPoint = [points[viaIndex].lng, points[viaIndex].lat];
863                    locations.push({ lat: viaPoint[1], lon: viaPoint[0] });
864                    fallbackLegs.push([startPoint, viaPoint], [viaPoint, endPoint]);
865                } else {
866                    fallbackLegs.push([startPoint, endPoint]);
867                }
868                locations.push({ lat: endPoint[1], lon: endPoint[0]});
869
870                let segmentResult;
871                let msg = '';
872                let error = false;
873                try {
874                    const distance = calculateDistanceApprox(startPoint, endPoint)*1000;
875                    if (distance > settings.maxDistanceForAutoPlot) {
876                       error = true;
877                        msg += i18n("autotrace_error_generic");
878                        msg += "<br />" + i18n("autotrace_distance_too_long", Math.floor(settings.maxDistanceForAutoPlot / 1000));
879                        await setTimeout(() => {}, 50); //Hack pour éviter un comportement bizarre avec Mapbox de la fonction drawGl.set() qui ajoute un point de trop si pas d'attente
880                        segmentResult = {
881                            success: true,
882                            legs: [fallbackLegs]
883                        };
884                    } else {
885                        const selectedLayer =
886                            // settings.carteType est maintenant synchronisé depuis le panel VisorandoMap
887                            settings.carteType || 'IGNTOPO';
888                        segmentResult = await this.callRoute(locations, routingMode, selectedLayer);
889                        if (segmentResult.success && segmentResult.legs < 1) {
890                            console.warn(`Valhalla a retourné un segment bizarre, fallback en ligne droite.`);
891                            error = true;
892                            msg += i18n("autotrace_error_fallback") + "<br />";
893                            segmentResult = {
894                                success: true,
895                                legs: [fallbackLegs]
896                            }
897                        } else if(!segmentResult.success) {
898                            segmentResult = {
899                                success: false,
900                                legs: [fallbackLegs]
901                            }
902                            msg += i18n("autotrace_error_fallback") + "<br />";
903                        } else {
904                            msg += i18n("autotrace_msg_success_done") + "<br />"
905                        }
906                    }
907                } catch (err) {
908                    console.warn("Services de routage indisponibles, fallback en ligne droite", err);
909                    await setTimeout(() => {}, 50); //Hack pour éviter un comportement bizarre avec Mapbox de la fonction drawGl.set() qui ajoute un point de trop si pas d'attente
910                    segmentResult = {
911                        success: true,
912                        legs: [fallbackLegs]
913                    };
914                }
915
916                const legs = segmentResult.legs;
917
918                let basePointsBefore, basePointsAfter;
919                if (preserveStart) {
920                    basePointsBefore = _this.store[idPath].slice(0, startIndex + 1);
921                } else {
922                    basePointsBefore = _this.store[idPath].slice(0, startIndex);
923                }
924                if (preserveEnd) {
925                    basePointsAfter = _this.store[idPath].slice(endIndex);
926                } else {
927                    basePointsAfter = _this.store[idPath].slice(endIndex + 1);
928                }
929
930                let freshPoints = [];
931                legs.forEach((leg, key) => {
932                    freshPoints = freshPoints.concat(leg.map(c => ({
933                        lng: +c[0].toFixed(6),
934                        lat: +c[1].toFixed(6),
935                        // Altitude éventuellement fournie par le serveur (3e composante du coord)
936                        ...(typeof c[2] === 'number' ? { altitude: c[2] } : {}),
937                        id: _this.path.generateUUIDv4(),
938                        meta: 'auto_vertex'
939                    })));
940                    if (key===0 && viaIndex!=null) {
941                        freshPoints[freshPoints.length-1].__isVia = true;
942                        freshPoints[freshPoints.length-1].wpt = points[viaIndex].wpt;
943                        freshPoints[freshPoints.length-1].idEG = points[viaIndex].idEG;
944                        freshPoints[freshPoints.length-1].textWP = points[viaIndex].textWP;
945                        freshPoints[freshPoints.length-1].textIndication = points[viaIndex].textIndication;
946                        freshPoints[freshPoints.length-1].type = points[viaIndex].type;
947                        freshPoints[freshPoints.length-1].angle = points[viaIndex].angle;
948                        freshPoints[freshPoints.length-1].descriptionWaypointInitialNumber = points[viaIndex].descriptionWaypointInitialNumber;
949                    }
950                    freshPoints[freshPoints.length - 1].anchor = 1;
951                });
952                if (preserveStart) {
953                    if (freshPoints[0].lat === basePointsBefore[basePointsBefore.length - 1].lat &&
954                        freshPoints[0].lng === basePointsBefore[basePointsBefore.length - 1].lng) {
955                        freshPoints.shift();
956                    }
957                    freshPoints[0].anchor = 0;
958                } else {
959                    freshPoints[0].anchor = 1;
960                }
961                if (preserveEnd) {
962                    if (freshPoints[freshPoints.length - 1].lat === basePointsAfter[0].lat &&
963                        freshPoints[freshPoints.length - 1].lng === basePointsAfter[0].lng) {
964                        freshPoints.pop();
965                    }
966                    freshPoints[freshPoints.length - 1].anchor = 0;
967                } else {
968                    freshPoints[freshPoints.length - 1].anchor = 1;
969                }
970
971                _this.store[idPath] = basePointsBefore.concat(freshPoints).concat(basePointsAfter);
972
973                _this.path.updateDrawGlCoordsWithPath([idPath])
974
975                const indexes = [];
976                for (let i = 0; i < freshPoints.length; i++) {
977                    indexes.push(basePointsBefore.length + i);
978                }
979                // Si le serveur a déjà fourni toutes les altitudes (DP 3D), on évite l'appel MNT.
980                // Le fallback ligne droite n'a pas d'altitude → on interroge alors le MNT.
981                const needAltitude = freshPoints.some(p => typeof p.altitude !== 'number');
982                if (needAltitude) {
983                    try {
984                        await _this.getAltitudeForPoints(idPath, indexes);
985                    } catch (err) {
986                        console.warn(`Échec enregistrement point :`, err);
987                    }
988                }
989                _this.path.computeOrder(idPath);
990                _this.mapGl.triggerRepaint();
991
992                _this.mapGl.fire('autotraceSegment', { idPath });
993
994                if (error) {
995                    iziToast.error({
996                        message: msg + i18n("autotrace_click_to_finish")
997                    });
998                } else {
999                    iziToast.success({
1000                        message: msg + i18n("autotrace_click_to_finish")
1001                    });
1002                }
1003                return true;
1004            } catch (err) {
1005                console.error("Erreur critique autoTraceSegment :", err);
1006                return false;
1007            } finally {
1008                if (!_this.isBlockingOperationOnGoing()) {
1009                    _this.mapGl.getCanvas().style.cursor = originalCursorStyle;
1010                }
1011            }
1012        },
1013
1014        /**
1015         * Helper pour changer de mode sans risque
1016         */
1017        safeChangeMode: function (mode, options = {}) {
1018            try {
1019                const draw = this.drawGl;
1020
1021                // 1. Protection contre LineString trop courts pour 'simple_select'
1022                if (mode === 'simple_select' && options.featureIds?.length === 1) {
1023                    const id = options.featureIds[0];
1024                    const feature = draw?.get?.(id);
1025
1026                    if (feature?.geometry?.type === 'LineString') {
1027                        const coords = feature.geometry.coordinates;
1028                        if (!Array.isArray(coords) || coords.length < 2) {
1029                            console.warn("safeChangeMode: tentative de 'simple_select' sur un LineString trop jeune → annulé");
1030                            return;
1031                        }
1032                    }
1033                }
1034
1035                // 2. Vérifier l'existence du feature pour les modes draw_*
1036                if (mode.startsWith("draw") && options.featureId && !draw.get(options.featureId)) {
1037                    console.warn("safeChangeMode bloqué : feature introuvable pour", options.featureId);
1038                    return;
1039                }
1040
1041                draw.changeMode(mode, options);
1042            } catch (err) {
1043                console.error(`safeChangeMode: échec lors du changement de mode vers '${mode}'`, err);
1044            }
1045        },
1046        /**
1047         * Méthode pour initialiser l'interface de traçage auto
1048         */
1049        initAutoTraceControls: function() {
1050            // Initialiser le tooltip
1051            tippy('.auto-trace-container', {
1052                content: i18n("label_tooltip_autotrace"),
1053                placement: 'bottom'
1054            });
1055
1056            // Attacher l'événement au changement de l'interrupteur
1057            $('#auto-trace-toggle').on('change', function(e) {
1058                _this.toggleAutoTracing(this);
1059            });
1060
1061            // Activer par défaut
1062            _this.autoTracingEnabled = true;
1063            $('#auto-trace-toggle')
1064                .prop('checked', true)
1065                .closest('#responsive-toolbar')
1066                .removeClass('auto-trace-toolbar-disabled')
1067                .addClass('auto-trace-toolbar-enabled');
1068
1069            // Vérifier ensuite si l'utilisateur a explicitement désactivé la fonctionnalité
1070            const userPrefs = Cookies.getJSON('software.opts') || {};
1071            if (userPrefs.hasOwnProperty('auto-trace') && userPrefs['auto-trace'] === false) {
1072                _this.autoTracingEnabled = false;
1073                $('#auto-trace-toggle')
1074                    .prop('checked', false)
1075                    .closest('#responsive-toolbar')
1076                    .removeClass('auto-trace-toolbar-enabled')
1077                    .addClass('auto-trace-toolbar-disabled');
1078            }
1079        },
1080        /**
1081         * Gestionnaire pour l'événement draw.vertex.create - création d'un point
1082         * Cette version améliorée gère l'auto-traçage et met à jour les métadonnées
1083         */
1084        setupVertexCreateHandler: function() {
1085            this.mapGl.on('draw.vertex.create', async function(e) {
1086                if (!_this.autoTraceOnGoing && !_this.isBlockingOperationOnGoing()) {
1087                    _this.autoTraceOnGoing = true;
1088                    try {
1089                        await _this.runBlockingOperation(async function() {
1090                    const p = e.point;
1091                    const idPath = p.properties.parent;
1092                    _this.changePathColor(idPath, $('#color-picker-dropdown .active .dropdown-item').data('color') || settings.hexColor);
1093
1094                    if (!idPath) return;
1095
1096                    const coord = [
1097                        parseFloat(p.geometry.coordinates[0].toFixed(6)),
1098                        parseFloat(p.geometry.coordinates[1].toFixed(6))
1099                    ];
1100                    // Ajouter le point au store
1101                    _this.path.insertPoint(idPath, +p.properties.coord_path, {
1102                        lng: coord[0],
1103                        lat: coord[1],
1104                        order: +p.properties.coord_path,
1105                        anchor: 1
1106                    });
1107
1108                    // en version simple, on doit désactiver le bouton de création de tracé si tracé >= 1 par ex
1109                    if ($('#center-last-point').is('.active')) {
1110                        setTimeout(function () {
1111                            _this.mapGl.flyTo({
1112                                center: p.geometry.coordinates
1113                            });
1114                        }, 30)
1115                    }
1116
1117                    if (_this.autoTracingEnabled && _this.store[idPath]?.length >= 2) {
1118                        // Si l'auto-traçage est activé et il y a au moins 2 points
1119                        try {
1120                            // Lancer l'auto-traçage avec l'option isAutomatic=true
1121                            await _this.autoTraceSegment(idPath, _this.store[idPath].length-2, _this.store[idPath].length-1);
1122                        } catch (err) {
1123                            console.error("Erreur auto-traçage:", err);
1124                        }
1125                    }
1126                    await _this.getAltitudeForPoints(idPath, [p.properties.coord_path]);
1127
1128                    _this.path.handleMarkersPath(true);
1129                    await _this.updateTracePostOperation(idPath);
1130                    _this.updateHelper();
1131                    _this.updateGroupActions();
1132
1133                    // Créer une entrée dans l'historique
1134                    _this.history.operation(() => {
1135                        return {
1136                            store: Immutable.fromJS(_this.path.getPoints()),
1137                            action: 'vertex.create'
1138                        };
1139                    }, i18n("history_vertex_create"));
1140                        });
1141                    } finally {
1142                    _this.autoTraceOnGoing = false;
1143                    }
1144                } else {
1145                    iziToast.warning({
1146                        message: i18n("toast_msg_wait_autotrace_end")
1147                    })
1148                }
1149            })
1150        },
1151
1152        /**
1153         * Toggle du traçage automatique
1154         */
1155        toggleAutoTracing: function(el) {
1156            const isChecked = $(el).prop('checked');
1157            _this.autoTracingEnabled = isChecked;
1158
1159            _paq.push(['trackEvent',
1160                'Route planner',
1161                'Toggle autoplot',
1162                (isChecked ? 'Enable' : 'Disable') + ' autoplot',
1163            ]);
1164
1165            const message = this.autoTracingEnabled
1166                ? i18n("toast_msg_autotrace_enabled")
1167                : i18n("toast_msg_autotrace_disabled");
1168
1169            iziToast.info({
1170                message,
1171                position: "bottomCenter",
1172                timeout: 3000
1173            });
1174
1175            // Stocker la préférence utilisateur
1176            _this.setCookie({ 'auto-trace': this.autoTracingEnabled });
1177        },
1178
1179        // ...existing code... (toggleAutoTracing)
1180
1181        init: async function(){
1182
1183            // maplibregl.supported() a été supprimée dans MapLibre GL JS v5 — vérification WebGL directe
1184            const _webglSupported = (function() {
1185                try {
1186                    const c = document.createElement('canvas');
1187                    return !!(c.getContext('webgl2') || c.getContext('webgl'));
1188                } catch(e) { return false; }
1189            })();
1190            if(booted || !_webglSupported) return
1191
1192            // Ajouter l'initialisation du traçage automatique
1193            this.initAutoTraceControls();
1194
1195            // Restaurer les préférences utilisateur
1196            const userPrefs = Cookies.getJSON('software.opts') || {};
1197            if (userPrefs['auto-trace']) {
1198                this.autoTracingEnabled = true;
1199                $('#auto-trace-toggle').addClass('active');
1200            }
1201
1202            iziToast.settings({
1203                closeOnClick: true,
1204                displayMode: 'once',
1205                maxWidth: '350px',
1206                transitionOut: 'flipOutX',
1207                layout: 2,
1208                timeout: 5000,
1209                position: 'bottomCenter'
1210            })
1211
1212            settings.isSimpleVersion = !settings.isAdvanced
1213
1214            if(!settings.softwareOpts){
1215                // Options par défaut
1216                Cookies.set('software.opts', {
1217                    "hide-txt": false, // Option pr affichage du texte après les icones
1218                    "advanced": settings.isAdvanced, // Option mode avancée ou non
1219                    "hide-tabs": false // Option pr cacher les onglets en bas à droite
1220                }, { expires: 365 })
1221            }
1222
1223            tippy.setDefaultProps({
1224                delay: [0, 0],
1225                theme: 'visorando',
1226                hideOnClick: settings.mapEl.hasClass('mobile-user') ? 'toggle' : false,
1227                arrow: false
1228            });
1229
1230            var tippyInstances = tippy('.tippy', {
1231                content: function(reference) {
1232                    var title = reference.getAttribute('title')
1233                    reference.removeAttribute('title')
1234                    return title
1235                }
1236            })
1237
1238            tippySingleton = tippy.createSingleton(tippyInstances, {
1239                ignoreAttributes: true,
1240                offset: [0, 7],
1241                moveTransition: 'transform .2s ease-out',
1242                animateFill: true,
1243                overrides: ['placement', 'content', 'popperOptions']
1244            })
1245
1246            priorityNav.init({
1247                mainNavWrapper:'.group-actions',
1248                mainNav:'.nav',
1249                navDropdownLabel:'',
1250                moved: function () {
1251                    $.each($('.'+this.navDropdownClassName).find('> *'), function(){
1252                        var newProps = {
1253                            placement: 'left',
1254                            popperOptions: {
1255                                modifiers: [
1256                                    {
1257                                        name: 'flip',
1258                                        options: {
1259                                            fallbackPlacements: ['right', 'bottom'],
1260                                        },
1261                                    },
1262                                ]
1263                            }
1264                        }
1265                        this._tippy.setProps(newProps)
1266                        tippySingleton.setProps(newProps)
1267                    })
1268                },
1269                movedBack: function () {
1270                    $.each(settings.groupActions.find('.nav > *'), function(){
1271                        var newProps = {
1272                            placement: 'bottom',
1273                            popperOptions: {}
1274                        }
1275                        this._tippy.setProps(newProps)
1276                        tippySingleton.setProps(newProps)
1277                    })
1278                }
1279            })
1280
1281            $(window).on('resize orientationchange', throttle(function(){
1282
1283                var $el = settings.groupActions.find('.priority-nav__dropdown')
1284                if($el.length && $el.hasScrollBar()){
1285                    if(!$('head').find('#fix-scrollbar').length){
1286                        var scrollBarWidth = $el.prop('offsetWidth') - $el.prop('clientWidth')
1287                        $('head').append('<style type="text/css" id="fix-scrollbar" >.nav__dropdown >
1287 a{padding-right:calc(2rem + '+scrollBarWidth+'px)}</style>')
1288                    }
1289                }else{
1290                    $('head').find('#fix-scrollbar').remove()
1291                }
1292                if($el.is(':parent') && !$el.is('.show') && $el.prev('.is-open').length){
1293                    $el.addClass('show')
1294                }
1295
1296                $el.css('max-height', (window.innerHeight - 50)+'px')
1297
1298            }, 1000));
1299
1300            $(window).on('orientationchange.software', _this.updateTopBar);
1301
1302            // Recalcul du menu overflow (priority-nav) lors du passage en plein écran
1303            // Un délai est nécessaire pour attendre la fin de la transition de layout du navigateur
1304            $(document).on('fullscreenchange.software webkitfullscreenchange.software', function() {
1305                setTimeout(function() {
1306                    _this.updateTopBar();
1307                }, 150);
1308            });
1309
1310            $(document).on('keydown', throttleWithoutTrailing(function(e){
1311                if($(e.target).is('input')) return
1312
1313                var _run = function(){
1314                    // 90 = touche 'z'
1315                    var isUndo = e.which === 90 && (e.ctrlKey || e.metaKey)
1316                    // redo
1317                    if( isUndo && e.shiftKey ){
1318                        _try(function(){
1319                            if(_this.history.hasRedo()) _this.history.redo(e)
1320                        })
1321                    }
1322                    // undo
1323                    else if( isUndo ){
1324                        _try(function(){
1325                            if(_this.history.hasUndo()) _this.history.undo(e)
1326                        })
1327                    }
1328                }
1329
1330                var _try = function(callback){
1331                    if($.ajaxq.isRunning('store')) {
1332                        $.ajaxq.getActiveRequest('store').done(function(){
1333                            if ($.ajaxq.getCountPendingRequest('store') === 0) {
1334                                $(document).one('history.operation', function(){
1335                                    callback()
1336                                })
1337                                return
1338                            }
1339                            _try(callback)
1340                        })
1341                    }else{
1342                        callback()
1343                    }
1344                }
1345
1346                _run()
1347
1348            }, 200));
1349
1350            $('#loc').autocomplete({
1351                source: settings.baseUrl+'index.php?'+$.param({
1352                    component: 'ajax',
1353                    task: 'searchEntiteAutoComplete',
1354                    field: 1,
1355                    v: 2
1356                }),
1357                minLength: 3,
1358                select:function(e,ui){
1359                    $(this).val(ui.item.label)
1360                        .closest('form')
1361                        .find('#meta-data').val(ui.item.metaData)
1362                        .trigger('submit');
1363                },
1364                delay: 200
1365            });
1366
1367            settings.hikeUrlEl.on('click', function(){
1368                if(settings.idRando && settings.idRando > 0){
1369                    Object.assign(document.createElement('a'), {
1370                        target: '_blank',
1371                        href: settings.baseUrl+'index.php?'+$.param({
1372                            component: 'rando',
1373                            task: 'showRando',
1374                            idRandonnee: settings.idRando
1375                        })
1376                    }).click()
1377                }else{
1378                    iziToast.info({
1379                        message: i18n("toast_msg_must_save_trace")
1380                    })
1381                }
1382            })
1383
1384            // Synchroniser settings.carteType depuis le panel VisorandoMap
1385            $(document).on('change', '.vrmap-map-layers-panel input[name="baselayer"]', function () {
1386                const newLayer = $(this).val();
1387                settings.carteType = newLayer;
1388                _paq.push(['trackEvent', 'Layers panel', 'Change base layer', newLayer]);
1389            });
1390
1391            settings.searchLocForm.on('submit', function (e) {
1392                e.preventDefault()
1393                $.getJSON(settings.baseUrl + 'index.php', [
1394                        $.param({task: 'getBounds', component: 'software', carteType: settings.carteType}),
1395                        $(this).serialize(),
1396                        $.param(computeMapDim())
1397                    ].join('&'), function (res) {
1398                        if (res.bounds) {
1399                            _this.mapGl.fitBounds(res.bounds, {
1400                                padding: 12,
1401                                duration: 500
1402                            })
1403                        } else {
1404                            iziToast.info({
1405                                message: i18n("toast_msg_no_result_found"),
1406                                timeout: 2000
1407                            })
1408                        }
1409
1410                        if (res.marker) {
1411                            _this.markers.createOnce(res.marker, 'search-result', 'search-location-marker')
1412                        } else {
1413                            _this.markers.deleteById(null, true, 'search-result')
1414                        }
1415                    }
1416                )
1417            })
1418
1419            // =====================================================================
1420            //  Initialisation via VisorandoMap (MapController)
1421            // =====================================================================
1422            const { MapController } = VisorandoMap;
1423            const boundsCenter = [
1424                (settings.bounds[0][0] + settings.bounds[1][0]) / 2,
1425                (settings.bounds[0][1] + settings.bounds[1][1]) / 2
1426            ];
1427
1428            try {
1429            _this.mapController = new MapController({
1430                // Options communes du site (URLs backend, catalogue, i18n, unités, gating
1431                // premium/gid).
1432                ...{"apiBaseUrl":"https://www.visorando.com/","stylesBaseUrl":"https://www.visorando.com","catalogUrl":"https://www.visorando.com/index.php?component=mapStyle&task=mapsCatalog","translationsBaseUrl":"https://www.visorando.com/i18n/visorando-map","locale":"fr","coordinateFormat":"DD","distanceUnit":"km","elevationUnit":"m","isPremium":false,"userGid":0,"premiumUrl":"https://www.visorando.com/premium.html"},
1433                container: settings.mapEl.get(0).id,
1434                fullscreenContainer: document.documentElement,
1435                apiBaseUrl: settings.baseUrl,
1436                maxZoom: 19,
1437                // Page de dessin : le fond vectoriel topo n'est proposé qu'aux admins (incompatible
1438                // MapboxDraw + perfs pour le grand public). Pour un non-admin, un carteType TOPO_VECTO
1439                // enregistré est remappé vers IGNTOPO.
1440                baseLayer: (function (ct) {
1441                    var allowed = ['IGNTOPO','IGNSAT','HIKINGMAP','MAPNIK','CYCLINGMAP'];
1442                                        return allowed.indexOf(ct) !== -1 ? ct : 'IGNTOPO';
1443                })(settings.carteType),
1444                enableLayerSelector: true,
1445                showOverlayLegends: true,
1446                enablePitch: true,
1447                enableRotation: true,
1448                userGid: settings.user.uGid,
1449                isPremium: settings.user.hasPremiumAccess,
1450                loginUrl: "https://www.visorando.com/index.php?component=user&task=loginForm&urlRedirect=https%253A%252F%252Fwww.visorando.com%252Flogiciel-randonnee.html",
1451                enableTracking: typeof _paq !== 'undefined',
1452            });
1453
1454            await _this.mapController.centerOnUserLocation({
1455                latitude: boundsCenter[1],
1456                longitude: boundsCenter[0],
1457                zoom: 6,
1458                animate: false,
1459            });
1460
1461            // Récupérer l'instance MapLibre brute pour MapboxDraw et les opérations bas-niveau
1462            this.mapGl = _this.mapController.getMapInstance();
1463            this.setupBlockingInputGuard();
1464
1465            } catch(e) {
1466                throw e;
1467            }
1468
1469            // Ajuster la vue aux bounds initiaux
1470            _this.mapGl.fitBounds(settings.bounds, { padding: 20, duration: 0 });
1471
1472            // ===================================================================
1473            //  Gestion clavier (sélections vertex / traces)
1474            //  - Shift enfoncé     → active la sélection de points (vertex visibles)
1475            //  - Shift relâché     → désactive la sélection de points
1476            //  - Click sur un point → sélectionne la trace si non sélectionnée
1477            // ===================================================================
1478
1479            const keysDown = {};
1480            let isBoxSelecting = false;
1481
1482            const layersToManage = [
1483                'gl-draw-polygon-midpoint',
1484                'gl-draw-polygon-and-line-vertex-inactive',
1485                'gl-draw-polygon-and-line-vertex-inactive-custom'
1486            ];
1487
1488            let originalFilters = {};
1489
1490            // -------------------------------------------------------------------
1491            // 1. Sauvegarde des filtres initiaux au chargement
1492            // NB : La carte est déjà chargée (waitForLoad). Cette fonction est appelée
1493            //      explicitement APRÈS addControl(drawGl) pour que les layers Draw soient présents.
1494            // -------------------------------------------------------------------
1495            const _onMapLoaded = () => {
1496
1497                layersToManage.forEach(layerId => {
1498                    const layer = _this.mapGl.getLayer(layerId);
1499                    if (layer) {
1500                        originalFilters[layerId] = layer.filter;
1501                    }
1502                });
1503                // Rendez le canvas focusable + retirez l'outline par défaut
1504                const canvas = _this.mapGl.getCanvas();
1505                canvas.setAttribute('tabindex', '0');
1506                canvas.style.outline = 'none';
1507
1508                // Donne le focus au canvas à chaque interaction sur la carte
1509                const container = _this.mapGl.getCanvasContainer();
1510                ['pointerdown', 'mousedown', 'touchstart', 'click'].forEach(evt => {
1511                    container.addEventListener(evt, () => {
1512                        if (document.activeElement !== canvas) {
1513                            canvas.focus();
1514                        }
1515                    }, { passive: true });
1516                });
1517            };
1518
1519            // -------------------------------------------------------------------
1520            // 2. Keydown : Shift = active les points pour sélection multiple
1521            // -------------------------------------------------------------------
1522            $(document).on('keydown.draw-filter', (e) => {
1523                keysDown[e.key] = true;
1524
1525                if (!e.shiftKey || isBoxSelecting) return;
1526
1527                const selectedIds = _this.drawGl.getSelectedIds();
1528                if (selectedIds.length === 0) return;
1529
1530                // Force la sélection des traces actives
1531                _this.safeChangeMode('simple_select', { featureIds: selectedIds });
1532
1533                // Restaure les vertex visibles
1534                layersToManage.forEach(layerId => {
1535                    if (_this.mapGl.getLayer(layerId) && originalFilters[layerId]) {
1536                        _this.mapGl.setFilter(layerId, originalFilters[layerId]);
1537                    }
1538                });
1539            });
1540
1541            // -------------------------------------------------------------------
1542            // 3. Keyup : relâchement Shift = retour état normal
1543            // -------------------------------------------------------------------
1544            $(document).on('keyup.draw-filter', (e) => {
1545                delete keysDown[e.key];
1546
1547                if (!keysDown['Shift']) {
1548
1549                    // Restaure filtres vertex à l’état initial
1550                    layersToManage.forEach(layerId => {
1551                        if (_this.mapGl.getLayer(layerId) && originalFilters[layerId]) {
1552                            _this.mapGl.setFilter(layerId, originalFilters[layerId]);
1553                        }
1554                    });
1555                }
1556            });
1557
1558            // -------------------------------------------------------------------
1559            // 4. Click sur point : si trace non sélectionnée → sélectionne la trace
1560            // -------------------------------------------------------------------
1561            this.mapGl.on('click', (e) => {
1562                if (!e.originalEvent.shiftKey) return;
1563
1564                const features = _this.mapGl.queryRenderedFeatures(e.point, {
1565                    layers: layersToManage
1566                });
1567
1568                if (features.length > 0) {
1569                    const point = features[0];
1570                    const parentId = point.properties.parent;
1571
1572                    const selectedIds = _this.drawGl.getSelectedIds();
1573                    if (!selectedIds.includes(parentId)) {
1574                        // On sélectionne la trace parent du point cliqué
1575                        _this.safeChangeMode('simple_select', { featureIds: [parentId] });
1576
1577                        // Affiche ses vertex (nécessaire si shift est maintenu)
1578                        layersToManage.forEach(layerId => {
1579                            if (_this.mapGl.getLayer(layerId) && originalFilters[layerId]) {
1580                                _this.mapGl.setFilter(layerId, originalFilters[layerId]);
1581                            }
1582                        });
1583                    }
1584                }
1585            });
1586
1587            // -------------------------------------------------------------------
1588
1589            if(!settings.user.isLoggedIn){
1590
1591                $.fancybox.open({
1592                    src  : '#not-logged-popup',
1593                    type : 'inline',
1594                    opts : {
1595                        beforeClose: function(){
1596                            Object.assign(document.createElement('a'), {
1597                                href: settings.baseUrl
1598                            }).click()
1599                            return false
1600                        },
1601                        trapFocus: 1,
1602                        idleTime: 0,
1603                        smallBtn: 0,
1604                        toolbar: 1,
1605                        animationEffect: 'material',
1606                        autoFocus: 0,
1607                        keyboard: 0,
1608                        slideShow: 0,
1609                        fullScreen: 0,
1610                        thumbs: 0,
1611                        touch: 0,
1612                        clickContent: false,
1613                        clickSlide: false,
1614                        clickOutside: false,
1615                        dblclickContent: false,
1616                        dblclickSlide: false,
1617                        dblclickOutside: false
1618                    }
1619                });
1620
1621                var validator = $('#quick-entry').validate({
1622                    submitHandler: function(form) {
1623
1624                        $(form).find('#regJS').val(settings.hashCode)
1625
1626                        var _commonOpts = {
1627                            displayMode: 'replace',
1628                            id:'inscription',
1629                            timeout: false,
1630                            position: 'bottomCenter'
1631                        }
1632
1633                        $.ajax({
1634                            url: settings.baseUrl+'index.php?'+$.param({
1635                                component: 'software',
1636                                task: 'register'
1637                            }),
1638                            dataType: 'json',
1639                            type: 'post',
1640                            beforeSend: function(){
1641                                settings.mapEl.addClass('loading high-z-index')
1642                                $(form).find(':submit').attr('disabled', 'disabled').addClass('disabled')
1643                            },
1644                            success: function(res){
1645
1646                                if(res.reset){
1647                                    form.reset()
1648                                }
1649
1650                                if(res.error){
1651                                    iziToast.error(Object.assign(_commonOpts, {
1652                                        message: escapeHtml(res.error)
1653                                    }))
1654                                }
1655
1656                                if(res.hasOwnProperty('msg')){
1657                                    iziToast.info(Object.assign(_commonOpts, {
1658                                        message: escapeHtml(res.msg)
1659                                    }))
1660                                }
1661                            },
1662                            error: function(){
1663                                iziToast.error(Object.assign(_commonOpts, {
1664                                    message: i18n("toast_msg_registration_error")
1665                                }))
1666                            },
1667                            complete: function(){
1668                                settings.mapEl.removeClass('loading high-z-index')
1669                                $(form).find(':submit').removeAttr('disabled').removeClass('disabled')
1670                            },
1671                            data: $(form).serialize()
1672                        })
1673                    },
1674                    rules: {
1675                        "email": {
1676                            email: true,
1677                            required: true
1678                        },
1679                        "password": {
1680                            notEmpty: true,
1681                            required: true,
1682                            nowhitespace: true,
1683                            minlength: 6
1684                        }
1685                    }
1686                })
1687
1688                var pwdInput = $('#password', '#quick-entry'),
1689                    pwdProgressBar = $('#progress_password', '#quick-entry');
1690
1691                var pwdIndicator = tippy(pwdInput.get(0), {
1692                    content: '',
1693                    appendTo: $('#not-logged-popup').get(0),
1694                    allowHTML: true,
1695                    trigger: 'manual',
1696                    arrow: tippy.roundArrow,
1697                    offset: [0, 10],
1698                    placement: 'right'
1699                })
1700
1701                var levelStrength = [
1702                    { label: i18n("password_strength_weak"), class: 'weak' },
1703                    { label: i18n("password_strength_good"), class: 'good' },
1704                    { label: i18n("password_strength_strong"), class: 'strong' }
1705                ]
1706
1707                var evaluateStrength = function(){
1708                    var value = pwdInput.val(),
1709
1710                        hasLower = /[a-z]+/.test(value),
1711                        hasUpp = /[A-Z]+/.test(value),
1712                        hasNumber = /[0-9]+/.test(value),
1713                        hasSpeChar = /[-!$%^&*()_+|~=`{}\[\]:\/;<>?,.@#]+/.test(value),
1714
1715                        strengthCriteria = [hasLower, hasUpp, hasNumber, hasSpeChar],
1716                        strength = strengthCriteria.filter(function(v){ return v }).length - 1
1717
1718                    var advices = [];
1719
1720                    if (!hasLower) {
1721                        advices.push(i18n("password_advice_lowercase"));
1722                    }
1723                    if (!hasUpp) {
1724                        advices.push(i18n("password_advice_uppercase"));
1725                    }
1726                    if (!hasNumber) {
1727                        advices.push(i18n("password_advice_number"));
1728                    }
1729                    if (!hasSpeChar) {
1730                        advices.push(i18n("password_advice_special"));
1731                    }
1732
1733                    return {
1734                        advices: advices,
1735                        indication: levelStrength[Math.min(Math.max(0, strength), levelStrength.length - 1)]
1736                    };
1737                }
1738
1739                pwdInput.on('focus', function () {
1740                    validator.element( $(this).get(0) )
1741                    if(validator.errorList.length) return
1742
1743                    var display = evaluateStrength()
1744                    if(display.advices.length){
1745                        var ul = $('<ul/>');
1746                        $.each(display.advices, function (i, value) {
1747                            ul.append($('<li/>', {
1748                                text: value
1749                            }));
1750                        });
1751                        pwdIndicator.setContent(display.indication.label + ul.prop('outerHTML'))
1752                    }else{
1753                        pwdIndicator.setContent(display.indication.label)
1754                    }
1755
1756                    pwdIndicator.show()
1757                    pwdProgressBar.removeClass().addClass(display.indication.class).show()
1758                }).on('focusout', function(){
1759                    pwdProgressBar.hide()
1760                    pwdIndicator.hide()
1761                }).on('input', function(){
1762                    validator.element( $(this).get(0) )
1763                    if(validator.errorList.length){
1764                        pwdProgressBar.hide()
1765                        pwdIndicator.hide()
1766                    }else{
1767                        var display = evaluateStrength()
1768                        if(display.advices.length){
1769                            var ul = $('<ul/>');
1770                            $.each(display.advices, function (i, value) {
1771                                ul.append($('<li/>', {
1772                                    text: value
1773                                }));
1774                            });
1775                            pwdIndicator.setContent($('<strong/>', {text : display.indication.label}).prop('outerHTML') + ul.prop('outerHTML'))
1776                        }else{
1777                            pwdIndicator.setContent(display.indication.label)
1778                        }
1779                        pwdIndicator.show()
1780                        pwdProgressBar.removeClass().addClass(display.indication.class).show()
1781                    }
1782                })
1783
1784                this.mapGl.once('load', function(){
1785                    settings.mapEl.removeClass('loading').addClass('done')
1786                    settings.topBar.prependTo(settings.mapEl)
1787                    $('body').addClass(settings.isSimpleVersion ? 'simple-version' : 'advanced-version')
1788                    _this.mapGl.resize()
1789                })
1790
1791                return
1792            }
1793
1794            function nearestSegmentIndex(map, coords, lngLat) {
1795                const p = map.project(lngLat);
1796                let bestK = -1, bestD = Infinity;
1797                for (let k = 0; k < coords.length - 1; k++) {
1798                    const a = map.project({lng: coords[k][0], lat: coords[k][1]});
1799                    const b = map.project({lng: coords[k+1][0], lat: coords[k+1][1]});
1800                    const abx = b.x - a.x, aby = b.y - a.y;
1801                    const apx = p.x - a.x, apy = p.y - a.y;
1802                    const ab2 = abx*abx + aby*aby || 1;
1803                    let t = (apx*abx + apy*aby) / ab2; t = Math.max(0, Math.min(1, t));
1804                    const px = a.x + t*abx, py = a.y + t*aby;
1805                    const dx = p.x - px, dy = p.y - py;
1806                    const d2 = dx*dx + dy*dy;
1807                    if (d2 < bestD) { bestD = d2; bestK = k; }
1808                }
1809                return bestK;
1810            }
1811
1812            //Ajout d'un mode pour gérer les ancres avec les styles
1813            const Base = MapboxDraw.modes.simple_select;
1814            const DrawLineLocked = {
1815                // On étend le mode, mais on ne modifie PAS ses handlers
1816                ...Base,
1817
1818                onSetup(opts) {
1819                    const state = Base.onSetup.call(this, opts);
1820                    // Récupération robuste de la feature et de son id
1821                    state.feature = null;
1822                    state.featureId = null;
1823                    state.anchorIndices = [];
1824                    state.interceptDrag = false;
1825                    state.draggingAnchor = null;     // index effectif une fois le prune fait
1826                    state.pendingAnchorIdx = null;   // index de l’ancre cliquée (si ancre)
1827                    state._wasDragPanEnabled = false;
1828                    return state;
1829                },
1830                onTouchStart(state, e) {
1831                    return this.onMouseDown(state, e);
1832                },
1833                onMouseDown(state, e) {
1834                    const ft = e?.featureTarget?.properties;
1835
1836                    // 1) Si clique sur un VERTEX ancre : (ton flux existant) on arme juste le drag d’ancre
1837                    if (ft?.meta === 'vertex' && ft.coord_path && ft.parent) {
1838                        state._wasDragPanEnabled = this.map.dragPan.isEnabled && this.map.dragPan.isEnabled();
1839                        if (state._wasDragPanEnabled) this.map.dragPan.disable();
1840
1841                        const coord_path = parseInt(ft.coord_path);
1842                        const pathId = ft.parent;
1843
1844                        if (_this.path.isAnchor(pathId, coord_path)) {
1845                            state.featureId = pathId;
1846                            state.feature = this._ctx.store.get(pathId);
1847                            state.anchorIndices = _this.path.computeAnchorIndicesFromStore(state.featureId);
1848                            state.draggingAnchor = null;
1849                            // On arme le drag d’ancre, sans prune pour l’instant
1850                            state.interceptDrag = true;
1851                            state.pendingAnchorIdx = coord_path;
1852
1853                            e.originalEvent?.preventDefault?.();
1854                            e.originalEvent?.stopPropagation?.();
1855                        }
1856                    }
1857                    // 2) Si clique sur la LIGNE (entre deux ancres) : on arme une insertion et on place le point
1858                    if (ft?.meta === 'feature' && ft.id && ft.active === 'true') {
1859                        state._wasDragPanEnabled = this.map.dragPan.isEnabled && this.map.dragPan.isEnabled();
1860                        if (state._wasDragPanEnabled) this.map.dragPan.disable();
1861
1862                        state.featureId = ft.id;
1863                        state.feature = this._ctx.store.get(ft.id);
1864                        state.anchorIndices = _this.path.computeAnchorIndicesFromStore(state.featureId);
1865                        state.interceptDrag = true;
1866
1867                        const coords = state.feature.coordinates;
1868                        const k = nearestSegmentIndex(this.map, coords, e.lngLat);
1869                        const insertIdx = k + 1;
1870
1871                        const segmentStart = coords[k];
1872                        const segmentEnd = coords[k + 1];
1873
1874                        const projectedPoint = _this.projectPointOnSegment(e.lngLat, segmentStart, segmentEnd);
1875
1876                        // INSÉRER la nouvelle ancre à k+1
1877                        state.feature.addCoordinate(insertIdx, projectedPoint.lng, projectedPoint.lat);
1878                        const pt = state.feature.getCoordinate(insertIdx);
1879                        // Marquer “ancre” dans ton store externe
1880                        const parentId = state.feature.id || state.featureId;
1881                        _this.path.insertPoint(parentId, insertIdx, {
1882                            lng: projectedPoint.lng.toFixed(6),
1883                            lat: projectedPoint.lat.toFixed(6),
1884                            anchor: 1
1885                        })
1886                        this.map.fire('draw.vertex.update', {
1887                            index: [insertIdx],
1888                            parent: state.feature.id,
1889                            points: [pt],
1890                            skipAutoTrace: true
1891                        });
1892
1893                        // Reset des flags pour ne PAS réinsérer au mouseup
1894                        state.pendingAnchorIdx = insertIdx;
1895                        state.draggingAnchor = null;
1896                    }
1897                    return Base.onMouseDown.call(this, state, e);
1898                },
1899                onDrag(state, e) {
1900                    // Si on intercepte et que le prune n’a pas encore été fait, on le fait MAINTENANT
1901                    if (state.interceptDrag && state.draggingAnchor == null && state.pendingAnchorIdx != null) {
1902                        const idx = state.pendingAnchorIdx;
1903                        const coords = state.feature.coordinates;
1904
1905                        state.anchorIndices = _this.path.computeAnchorIndicesFromStore(state.feature.id);
1906                        const anchorIndex = state.anchorIndices.indexOf(idx);
1907
1908                        const prev = _this.path.prevAnchor(idx, state.anchorIndices);
1909                        const next = _this.path.nextAnchor(idx, state.anchorIndices, coords.length);
1910
1911                        // Supprimer tous les points non-ancres STRICTEMENT entre prev et next
1912                        // Itération à rebours !
1913                        for (let i = next - 1; i > prev; i--) {
1914                            if (!_this.path.isAnchor(state.feature.id, i)) {
1915                                state.feature.removeCoordinate(i);
1916                                // (optionnel) Si tu tiens ton _this.store en phase, nettoie ici la clé correspondante.
1917                                _this.path.deletePoint(state.feature.id, i);
1918                            }
1919                        }
1920                        state.anchorIndices = _this.path.computeAnchorIndicesFromStore(state.feature.id);
1921                        state.draggingAnchor = state.anchorIndices[anchorIndex];
1922                        this.doRender(state.featureId || state.initiallySelectedFeatureIds?.[0]);
1923                    }
1924
1925                    // Si on est en drag d’ancre : on déplace SEULEMENT l’ancre
1926                    if (state.interceptDrag && state.draggingAnchor != null) {
1927                        state.feature.updateCoordinate(state.draggingAnchor, e.lngLat.lng, e.lngLat.lat);
1928                        this.doRender(state.featureId || state.initiallySelectedFeatureIds?.[0]);
1929                        return;
1930                    }
1931
1932                    // Sinon, comportement natif
1933                    return Base.onDrag.call(this, state, e);
1934                },
1935                onTouchEnd(state, e) {
1936                    return this.onMouseUp(state, e);
1937                },
1938                onMouseUp(state, e) {
1939                    // reset flags
1940                    if (state.interceptDrag && state.draggingAnchor != null) {
1941                        const pt = state.feature.getCoordinate(state.draggingAnchor); // [lng, lat]
1942                        this.map.fire('draw.vertex.update', {
1943                            index: [state.draggingAnchor],
1944                            parent: state.feature.id,
1945                            points: [pt]
1946                        });
1947                    }
1948                    state.pendingAnchorIdx = null;
1949                    state.draggingAnchor = null;
1950                    state.interceptDrag = false;
1951                    if (state._wasDragPanEnabled) this.map.dragPan.enable();
1952                    state._wasDragPanEnabled = false;
1953                    return Base.onMouseUp?.call(this, state, e);
1954                },
1955                toDisplayFeatures(state, geojson, display) {
1956                    Base.toDisplayFeatures.call(this, state, geojson, (g) => {
1957                        // 1) Cacher tous les midpoints
1958                        if (g.properties?.meta === 'midpoint') return;
1959
1960                        // 2) Cacher les vertex non-ancres (Variante A)
1961                        if (g.properties?.meta === 'vertex' && g.properties.coord_path) {
1962                            const isAnchor = _this.path.isAnchor(g.properties.parent, g.properties.coord_path);
1963                            if (!isAnchor) return;
1964                        }
1965
1966                        display(g);
1967                    });
1968                }
1969            };
1970
1971            // Override du mode draw_line_string pour ignorer les clics droits
1972            // (le clic droit ouvre le menu contextuel et ne doit pas poser de point)
1973            const DrawLineStringBase = MapboxDraw.modes.draw_line_string;
1974            const DrawLineStringNoRightClick = {
1975                ...DrawLineStringBase,
1976                onClick(state, e) {
1977                    // Ignorer tous les boutons autres que le bouton gauche (button === 0)
1978                    if (e.originalEvent && e.originalEvent.button !== 0) return;
1979                    return DrawLineStringBase.onClick.call(this, state, e);
1980                }
1981            };
1982
1983            var modes = {
1984                ...MapboxDraw.modes,
1985                simple_select: DrawLineLocked,
1986                draw_line_string: DrawLineStringNoRightClick
1987            }
1988
1989            delete modes.draw_point;
1990            delete modes.draw_polygon;
1991
1992            const HOVER_RING_SRC = 'hover-ring-src';
1993            const HOVER_RING_LYR = 'hover-ring-layer';
1994            this.drawGl = new MapboxDraw({
1995                /* Permet de gérer la (dé)sélection quand un tracé perd son (faux) "focus"
1996                (Ne concerne pas les points) */
1997                handleBlurSelection: !settings.isSimpleVersion,
1998                displayControlsDefault: false,
1999                userProperties: true,
2000                colorPalette: settings.colorPalette,
2001                controls: { line_string: false },
2002                modes: modes,
2003                defaultMode: 'draw_line_string',
2004                styles: [
2005                    {
2006                        'id': 'gl-draw-line-inactive-outline',
2007                        'maxzoom': 17,
2008                        'type': 'line',
2009                        'filter': ['all',
2010                            ['==', 'active', 'false'],
2011                            ['==', '$type', 'LineString'],
2012                            ['!=', 'mode', 'static']
2013                        ],
2014                        'layout': {
2015                            'line-cap': 'round',
2016                            'line-join': 'round'
2017                        },
2018                        'paint': {
2019                            'line-color': '#ffffff',
2020                            'line-width': [
2021                                'interpolate', ['linear'], ['zoom'],
2022                                8, 5,
2023                                16, 7
2024                            ],
2025                            'line-opacity': 0.9
2026                        }
2027                    },
2028                    {
2029                        'id': 'gl-draw-line-inactive',
2030                        'type': 'line',
2031                        'filter': ['all',
2032                            ['==', 'active', 'false'],
2033                            ['==', '$type', 'LineString'],
2034                            ['!=', 'mode', 'static']
2035                        ],
2036                        'layout': {
2037                            'line-cap': 'round',
2038                            'line-join': 'round'
2039                        },
2040                        'paint': {
2041                            'line-color': [
2042                                'case',
2043                                ['has','user_color'], ["get","user_color"],
2044                                ['has','color'], ["get","color"],
2045                                settings.hexColor
2046                            ],
2047                            'line-width': [
2048                                'interpolate', ['linear'], ['zoom'],
2049                                8, 1.5,
2050                                16, 3.5
2051                            ]
2052                        }
2053                    },
2054                    {
2055                        'id': 'gl-draw-line-active-outline',
2056                        'maxzoom': 17,
2057                        'type': 'line',
2058                        'filter': ['all',
2059                            ['==', '$type', 'LineString'],
2060                            ['==', 'active', 'true']
2061                        ],
2062                        'layout': {
2063                            'line-cap': 'round',
2064                            'line-join': 'round'
2065                        },
2066                        'paint': {
2067                            'line-color': '#ffffff',
2068                            'line-width': [
2069                                'interpolate', ['linear'], ['zoom'],
2070                                8, 3,
2071                                16, 6
2072                            ],
2073                            'line-opacity': 0.9
2074                        }
2075                    },
2076                    {
2077                        'id': 'gl-draw-line-active',
2078                        'type': 'line',
2079                        'filter': ['all',
2080                            ['==', '$type', 'LineString'],
2081                            ['==', 'active', 'true']
2082                        ],
2083                        'layout': {
2084                            'line-cap': 'round',
2085                            'line-join': 'round'
2086                        },
2087                        'paint': {
2088                            'line-color': [
2089                                'case',
2090                                ['has','user_color'], ["get","user_color"],
2091                                ['has','color'], ["get","color"],
2092                                settings.hexColor
2093                            ],
2094                            'line-dasharray': [0.2, 1.5],
2095                            'line-width': [
2096                                'interpolate', ['linear'], ['zoom'],
2097                                8, 1.5,
2098                                16, 3.5
2099                            ]
2100                        }
2101                    },
2102                    {
2103                        'id': 'gl-draw-polygon-midpoint',
2104                        'type': 'circle',
2105                        'filter': ['all',
2106                            ['==', '$type', 'Point'],
2107                            ['==', 'meta', 'midpoint'],
2108                            ['==', 'active', 'true']],
2109                        'paint': {
2110                            'circle-radius': 6,
2111                            'circle-color': [
2112                                "let", "_rgba", ["to-rgba", ["to-color",
2113                                    [
2114                                        'case',
2115                                        ['has','user_color'], ["get","user_color"],
2116                                        ['has','color'], ["get","color"],
2117                                        settings.hexColor
2118                                    ]
2119                                ]],
2120                                [
2121                                    "rgba",
2122                                    ["number", ["at", 0, ["var", "_rgba"]]],
2123                                    ["number", ["at", 1, ["var", "_rgba"]]],
2124                                    ["number", ["at", 2, ["var", "_rgba"]]],
2125                                    .5
2126                                ]
2127                            ]
2128                        }
2129                    },
2130                    {
2131                        'id': 'gl-draw-polygon-and-line-vertex-inactive',
2132                        'type': 'circle',
2133                        'filter': ['all',
2134                            ['==', 'meta', 'vertex'],
2135                            ['==', '$type', 'Point'],
2136                            ['==', 'active', 'false'],
2137                            ['==', 'parent_active', 'true'],
2138                            ['!=', 'mode', 'static']
2139                        ],
2140                        'paint': {
2141                            'circle-radius': [
2142                                'interpolate', ['linear'], ['zoom'],
2143                                8, 3,
2144                                16, 9
2145                            ],
2146                            'circle-stroke-color': '#ffffff',
2147                            'circle-stroke-width': [
2148                                'interpolate', ['linear'], ['zoom'],
2149                                8, 1,
2150                                16, 2
2151                            ],
2152                            'circle-color': [
2153                                'case',
2154                                ['has','user_color'], ["get","user_color"],
2155                                ['has','color'], ["get","color"],
2156                                settings.hexColor
2157                            ],
2158                        }
2159                    },
2160                    {
2161                        'id': 'gl-draw-point-active',
2162                        'type': 'circle',
2163                        'filter': ['all',
2164                            ['==', '$type', 'Point'],
2165                            ['!=', 'meta', 'midpoint'],
2166                            ['==', 'active', 'true'],
2167                            ['==', 'parent_active', 'true']
2168                        ],
2169                        'paint': {
2170                            'circle-radius': [
2171                                'interpolate', ['linear'], ['zoom'],
2172                                8, 5,
2173                                16, 11
2174                            ],
2175                            'circle-stroke-color': '#ffffff',
2176                            'circle-stroke-width': 4,
2177                            'circle-color': [
2178                                'case',
2179                                ['has','user_color'], ["get","user_color"],
2180                                ['has','color'], ["get","color"],
2181                                settings.hexColor
2182                            ],
2183                        }
2184                    },
2185                    {
2186                        'id': 'gl-draw-start-marker',
2187                        'type': 'circle',
2188                        'layout': {
2189                            'visibility': 'visible'
2190                        },
2191                        'filter': ['all',
2192                            ['==', '$type', 'Point'],
2193                            ['has', 'coord_path'],
2194                            ['==', 'coord_path', '0'],
2195                            ['==', 'parent_active', 'false'],
2196                            ['==', 'meta', 'vertex']
2197                        ],
2198                        'paint': {
2199                            'circle-radius': 6,
2200                            'circle-stroke-width': 2,
2201                            'circle-stroke-color': [
2202                                'case',
2203                                ['has','user_color'], ["get","user_color"],
2204                                ['has','color'], ["get","color"],
2205                                settings.hexColor
2206                            ],
2207                            'circle-color': [
2208                                "let", "_rgba", ["to-rgba", ["to-color",
2209                                    [
2210                                        'case',
2211                                        ['has','user_color'], ["get","user_color"],
2212                                        ['has','color'], ["get","color"],
2213                                        settings.hexColor
2214                                    ]
2215                                ]],
2216                                [
2217                                    "rgba",
2218                                    ["number", ["at", 0, ["var", "_rgba"]]],
2219                                    ["number", ["at", 1, ["var", "_rgba"]]],
2220                                    ["number", ["at", 2, ["var", "_rgba"]]],
2221                                    .3
2222                                ]
2223                            ]
2224                        }
2225                    },
2226                    {
2227                        id: 'gl-draw-auto-vertex',
2228                        type: 'circle',
2229                        filter: ['all',
2230                            ['==', '$type', 'Point'],
2231                            ['==', 'meta', 'auto_vertex']
2232                        ],
2233                        paint: {
2234                            'circle-radius': 0.0001, // camouflage intégral
2235                            'circle-opacity': 0,
2236                            'circle-color': 'rgba(0,0,0,0)',
2237                            'circle-stroke-width': 0,
2238                            'circle-stroke-color': 'rgba(0,0,0,0)'
2239                        }
2240                    },
2241                    {
2242                        id: 'gl-draw-polygon-and-line-vertex-inactive-custom',
2243                        type: 'circle',
2244                        filter: ['==', 'meta', 'Point'],
2245                        paint: {
2246                            'circle-radius': 0.0001, // camouflage intégral
2247                            'circle-opacity': 0,
2248                            'circle-color': 'rgba(0,0,0,0)',
2249                            'circle-stroke-width': 0,
2250                            'circle-stroke-color': 'rgba(0,0,0,0)'
2251                        }
2252                    }
2253                ]
2254            });
2255            function addHoverRingSourceAndLayer() {
2256                if (_this.mapGl.getSource(HOVER_RING_SRC)) return; // déjà ajouté
2257                _this.mapGl.addSource(HOVER_RING_SRC, {
2258                    type: 'geojson',
2259                    data: { type: 'FeatureCollection', features: [] }
2260                });
2261
2262                _this.mapGl.addLayer({
2263                    id: HOVER_RING_LYR,
2264                    type: 'circle',
2265                    source: HOVER_RING_SRC,
2266                    paint: {
2267                        'circle-radius': [
2268                            'interpolate', ['linear'], ['zoom'],
2269                            8, 3,
2270                            16, 9
2271                        ],
2272                        'circle-stroke-color': '#FFFFFF',
2273                        'circle-stroke-width': 3,
2274                        'circle-color': [
2275                            "let", "_rgba", ["to-rgba", ["to-color",
2276                                [
2277                                    'case',
2278                                    ['has','user_color'], ["get","user_color"],
2279                                    ['has','color'], ["get","color"],
2280                                    settings.hexColor
2281                                ]
2282                            ]],
2283                            [
2284                                "rgba",
2285                                ["number", ["at", 0, ["var", "_rgba"]]],
2286                                ["number", ["at", 1, ["var", "_rgba"]]],
2287                                ["number", ["at", 2, ["var", "_rgba"]]],
2288                                .33
2289                            ]
2290                        ]
2291                    }
2292                });
2293
2294                _this.mapGl.moveLayer(HOVER_RING_LYR); // au-dessus des calques draw
2295            }
2296
2297            // La carte est peut-être déjà chargée (cas normal après waitForLoad de Visorandomap)
2298            if (_this.mapGl.isStyleLoaded()) {
2299                addHoverRingSourceAndLayer();
2300            } else {
2301                _this.mapGl.once('load', addHoverRingSourceAndLayer);
2302            }
2303
2304            function setHoverRing(lng, lat, color) {
2305                const src = _this.mapGl.getSource(HOVER_RING_SRC);
2306                if (!src) return;
2307                src.setData({
2308                    type: 'FeatureCollection',
2309                    features: [{
2310                        type: 'Feature',
2311                        geometry: { type: 'Point', coordinates: [lng, lat] },
2312                        properties: {
2313                            user_color: color || settings.hexColor
2314                        }
2315                    }]
2316                });
2317            }
2318            function clearHoverRing() {
2319                if (_this.mapGl.getSource(HOVER_RING_SRC)) _this.mapGl.getSource(HOVER_RING_SRC).setData({ type: 'FeatureCollection', features: [] });
2320            }
2321
2322            this.mapGl.on('mousemove', (e) => {
2323                if (_this.isBlockingOperationOnGoing()) {
2324                    _this.mapGl.getCanvas().style.cursor = 'wait';
2325                    return;
2326                }
2327
2328                function nearestSegmentAndPoint(map, coords, lngLat) {
2329                    const p = _this.mapGl.project(lngLat);
2330                    let best = { k: -1, px: 0, py: 0, d2: Infinity };
2331
2332                    for (let k = 0; k < coords.length - 1; k++) {
2333                        const a = _this.mapGl.project({lng: coords[k][0], lat: coords[k][1]});
2334                        const b = _this.mapGl.project({lng: coords[k + 1][0], lat: coords[k + 1][1]});
2335                        const abx = b.x - a.x, aby = b.y - a.y;
2336                        const apx = p.x - a.x, apy = p.y - a.y;
2337                        const ab2 = abx * abx + aby * aby || 1;
2338                        let t = (apx * abx + apy * aby) / ab2;
2339                        t = Math.max(0, Math.min(1, t));
2340                        const px = a.x + t * abx, py = a.y + t * aby;
2341                        const dx = p.x - px, dy = p.y - py, d2 = dx * dx + dy * dy;
2342                        if (d2 < best.d2) best = {k, px, py, d2};
2343                    }
2344
2345                    if (best.k < 0) return null;
2346                    const snap = _this.mapGl.unproject({ x: best.px, y: best.py });
2347                    return { lng: snap.lng, lat: snap.lat, d2px: best.d2 };
2348                }
2349                const mode = _this.drawGl?.getMode?.();
2350
2351                if (mode === 'draw_line_string') {
2352                    // Pas de hover en cours de dessin
2353                    _this.mapGl.getCanvas().style.cursor = 'crosshair';
2354                    _this.updateHelper();
2355                    return clearHoverRing();
2356                }
2357
2358                let feats = _this.mapGl.queryRenderedFeatures(e.point);
2359
2360                if (feats.length === 0) {
2361                    _this.mapGl.getCanvas().style.cursor = 'grab';
2362                    _this.updateHelper();
2363                    clearHoverRing();
2364                    return;
2365                }
2366
2367                const points = feats.filter(f =>
2368                    f?.properties?.meta === 'vertex' &&
2369                    (f.source && /mapbox-gl-draw/.test(f.source))
2370                );
2371
2372                _this.mapGl.getCanvas().style.cursor = 'pointer';
2373
2374                if (points.length) {
2375                    _this.helperTootlip.setContent(i18n("tooltip_msg_click_and_drag_to_move_or_del"))
2376                    clearHoverRing();
2377                    return;
2378                }
2379
2380                const featUnactive = feats.filter(f =>
2381                    f?.properties?.meta === 'feature' &&
2382                    (f?.geometry?.type === 'MultiLineString' || f?.geometry?.type === 'LineString') &&
2383                    (f?.layer?.id === 'gl-draw-line-inactive' || f?.layer?.id === 'gl-draw-line-inactive-outline') &&
2384                    f?.properties?.active === 'false' &&
2385                    (f.source && /mapbox-gl-draw/.test(f.source))
2386                );
2387                if (featUnactive.length) {
2388                    _this.helperTootlip.setContent(i18n("tooltip_msg_select_trace"))
2389                    _this.mapGl.getCanvas().style.cursor = 'default';
2390                }
2391
2392                feats = feats.filter(f =>
2393                    f?.properties?.meta === 'feature' &&
2394                    (f?.geometry?.type === 'MultiLineString' || f?.geometry?.type === 'LineString') &&
2395                    f?.layer?.id === 'gl-draw-line-active' &&
2396                    f?.properties?.active === 'true' &&
2397                    (f.source && /mapbox-gl-draw/.test(f.source))
2398                );
2399
2400                if (!feats.length) {
2401                    clearHoverRing(); return;
2402                }
2403
2404                const line = feats[0];
2405
2406                if (!line.geometry || (line.geometry.type !== 'MultiLineString' && line.geometry.type !== 'LineString')) {
2407                    clearHoverRing();
2408                    return;
2409                }
2410
2411                let coordinates = [];
2412                if (line.geometry.type === 'MultiLineString') {
2413                    coordinates = line.geometry.coordinates.reduce((acc, cur) => acc.concat(cur), []);
2414                } else {
2415                    coordinates = line.geometry.coordinates;
2416                }
2417
2418                const snap = nearestSegmentAndPoint(_this.mapGl, coordinates, e.lngLat);
2419                if (!snap) return clearHoverRing();
2420
2421                _this.helperTootlip.setContent(i18n("tooltip_msg_click_to_add_anchor_or_drag"))
2422                setHoverRing(snap.lng, snap.lat, line.properties.user_color);
2423            });
2424
2425            this.mapGl.on('mouseleave', clearHoverRing);
2426
2427            this.mapGl.on('draw.modechange', function (e) {
2428                if(settings.isSimpleVersion) {
2429                    if(e.mode == _this.drawGl.modes.DRAW_LINE_STRING
2430                        && _this.path.getAll().length >= 1) {
2431
2432                        iziToast.error({
2433                            message: i18n("toast_msg_simple_trace_limit"),
2434                            displayMode: 'replace',
2435                        })
2436
2437                        _this.drawGl.changeMode('simple_select', {
2438                            featureIds: _this.path.getAll()
2439                        })
2440
2441                        e.mode = _this.drawGl.modes.DRAW_LINE_STRING
2442
2443                    }else if( _this.path.getAll().length === 0
2444                        && e.mode == _this.drawGl.modes.SIMPLE_SELECT) {
2445
2446                        _this.drawGl.changeMode('draw_line_string')
2447                        e.mode = _this.drawGl.modes.DRAW_LINE_STRING
2448
2449                    }
2450                }
2451                _this.activeMode(e.mode)
2452            })
2453
2454            this.mapGl.on('draw.warning', function(e){
2455                iziToast.warning({
2456                    displayMode: 'replace',
2457                    message: e.msg
2458                })
2459                _this.updateGroupActions()
2460            })
2461
2462            var onConfirmDelete = function(msg, onConfirm, onReject){
2463                iziToast.show({
2464                    titleSize:'18px',
2465                    color: 'white',
2466                    closeOnClick: false,
2467                    progressBar: false,
2468                    drag: false,
2469                    timeout: false,
2470                    close: false,
2471                    overlay: true,
2472                    title: i18n("toast_title_confirmation"),
2473                    message: msg,
2474                    position: 'center',
2475                    buttons: [
2476                        [`<button class="btn s">${i18n("toast_button_yes")}</button>`,
2477                            function(instance, toast) {
2478                                if($.isFunction(onConfirm)){
2479                                    onConfirm.call(this, instance, toast)
2480                                }
2481                            },
2482                            true
2483                        ],
2484                        [`<button class="btn e">${i18n("toast_button_no")}</button>`,
2485                            function(instance, toast) {
2486                                if($.isFunction(onReject)){
2487                                    onReject.call(this, instance, toast)
2488                                }
2489                            },
2490                            false
2491                        ]
2492                    ]
2493                })
2494            }
2495
2496            this.mapGl.on('draw.vertex.confirm.delete', function (e) {
2497                // Filtrage : évite les points invalides (points fantômes ou hors bornes)
2498                e.points = e.points.filter(p =>
2499                    Array.isArray(_this.store[p.parent]) &&
2500                    _this.store[p.parent][p.index]
2501                );
2502
2503                if (e.points.length === 0) {
2504                    console.warn('[draw.vertex.confirm.delete] Aucun point valide à supprimer, annulation.');
2505                    return;
2506                }
2507
2508                let nbWpts = 0;
2509
2510                // Comptage des waypoints sécurisés
2511   
2511             e.points.forEach(p => {
2512                    const pt = _this.store[p.parent][p.index];
2513                    if (pt && +pt.wpt === 1) nbWpts++;
2514                });
2515
2516                const triggerDelete = function () {
2517
2518                    // Regroupement des points par tracé pour suppression
2519                    const formattedArr = {};
2520                    e.points.forEach(p => {
2521                        if (!formattedArr[p.parent]) {
2522                            formattedArr[p.parent] = [];
2523                        }
2524                        formattedArr[p.parent].push(p.index);
2525                    });
2526
2527                    // Envoi des events DELETE_VERTEX pour chaque trace
2528                    Object.entries(formattedArr).forEach(([parent, indexes]) => {
2529                        _this.drawGl.fireEvent('DELETE_VERTEX', {
2530                            points: indexes,
2531                            parent: parent
2532                        });
2533                    });
2534
2535                    _this.drawGl.renderCanvas();
2536                };
2537
2538                if (nbWpts > 0) {
2539                    let _confirmMsg = nbWpts === 1
2540                        ? i18n("toast_confirm_msg_one_waypoint")
2541                        : i18n("toast_confirm_msg_multiple_waypoints");
2542
2543                    onConfirmDelete(_confirmMsg,
2544                        function (inst, toast) {
2545                            triggerDelete();
2546                            inst.hide({ transitionOut: 'fadeOut' }, toast);
2547                        },
2548                        function (inst, toast) {
2549                            inst.hide({ transitionOut: 'fadeOut' }, toast);
2550                            _this.drawGl.renderCanvas();
2551                        });
2552
2553                } else {
2554                    triggerDelete();
2555                }
2556
2557            })
2558
2559            this.mapGl.on('draw.confirm.delete', function (e) {
2560
2561                onConfirmDelete(
2562                    sprintf(
2563                        i18n("toast_msg_confirm_delete_traces"),
2564                        e.countPath,
2565                        getS(e.countPath)
2566                    ),
2567                    function(inst, toast){
2568                        inst.hide({ transitionOut: 'fadeOut' }, toast);
2569                        _this.drawGl[e.action]({ confirm: false });
2570                    },
2571                    function(inst, toast){
2572                        inst.hide({ transitionOut: 'fadeOut' }, toast);
2573                        _this.drawGl.renderCanvas();
2574                        _this.updateGroupActions();
2575                    }
2576                )
2577
2578            })
2579
2580            this.mapGl.on('draw.delete', function (e) {
2581
2582                var ids=[];
2583
2584                for(var i=0; i < e.features.length; i++){
2585                    var id = e.features[i].id
2586                    // vérification si le tracé est dans le store
2587                    if(!_this.store.hasOwnProperty(id)){
2588                        continue;
2589                    }
2590                    ids.push(id)
2591                }
2592
2593                if(!ids.length){
2594                    if(settings.isSimpleVersion && !_this.path.getAll().length){
2595                        setTimeout(function(){
2596                            _this.drawGl.changeMode('draw_line_string')
2597                            _this.activeMode('draw_line_string')
2598                        }, 0)
2599                    }
2600                    return
2601                }
2602
2603                _this.markers.delete(Array.prototype.concat.apply([], ids.map(function(key){
2604                    return _this.store[key].filter(function(p,i){
2605                        return p.wpt == 1 || i==0 || i == _this.store[key].length-1
2606                    }).map(function(p){
2607                        return p.id
2608                    })
2609                })))
2610
2611                for(var j=0; j < ids.length; j++){
2612                    $.each(['infos', 'diagrams'], function(i, tab){
2613                        _this.tab.deleteSlide(tab, ids[j], _this.swiper[tab])
2614                    })
2615                }
2616
2617                // On attend pas la fin du traitement asynchrone à cause de la suppression des markers
2618                _this.path.deletePath(ids)
2619                // BUGFIX : Ne réinitialiser que si TOUS les tracés sont supprimés
2620                // Sinon, on conserve le contexte d'édition de la rando existante
2621                if (_this.path.getAll().length === 0) {
2622                    settings.idRando = 0;
2623                    settings.idRandoHash = '';
2624                }
2625                _this.updateGroupActions()
2626                _this.updateHelper()
2627                _this.storePathsLocalStorage()
2628
2629                // WORKAROUND (pas le choix): Forcer la ré-application des couleurs après suppression
2630                setTimeout(() => {
2631                    const savedColors = JSON.parse(localStorage.getItem('visorando_paths_colors') || '{}');
2632                    const remainingPathIds = _this.path.getAll({ saved: true });
2633                    remainingPathIds.forEach(pathId => {
2634                        if (savedColors[pathId]) {
2635                            _this.changePathColor(pathId, savedColors[pathId]);
2636                        }
2637                    });
2638                    if (_this.swiper.infos && _this.swiper.infos.pagination) {
2639                        _this.swiper.infos.pagination.update();
2640                    }
2641                    if (_this.swiper.diagrams && _this.swiper.diagrams.pagination) {
2642                        _this.swiper.diagrams.pagination.update();
2643                    }
2644                    _this.drawGl.renderCanvas();
2645                }, 0);
2646
2647                iziToast.success({
2648                    displayMode: 'replace',
2649                    message: sprintf(
2650                        i18n("toast_msg_traces_deleted"),
2651                        ids.length,
2652                        getS(ids.length),
2653                        getS(ids.length)
2654                    )
2655                })
2656
2657                if(settings.isSimpleVersion && !_this.path.getAll().length){
2658                    _this.drawGl.changeMode('draw_line_string')
2659                    _this.activeMode('draw_line_string')
2660                }
2661
2662                _this.history.operation(function () {
2663                    return {
2664                        store: Immutable.fromJS(_this.store),
2665                        action: 'deletePath'
2666                    };
2667                }, sprintf(
2668                    i18n("history_msg_delete_traces"),
2669                    ids.length,
2670                    getS(ids.length)
2671                ));
2672            })
2673            this.setupVertexCreateHandler();
2674
2675            this.mapGl.on('draw.vertex.delete', async function (e) {
2676                if (_this.vertexDeleteOnGoing) return;
2677                _this.vertexDeleteOnGoing = true;
2678                _this.beginBlockingOperation();
2679
2680                var ids = [];
2681
2682                try {
2683                    for (const [index, i] of e.points.entries()) {
2684                        if(!_this.store[e.parent]){
2685                            return
2686                        }
2687
2688                        var point = _this.store[e.parent][i];
2689                        if (_get(point, 'id')) {
2690                            const anchorIndices = _this.path.computeAnchorIndicesFromStore(e.parent);
2691
2692                            const prevAnchor = _this.path.prevAnchor(i, anchorIndices);
2693                            const nextAnchor = _this.path.nextAnchor(i, anchorIndices, _this.store[e.parent].length - 1);
2694
2695                            if (prevAnchor === Number(i)) {
2696                                // Si on supprime le premier point, on doit supprimer tous les points jusqu'à la prochaine ancre
2697                                for (let j = nextAnchor - 1; j >= i; j--) {
2698                                    ids.push(_this.store[e.parent][j].id);
2699                                    _this.drawGl.deletePoint(e.parent, j);
2700                                    _this.path.deletePoint(e.parent, j);
2701                                }
2702                            } else if (nextAnchor === Number(i)) {
2703                                // Si on supprime le dernier point, on doit supprimer tous les points jusqu'à l'avant-dernière ancre
2704                                for (let j = i; j > prevAnchor; j--) {
2705                                    ids.push(_this.store[e.parent][j].id);
2706                                    _this.drawGl.deletePoint(e.parent, j);
2707                                    _this.path.deletePoint(e.parent, j);
2708                                }
2709                            } else if (_this.autoTracingEnabled) {
2710                                // MODE AUTO : retracage via routing
2711                                ids.push(_this.store[e.parent][i].id);
2712                                _this.path.deletePoint(e.parent, i);
2713                                // Avoid an intermediate straight-line render from MapboxDraw; autoTraceSegment rewrites Draw from _this.store.
2714                                await _this.autoTraceSegment(e.parent, prevAnchor, nextAnchor - 1, null, true, true);
2715                            } else {
2716                                // MODE MANUEL : relier prevAnchor -> nextAnchor en ligne droite
2717                                ids.push(_this.store[e.parent][i].id);
2718                                _this.drawGl.deletePoint(e.parent, i);
2719                                _this.path.deletePoint(e.parent, i);
2720                                _this.manualReplotSegment(e.parent, prevAnchor, nextAnchor - 1, ids);
2721                            }
2722                        }
2723                    }
2724
2725                    var doneFunc = async function(){
2726                        if(ids.length){
2727                            _this.markers.delete(ids);
2728                            _this.path.handleMarkersPath(true);
2729
2730                            iziToast.success({
2731                                message: sprintf(
2732                                    i18n("toast_msg_points_deleted"),
2733                                    ids.length,
2734                                    getS(ids.length),
2735                                    getS(ids.length)
2736                                ),
2737                                displayMode: 'replace'
2738                            })
2739
2740                            _this.path.computeOrder(e.parent)
2741                            await _this.updateTracePostOperation(e.parent)
2742
2743                            _this.history.operation(function () {
2744                                return {
2745                                    store: Immutable.fromJS(_this.store),
2746                                    action: 'deletePoint'
2747                                };
2748                            }, sprintf(
2749                                i18n("history_msg_delete_points"),
2750                                ids.length,
2751                                getS(ids.length)
2752                            ));
2753
2754                            if(settings.isSimpleVersion && !_this.path.getAll().length){
2755                                _this.drawGl.changeMode('draw_line_string');
2756                                _this.activeMode('draw_line_string');
2757                            }
2758                        }else{
2759                            if(settings.isSimpleVersion && !_this.path.getAll().length){
2760                                setTimeout(function(){
2761                                    _this.drawGl.changeMode('draw_line_string')
2762                                    _this.activeMode('draw_line_string')
2763                                }, 0)
2764                            }
2765                        }
2766                    }
2767
2768                    await doneFunc()
2769                } finally {
2770                    _this.vertexDeleteOnGoing = false;
2771                    _this.endBlockingOperation();
2772                }
2773            })
2774
2775            this.mapGl.on('draw.vertex.update', async function (e) {
2776                await _this.runBlockingOperation(async function() {
2777                    const indexes = [];
2778                    const skipAutoTrace = e.skipAutoTrace === true;
2779                    for (const [index, i] of e.index.entries()) {
2780                        const point = _this.store[e.parent][i];
2781                        indexes.push(i);
2782                        point.lat = Number(e.points[index][1].toFixed(6));
2783                        point.lng = Number(e.points[index][0].toFixed(6));
2784                        if (_this.autoTracingEnabled && !skipAutoTrace) {
2785                            const anchorIndices = _this.path.computeAnchorIndicesFromStore(e.parent);
2786                            if (i == (_this.store[e.parent].length-1)) {
2787                                await _this.autoTraceSegment(e.parent, _this.path.prevAnchor(i, anchorIndices), i, null, true, false);
2788                            } else if (i == 0) {
2789                                await _this.autoTraceSegment(e.parent, i, _this.path.nextAnchor(i, anchorIndices, _this.store[e.parent].length - 1), null, false, true);
2790                            } else {
2791                                await _this.autoTraceSegment(e.parent, _this.path.prevAnchor(i, anchorIndices), _this.path.nextAnchor(i, anchorIndices, _this.store[e.parent].length - 1), i, true, true);
2792                            }
2793                        }
2794                    }
2795
2796                    if (indexes.length) {
2797                        _this.path.handleMarkersPath(true);
2798                        if (!_this.autoTracingEnabled || skipAutoTrace) {
2799                            await _this.getAltitudeForPoints(e.parent, indexes);
2800                        }
2801                        await _this.updateTracePostOperation(e.parent);
2802
2803                        _this.history.operation(function () {
2804                            return {
2805                                store: Immutable.fromJS(_this.store),
2806                                action: 'updatePoint'
2807                            };
2808                        }, sprintf(
2809                            i18n("history_msg_update_points"),
2810                            indexes.length,
2811                            getS(indexes.length)
2812                        ));
2813                    }
2814                });
2815            })
2816
2817            $.each($.merge(settings.groupActions.find('a'), $('#reset, #center')), function(){
2818
2819                _this.markers.deleteById(null, true, 'here-indicator')
2820                var _that = this
2821                if($(this).is('[id]')){
2822                    $(this).on('click', async function (e, payload) {
2823
2824                        e.preventDefault()
2825                        e.stopPropagation()
2826
2827                        if ($(this).is('[disabled]')) {
2828                            return
2829                        }
2830
2831                        var isHistory = $(this).is('#undo, #redo'),
2832                            showHistoryToast = function (t) {
2833                                iziToast.info({
2834                                    id: 'history',
2835                                    class: 'history',
2836                                    displayMode: 'once',
2837                                    timeout: t,
2838                                    message: i18n("toast_msg_sync_pending")
2839                                })
2840                            }
2841
2842                        // toutes les actions sauf undo & redo pendant une mutation en back de l'historique
2843                        if (!isHistory && settings.mapEl.hasClass('loading')) {
2844                            showHistoryToast(4000)
2845                            return
2846                        }
2847
2848                        if (!isHistory && _this.isBlockingOperationOnGoing()) {
2849                            iziToast.info({
2850                                message: i18n("toast_msg_action_pending"),
2851                                displayMode: 'replace',
2852                                timeout: 2000
2853                            })
2854                            return
2855                        }
2856
2857                        if ($.ajaxq.isRunning('store')) {
2858
2859                            if (isHistory) {
2860
2861                                showHistoryToast(false)
2862
2863                                $.ajaxq.getActiveRequest('store').done(function () {
2864                                    if ($.ajaxq.getCountPendingRequest('store') === 0) {
2865                                        iziToast.hide({transitionOut: 'fadeOut'}, document.querySelector('.iziToast.history'))
2866                                    }
2867                                    $(_that).trigger('click')
2868                                })
2869                                return
2870
2871                            } else {
2872
2873                                iziToast.info({
2874                                    message: i18n("toast_msg_action_pending"),
2875                                    displayMode: 'replace',
2876                                    timeout: 2000
2877                                })
2878                                return
2879
2880                            }
2881                        }
2882
2883                        if (_this.actions.hasOwnProperty(this.id)) {
2884                            if (_this.actions[this.id].constructor.name === 'AsyncFunction') {
2885                                await _this.runBlockingOperation(() => _this.actions[this.id].call(this, e, payload));
2886                            } else {
2887                                _this.actions[this.id].call(this, e, payload)
2888                            }
2889                            _this.updateHelper()
2890                            _this.updateGroupActions()
2891                        }
2892                    })
2893                }
2894            })
2895
2896            var mapReady = this.addAsyncTask(); // permet de savoir quand est-ce que la map est prête
2897
2898            // Note : highcharts.stock.min.js est chargé de manière synchrone dans le template HTML
2899            // (avant visorando-map.umd.js), comme dans showCarteDiagramme2.php.
2900
2901            // Migration MapController : la carte est déjà chargée après waitForLoad()
2902            // L'événement 'styledata' ne se déclenchera plus au démarrage → résoudre mapReady immédiatement
2903            if (_this.mapGl.isStyleLoaded()) {
2904                mapReady.resolve();
2905            } else {
2906                // Fallback défensif si le style n'est pas encore prêt (ne devrait pas arriver)
2907                _this.mapGl.once('styledata', function () {
2908                    var waiting = function () {
2909                        if (!_this.mapGl.isStyleLoaded()) {
2910                            setTimeout(waiting, 50);
2911                        } else {
2912                            mapReady.resolve();
2913                        }
2914                    };
2915                    waiting();
2916                });
2917            }
2918
2919
2920            mapReady.done(function(){
2921                // On met à jour l'url pour que si l'utilisateur fait un F5, il retrouve ses tracés
2922                if(History.enabled){
2923                    if(settings.idRando){
2924
2925                        iziToast.info({
2926                            message: i18n("toast_msg_loading_from_existing"),
2927                            timeout: 2000
2928                        })
2929
2930                    }
2931                }else{
2932                    iziToast.warning({
2933                        message: i18n("toast_msg_history_api_unsupported"),
2934                        timeout: false
2935                    })
2936                }
2937
2938                var canvas = _this.mapGl.getCanvas()
2939
2940                _this.helperTootlip = tippy(settings.mapEl.get(0), {
2941                    content: i18n("tooltip_msg_help_enabled"),
2942                    allowHTML: true,
2943                    offset: [0, 19],
2944                    followCursor: true,
2945                    triggerTarget: canvas,
2946                    placement: 'right'
2947                })
2948            })
2949
2950            _this.mapGl.loadImage('/img/fiches/direction-symbol.png', function(error, image) {
2951
2952                if (error) return;
2953
2954                var layerDirectionOpts = {
2955                    type: 'symbol',
2956                    filter: ['all',
2957                        ['==', 'active', 'false'],
2958                        ['==', '$type', 'LineString'],
2959                        ['!=', 'mode', 'static']
2960                    ],
2961                    layout: {
2962                        'symbol-placement': 'line',
2963                        'symbol-spacing': [
2964                            'interpolate',
2965                            ['exponential', 0.5],
2966                            ['zoom'],
2967                            10,
2968                            140,
2969                            18,
2970                            200
2971                        ],
2972                        'icon-allow-overlap': true,
2973                        'icon-image': 'direction',
2974                        'icon-size': .27
2975                    },
2976                    paint: {
2977                        'icon-color': [
2978                            'case',
2979                            ['has', 'user_color'], ['get', 'user_color'],
2980                            ['has', 'color'], ['get', 'color'],
2981                            settings.hexColor
2982                        ],
2983                        'icon-opacity': .7
2984                    }
2985                }
2986
2987                _this.mapGl.addImage('direction', image, {sdf: true});
2988
2989                var catchSourceExist = function(){
2990
2991                    if(typeof _this.mapGl.getSource('mapbox-gl-draw') === 'undefined'){
2992                        setTimeout(catchSourceExist, 200);
2993                    }else{
2994                        _this.mapGl.addLayer(Object.assign({}, {
2995                            id: 'direction',
2996                            source: 'mapbox-gl-draw'
2997                        }, layerDirectionOpts));
2998                    }
2999
3000                }
3001
3002                catchSourceExist()
3003
3004            })
3005
3006            this.mapGl.once('mousemove', function() {
3007                $('.mouse-coordinates-indicator').removeClass('hidden')
3008            });
3009
3010            // Affichage de la position et de l'altitude de la souris dans l'indicateur en haut à droite
3011            (function() {
3012                var getElevationAtPoint = (VisorandoMap && VisorandoMap.getElevationAtPoint) ? VisorandoMap.getElevationAtPoint : null;
3013                var lastElevTime = 0;
3014                var pendingElev = null;
3015                var elevAnimFrame = null;
3016                var currentLat = null;
3017                var currentLng = null;
3018
3019                var updateAlt = function(lat, lng) {
3020                    if (!getElevationAtPoint) return;
3021                    var zoom = Math.min(Math.floor(_this.mapGl.getZoom()), 17);
3022                    getElevationAtPoint(lat, lng, zoom).then(function(elevation) {
3023                        if (lat !== currentLat || lng !== currentLng) return;
3024                        var $altEl = $('.mouse-coordinates-indicator .ci-alt');
3025                        if (elevation !== null && !isNaN(elevation)) {
3026                            var alt = _this.convertAltitude(elevation);
3027                            $altEl.text('Alt\u00a0: ' + Math.round(alt) + '\u00a0' + settings.currentUnits.altitude);
3028                        } else {
3029                            $altEl.text('Alt\u00a0: —');
3030                        }
3031                    }).catch(function() { /* silent */ });
3032                };
3033
3034                _this.mapGl.on('mousemove', function(e) {
3035                    currentLat = e.lngLat.lat;
3036                    currentLng = e.lngLat.lng;
3037
3038                    var $indicator = $('.mouse-coordinates-indicator');
3039                    if ($indicator.hasClass('hidden')) $indicator.removeClass('hidden');
3040
3041                    var coordText = _this.getFormattedCoordinates({ lat: currentLat, lng: currentLng });
3042                    var altInit = getElevationAtPoint ? 'Alt\u00a0: \u2026' : '';
3043                    $indicator.html(
3044                        '<span class="ci-coords">' + coordText + '</span>' +
3045                        (altInit ? '<span class="ci-alt">' + altInit + '</span>' : '')
3046                    );
3047
3048                    if (!getElevationAtPoint) return;
3049
3050                    // Throttle les requêtes altitude à 100 ms max
3051                    var now = Date.now();
3052                    if (now - lastElevTime >= 100) {
3053                        lastElevTime = now;
3054                        updateAlt(currentLat, currentLng);
3055                    } else {
3056                        pendingElev = { lat: currentLat, lng: currentLng };
3057                        if (elevAnimFrame !== null) cancelAnimationFrame(elevAnimFrame);
3058                        elevAnimFrame = requestAnimationFrame(function() {
3059                            if (pendingElev) {
3060                                var p = pendingElev;
3061                                lastElevTime = Date.now();
3062                                updateAlt(p.lat, p.lng);
3063                                pendingElev = null;
3064                            }
3065                            elevAnimFrame = null;
3066                        });
3067                    }
3068                });
3069
3070                _this.mapGl.on('mouseout', function() {
3071                    if (elevAnimFrame !== null) {
3072                        cancelAnimationFrame(elevAnimFrame);
3073                        elevAnimFrame = null;
3074                    }
3075                    pendingElev = null;
3076                    currentLat = null;
3077                    currentLng = null;
3078                    $('.mouse-coordinates-indicator').text('n.c');
3079                });
3080            })();
3081
3082            var handleMarkersVisibility = function(){
3083                if(_this.drawGl.getMode() == 'draw_line_string') return
3084                var state = _this.drawGl.getState()
3085                if(state.selectedCoordPaths.length){
3086                    setTimeout(function(){
3087                        if($.ajaxq.isRunning('store')){
3088                            $.ajaxq.getActiveRequest('store').always(function () {
3089                                settings.mapEl.removeClass('hide-markers')
3090                            })
3091                        }else{
3092                            settings.mapEl.removeClass('hide-markers')
3093                        }
3094                    }, 0)
3095                }
3096            }
3097
3098            // MapLibre GL v5 : map.on() retourne void — chaînage impossible, appels séparés
3099            this.mapGl.on('mousemove', function (){
3100                if(_this.drawGl.getMode() == 'draw_line_string') return
3101                var state = _this.drawGl.getState()
3102                if(state.selectedCoordPaths.length && state.dragMoving && state.canDragMove){
3103                    settings.mapEl.addClass('hide-markers')
3104                }
3105            })
3106            this.mapGl.on('touchmove', function (){
3107                if(_this.drawGl.getMode() == 'draw_line_string') return
3108                var state = _this.drawGl.getState()
3109                if(state.selectedCoordPaths.length && state.dragMoving && state.canDragMove){
3110                    settings.mapEl.addClass('hide-markers')
3111                }
3112            })
3113            this.mapGl.on('mouseup', handleMarkersVisibility)
3114            this.mapGl.on('touchend', handleMarkersVisibility)
3115
3116            this.mapGl.on('click', _this.updateHelper)
3117            this.mapGl.on('touchstart', _this.updateHelper)
3118
3119            this.mapGl.on('draw.selectionchange', function(features){
3120                if (features.features !== undefined && features.features.length > 0) {
3121                    let color = features.features[0].properties.color || features.features[0].properties.user_color;
3122                    $('#color-picker-dropdown ul li').removeClass('active');
3123                    $('#color-picker-dropdown ul li a').each(function() {
3124                        if ($(this).data('color') === color) {
3125                            $(this).parent().addClass('active');
3126                            $('#color-picker-dropdown .dropdown-toggle .color-circle').
3126css('background-color', color);
3127                        }
3128                    });
3129                }
3130                _this.updateHelper()
3131                $.each(['infos', 'diagrams'], function(i, tab){
3132                    _this.tab.handleSelection(tab, _this.swiper[tab])
3133                })
3134            })
3135
3136            // Version la plus simple possible utilisant uniquement l'API publique
3137            this.drawGl.setFeatureCoordinates = function(id, coords) {
3138                try {
3139                    // Récupérer le feature original
3140                    const feature = this.get(id);
3141                    if (!feature) return false;
3142
3143                    // Sauvegarder le mode et les propriétés
3144                    const wasInDirectSelect = this.getMode() === 'direct_select';
3145                    const properties = {...feature.properties};
3146
3147                    // Créer une copie du feature avec les nouvelles coordonnées
3148                    const updatedFeature = {
3149                        id: id,
3150                        type: 'Feature',
3151                        properties: properties,
3152                        geometry: {
3153                            type: 'LineString',
3154                            coordinates: coords
3155                        }
3156                    };
3157
3158                    // Utiliser la méthode add qui remplacera automatiquement le feature existant
3159                    this.add(updatedFeature);
3160
3161                    // Restaurer le mode d'édition
3162                    if (wasInDirectSelect) {
3163                        setTimeout(() => {
3164                            this.changeMode('direct_select', { featureId: id });
3165                        }, 20);
3166                    }
3167
3168                    return true;
3169                } catch (err) {
3170                    console.error("Erreur dans setFeatureCoordinates:", err);
3171                    return false;
3172                }
3173            };
3174
3175            this.mapGl.addControl(this.drawGl);
3176
3177            // ===================================================================
3178            // COMPAT iOS: mapbox-gl-draw touch → mouse bridge
3179            // mapbox-gl-draw has two interaction paths:
3180            //   - Mouse (mousedown/mouseup): ALWAYS works, even on empty space
3181            //     (no feature required at the click point).
3182            //   - Touch (touchstart/touchend): ONLY works if a draw feature
3183            //     exists under the finger (queryRenderedFeatures in z.touch).
3184            //     → The first point of a trace is NEVER placed through this path.
3185            //
3186            // On iOS, synthetic mouse events from touch may not be delivered
3187            // (touch-action:none, pointer events, etc.).
3188            // This bridge intercepts touch pointer events on the canvas and fires
3189            // mousedown/mouseup events on the map to use the mouse path of
3190            // mapbox-gl-draw, which always works.
3191            // ===================================================================
3192            (function(map, draw) {
3193                // Only needed on iOS where touch-action:none prevents synthetic mouse events
3194                var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
3195                if (!isIOS) return;
3196
3197                var canvas = map.getCanvas();
3198                var downEvent = null; // last touch pointerdown
3199
3200                function buildMapEvent(type, e) {
3201                    var rect = canvas.getBoundingClientRect();
3202                    var x = e.clientX - rect.left;
3203                    var y = e.clientY - rect.top;
3204                    var point = new maplibregl.Point(x, y);
3205                    var lngLat = map.unproject(point);
3206                    return {
3207                        type: type,
3208                        point: point,
3209                        lngLat: lngLat,
3210                        originalEvent: e,
3211                        target: map,
3212                        preventDefault: function() { e.preventDefault(); },
3213                        defaultPrevented: false
3214                    };
3215                }
3216
3217                canvas.addEventListener('pointerdown', function(e) {
3218                    if (e.pointerType !== 'touch') return;
3219                    downEvent = { pointerId: e.pointerId, time: Date.now(), x: e.clientX, y: e.clientY };
3220                    // Fire mousedown on the map → draw records r = {time, point}
3221                    map.fire('mousedown', buildMapEvent('mousedown', e));
3222                }, { passive: true });
3223
3224                canvas.addEventListener('pointermove', function(e) {
3225                    if (e.pointerType !== 'touch' || !downEvent) return;
3226                    if (e.pointerId !== downEvent.pointerId) return;
3227                    // Fire mousemove so draw can detect a drag
3228                    map.fire('mousemove', buildMapEvent('mousemove', e));
3229                }, { passive: true });
3230
3231                canvas.addEventListener('pointerup', function(e) {
3232                    if (e.pointerType !== 'touch' || !downEvent) return;
3233                    if (e.pointerId !== downEvent.pointerId) return;
3234                    // Fire mouseup → draw detects a click (distance < 4px, duration < 500ms)
3235                    // and calls a.click(e) → clickAnywhere → adds a point
3236                    map.fire('mouseup', buildMapEvent('mouseup', e));
3237                    downEvent = null;
3238                });
3239
3240                canvas.addEventListener('pointercancel', function(e) {
3241                    if (e.pointerType !== 'touch') return;
3242                    downEvent = null;
3243                });
3244            })(this.mapGl, this.drawGl);
3245
3246            // COMPAT: mapbox-gl-draw vérifie classList[0]==="mapboxgl-canvas" dans son keydown handler,
3247            // mais MapLibre utilise "maplibregl-canvas" → la détection clavier native de la lib est cassée.
3248            // On reproduit ici le comportement exact du keydown de la lib (setState keyCode + trash confirm).
3249            $(document).on('keydown.draw-delete', function(e) {
3250                if ($(e.target).is(':input')) return;
3251                var kc = e.keyCode;
3252                if (kc === 46 || kc === 8) { // Delete ou Backspace
3253                    e.preventDefault();
3254                    if (_this.vertexDeleteOnGoing || _this.isBlockingOperationOnGoing()) return;
3255                    var state = _this.drawGl.getState();
3256                    if (state) state.keyCode = kc;
3257                    _this.drawGl.trash({ confirm: true });
3258                }
3259            });
3260
3261            // Carte deja chargee apres waitForLoad() - executer le code d'init post-load
3262            _onMapLoaded();
3263
3264            // -------------------------------------------------------------------
3265            // Maintenir les couches MapboxDraw (gl-draw-*) toujours au-dessus
3266            // des overlays ajoutés dynamiquement par VisorandoMap.
3267            // styledata est émis après chaque modification de style (addLayer, moveLayer…).
3268            // Le debounce via requestAnimationFrame évite les appels répétés sur un même frame.
3269            // -------------------------------------------------------------------
3270            const _keepDrawLayersOnTop = (() => {
3271                let pending = false;
3272                return () => {
3273                    if (pending) return;
3274                    pending = true;
3275                    requestAnimationFrame(() => {
3276                        pending = false;
3277                        const style = _this.mapGl.getStyle?.();
3278                        if (!style?.layers) return;
3279                        style.layers
3280                            .filter(l => l.id.startsWith('gl-draw-'))
3281                            .forEach(l => {
3282                                if (_this.mapGl.getLayer(l.id)) {
3283                                    _this.mapGl.moveLayer(l.id);
3284                                }
3285                            });
3286                    });
3287                };
3288            })();
3289            _this.mapGl.on('styledata', _keepDrawLayersOnTop);
3290
3291            // NavigationControl et FullscreenControl geres par VisorandoMap
3292            // NavigationControl et FullscreenControl geres par VisorandoMap (showControls par defaut)
3293
3294            const $geoloc = $(settings.searchLocForm).find('.btn-geoloc');
3295            const geolocate = new maplibregl.GeolocateControl({
3296                positionOptions: {
3297                    enableHighAccuracy: true
3298                },
3299                trackUserLocation: false
3300            });
3301            this.mapGl.addControl(geolocate); // requis pour brancher tout l'écosystème
3302            $geoloc.on('click', (e) => {
3303                e.preventDefault();
3304                $geoloc.addClass('btn-loading');
3305                geolocate.trigger();
3306            });
3307            geolocate.on('geolocate', e => {
3308                $geoloc.removeClass('btn-loading geoloc-error').addClass('geoloc-enabled');
3309            });
3310            geolocate.on('error', (e) => {
3311                $geoloc.removeClass('btn-loading geoloc-enabled').addClass('geoloc-error');
3312            });
3313
3314            this.mapGl.addControl({
3315                onAdd: function() {
3316                    var isOpen = false;
3317                    var btn = document.createElement('button');
3318                    btn.className = 'maplibregl-ctrl-icon mapboxgl-ctrl-help';
3319                    btn.type = 'button';
3320                    btn.setAttribute('title', 'Aide contextuelle');
3321                    btn.setAttribute('aria-label', 'Aide contextuelle');
3322                    btn.onclick = function() {
3323                        isOpen
3324                            ? (document.body.classList.remove('panel-opened'), btn.classList.remove('active'))
3325                            : (document.body.classList.add('panel-opened'), btn.classList.add('active'));
3326                        isOpen = !isOpen;
3327                    };
3328                    var container = document.createElement('div');
3329                    container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
3330                    container.appendChild(btn);
3331                    return container;
3332                },
3333                onRemove: function() {}
3334            });
3335
3336            // AttributionControl, ScaleControl et drawGl (doublon) supprimes - geres par VisorandoMap
3337
3338            settings.bottomRightBar.find('.switch-handler').on('click', function(){
3339                var el = $(this).closest(settings.bottomRightBar.selector)
3340                el.toggleClass('pushed-down')
3341                _this.setCookie({ 'hide-tabs': el.is('.pushed-down') })
3342            })
3343
3344            _this.swiper.diagrams = new Swiper(settings.bottomRightBar.find('.swiper-diagrams'), {
3345                autoHeight: true,
3346                effect: 'fade',
3347                fadeEffect: {
3348                    crossFade: true
3349                },
3350                pagination: {
3351                    el: '.swiper-pagination',
3352                    clickable: true,
3353                    renderBullet: function(i, className){
3354                        var $bullet = $('<span />', { class: className })
3355
3356                        if($(this.slides[i]).is('[id]')){
3357                                var pathId = $(this.slides[i]).attr('id').split('-').pop()
3358                                    var feature = _this.drawGl.get(pathId)
3359
3360                            if (feature && feature.properties) {
3361                                const bulletColor = feature.properties.user_color || feature.properties.color || settings.hexColor;
3362                                $bullet.css('background-color', bulletColor)
3363                                    .attr('data-path-id', pathId);
3364                            }
3365                        }
3366
3367                        return $bullet.prop('outerHTML');
3368                    }
3369                },
3370                mousewheel: {
3371                    forceToAxis: false
3372                },
3373                on: {
3374                    transitionEnd: function(){
3375                        this.updateAutoHeight(0)
3376                        _this.swiper.tabs.updateAutoHeight(200)
3377                    }
3378                }
3379            })
3380
3381            _this.swiper.tabs = new Swiper(settings.bottomRightBar.find('.swiper-tabs'), {
3382                autoHeight: true,
3383                on: {
3384                    init: function () {
3385                        _this.swiper.infos = new Swiper(settings.bottomRightBar.find('.swiper-container.swiper-infos'), {
3386                            autoHeight: true,
3387                            effect: 'fade',
3388                            nested: true,
3389                            fadeEffect: {
3390                                crossFade: true
3391                            },
3392                            pagination: {
3393                                el: '.swiper-pagination',
3394                                clickable: true,
3395                                renderBullet: function(i, className) {
3396                                    const $bullet = $('<span />', { class: className });
3397                                    const slide = this.slides[i];
3398
3399                                    if (!$(slide).is('[id]')) {
3400                                        return $bullet.prop('outerHTML');
3401                                    }
3402
3403                                    const pathId = $(slide).attr('id').split('-').pop();
3404                                    const feature = _this.drawGl.get(pathId);
3405
3406                                    if (!feature || !feature.properties) {
3407                                        return $bullet.prop('outerHTML');
3408                                    }
3409
3410                                    // Détermine la couleur à appliquer. La priorité est donnée à `user_color` (choix manuel)
3411                                    // sur `color` (couleur automatique), avec une couleur par défaut en dernier recours.
3412                                    const bulletColor = feature.properties.user_color || feature.properties.color || settings.hexColor;
3413
3414                                    $bullet.css('background-color', bulletColor).attr('data-path-id', pathId);
3415
3416                                    return $bullet.prop('outerHTML');
3417                                }
3418                            },
3419                            mousewheel: {
3420                                forceToAxis: false
3421                            }
3422                        })
3423
3424                        // Synchro des deux instances
3425                        _this.swiper.infos.controller.control = _this.swiper.diagrams
3426                        _this.swiper.diagrams.controller.control = _this.swiper.infos
3427
3428                        var selectPathOnPaginationClick = function(instance){
3429                            _this.drawGl.changeMode('simple_select', {
3430                                featureIds: [$(instance.pagination.bullets).eq(instance.activeIndex).data('path-id')]
3431                            })
3432                            _this.activeMode('simple_select')
3433                        }
3434
3435                        _this.swiper.diagrams.on('paginationClick', selectPathOnPaginationClick)
3436                        _this.swiper.infos.on('paginationClick', selectPathOnPaginationClick)
3437
3438                    }
3439                }
3440            })
3441
3442            $('.js-cd-close', 'body').on('click', function(e){
3443                e.preventDefault()
3444                $('.mapboxgl-ctrl-help').trigger('click')
3445            })
3446
3447            $('.expand-handler', 'body').on('click', function(e){
3448
3449                var el = $(this).next('.expand-content')
3450                el.toggleClass('s')
3451                $(this).toggleClass('active')
3452
3453                if (el.is('.s'))
3454                    el.slideDownTransition(true);
3455                else
3456                    el.slideUpTransition(true);
3457            })
3458
3459            settings.bottomRightBar.find('.tab').on('click', function(){
3460                _this.swiper.tabs.slideTo($(this).index())
3461                _this.markers.deleteById(null, true, 'here-indicator')
3462            })
3463
3464            _this.swiper.tabs.on('slideChangeTransitionEnd', function(){
3465                settings.bottomRightBar.find('.tab').eq(_this.swiper.tabs.realIndex).siblings().removeClass('active')
3466                    .end().addClass('active')
3467            })
3468
3469            // quand toutes les tâches sont terminées
3470            $.when.apply($, this.asyncTasks).done(function() {
3471                if(settings.isCompatible){
3472                    settings.mapEl.removeClass('loading') // bye bye le loader
3473                }else{
3474                    settings.mapEl.addClass('freeze')
3475                    iziToast.warning({
3476                        message: i18n("toast_msg_browser_incompatible"),
3477                        timeout: false,
3478                        overlay: true,
3479                        close: false,
3480                        closeOnClick: false,
3481                        drag: false,
3482                        position: 'center'
3483                    })
3484                }
3485
3486                settings.mapEl.addClass('done')
3487
3488                settings.topBar.prependTo(settings.mapEl)
3489                _this.asyncTasks = [] // clean tasks
3490
3491                _this.activeMode(_this.drawGl.getMode())
3492
3493                $('body').addClass(settings.isSimpleVersion ? 'simple-version' : 'advanced-version')
3494                if(settings.user.showPub){
3495                    $('body').addClass('d-pub')
3496                }
3497
3498                // Si version simple, aide activée + affichage départs
3499                if(settings.isSimpleVersion){
3500                    // L'aide dégrade l'UX plutot que l'inverse sur mobile...
3501                    if(settings.deviceType != 'mobile'){
3502                        _this.activeAction('help')
3503                    } else {
3504                        _this.helperTootlip.disable()
3505                        tippySingleton.disable()
3506                    }
3507                }else{
3508                    // Si version avancée, mesure en temps réel activée
3509                    _this.activeAction('tape-measure');
3510                    _this.helperTootlip.disable()
3511                    tippySingleton.disable()
3512                }
3513
3514                _this.swiper.diagrams.placeHolder =
3515                    $(_this.swiper.diagrams.$wrapperEl).find('#placeholder-diagram').clone()
3516
3517                $(window).trigger('resize')
3518
3519                // Quand on n'a pas snowpack/webpack ...
3520                if(!$('head').find('#current-layer').length){
3521                    $('head').append('<style type="text/css" id="current-layer" >
3521.cd-panel .legend .icon{background-image: url('+settings.baseUrl+'templatesR/visorando/images/software/layers-legend/DEFAULT.jpg)}</style>')
3522                }
3523
3524                let fetchGeoJSON = new $.Deferred()
3525                // Si la rando a des points (de la bdd ou fichier de points .var sur le serveur)
3526                if(settings.idRando){
3527                    $.getJSON(settings.baseUrl+'index.php',
3528                        {component: 'software', task: 'getAllPath', idRandonnee: settings.idRando},
3529                        function(res) {
3530                            _this.processInitPaths(res.paths, [], mapReady, fetchGeoJSON, false);
3531                        }
3532                    )
3533                } else {
3534                    _this.getPathsLocalStorage(mapReady, fetchGeoJSON);
3535                }
3536
3537                _this.activeAction('go-to')
3538                _this.mapGl.resize()
3539
3540
3541                Offline.on('confirmed-up', function(){
3542                    settings.mapEl.removeClass('loading')
3543                })
3544
3545                /* Offline relance les requêtes qui étaient en pending (et donc qui ont fail pendant la perte
3546                 * de la connexion) lorsque la connexion est rétablie */
3547                Offline.on('up', function(){
3548                    iziToast.success({
3549                        timeout: 5000,
3550                        displayMode: 'replace',
3551                        id: 'network',
3552                        message: i18n("toast_msg_connection_restored")
3553                    })
3554                })
3555
3556                Offline.on('confirmed-down', function(){
3557                    settings.mapEl.addClass('loading')
3558                    iziToast.warning({
3559                        timeout: false,
3560                        id: 'network',
3561                        message: i18n("toast_msg_connection_lost")
3562                    })
3563                })
3564            })
3565
3566            booted = true
3567
3568            return this
3569        },
3570        manualReplotSegment: function (idPath, startAnchor, endAnchor, idsOpt) {
3571            if (!Number.isFinite(startAnchor) || !Number.isFinite(endAnchor) || endAnchor - startAnchor <= 1) return;
3572            for (let j = endAnchor - 1; j > startAnchor; j--) {
3573                if (_this.store[idPath] && _this.store[idPath][j]) {
3574                    if (idsOpt) idsOpt.push(_this.store[idPath][j].id);
3575                    _this.drawGl.deletePoint(idPath, j);
3576                    _this.path.deletePoint(idPath, j);
3577                }
3578            }
3579            _this.drawGl.renderCanvas();
3580        },
3581        actions: {
3582            redo: function(e){
3583                if(!_this.history.hasRedo()) return
3584                _this.history.redo(e)
3585            },
3586            undo: function(e){
3587                if(!_this.history.hasUndo()) return
3588                _this.history.undo(e)
3589            },
3590            draw: function(){
3591                var newMode = _this.drawGl.getMode() == 'draw_line_string' ?
3592                    'simple_select' :
3593                    'draw_line_string'
3594
3595                _this.drawGl.changeMode(newMode)
3596                _this.activeMode(newMode)
3597
3598                if(settings.isSimpleVersion){
3599                    $(this).trigger('focusout')
3600                }
3601            },
3602            delete: function(){
3603                _this.drawGl.trash()
3604            },
3605            continue: function(e){
3606                if( $.inArray(_this.drawGl.getMode(), ['simple_select', 'direct_select']) != -1 ){
3607
3608                    _this.path.handleFallbackSelection();
3609
3610                    var lineString = _this.drawGl.getSelected();
3611
3612                    if(lineString.features.length > 1) {
3613                        iziToast.info({
3614                            message: i18n("toast_msg_select_only_one_trace")
3615                        })
3616                        return
3617                    }else if(lineString.features.length == 1) {
3618
3619                        $(this).trigger('focusout')
3620
3621                        _this.drawGl.changeMode('draw_line_string', {
3622                            featureId: lineString.features[0].id,
3623                            from: 'end'
3624                        })
3625                        _this.activeMode('draw_line_string')
3626
3627                        return
3628                    }
3629                }
3630                // On peut vouloir continuer un autre tracé alors qu'on est en train de faire un autre tracé ...
3631                if(_this.drawGl.getMode() == 'draw_line_string'){
3632                    return
3633                }
3634
3635                iziToast.info({
3636                    message: (_this.path.getAll().length === 0)
3637                        ? i18n("toast_msg_no_trace_on_map")
3638                        : i18n("toast_msg_select_trace_to_continue")
3639                });
3640
3641            },
3642            center: function(e, duration){
3643                if(!_this.path.getAll().length){
3644                    iziToast.error({
3645                        message: i18n("toast_msg_centering_impossible")
3646                    })
3647                    return
3648                }
3649
3650                var featureCollection = _this.drawGl.getAll()
3651                var filteredFeatures = featureCollection.features.filter(function(feature){
3652                    return $.inArray(feature.id, _this.path.getAll()) != -1
3653                })
3654                featureCollection.features = filteredFeatures
3655
3656                _this.mapGl.fitBounds(turf.bbox(featureCollection), {
3657                    padding: {
3658                        top: 30,
3659                        bottom:30,
3660                        left: 30,
3661                        right: 70
3662                    },
3663                    duration: duration || 500
3664                })
3665            },
3666            'move-start': async function(e){
3667
3668                var coordinates = turf.coordAll(_this.drawGl.getSelectedPoints());
3669
3670                if (coordinates.length > 1) {
3671                    iziToast.info({
3672                        message: i18n("toast_msg_select_only_one_point")
3673                    });
3674                    return;
3675                }
3676
3677                if (!coordinates.length) {
3678                    iziToast.info({
3679                        message: i18n("toast_msg_select_start_point")
3680                    });
3681                    return;
3682                }
3683
3684                var idPath = _this.drawGl.getSelectedIds()[0],
3685                    coordinates = _this.store[idPath]
3686                const feature = _this.drawGl.get(idPath);
3687
3688                const line = turf.lineString([coordinates[0], coordinates[coordinates.length - 1]].map(function(p){
3689                    return [p.lng, p.lat]
3690                })),
3691                    length = turf.length(line),
3692                    atIndex = _this.drawGl.getState().selectedCoordPaths[0];
3693
3694                if (+atIndex === 0) {
3695                    iziToast.info({
3696                        message: i18n("toast_msg_already_start_point")
3697                    });
3698                    return;
3699                }
3700
3701                iziToast.success({
3702                    message: i18n("toast_msg_departure_moved")
3703                });
3704
3705                if(!settings.groupActions.find('#display-start').is('.active')){
3706                    _this.actions['display-start'].call(settings.groupActions.find('#display-start').get(0), e)
3707                }else{
3708                    if(_this.drawGl.getSelectedIds().length > 0){
3709                        _this.drawGl.changeMode('simple_select')
3710                        _this.activeMode('simple_select')
3711                    }
3712                }
3713
3714                let coords = JSON.parse(JSON.stringify(coordinates));
3715                let splice = coords.splice(0, +atIndex + 1);
3716                _this.store[idPath] = [].concat([splice[splice.length - 1]], coords, splice);
3717
3718                if (_this.autoTracingEnabled) {
3719                    await _this.autoTraceSegment(idPath, coords.length, coords.length + 1, null, true, true);
3720                } else {
3721                    _this.path.updateDrawGlCoordsWithPath([idPath]);
3722                }
3723
3724                _this.path.computeOrder(idPath)
3725                _this.path.handleMarkersPath(true)
3726                await _this.updateTracePostOperation(idPath);
3727
3728                _this.history.operation(() => {
3729                    return {
3730                        store: Immutable.fromJS(_this.store),
3731                        action: 'moveDeparture'
3732                    };
3733                }, i18n("history_msg_move_departure"));
3734            },
3735            'delete-last-point': function(){
3736
3737                _this.path.handleFallbackSelection()
3738
3739                if (!_this.drawGl.deleteLastPoint()) {
3740                    iziToast.error({
3741                        message: i18n("toast_msg_action_impossible_no_trace")
3742                    });
3743                }
3744
3745            },
3746            'center-last-point': function(){
3747
3748                $(this).toggleClass('active')
3749
3750                var switchText = $(this).data('toggle-text');
3751                $(this).data('toggle-text', $(this._tippy.popper).text())
3752                this._tippy.setContent(switchText)
3753                tippySingleton.setContent(switchText)
3754
3755            },
3756            'point-coordinates': function () {
3757                var coordinates = turf.coordAll(_this.drawGl.getSelectedPoints());
3758
3759                if (coordinates.length > 1) {
3760                    iziToast.info({
3761                        message: i18n("toast_msg_select_only_one_point")
3762                    });
3763                    return;
3764                }
3765
3766                if (!coordinates.length) {
3767                    iziToast.info({
3768                        message: i18n("toast_msg_select_one_point")
3769                    });
3770                    return;
3771                }
3772
3773                var point = coordinates.pop()
3774
3775                iziToast.info({
3776                    class: 'selectable-txt',
3777                    timeout: false,
3778                    closeOnClick: false,
3779                    drag: false,
3780                    message: _this.getFormattedCoordinates({lng: point[0], lat: point[1]})
3781                })
3782            },
3783            'os-grid-ref': function () {
3784                var coordinates = turf.coordAll(_this.drawGl.getSelectedPoints());
3785
3786                if (coordinates.length > 1) {
3787                    iziToast.info({
3788                        message: i18n("toast_msg_select_only_one_point")
3789                    })
3790                    return
3791                }
3792
3793                if (!coordinates.length) {
3794                    iziToast.info({
3795                        message: i18n("toast_msg_select_one_point")
3796                    })
3797                    return
3798                }
3799
3800                var point = coordinates.pop()
3801
3802                var showInfo = function (point) {
3803                    var wgs84 = LatLon(point[1], point[0]);
3804                    var gridref = OsGridRef.latLonToOsGrid(wgs84);
3805
3806                    if (!gridref.toString().length) {
3807                        iziToast.error({
3808                            message: i18n("toast_msg_os_grid_unavailable")
3809                        })
3810                        return
3811                    }
3812
3813                    iziToast.info({
3814                        class: 'selectable-txt',
3815                        timeout: false,
3816                        closeOnClick: false,
3817                        drag: false,
3818                        message: gridref.toString() + ' / ' + gridref.toString(6)
3819                    })
3820                }
3821
3822                if (typeof OsGridRef != "undefined") {
3823                    showInfo(point)
3824                } else {
3825                    $.ajax({
3826                        url: '/libs/js/osgridref.min.js',
3827                        cache: true,
3828                        dataType: 'script'
3829                    }).done(function () {
3830                        showInfo(point)
3831                    })
3832                }
3833            },
3834            'measure-rule': function(){
3835
3836                var coordinates = turf.coordAll(_this.drawGl.getSelectedPoints());
3837
3838                if (coordinates.length < 2) {
3839                    iziToast.info({
3840                        message: i18n("toast_msg_select_at_least_two_points")
3841                    });
3842                    return;
3843                }
3844
3845                var lineString = turf.lineString(coordinates),
3846                    length = turf.length(lineString, {
3847                        units: settings.currentUnits.distance == 'mi' ? 'miles' : 'kilometers'
3848                    });
3849
3850                iziToast.info({
3851                    message: sprintf(
3852                        i18n("toast_msg_distance_computed"),
3853                        length.toFixed(3),
3854                        settings.currentUnits.distance
3855                    )
3856                });
3857            },
3858            cut: function(e){
3859
3860                if(_this.drawGl.getMode() == 'draw_line_string'){
3861                    _this.drawGl.changeMode('simple_select')
3862                    _this.activeMode('simple_select')
3863                }
3864
3865                var points = _this.drawGl.getSelectedPoints().features;
3866
3867                if (!points.length) {
3868                    iziToast.info({
3869                        message: i18n("toast_msg_select_one_point")
3870                    });
3871                    return;
3872                } else if (points.length > 1) {
3873                    iziToast.info({
3874                        message: i18n("toast_msg_select_only_one_point_again")
3875                    });
3876                }else if(points.length === 1){
3877
3878                    var featuresCollection = _this.drawGl.getSelected(),
3879                        coordinates = turf.coordAll(featuresCollection),
3880                        featureId = featuresCollection.features.pop().id,
3881                        point = turf.nearestPoint(
3882                            points.pop(),
3883                            turf.points(coordinates)
3884                        ),
3885                        cutAt = point.properties.featureIndex;
3886
3887                    if ($.inArray(cutAt, [0, coordinates.length - 1]) !== -1) {
3888                        iziToast.error({
3889                            message: i18n("toast_msg_cut_extremity_impossible")
3890                        });
3891                        return;
3892                    }
3893
3894                    var newId = _this.drawGl.cutLineStringAt(cutAt, featureId)
3895                    var id = _this.store[featureId][cutAt].id
3896
3897                    var coord = _this.store[featureId].splice(cutAt + 1, _this.store[featureId].length - (cutAt + 1));
3898                    _this.store[newId] = coord
3899
3900                    var cloneLastPoint = JSON.parse(JSON.stringify(_this.store[featureId][_this.store[featureId].length-1]));
3901                    cloneLastPoint.order = 0
3902
3903                    // BUGFIX : Conserver le contexte d'édition lors de la découpe
3904                    // Ne pas réinitialiser idRando pour permettre la modification de la rando existante
3905                    // settings.idRando = 0;
3906                    // settings.idRandoHash = '';
3907
3908                    // inserer au début de coord, dernier point de l'ancien tracé
3909                    _this.path.insertPoint(newId, 0, cloneLastPoint)
3910                    _this.drawGl.changeMode('simple_select', {
3911                        featureIds: [featureId]
3912                    })
3913
3914                    iziToast.success({
3915                        message: i18n("toast_msg_cut_success")
3916                    })
3917
3918                    _this.path.handleMarkersPath(true)
3919                    _this.updateTracePostOperation(newId);
3920                    _this.updateTracePostOperation(featureId);
3921
3922                    _this.history.operation(() => {
3923                        return {
3924                            store: Immutable.fromJS(_this.store),
3925                            action: 'cutPath'
3926                        };
3927                    }, sprintf(
3928                        i18n("history_msg_cut_trace"),
3929                        1,
3930                        getS(1)
3931                    ))
3932                }
3933            },
3934            merge: function(e){
3935                var ids = _this.drawGl.merge();
3936
3937                if (ids.oldIds.length < 2) {
3938                    iziToast.info({
3939                        message: i18n("toast_msg_select_at_least_two_traces")
3940                    });
3941                    return;
3942                }
3943
3944                // BUGFIX : Conserver le contexte d'édition lors de la fusion
3945                // Ne pas réinitialiser idRando pour permettre la modification de la rando existante
3946                // settings.idRando = 0;
3947                // settings.idRandoHash = '';
3948                var coordinates = [];
3949
3950                for(var i=0; i < ids.oldIds.length; i++){
3951
3952                    $.each(['infos', 'diagrams'], function(j, tab){
3953                        _this.tab.deleteSlide(tab, ids.oldIds[i], _this.swiper[tab])
3954                    })
3955
3956                    coordinates = coordinates.concat(_this.store[ids.oldIds[i]])
3957                    delete _this.store[ids.oldIds[i]]
3958
3959                }
3960
3961                _this.store[ids.newId] = coordinates
3962                _this.path.computeOrder(ids.newId)
3963                _this.path.handleMarkersPath(true)
3964
3965                _this.updateTracePostOperation(ids.newId);
3966
3967                iziToast.success({
3968                    message: sprintf(
3969                        i18n("toast_msg_merge_success"),
3970                        ids.oldIds.length,
3971                        getS(ids.oldIds.length)
3972                    )
3973                });
3974
3975                _this.history.operation(function () {
3976                    return {
3977                        store: Immutable.fromJS(_this.store),
3978                        action: 'mergePaths'
3979                    };
3980                }, sprintf(
3981                    i18n("history_msg_merge_paths"),
3982                    ids.oldIds.length,
3983                    getS(ids.oldIds.length)
3984                ));
3985            },
3986            'draw-close-path': function(){
3987
3988                _this.path.handleFallbackSelection()
3989
3990                var selectedPath = _this.drawGl.getSelectedIds(),
3991                    len = selectedPath.length,
3992                    alreadyClosed = 0;
3993
3994                if(selectedPath.length === 0){
3995                    iziToast.info({
3996                        message: i18n("toast_msg_select_at_least_one_trace")
3997                    })
3998                    return
3999                }else{
4000                    selectedPath.forEach(function(pathId){
4001                        var coordinates = _this.store[pathId],
4002                            firstPoint = coordinates[0],
4003                            lastPoint = coordinates[coordinates.length - 1];
4004
4005                        if(firstPoint.lat == lastPoint.lat && firstPoint.lng == lastPoint.lng){
4006                            alreadyClosed++
4007                        }
4008                    })
4009                }
4010
4011                if(alreadyClosed > 0){
4012                    var s = getS(alreadyClosed);
4013                    iziToast.error({
4014                        message: sprintf(
4015                            i18n("toast_msg_merge_already_closed_error"),
4016                            alreadyClosed, getS(alreadyClosed), getS(alreadyClosed), getS(alreadyClosed), getS(alreadyClosed)
4017                        )
4018                    })
4019                    return
4020                }
4021
4022                _this.drawGl.closePath()
4023
4024                if($.ajaxq.isRunning('store')){
4025                    $.ajaxq.getActiveRequest('store').done(function(){
4026                        iziToast.success({
4027                            message: sprintf(
4028                                i18n("toast_msg_finish_success"),
4029                                len,
4030                                getS(len), // pour "tracé" → "tracés"
4031                                getS(len), // pour "terminé" → "terminés"
4032                                getS(len)  // pour "fermé" → "fermés"
4033                            )
4034                        })
4035                    })
4036                }
4037            },
4038            'draw-back-path': function(e){
4039
4040                _this.path.handleFallbackSelection()
4041
4042                var selectedPath = _this.drawGl.getSelectedIds(),
4043                    alreadyClosed = 0,
4044                    lengths = {}
4045
4046                if(!selectedPath.length){
4047                    iziToast.info({
4048                        message: i18n("toast_msg_select_at_least_one_trace")
4049                    })
4050                    return
4051                }else{
4052                    selectedPath.forEach(function(pathId){
4053                        var coordinates = _this.store[pathId],
4054                            firstPoint = coordinates[0],
4055                            lastPoint = coordinates[coordinates.length - 1];
4056
4057                        if(firstPoint.lat == lastPoint.lat && firstPoint.lng == lastPoint.lng){
4058                            alreadyClosed++
4059                        }
4060                    })
4061                }
4062
4063                // On empêche l'action si un des tracés est déjà fermé
4064                if(alreadyClosed > 0){
4065                    var s = getS(alreadyClosed);
4066                    iziToast.error({
4067                        message: sprintf(
4068                            i18n("toast_msg_already_closed_error"),
4069                            alreadyClosed,
4070                            getS(alreadyClosed), // → tracé[s]
4071                            getS(alreadyClosed), // → terminé[s]
4072                            getS(alreadyClosed), // → fermé[s]
4073                            getS(alreadyClosed)  // → trouvé[s]
4074                        )
4075                    })
4076                    return
4077                }
4078
4079                var ids = _this.drawGl.addBackPath()
4080
4081                _this.path.getAll({saved: true}).map(function(id){
4082                    lengths[id] = _this.store[id].length
4083                })
4084
4085                // Pour chaque tracé...
4086                for(var i=0; i < ids.length; i++){
4087                    var backPath = JSON.parse(JSON.stringify(_this.store[ids[i]])).reverse()
4088                    backPath.shift()
4089
4090                    var length = _this.store[ids[i]].length
4091
4092                    backPath = backPath.map(function(p){
4093                        return {
4094                            lng: p.lng,
4095                            lat: p.lat,
4096                            altitude: p.altitude
4097                        }
4098                    })
4099
4100                    _this.store[ids[i]] = _this.store[ids[i]].concat(backPath)
4101                    _this.path.computeOrder(ids[i])
4102
4103                    // Pour chaque nouveau point constituant le chemin du retour...
4104                    for (var j=length; j < _this.store[ids[i]].length; j++){
4105                        // J'avoue c'est pas simple à lire... mais ai-je le choix ?
4106                        _this.path.updatePoint(ids[i], j, {
4107                            id: _this.path.generateUUIDv4()
4108                        })
4109                    }
4110                    _this.path.handleMarkersPath(true)
4111                    _this.updateTracePostOperation(ids[i]);
4112                }
4113
4114                iziToast.success({
4115                    message: sprintf(
4116                        i18n("toast_msg_back_success"),
4117                        getS(ids.length), // → pour "Retour" → "Retours"
4118                        getS(ids.length)  // → pour "tracé" → "tracés"
4119                    )
4120                })
4121
4122                _this.history.operation(() => {
4123                    return {
4124                        store: Immutable.fromJS(_this.store),
4125                        action: 'backPath'
4126                    };
4127                }, sprintf(
4128                    i18n("history_msg_back_path"),
4129                    ids.length,
4130                    getS(ids.length)
4131                ))
4132            },
4133            'reverse-direction': function(e){
4134
4135                _this.path.handleFallbackSelection()
4136
4137                var ids = _this.drawGl.reversePath()
4138                _this.path.handleMarkersPath(true)
4139
4140                if(!ids.length){
4141                    iziToast.info({
4142                        message: i18n("toast_msg_select_at_least_one_trace")
4143                    })
4144                    return
4145                }
4146                for(var i=0; i < ids.length; i++){
4147                    _this.store[ids[i]].reverse()
4148                    _this.path.computeOrder(ids[i])
4149                    _this.updateTracePostOperation(ids[i]);
4150                }
4151
4152                _this.path.handleMarkersPath(true)
4153
4154                iziToast.success({
4155                    displayMode: 'replace',
4156                    message: sprintf(i18n("toast_msg_reverse_success"), getS(ids.length), getS(ids.length))
4157                })
4158                _this.history.operation(() => {
4159                    return {
4160                        store: Immutable.fromJS(_this.store),
4161                        action: 'reversePath'
4162                    };
4163                }, sprintf(
4164                    i18n("history_msg_reverse_path"),
4165                    ids.length,
4166                    getS(ids.length)
4167                ));
4168            },
4169            reset: function(){
4170                var ids = _this.path.getAll();
4171
4172                if(!ids.length){
4173                    iziToast.info({
4174                        message: i18n("toast_msg_no_trace_to_delete")
4175                    })
4176                    return
4177                }
4178
4179                iziToast.show({
4180                    titleSize:'18px',
4181                    color: 'white',
4182                    closeOnClick: false,
4183                    progressBar: false,
4184                    drag: false,
4185                    timeout: false,
4186                    close: false,
4187                    overlay: true,
4188                    title: i18n("toast_title_confirmation"),
4189                    message: i18n("toast_msg_confirm_delete_all"),
4190                    position: 'center',
4191                    buttons: [
4192                        [`<button class="btn s">${i18n("toast_button_yes")}</button>`,
4193                            function(instance, toast) {
4194                                instance.hide({transitionOut: 'fadeOut'}, toast);
4195                                _this.drawGl.delete(ids, {silent: false});
4196                            },
4197                            true
4198                        ],
4199                        [`<button class="btn e">${i18n("toast_button_no")}</button>`,
4200                            function(instance, toast) {
4201                                instance.hide({transitionOut: 'fadeOut'}, toast)
4202                            },
4203                            false
4204                        ]
4205                    ]
4206                })
4207            },
4208            'go-to': function(){
4209                var el = settings.searchLocForm
4210
4211                el.toggleClass('s')
4212                $(this).toggleClass('active')
4213
4214                if (el.is('.s'))
4215                    el.slideDownTransition(true);
4216                else
4217                    el.slideUpTransition(true);
4218
4219                /* bsTransitionEnd est géré par boostrap pour assurer un support minimal pour transitionend
4220                car chaque navigateur a fait sa soupe évidemment... */
4221                el.one('bsTransitionEnd', function(){
4222                    if(el.hasClass('s')){
4223                        el.find('input[type=search]').get(0).focus()
4224                    }
4225                })
4226            },
4227            'import-gpx': function(e){
4228
4229                var _that = this
4230
4231                if(typeof Dropzone == 'function'){
4232
4233                    Dropzone.autoDiscover = false;
4234
4235                    $.fancybox.open('<div><form class="drop-box dropzone flex">\n' +
4236                        '<div class="dz-default dz-message">\n' +
4237                        '<button class="dz-button" type="button">' + sprintf(i18n("dropzone_upload_msg"), "6MB") + '</button>' +
4238                        '<div class="loader hide">0%</div>' +
4239                        '</div>\n' +
4240                        '<div class="fallback">\n' +
4241                        '<input type="file" />\n' +
4242                        '</div>\n' +
4243                        '</form>' +
4244                        '<div class="xs:mt-4 xs:spacey-2 md:mt-8">' +
4245                        '<div class="form-switch">' +
4246                        '<input type="checkbox" id="import-gpx-altitude" name="altitude" value="1" checked="checked" />' +
4247                        '<label for="import-gpx-altitude">' + i18n("gpx_import_option_altitude") + '</label>' +
4248                        '</div>' +
4249                        '<div class="form-switch">' +
4250                        '<input type="checkbox" id="import-gpx-waypoints" name="waypoints" value="1" checked="checked" />' +
4251                        '<label for="import-gpx-waypoints">' + i18n("gpx_import_option_waypoints") + '</label>' +
4252                        '</div>' +
4253                        '</div>' +
4254                        '</div>',
4255                        {
4256                            touch: false,
4257                            baseClass: 'dropzone-layout',
4258                            animationEffect: 'material'
4259                        });
4260
4261                    var dropzone = $('.drop-box').dropzone({
4262                        url: settings.baseUrl+'index.php?'+$.param({
4263                            component:'software',
4264                            task:'importGPX',
4265                            idRandonnee: settings.idRando
4266                        }),
4267                        maxFilesize: 6, // MB
4268                        paramName: 'gpx',
4269                        previewsContainer: false,
4270                        acceptedFiles: 'application/gpx+xml, .gpx',
4271                        parallelUploads: 1,
4272                        createImageThumbnails: false, // pas de gestion d'image
4273                        timeout: 30000 // augmente le timeout si certains GPX sont lents
4274                    }).get(0).dropzone;
4275                    var importGpxBlockingStarted = false;
4276
4277                    dropzone.on('sending', function(file, xhr, formData){
4278                        if (!importGpxBlockingStarted) {
4279                            importGpxBlockingStarted = true;
4280                            _this.beginBlockingOperation();
4281                        }
4282                        formData.append('altitude', $('#import-gpx-altitude').is(':checked') ? 1 : 0);
4283                        formData.append('waypoints', $('#import-gpx-waypoints').is(':checked') ? 1 : 0);
4284                        $('#import-gpx-altitude, #import-gpx-waypoints').prop('disabled', true);
4285                    });
4286
4287                    dropzone.on('queuecomplete', function(){
4288                        $('#import-gpx-altitude, #import-gpx-waypoints').prop('disabled', false);
4289                        if (importGpxBlockingStarted) {
4290                            importGpxBlockingStarted = false;
4291                            _this.endBlockingOperation();
4292                        }
4293                    });
4294
4295                    dropzone.on('totaluploadprogress', function(p){
4296                        $(this.element).find('.loader').text(Math.round(p)+'%')
4297                    })
4298
4299                    dropzone.on('addedfile', function(){
4300                        $(this.element).find('.dz-message > *').addClass('hide')
4301                            .not('.dz-button').removeClass('hide')
4302                    })
4303
4304                    dropzone.on('queuecomplete', function(){
4305                        const element = this.element
4306
4307                        const successfulFiles = dropzone.files.filter(file => file.status === 'success')
4308
4309                        if (successfulFiles.length > 0) {
4310                            iziToast.info({
4311                                message: i18n("toast_msg_upload_complete"),
4312                                timeout: 3000,
4313                                onOpened: function(){
4314                                    if(dropzone.getQueuedFiles().length || dropzone.getUploadingFiles().length) return
4315                                    $(element).find('.dz-message > *').addClass('hide')
4316                                        .end().find('.dz-message > *').not('.loader').removeClass('hide')
4317                                }
4318                            })
4319                        }
4320
4321                        // Nettoyage
4322                        this.removeAllFiles()
4323                        $.fancybox.close()
4324
4325                        if (!successfulFiles.length) return
4326
4327                        _this.actions.center(null, 1)
4328                        _this.history.operation(function() {
4329                            return {
4330                                store: Immutable.fromJS(_this.store),
4331                                action: 'importGPX'
4332                            };
4333                        }, i18n("history_msg_import_gpx"))
4334                    })
4335
4336                    dropzone.on('error', function(file, errorMessage){
4337                        let errorText = ''
4338
4339                        if (typeof errorMessage === 'object' && errorMessage !== null && errorMessage.hasOwnProperty('error')) {
4340                            // Réponse JSON structurée venant du serveur
4341                            errorText = escapeHtml(errorMessage.error)
4342                        } else if (typeof errorMessage === 'string') {
4343                            // Texte brut d'erreur
4344                            errorText = escapeHtml(errorMessage)
4345                        } else {
4346                            // Fallback générique
4347                            errorText = i18n("toast_msg_upload_error", escapeHtml(file.name))
4348                        }
4349
4350                        iziToast.error({
4351                            message: errorText,
4352                            timeout: 8000
4353                        })
4354                    })
4355
4356                    // Pour chaque fichier(s)
4357                    dropzone.on('success', function(file, json){
4358
4359                        try {
4360                            // On réinitialise
4361                            sessionStorage.setItem("stoGpxTitle", "");
4362                            sessionStorage.setItem("stoGpxDescription", "");
4363
4364                            const paths = json.paths || {};
4365                            const pathIds = Object.keys(paths);
4366                            let totalPoints = 0;
4367
4368                            pathIds.forEach(id => {
4369                                totalPoints += Array.isArray(paths[id]) ? paths[id].length : 0;
4370                            });
4371
4372                            // Aucune trace importée ou aucun point dans les traces
4373                            if (pathIds.length === 0 || totalPoints === 0) {
4374                                iziToast.error({
4375                                    message: i18n('gpx_import_error_no_points_found'),
4376                                    timeout: 6000
4377                                });
4378                                return;
4379                            }
4380
4381                            // Avertissement si trace tronquée (le serveur fournit metaData.totalPoints et nbPointsMax)
4382                            if (json.metaData && typeof json.metaData.totalPoints === 'number' && typeof json.metaData.nbPointsMax === 'number') {
4383                                if (json.metaData.totalPoints > json.metaData.nbPointsMax) {
4384                                    iziToast.warning({
4385                                        message: sprintf(i18n('gpx_import_warning_truncation'), json.metaData.nbPointsMax),
4386                                        timeout: 6000
4387                                    });
4388                                }
4389                            }
4390
4391                            // Récupération depuis le JSON (issu du GPX parser)
4392                            if (json.name !== undefined) {
4393                                sessionStorage.setItem("stoGpxTitle", Array.isArray(json.name) ? json.name.join(" - ") : json.name);
4394                            }
4395
4396                            if (json.desc !== undefined) {
4397                                sessionStorage.setItem("stoGpxDescription", Array.isArray(json.desc) ? json.desc.join("\n") : json.desc);
4398                            }
4399                        } catch (error) {
4400                            console.error(error);
4401                        }
4402
4403                        // BUGFIX : Ne pas réinitialiser le contexte d'édition lors d'un import GPX.
4404                        // Si une rando est ouverte en édition (settings.idRando > 0), le serveur
4405                        // assignera le 1er tracé à la rando existante et 
4405créera de nouvelles randos
4406                        // pour les tracés supplémentaires (même logique que pour le tracé manuel).
4407                        // settings.idRando = 0;
4408                        // settings.idRandoHash = '';
4409
4410                        if(_this.drawGl.getMode() != _this.drawGl.modes.SIMPLE_SELECT){
4411                            _this.drawGl.changeMode('simple_select')
4412                            _this.activeMode('simple_select')
4413                        }
4414
4415                        $.each(json.paths, function(pathId, points){
4416
4417                            var path = turf.feature({
4418                                type: 'LineString',
4419                                coordinates: []
4420                            })
4421
4422                            // skip iteration
4423                            if(points.length < 2) {
4424                                // On vérifie que metaData existe avant de l'utiliser
4425                                if (json.metaData) {
4426                                    delete json.metaData[pathId]
4427                                }
4428                                return true
4429                            }
4430
4431                            // Par convention et pour maintenir une unicité dans drawGl
4432                            path.id = pathId
4433
4434                            // Pour chaque points du tracé courant...
4435                            $.each(points, function(order, point){
4436                                point.order = +order
4437                                _this.path.insertPoint(pathId, order, point)
4438                                path.geometry.coordinates.push([point.lng, point.lat])
4439                            })
4440
4441                            _this.drawGl.add(path)
4442                            _this.updateTracePostOperation(pathId)
4443                        })
4444
4445                        _this.path.handleMarkersPath(true)
4446                    })
4447
4448                    return
4449                }
4450
4451                settings.mapEl.addClass('loading high-z-index')
4452
4453                // Load async, optimisation à défaut de ne pas avoir webpack
4454                $.when.apply($, [
4455                    $.getCSS(['/templatesR/visorando/css/dropzone.min.css'], null, true),
4456                    $.getScript('/libs/js/dropzone.min.js')
4457                ]).done(function(){
4458                    settings.mapEl.removeClass('loading high-z-index')
4459                    _this.actions['import-gpx'].call(_that, e)
4460                })
4461            },
4462            'export-gpx': function(){
4463
4464                if(_this.drawGl.getMode() === 'draw_line_string'){
4465                    iziToast.error({
4466                        message: i18n("toast_msg_export_unfinished_trace")
4467                    })
4468                    return
4469                }
4470
4471                let nbFeatures = _this.path.getAll({saved: true}).length;
4472                if(nbFeatures > 1){
4473                    iziToast.error({
4474                        timeout: 10000,
4475                        message: i18n("toast_msg_export_multiple_traces_error")
4476                    })
4477                    return
4478                }
4479
4480                var idsPathSelected = _this.drawGl.getSelectedIds(),
4481                    internalIdsPath = _this.path.getAll({saved: true}),
4482                    idsPathForExport = null;
4483
4484                if(internalIdsPath.length > 0 && idsPathSelected.length === 0){
4485                    idsPathForExport = internalIdsPath
4486                }else if(idsPathSelected.length && internalIdsPath.length){
4487                    idsPathForExport = idsPathSelected
4488                }else if(!internalIdsPath.length){
4489                    iziToast.error({
4490                        message: i18n("toast_msg_no_trace_to_export"),
4491                        timeout: 2000
4492                    })
4493                    return
4494                }
4495
4496                // On est jamais trop prudent
4497                if(!idsPathForExport){
4498                    iziToast.error({
4499                        message: i18n("toast_msg_export_failed"),
4500                        timeout: 2000
4501                    })
4502                    return
4503                }
4504
4505                // Sauvegarde manuelle "programmatically"
4506                settings.groupActions.find('#save').trigger('click', [{preventConfirm: true}])
4507
4508                $.ajaxq.getActiveRequest('store').done(function(res){
4509
4510                    if(res.error){
4511                        iziToast.error({
4512                            id: 'error',
4513                            displayMode: 'replace',
4514                            message: res.error
4515                        })
4516                        return
4517                    }
4518
4519                    var a = document.createElement('a');
4520                    a.href = settings.baseUrl+'index.php?'+$.param({
4521                        component:'rando',
4522                        task:'gpxRandoPre',
4523                        idRandonnee: settings.idRandoHash
4524                    });
4525                    document.body.append(a);
4526                    a.click();
4527                    a.remove();
4528
4529                }).fail(function(){
4530                    // Les causes peuvent être diverses...(ex: utilisateur déconnecté)
4531                    iziToast.error({
4532                        message: i18n("toast_msg_gpx_generation_failed")
4533                    })
4534                })
4535            },
4536            'hide-txt-button': function(){
4537
4538                var switchText = $(this).data('toggle-text'),
4539                    switchTooltipText = $(this).data('toggle-text-tooltip');
4540
4541                $(this).data('toggle-text', $(this).text()).data('toggle-text-tooltip', $(this._tippy.popper).text())
4542                $(this).toggleClass('active').find('span').text(switchText)
4543
4544                this._tippy.setContent(switchTooltipText)
4545                tippySingleton.setContent(switchTooltipText)
4546
4547                settings.groupActions.addClass('mod').toggleClass('only-icon-mode')
4548
4549                if(settings.groupActions.is('.only-icon-mode')){
4550
4551                    $.each(settings.groupActions.find('.nav__dropdown > *'), function(){
4552                        priorityNav.toMenu(settings.groupActions.get(0), settings.groupActions.attr('instance'))
4553                    });
4554
4555                    var toggleButton = settings.groupActions.find('.priority-nav__dropdown-toggle')
4556                    if(toggleButton.is('.is-open')){
4557                        toggleButton.trigger('click')
4558                    }
4559                }
4560
4561                tippy.hideAll({duration: 0})
4562
4563                var catchEndFit = new $.Deferred()
4564                priorityNav.doesItFit(settings.groupActions.get(0), catchEndFit)
4565
4566                catchEndFit.then(function(){
4567                    settings.groupActions.removeClass('mod')
4568                    $.each(settings.groupActions.find('.nav > *'), function(){
4569                        var newProps = {
4570                            placement: 'bottom'
4571                        }
4572                        this._tippy.setProps(newProps)
4573                        tippySingleton.setProps(newProps)
4574                    })
4575                })
4576
4577                _this.setCookie({ 'hide-txt': settings.groupActions.is('.only-icon-mode') })
4578            },
4579            'switch-version': function(){
4580
4581                if(!settings.user.hasPremiumAccess && !$(this).is('.active')){
4582
4583                    _paq.push(['trackEvent',
4584                        'Logiciel simple',
4585                        'Vers logiciel avancé',
4586                        'Click on link that redirect to club page',
4587                    ]);
4588
4589                    Object.assign(document.createElement('a'), {
4590                        target: '_blank',
4591                        href: settings.clubUrl
4592                    }).click()
4593
4594                    return
4595                }
4596
4597                var elContext = this,
4598                    run = function(reset) {
4599                        var switchText = $(elContext).data('toggle-text'),
4600                            switchTooltipText = $(elContext).data('toggle-text-tooltip');
4601
4602                        $(elContext).data('toggle-text', $(elContext).text()).data('toggle-text-tooltip', $(elContext._tippy.popper).text())
4603                        $(elContext).toggleClass('active').find('span').text(switchText)
4604
4605                        elContext._tippy.setContent(switchTooltipText)
4606                        tippySingleton.setContent(switchTooltipText)
4607
4608                        settings.groupActions.addClass('mod').find('.advanced, .not-advanced').toggleClass('hide')
4609                        settings.isSimpleVersion = !$(elContext).is('.active')
4610
4611                        $('body').removeClass('simple-version advanced-version')
4612                            .addClass(settings.isSimpleVersion ? 'simple-version' : 'advanced-version')
4613
4614                        if(settings.isSimpleVersion && !settings.groupActions.find('#hide-txt-button').is('.active')){
4615                            settings.groupActions.find('#hide-txt-button').trigger('click')
4616                        }
4617
4618                        // if(settings.isSimpleVersion && !settings.groupActions.find('#go-to').is('.active')){
4619                        //     _this.activeAction('go-to')
4620                        // }
4621
4622                        _this.drawGl.setBlurSelection(!settings.isSimpleVersion)
4623
4624                        _this.setCookie({ 'advanced': !settings.isSimpleVersion })
4625
4626                        _this.updateTopBar()
4627
4628                        if(reset){
4629                            // Nettoyage onglets
4630                            var ids = _this.path.getAll()
4631                            for (var i = 0; i < ids.length; i++) {
4632                                $.each(['infos', 'diagrams'], function (p, tab) {
4633                                    _this.tab.deleteSlide(tab, ids[i], _this.swiper[tab])
4634                                })
4635                            }
4636
4637                            // Nettoyage map
4638                            _this.drawGl.delete(ids, {silent: false});
4639
4640                            var currentRequest = new $.Deferred()
4641
4642                            setTimeout(function(){ currentRequest.resolve($.ajaxq.getActiveRequest('store')) }, 1)
4643
4644                            settings.idRando = 0;
4645                            settings.idRandoHash = '';
4646
4647                            currentRequest.done(function(xhr){
4648
4649                                if(!xhr) return
4650                                xhr.done(function(){
4651                                    // Reset le store
4652                                    _this.store = {}
4653
4654                                    iziToast.success({
4655                                        displayMode: 0,
4656                                        message: i18n("toast_msg_instance_created")
4657                                    })
4658
4659                                }).always(function(){
4660                                    settings.mapEl.removeClass('loading')
4661                                    // MAJ barre d'actions
4662                                    _this.updateGroupActions()
4663                                    // MAJ des helpers
4664                                    _this.updateHelper()
4665                                })
4666
4667                            })
4668
4669                            return currentRequest
4670
4671                        }else{
4672                            // MAJ barre d'actions
4673                            _this.updateGroupActions()
4674                            // MAJ des helpers
4675                            _this.updateHelper()
4676                        }
4677                    }
4678
4679                // Si on veut aller vers la version simple & plus d'un tracé
4680                if(!settings.isSimpleVersion && _this.path.getAll().length > 1){
4681
4682                    iziToast.show({
4683                        titleSize:'18px',
4684                        color: 'white',
4685                        closeOnClick: false,
4686                        progressBar: false,
4687                        drag: false,
4688                        timeout: false,
4689                        close: false,
4690                        overlay: true,
4691                        title: i18n("toast_title_confirmation"),
4692                        message: i18n("toast_msg_simple_mode_reset_warning"),
4693                        position: 'center',
4694                        buttons: [
4695                            [`<button class="btn s">${i18n("toast_button_yes")}</button>`,
4696                                function(instance, toast) {
4697                                    instance.hide({transitionOut: 'fadeOut'}, toast)
4698                                    run(true).then(function(xhr){
4699
4700                                        if(!xhr) return
4701
4702                                        xhr.done(function(){
4703                                            _this.drawGl.changeMode('draw_line_string')
4704                                            _this.activeMode('draw_line_string')
4705                                        })
4706
4707                                    })
4708                                },
4709                                true
4710                            ],
4711                            [`<button class="btn e">${i18n("toast_button_no")}</button>`,
4712                                function(instance, toast) {
4713                                    instance.hide({transitionOut: 'fadeOut'}, toast)
4714                                },
4715                                false
4716                            ]
4717                        ]
4718                    })
4719
4720                }else{
4721
4722                    run(false)
4723
4724                    if(settings.isSimpleVersion){
4725                        if(_this.path.getAll().length >= 1){
4726                            _this.drawGl.changeMode('simple_select', {
4727                                featureIds: _this.path.getAll()
4728                            })
4729                            _this.activeMode('simple_select')
4730                        }else {
4731                            _this.drawGl.changeMode('draw_line_string')
4732                            _this.activeMode('draw_line_string')
4733                        }
4734                    }
4735                }
4736            },
4737            'display-start': function(){
4738
4739                if(_this.path.getAll().length > 0){
4740                    _this.drawGl.changeMode('simple_select')
4741                    _this.activeMode('simple_select')
4742                }
4743
4744                var switchText = $(this).data('toggle-text'),
4745                    switchTooltipText = $(this).data('toggle-text-tooltip');
4746
4747                $(this).data('toggle-text', $(this).text()).data('toggle-text-tooltip', $(this._tippy.popper).text())
4748                $(this).toggleClass('active').find('span').text(switchText)
4749
4750                this._tippy.setContent(switchTooltipText)
4751                tippySingleton.setContent(switchTooltipText)
4752
4753                var none = $(this).is('.active') === false
4754                $('body').toggleClass('hide-start', none);
4755
4756                _this.mapGl.setLayoutProperty('gl-draw-start-marker', 'visibility', none ? 'none' : 'visible',
4757                    {validate: false})
4758            },
4759            help: function(){
4760                $(this).toggleClass('active')
4761
4762                var switchText = $(this).data('toggle-text');
4763                $(this).data('toggle-text', $(this._tippy.popper).text())
4764
4765                var helpActive = $(this).is('.active');
4766                helpActive ? _this.helperTootlip.enable() : _this.helperTootlip.disable();
4767                helpActive ? tippySingleton.enable() : tippySingleton.disable();
4768
4769                var t = this
4770                // event loop
4771                setTimeout(function(){
4772                    t._tippy.setContent(switchText)
4773                    tippySingleton.setContent(switchText)
4774                }, 0)
4775            },
4776            save: function(e, payload){
4777
4778                var _ids = _this.path.getAll({saved: true}),
4779                    _allIds = _this.path.getAll();
4780
4781                var gpxTitle = sessionStorage.getItem("stoGpxTitle") || "";
4782                var gpxDescription = sessionStorage.getItem("stoGpxDescription") || "";
4783
4784                // Un tracé non actif ne devrait pas avoir 1 seul point, mais ça arrive
4785                // Avant enregistrement, nettoyer automatiquement les tracés à 1 seul point pour empêcher toute erreur impossible à corriger.
4786                var singlePointPaths = [];
4787                _allIds.forEach(function (pathId) {
4788                    if (_this.store[pathId] && _this.store[pathId].length === 1) {
4789                        singlePointPaths.push(pathId);
4790                    }
4791                });
4792
4793                if (singlePointPaths.length > 0) {
4794                    console.warn("Nettoyage automatique des tracés à 1 point:", singlePointPaths);
4795
4796                    // Supprimer les features de la carte
4797                    singlePointPaths.forEach(function (pathId) {
4798                        try {
4799                            _this.drawGl.delete(pathId);
4800                        } catch (e) {
4801                            console.warn("Impossible de supprimer le feature:", pathId, e);
4802                        }
4803                    });
4804
4805                    // Supprimer du store
4806                    _this.path.deletePath(singlePointPaths);
4807
4808                    // Mettre à jour l'interface
4809                    _this.updateGroupActions();
4810                    _this.updateHelper();
4811                    _this.storePathsLocalStorage();
4812                }
4813
4814                // Fin - Gestion cas points qui ont pu être sauvegardés sur le serveur
4815                var pathsToSave = _this.path.getPoints({saved: true}),
4816                    nbFeatures = Object.keys(pathsToSave).length,
4817                    payload = $.extend({}, payload)
4818
4819                if(nbFeatures < 1){
4820                    iziToast.error({
4821                        message: i18n("toast_msg_action_impossible_no_trace_on_map")
4822                    })
4823                    return
4824                }
4825
4826                var confirmOpts = Object.assign({
4827                    title: i18n("toast_title_save_confirmation"),
4828                    buttons: [
4829                        [`<button class="btn e">${i18n("toast_button_no")}</button>`,
4830                            function(instance, toast) {
4831                                instance.hide({transitionOut: 'fadeOut'}, toast)
4832                            },
4833                            false
4834                        ]
4835                    ]
4836                }, _this.commonConfirmOpts)
4837
4838                var runSaveRequest = function(instance, toast){
4839                    var processResponse = function(res){
4840                        if(res.redirect){
4841                            // on vire la possibilité de faire un go(-1) dans l'historique
4842                            History.replaceState(
4843                                {idRandonnee: settings.idRando},
4844                                window.document.title,
4845                                settings.baseUrl +'index.php?'+ $.param({
4846                                    component: 'rando',
4847                                    task: 'newCircuitV2'
4848                                })
4849                            );
4850                            window.location = res.redirect
4851                            return
4852                        }
4853                        // Pas bien, à mettre ds le callback "error" de aja
4853xq
4854                        // Pas de rework à ce jour car ce logiciel va droit à la poubelle
4855                        if(res.error){
4856                            iziToast.error({
4857                                id: 'error',
4858                                displayMode: 'replace',
4859                                message: res.error
4860                            })
4861                            return
4862                        }
4863                        _this.rebaseDescriptionWaypointNumbers();
4864                        if(res.confirm){
4865                            if(res.idRando){
4866                                settings.idRando = res.idRando;
4867                                settings.idRandoHash = res.idRandoHash
4868                            }
4869                            if(payload.preventConfirm){
4870                                return
4871                            }
4872                            iziToast.show(Object.assign({
4873                                title: i18n("toast_title_action_choice"),
4874                                message: res.confirm,
4875                                buttons: [
4876                                    [`<button class="btn s w-full"><i class="vr-icon vr-icon-file"></i>  ${i18n("toast_button_view_sheet")}</button>`,
4877                                        function (instance, toast) {
4878                                            settings.hikeUrlEl.click()
4879                                            instance.hide({transitionOut: 'fadeOut'}, toast)
4880                                        },
4881                                        true
4882                                    ],
4883                                    [`<button class="btn o w-full"><i class="vr-icon vr-icon-text"></i> ${i18n("toast_button_describe_route")}</button>`,
4884                                        function (_instance, _toast) {
4885                                            window.location = res.onConfirm
4886                                        },
4887                                        true
4888                                    ],
4889                                    [`<button class="btn o w-full">${i18n("toast_button_close")}</button>`,
4890                                        function (_instance, _toast) {
4891                                            _instance.hide({transitionOut: 'fadeOut'}, _toast)
4892                                        },
4893                                        false
4894                                    ]
4895                                ]
4896                            }, _this.commonConfirmOpts))
4897                        }
4898                    }
4899                    $.ajaxq('store', {
4900                        url: settings.baseUrl+'index.php?component=software&task=save',
4901                        dataType: 'json',
4902                        type: 'post',
4903                        beforeSend: function(){
4904                            _this.beginBlockingOperation();
4905                            settings.mapEl.addClass('loading')
4906                        },
4907                        success: function(res){
4908                            if(payload.preventConfirm){
4909                                processResponse(res)
4910                            }else{
4911                                if(instance){
4912                                    instance.hide({transitionOut: 'fadeOut',
4913                                        onClosing: function(){
4914                                            processResponse(res)
4915                                        }
4916                                    }, toast)
4917                                }else{
4918                                    processResponse(res)
4919                                }
4920                            }
4921                            _this.cleanLocalStorage();
4922                        },
4923                        error: function(){
4924                            iziToast.error({
4925                                message: i18n("toast_msg_save_failed"),
4926                                displayMode: 'replace'
4927                            })
4928                        },
4929                        complete: function(){
4930                            settings.mapEl.removeClass('loading')
4931                            _this.endBlockingOperation();
4932                        },
4933                        data: {
4934                            idRandonnee: settings.idRando,
4935                            carteType: settings.carteType,
4936                            paths: JSON.stringify(pathsToSave),
4937                            // Planner version, kept in the modification history (channel::softwareV2 / V3)
4938                            editor: 'softwareV2',
4939                            // Only waypoint numbers are sent; structured-description JSON remains server-side.
4940                            deletedDescriptionWaypointIndexes: JSON.stringify(_this.getDeletedDescriptionWaypointIndexes()),
4941                            gpxTitle: gpxTitle,
4942                            gpxDescription: gpxDescription
4943                        }
4944                    })
4945                }
4946
4947                if (payload.preventConfirm) {
4948                    runSaveRequest()
4949                    return
4950                }
4951
4952                if (nbFeatures === 1) {
4953
4954                    if (settings.idRando && settings.idRando > 0) {
4955
4956                        var randoLabel = settings.titreRando
4957                            ? escapeHtml(settings.titreRando)
4958                            : 'n°' + settings.idRando;
4959                        confirmOpts.message = sprintf(
4960                            i18n("toast_msg_save_origin_modification"),
4961                            randoLabel
4962                        );
4963
4964                        confirmOpts.buttons.unshift(
4965                            [`<button class="btn s">${i18n("toast_button_yes")}</button>`,
4966                                function (instance, toast) {
4967                                    runSaveRequest(instance, toast);
4968                                },
4969                                true]
4970                        )
4971
4972                        iziToast.show(confirmOpts)
4973
4974                    } else {
4975                        runSaveRequest(null, null)
4976                    }
4977
4978                } else if (nbFeatures > 1) {
4979
4980                    if (settings.idRando && settings.idRando > 0) {
4981                        // Édition d'une rando existante + création de nouvelles :
4982                        // 1er tracé du store → modifie la rando existante
4983                        // Tracés suivants → nouvelles randonnées
4984                        var randoLabel = settings.titreRando
4985                            ? escapeHtml(settings.titreRando)
4986                            : 'n°' + settings.idRando;
4987                        confirmOpts.message = sprintf(
4988                            i18n("toast_msg_save_multiple_traces_with_edit"),
4989                            randoLabel,
4990                            nbFeatures - 1
4991                        );
4992                    } else {
4993                        // Création de plusieurs nouvelles randonnées from scratch
4994                        confirmOpts.message = sprintf(
4995                            i18n("toast_msg_save_multiple_traces"),
4996                            nbFeatures
4997                        );
4998                    }
4999
5000                    confirmOpts.buttons.unshift(
5001                        [`<button class="btn s">${i18n("toast_button_yes")}</button>`, (instance, toast) => runSaveRequest(instance, toast), true]
5002                    );
5003
5004                    iziToast.show(confirmOpts)
5005                }
5006            },
5007            print: function(){
5008                var nbFeatures = _this.path.getAll({saved: true}).length;
5009
5010                if (!nbFeatures) {
5011                    iziToast.error({ message: i18n("toast_msg_no_valid_trace") });
5012                    return;
5013                }
5014
5015                if (nbFeatures > 1) {
5016                    iziToast.error({
5017                        timeout: 10000,
5018                        message: i18n("toast_msg_pdf_multiple_traces_error")
5019                    });
5020                    return;
5021                }
5022
5023                // Sauvegarde manuelle "programmatically"
5024                settings.groupActions.find('#save').trigger('click', [{preventConfirm: true}])
5025
5026                $.ajaxq.getActiveRequest('store').done(function(res){
5027
5028                    if(res.error){
5029                        iziToast.error({
5030                            id: 'error',
5031                            displayMode: 'replace',
5032                            message: res.error
5033                        })
5034                        return
5035                    }
5036
5037                    var a = document.createElement('a');
5038                    a.href = settings.baseUrl+'index.php?'+$.param({
5039                        component:'rando',
5040                        task:'pdfRandoPre',
5041                        idRandonnee: settings.idRando
5042                    });
5043                    document.body.append(a);
5044                    a.click();
5045                    a.remove();
5046
5047                }).fail(function(){
5048                    // Les causes peuvent être diverses...(ex: utilisateur déconnecté)
5049                    iziToast.error({
5050                        message: i18n("toast_msg_pdf_generation_failed")
5051                    })
5052                })
5053            }
5054        },
5055        path: {
5056            handleFallbackSelection: function(opts){
5057
5058                opts = $.extend({}, {showToast: true}, opts)
5059
5060                /* On simplifie la vie si un seul tracé */
5061                if(_this.drawGl.getMode() === 'simple_select'){
5062                    var lineString = _this.drawGl.getSelected(),
5063                        savedFeaturesId = this.getAll({saved: true});
5064                    savedFeaturesId
5065
5066                    if(lineString.features.length === 0 && savedFeaturesId.length === 1){
5067                        // auto-selection
5068                        _this.drawGl.changeMode('simple_select', {
5069                            featureIds: [savedFeaturesId[0]]
5070                        })
5071
5072                        if(opts.showToast){
5073                            iziToast.info({
5074                                displayMode: 'replace',
5075                                message: i18n("toast_msg_auto_trace_selected")
5076                            })
5077                        }
5078                    }
5079                }
5080                _this.path.getAll().forEach((pathId) => {
5081                    _this.updateTracePostOperation(pathId);
5082                })
5083            },
5084            handleMarkersPath(reset){
5085                if(!!reset && !!_this.markers.list['wpts']){
5086                    for (var i = 0; i < _this.markers.list.wpts.length; i++) {
5087                        _this.markers.list.wpts[i].remove()
5088                    }
5089                }
5090
5091                _this.markers.list.wpts = []
5092
5093                $.each(_this.store, function (pathId, pts) {
5094
5095                    if(pts.length < 2) return
5096
5097                    var lineString = turf.lineString([
5098                            [_this.store[pathId][0]['lng'], _this.store[pathId][0]['lat']],
5099                            [_this.store[pathId][_this.store[pathId].length-1]['lng'], _this.store[pathId][_this.store[pathId].length-1]['lat']]
5100                        ]),
5101                        markerNum = 1,
5102                        length = turf.length(lineString, { units: 'kilometers' }) * 1000; // km => m
5103
5104                    var retourDepart = length <= settings.departureThreshold
5105
5106                    for (var i = 0; i < pts.length; i++) {
5107                        if(retourDepart) {
5108                            if(i == pts.length - 1){
5109                                _this.markers.list.wpts.push(_this.markers.create({
5110                                    className: 'marker start_end',
5111                                    coordinates: [pts[i]['lng'],pts[i]['lat']],
5112                                    attachToMap: true,
5113                                    id: pts[i].id
5114                                }))
5115                            }
5116                        } else {
5117                            if(i == 0) {
5118                                _this.markers.list.wpts.push(_this.markers.create({
5119                                    className: 'marker start',
5120                                    coordinates: [pts[i]['lng'],pts[i]['lat']],
5121                                    attachToMap: true,
5122                                    id: pts[i].id
5123                                }))
5124                            } else if(i == pts.length - 1) {
5125                                _this.markers.list.wpts.push(_this.markers.create({
5126                                    className: 'marker end',
5127                                    coordinates: [pts[i]['lng'],pts[i]['lat']],
5128                                    attachToMap: true,
5129                                    id: pts[i].id
5130                                }))
5131                            }
5132                        }
5133
5134                        // pas de wpt aux extrémités des tracés
5135                        if( !(i == pts.length - 1 || i == 0) ){
5136                            if(pts[i].wpt == 1){
5137                                _this.markers.list.wpts.push(_this.markers.create({
5138                                    className: 'marker',
5139                                    text: ''+markerNum,
5140                                    coordinates: [pts[i]['lng'],pts[i]['lat']],
5141                                    attachToMap: true,
5142                                    id: pts[i].id
5143                                }))
5144                                markerNum++
5145                            }
5146                        }
5147                    }
5148                })
5149
5150            },
5151            getPoints: function(opts){
5152
5153                var opts = $.extend({}, {saved: true}, opts),
5154                    allPath = Object.keys(_this.store),
5155                    pts = {};
5156
5157                for (var i = 0; i < allPath.length; i++) {
5158                    pts[allPath[i]] = _this.store[allPath[i]]
5159                }
5160
5161                if (opts.saved) {
5162                    $.each(pts, function (pathId) {
5163                        if (pts[pathId].length <= 1) delete pts[pathId]
5164                    })
5165                }
5166
5167                return pts
5168            },
5169            // Attention, comportement différent de getPoints()
5170            getAll: function(opts){
5171                var opts = $.extend({}, {saved: false}, opts),
5172                    allPath = Object.keys(_this.store);
5173
5174                if(opts.saved){
5175                    var filteredPath = [];
5176
5177                    for(var i=0; i < allPath.length; i++){
5178                        // Vérifier d'abord que le store contient des données pour ce chemin
5179                        if(!_this.store[allPath[i]] || !_this.store[allPath[i]].length) {
5180                            continue;
5181                        }
5182                        filteredPath.push(allPath[i]);
5183                    }
5184
5185                    return filteredPath;
5186                }else{
5187                    // Filtrer les chemins sans données
5188                    return allPath.filter(function(path) {
5189                        return _this.store[path] && _this.store[path].length;
5190                    });
5191                }
5192            },
5193            deletePath: function(idsPath){
5194                if(!$.isArray(idsPath) || idsPath.length === 0) return
5195                for(var i=0; i < idsPath.length; i++){
5196                    delete _this.store[idsPath[i]]
5197                }
5198            },
5199            deletePoint: function(idPath, index) {
5200                if(index !== -1){
5201                    _this.store[idPath].splice(index, 1)
5202                }
5203            },
5204            computeOrder: function(idPath){
5205                $.each(_this.store[idPath], function(i, p){
5206                    p.order = i
5207                })
5208            },
5209            // Insère un point dans le store du tracé
5210            insertPoint: function(idPath, index, data){
5211                if (!_this.store[idPath]) {
5212                    _this.store[idPath] = [];
5213                }
5214                if(typeof index != 'undefined'){
5215                    _this.store[idPath].splice(index, 0, data);
5216                    if (typeof _this.store[idPath][index] != 'undefined') {
5217                        _this.store[idPath][index].id = this.generateUUIDv4();
5218                        if(_this.store[idPath].length-1 != index){
5219                            _this.path.computeOrder(idPath)
5220                        }
5221                    }
5222                    return
5223                }
5224                _this.store[idPath] = [data]
5225            },
5226            //Create a UUIDv4 for points
5227            generateUUIDv4: function() {
5228                const bytes = crypto.getRandomValues(new Uint8Array(16));
5229
5230                // Ajustements spécifiques UUIDv4 :
5231                bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
5232                bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xxxxxx
5233
5234                const toHex = (n) => n.toString(16).padStart(2, '0');
5235
5236                return [
5237                    ...bytes.slice(0, 4),
5238                    '-',
5239                    ...bytes.slice(4, 6),
5240                    '-',
5241                    ...bytes.slice(6, 8),
5242                    '-',
5243                    ...bytes.slice(8, 10),
5244                    '-',
5245                    ...bytes.slice(10, 16),
5246                ].map(toHex).join('');
5247            },
5248
5249            // Permet de mettre à jour un point sur un tracé
5250            updatePoint: function(idPath, index, data){
5251                if(_get(_this.store, idPath, false) === false) return
5252                var newData = $.extend({}, _this.store[idPath][index], data)
5253
5254                // Attention aux shallow copy !
5255                delete newData.metaData
5256                _this.store[idPath][index] = newData
5257            },
5258            // Renseigne le nombre de points dans un tracé
5259            countPoints: function(idPath){
5260                return _this.store.hasOwnProperty(idPath) ? _this.store[idPath].length : 0
5261            },
5262            //Pour savoir si un point est une ancre ou pas
5263            isAnchor: function(pathId, coordPath) {
5264                try {
5265                    return _this.store[pathId][coordPath]?.anchor === 1;
5266                } catch (e) {
5267                    return 0;
5268                }
5269            },
5270            // Reconstruit la liste des indices d'ancres à partir du store
5271            computeAnchorIndicesFromStore: function(pathId) {
5272                const anchors = []
5273                _this.store[pathId].forEach((point, index) => {
5274                    if (point.anchor === 1) anchors.push(index);
5275                });
5276                return anchors;
5277            },
5278            nextAnchor: function(idx, anchorIndices, n) {
5279                idx = parseInt(idx);
5280                const set = new Set(anchorIndices);
5281                let next = idx;
5282                for (let i = idx + 1; i <= n; i++)  {if (set.has(i)) { next = i; break; } }
5283                return next;
5284            },
5285            prevAnchor: function(idx, anchorIndices) {
5286                idx = parseInt(idx);
5287                const set = new Set(anchorIndices);
5288                let prev = idx;
5289                for (let i = idx - 1; i >= 0; i--) { if (set.has(i)) { prev = i; break; } }
5290                return prev;
5291            },
5292            /**
5293             * Obtient l'ID du tracé actif (en cours de dessin ou sélectionné)
5294             * @returns {string|null} ID du tracé ou null si aucun tracé actif
5295             */
5296            getActivePathId: function() {
5297                // Vérification rapide de drawGl
5298                if (!_this.drawGl) {
5299                    console.warn("getActivePathId: drawGl non disponible");
5300                    return null;
5301                }
5302
5303                try {
5304                    // Cas 1: Mode dessin actif
5305                    if (_this.drawGl.getMode() === 'draw_line_string') {
5306                        const state = _this.drawGl.getState();
5307                        if (state?.line?.id) {
5308                            return [state.line.id];
5309                        }
5310                    }
5311
5312                    // Cas 2: Mode sélection
5313                    const selectedIds = _this.drawGl.getSelectedIds();
5314                    if (selectedIds.length > 0) {
5315                        return selectedIds;
5316                    }
5317
5318                    // Aucun tracé actif
5319                    return [];
5320
5321                } catch (err) {
5322                    console.error("Erreur dans getActivePathId:", err);
5323                    return null;
5324                }
5325            },
5326            updateDrawGlCoordsWithPath: function (paths) {
5327                let newCoords = [];
5328                const state = _this.drawGl.getAll();
5329                let redraw = true;
5330                if (paths.length === 0) {
5331                    paths = Object.keys(_this.store);
5332                    paths.push(...state.features.map(f => f.id))
5333                }
5334                //Rendre uniques les valeurs de paths
5335                paths = [...new Set(paths)];
5336                paths.forEach(idPath => {
5337                    newCoords = [];
5338                    if (_this.store[idPath]) {
5339                        const index = state.features.findIndex(f => f.id === idPath);
5340                        _this.store[idPath].forEach(point => {
5341                            newCoords = newCoords.concat([[point.lng, point.lat]]);
5342                        })
5343                        if (index === -1) {
5344                            const originalFeature = _this.drawGl.get(idPath);
5345                            const originalProperties = originalFeature ? originalFeature.properties : {};
5346                            state.features.push({
5347                                'type': 'Feature',
5348                                'properties': originalProperties,
5349                                'geometry': {
5350                                    'type': 'LineString',
5351                                    'coordinates': newCoords
5352                                },
5353                                'id': idPath
5354                            });
5355                        } else {
5356                            state.features[index].geometry.coordinates = newCoords;
5357                        }
5358                    } else {
5359                        state.features = state.features.filter(f => f.id !== idPath);
5360                    }
5361                });
5362                state.features.forEach(f => {
5363                    if (!f.geometry.coordinates || f.geometry.coordinates.length < 2) {
5364                        redraw = false;
5365                    }
5366                });
5367                if (redraw) {
5368                    _this.drawGl.set(state);
5369                    _this.drawGl.renderCanvas();
5370                }
5371            }
5372        },
5373        tab: {
5374            /** Stockage des instances ElevationChart (Visorandomap) par pathId */
5375            elevationCharts: {},
5376
5377            /**
5378             * Construit un objet Trail (domaine Visorandomap) depuis les points du store.
5379             * @param {string} idPath
5380             * @returns {Trail|null}
5381             */
5382            buildTrail: function(idPath) {
5383                const rawPoints = _this.store[idPath] || [];
5384                const validPoints = rawPoints.filter(p => typeof p.altitude === 'number');
5385                if (validPoints.length < 2) return null;
5386
5387                const { Trail, TrailPoint, Coordinates, Elevation } = VisorandoMap;
5388                const trailPoints = validPoints.map(p =>
5389                    new TrailPoint(new Coordinates(p.lat, p.lng), new Elevation(p.altitude))
5390                );
5391                return new Trail(idPath, trailPoints);
5392            },
5393
5394            handleSelection: function(tabName, swiperInstance, idPath){
5395
5396                var selectedIds = _this.drawGl.getSelectedIds(),
5397                    slideIndex = -1;
5398
5399                if(selectedIds.length || idPath){
5400                    idPath = selectedIds && selectedIds[selectedIds.length - 1] || idPath
5401                    // On affiche le dernier tracé sélectionné
5402                    var slideIndex = $(swiperInstance.slides)
5403                        .filter('#'+tabName+'-'+idPath).index()
5404                }
5405
5406                if(slideIndex != -1){
5407                    swiperInstance.slideTo(slideIndex)
5408                }
5409            },
5410            render: async function(slide, tabName, data, swiperInstance, pathId){
5411                switch (tabName) {
5412                    case 'infos':
5413                        Object.entries(data.metaData).forEach(([cls, val]) => {
5414                            $(slide).find('.' + cls + ' > span').eq(0).text(val);
5415                        });
5416
5417                        $(slide).data('metaData', data.metadata);
5418                        break;
5419
5420                    case 'diagrams':
5421                        const feature = _this.drawGl.get(pathId);
5422                        if (!feature) return;
5423
5424                        const $infoSlide = $(_this.swiper.infos.slides).filter(function () {
5425                            return this.id === 'infos-' + pathId;
5426                        }).first();
5427
5428                        // Mise à jour des métadonnées à partir des points enrichis
5429                        const params = data.metaData;
5430
5431                        if (!params) {
5432                            console.warn(`[render] Aucune métadonnée trouvée pour ${pathId}`);
5433                            return;
5434                        }
5435
5436                        const deltaX = params['distance'] * 1000;
5437                        const deltaY = +params['high-point'] - +params['low-point'];
5438                        const ratio = (deltaY / deltaX * 3.7) * 100;
5439                        const percentage = Math.max(35, Math.min(ratio, 60));
5440                        const padding = Math.max(0.02, Math.min(deltaX / deltaY * 0.00466, 6));
5441
5442                        const distanceMax = params['distance'];
5443
5444                        // --- Visorandomap ElevationChart ---
5445                        const { ElevationChart } = VisorandoMap;
5446                        const existingChart = _this.tab.elevationCharts[pathId];
5447
5448                        // Si une instance existe déjà : mise à jour des données et de la couleur
5449                        if (existingChart) {
5450                            const updatedTrail = _this.tab.buildTrail(pathId);
5451                            if (updatedTrail) {
5452                                existingChart.update(updatedTrail);
5453                            }
5454                            const featureColor = feature.properties.user_color || feature.properties.color;
5455                            if (featureColor) {
5456                                existingChart.updateColor(featureColor);
5457                            }
5458                            swiperInstance.updateAutoHeight(0);
5459                            _this.swiper.tabs.updateAutoHeight(200);
5460                            return;
5461                        }
5462
5463                        // Création d'une nouvelle instance
5464                        const trail = _this.tab.buildTrail(pathId);
5465                        if (!trail) return;
5466
5467
5468                        $(slide).empty();
5469                        const bootElem = $($('<div/>', {'class': 'boot-chart'})).appendTo(slide);
5470
5471                        $(slide).off('mouseleave.indicator').on('mouseleave.indicator', function(){
5472                            _this.markers.deleteById(null, true, 'here-indicator');
5473                        });
5474
5475                        const seriesColor = feature.properties.user_color || feature.properties.color || undefined;
5476                        const distanceUnit = settings.currentUnits.distance === 'mi' ? 'mi' : 'km';
5477                        const elevationUnit = settings.currentUnits.altitude === 'ft' ? 'ft' : 'm';
5478
5479                        // Marker local pour la synchronisation avec le diagramme d'élévation
5480                        var hereMarker = null;
5481
5482                        _this.tab.elevationCharts[pathId] = new ElevationChart({
5483                            container: bootElem.get(0),
5484                            trail: trail,
5485                            height: percentage + '%',
5486                            distanceUnit: distanceUnit,
5487                            elevationUnit: elevationUnit,
5488                            seriesColor: seriesColor,
5489                            slopeColoring: settings.slopeColoring,
5490                            enableNavigator: false,
5491                            enableScrollbar: false,
5492                            comparativeScale: false,
5493                            onHover: function(point) {
5494                                if (!_this.mapGl) return;
5495                                if (point) {
5496                                    if (!hereMarker) {
5497                                        var el = document.createElement('div');
5498                                        el.className = 'elevation-chart-highlight';
5499                                        hereMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
5500                                            .setLngLat([point.position.lng, point.position.lat])
5501                                            .addTo(_this.mapGl);
5502                                    } else {
5503                                        hereMarker.setLngLat([point.position.lng, point.position.lat]);
5504                                    }
5505                                } else {
5506                                    if (hereMarker) {
5507                                        hereMarker.remove();
5508                                        hereMarker = null;
5509                                    }
5510                                }
5511                            },
5512                            onClick: function(point) {
5513                                if (point) {
5514                                    _this.markers.createOnce(
5515                                        [point.position.lng, point.position.lat],
5516                                        'here-indicator',
5517                                        'mapboxgl-user-location-dot'
5518                                    );
5519                                }
5520                            }
5521                        });
5522
5523                        const chartPathId = pathId;
5524                        setTimeout(function(){
5525                            swiperInstance.updateAutoHeight(0);
5526                            _this.swiper.tabs.updateAutoHeight(200);
5527                            // Reflow du chart après redimensionnement du Swiper :
5528                            // le container peut ne pas avoir eu ses dimensions finales
5529                            // au moment de la création du chart (autoHeight Swiper).
5530                            const elevChart = _this.tab.elevationCharts[chartPathId];
5531                            if (elevChart && typeof elevChart.reflow === 'function') {
5532                                elevChart.reflow();
5533                            }
5534                        }, 0);
5535
5536                        break;
5537                    default:
5538                }
5539            },
5540            createSlide: function(tabName, swiperInstance){
5541                if(!swiperInstance.slides.length){
5542                    tabName = 'default'
5543                }
5544                switch (tabName) {
5545                    case 'infos':
5546                        return $(swiperInstance.slides).eq(0).clone()
5547                            .removeAttr('style id').attr('class', 'swiper-slide')
5548                        break;
5549                    default:
5550                        return $('<div />', { class: 'swiper-slide' }).removeAttr('style')
5551                }
5552            },
5553            deleteSlide: function(tabName, pathId, swiperInstance){
5554
5555                var slide = $(swiperInstance.slides).filter('#'+tabName+'-'+pathId),
5556                    slideIndex = slide.index();
5557
5558                if(slideIndex == -1) return
5559
5560                switch (tabName) {
5561                    case 'infos':
5562
5563                        if(swiperInstance.slides.length == 1){
5564
5565                            slide.removeAttr('id').find('> div > span').text(0)
5566
5567                        }else if(swiperInstance.slides.length){
5568
5569                            swiperInstance.removeSlide(slideIndex)
5570                            swiperInstance.pagination.update()
5571                        }
5572
5573                        break;
5574                    case 'diagrams':
5575                        // Libération de la mémoire (évite les memory leaks)
5576                        const chartToDestroy = _this.tab.elevationCharts[pathId];
5577                        if (chartToDestroy) {
5578                            chartToDestroy.destroy();
5579                            delete _this.tab.elevationCharts[pathId];
5580
5581                            // Rafraîchir la couleur des instances restantes
5582                            $.each(swiperInstance.slides, function(){
5583                                const slidePathId = ($(this).attr('id') || '').split('-').pop();
5584                                const currentChart = _this.tab.elevationCharts[slidePathId];
5585                                const currentFeature = slidePathId ? _this.drawGl.get(slidePathId) : null;
5586                                if (currentChart && currentFeature && currentFeature.properties) {
5587                                    const featureColor = currentFeature.properties.user_color || currentFeature.properties.color;
5588                                    if (featureColor) {
5589                                        currentChart.updateColor(featureColor);
5590                                    }
5591                                }
5592                            });
5593                        }
5594
5595                        if(swiperInstance.slides.length == 1){
5596                            slide.removeAttr('id').empty()
5597                            $(swiperInstance.placeHolder.clone()).appendTo(slide)
5598                            swiperInstance.updateAutoHeight(0)
5599                            _this.swiper.tabs.updateAutoHeight(200)
5600                        }
5600else if(swiperInstance.slides.length){
5601                            swiperInstance.removeSlide(slideIndex)
5602                            swiperInstance.pagination.update()
5603                        }
5604
5605                        break;
5606                    default:
5607                }
5608            },
5609            update: function(tabName, pathId, data, swiperInstance){
5610
5611                var shouldCreate = true;
5612
5613                $(swiperInstance.slides).each(function(){
5614                    if( $(this).is('[id]') ){
5615                        if(this.id == tabName+'-'+pathId){
5616                            _this.tab.render(this, tabName, data, swiperInstance, pathId)
5617                            shouldCreate = false
5618                        }
5619                    }else{
5620                        // attach id + update existing orphan slide (ex: first page load)
5621                        $(this).attr('id', tabName+'-'+pathId)
5622                        _this.tab.render(this, tabName, data, swiperInstance, pathId)
5623                        shouldCreate = false
5624                    }
5625                })
5626
5627                // Pas de slide dispo, pathId non encore affiché mais présent dans le store
5628                if(shouldCreate && $.inArray(pathId, _this.path.getAll()) != -1){
5629                    var slide  = _this.tab.createSlide(tabName, swiperInstance)
5630                    slide.attr('id', tabName+'-'+pathId)
5631                    swiperInstance.appendSlide(slide)
5632                    _this.tab.render(slide, tabName, data, swiperInstance, pathId)
5633
5634                    if(_this.path.countPoints(pathId) > 1 && _this.drawGl.getMode() == _this.drawGl.modes.DRAW_LINE_STRING) {
5635                        _this.tab.handleSelection(tabName, _this.swiper[tabName], pathId)
5636                    }
5637                }
5638
5639                swiperInstance.pagination.update()
5640            }
5641        },
5642        // Pas d'historique sur les états des tracés ou lorsqu'une action ne modifie pas la BDD
5643        history: {
5644            index: 0,
5645            historyStore: [{
5646                store: Immutable.Map({})
5647            }],
5648            annotations: Immutable.List([]),
5649            operation: function(fn, annotation){
5650                this.annotations = this.annotations.slice(0, this.index + 1);
5651                this.historyStore = this.historyStore.slice(0, this.index + 1);
5652                var newVersion = fn(this.historyStore[this.index]);
5653                this.historyStore.push(newVersion);
5654                this.annotations = this.annotations.push(annotation);
5655                this.index++;
5656                _this.updateGroupActions();
5657                $(document).trigger('history.operation')
5658            },
5659            // Du fait des traitements asynchrones on a besoin de modifier l'historique
5660            commit: function(fn, index){
5661                var _index = index || this.index
5662                if(!this.historyStore[_index]) return
5663                this.historyStore[_index] = fn(this.historyStore[_index],
5664                    (this.historyStore.length) ? this.historyStore[_index-1]: null);
5665            },
5666            get: function(){
5667                return (!!this.historyStore[this.index]) ? this.historyStore[this.index] : null
5668            },
5669            getAnnotation: function(atIndex){
5670                var currentAnnotaion = this.annotations.get(atIndex)
5671                return (!!currentAnnotaion) ? currentAnnotaion : null
5672            },
5673            getGeoJSON: function() {
5674                var storeData = this.get();
5675                if(!storeData) return storeData;
5676
5677                var features = [];
5678                // Pour chaque tracé
5679                storeData.store.mapKeys(function(idPath) {
5680                    // Récupérer le tracé actuel et ses propriétés
5681                    var internalPath = _this.drawGl.get(idPath);
5682
5683                    // Récupération complète des propriétés, pas seulement la couleur
5684                    var internalProperties = internalPath ?
5685                        Immutable.Map(internalPath.properties || {}) :
5686                        Immutable.Map({});
5687
5688                    // Conserver à la fois color et user_color
5689                    var internalColor = internalProperties.get('color');
5690                    var internalUserColor = internalProperties.get('user_color');
5691
5692                    var lineString = turf.feature({
5693                        type: 'LineString',
5694                        coordinates: []
5695                    });
5696
5697                    // Chaque point de chaque tracé
5698                    var points = storeData.store.get(idPath).toJS();
5699                    for(var j=0; j < points.length; j++) {
5700                        var point = points[j];
5701                        lineString.geometry.coordinates.push([point.lng, point.lat]);
5702                    }
5703
5704                    lineString.id = idPath;
5705
5706                    // Préserver toutes les propriétés importantes
5707                    lineString.properties = {
5708                        // Copier toutes les propriétés existantes si disponibles
5709                        ...(internalPath ? internalPath.properties || {} : {}),
5710                        // S'assurer que les propriétés de couleur sont définies
5711                        color: internalColor || internalUserColor || settings.hexColor,
5712                        user_color: internalUserColor || internalColor || settings.hexColor
5713                    };
5714
5715                    features.push(lineString);
5716                });
5717
5718                return turf.featureCollection(features);
5719            },
5720            redo: function(e) {
5721                if (this.index < this.historyStore.length) {
5722                    this.index++;
5723                    // Empêcher de restaurer plus de 1 tracé en version simple en one shot
5724                    if(settings.isSimpleVersion){
5725                        if(this.getGeoJSON().features.length > 1){
5726                            this.index--;
5727                            iziToast.error({
5728                                displayMode: 'replace',
5729                                message: i18n("toast_msg_simple_mode_limit")
5730                            })
5731                            return
5732                        }
5733                    }
5734                }
5735                this.afterSwitchVersion(i18n("toast_msg_restore_done"), e)
5736            },
5737            undo: function(e) {
5738                if (this.index > 0){
5739                    this.index--;
5740                    // Empêcher de restaurer plus de 1 tracé en version simple en one shot
5741                    if(settings.isSimpleVersion){
5742                        if(this.getGeoJSON().features.length > 1){
5743                            this.index++;
5744                            iziToast.error({
5745                                displayMode: 'replace',
5746                                message: i18n("toast_msg_simple_mode_limit")
5747                            })
5748                            return
5749                        }
5750                    }
5751                }
5752                this.afterSwitchVersion(i18n("toast_msg_cancel_done"), e)
5753            },
5754            afterSwitchVersion: function(msg, e){
5755                _this.path.getAll().forEach((pathId) => {
5756                    $.each(['infos', 'diagrams'], function(i, tab){
5757                        _this.tab.deleteSlide(tab, pathId, _this.swiper[tab])
5758                    })
5759                });
5760                _this.activeMode(_this.drawGl.getMode())
5761                _this.store = this.get().store.toJS()
5762                _this.path.updateDrawGlCoordsWithPath([]);
5763                _this.updateGroupActions()
5764                _this.path.handleMarkersPath(true)
5765                _this.path.getAll().forEach((pathId) => {
5766                    _this.updateTracePostOperation(pathId)
5767                })
5768
5769                if(settings.isSimpleVersion) _this.path.handleFallbackSelection({showToast: false})
5770            },
5771            hasUndo: function(){
5772                return this.index !== 0
5773            },
5774            hasRedo: function(){
5775                return this.index !== this.historyStore.length - 1
5776            },
5777        },
5778        activeMode: function(mode){
5779            $(_this.mapGl.getContainer()).alterClass('mode-*', 'mode-'+mode)
5780
5781            settings.groupActions.find('.mode').removeClass('active').
5782            end().find('.'+mode).addClass('active');
5783
5784            this.updateHelper();
5785        },
5786        activeAction: function(action, payload){
5787            if(!settings.isCompatible) return
5788            var el = settings.groupActions.find('#'+action)
5789
5790            el.trigger('click', [payload])
5791            if(!el.is('.active')){
5792                el.addClass('active')
5793            }
5794        },
5795        updateGroupActions: function(){
5796            var countPath = _this.path.getAll().length
5797
5798            // Gestion bouton création de tracé
5799            // Un seul tracé possible pour la version simple (non premium)
5800            let newPathElHandler = settings.groupActions.find('#draw'),
5801                undoHandler = document.querySelectorAll('#undo'),
5802                redoHandler = document.querySelectorAll('#redo')
5803            if(settings.isSimpleVersion){
5804                if(countPath >= 1){
5805                    newPathElHandler.attr('disabled', 'disabled')
5806                }else{
5807                    newPathElHandler.removeAttr('disabled')
5808                }
5809            }else{
5810                newPathElHandler.removeAttr('disabled')
5811            }
5812            if (_this.history.hasRedo()) {
5813                redoHandler.forEach(e => {
5814                    e.removeAttribute('disabled')
5815                })
5816            } else {
5817                redoHandler.forEach(e => {
5818                    e.setAttribute('disabled', 'disabled')
5819                })
5820            }
5821            if (_this.history.hasUndo()) {
5822                undoHandler.forEach(e => {
5823                    e.removeAttribute('disabled')
5824                })
5825            } else {
5826                undoHandler.forEach(e => {
5827                    e.setAttribute('disabled', 'disabled')
5828                })
5829            }
5830        },
5831        updateHelper: function(){
5832
5833            _this.markers.deleteById(null, true, 'here-indicator')
5834
5835            if (!$('#help').is('.active'))  return;
5836
5837            var currentMode = _this.drawGl.getMode(),
5838                currentState = _this.drawGl.getState() // metadonnées du mode
5839
5840            // Chaque mode a ses instructions
5841            switch (currentMode) {
5842                case 'draw_line_string':
5843                    var vertexPos = currentState.currentVertexPosition,
5844                        coordLength = currentState.line.coordinates.length;
5845
5846                    if(vertexPos === 0 && coordLength <= 1 && !_this.store[currentState.line.id]){
5847                        _this.helperTootlip.setContent(i18n("tooltip_msg_place_first_point"))
5848                    }else if(vertexPos === 1 && coordLength > 0){
5849                        _this.helperTootlip.setContent(i18n("tooltip_msg_place_second_point"))
5850                    }else if(vertexPos > 0 && coordLength > 1){
5851                        _this.helperTootlip.setContent(i18n("tooltip_msg_finish_trace"))
5852                    }
5853                    break;
5854                case 'simple_select':
5855                    if(_this.drawGl.getSelectedIds().length){
5856                        if(_this.drawGl.getSelectedPoints().features.length){
5857                            _this.helperTootlip.setContent(i18n("tooltip_msg_drag_points"))
5858                        }else{
5859                            _this.helperTootlip.setContent(i18n("tooltip_msg_click_point_edit"))
5860                        }
5861                    } else {
5862                        _this.helperTootlip.setContent(i18n("tooltip_msg_select_multiple_traces"))
5863                    }
5864                    break;
5865                default:
5866                    _this.helperTootlip.disable()
5867            }
5868        },
5869        markers: {
5870            list: {},
5871            defaultOptions: {
5872                className: '',
5873                text: '',
5874                id: '',
5875                anchor: 'bottom', // ancre en bas : la pointe du SVG pin est exactement sur le point
5876                offset: [0, 0],
5877                attachToMap: false,
5878                coordinates: false
5879            },
5880            delete: function(idPoints){
5881                if(!!_this.markers.list['wpts']) {
5882                    for(var j = 0; j < idPoints.length; j++){
5883                        for (var i = 0; i < _this.markers.list.wpts.length; i++) {
5884
5885                            var el = _this.markers.list.wpts[i].getElement()
5886
5887                            if(!!el.id && el.id == 'point-'+idPoints[j]) {
5888                                _this.markers.list.wpts[i].remove()
5889                                break;
5890                            }
5891
5892                        }
5893                    }
5894                }
5895            },
5896            create: function(markerOpts){
5897
5898                markerOpts = $.extend({},
5899                    Object.assign(this.defaultOptions, {
5900                        el : document.createElement('div')
5901                    }), markerOpts)
5902
5903                markerOpts.el.className = markerOpts.className;
5904                markerOpts.el.style.cursor = 'default';
5905
5906                if (isPromise(markerOpts.id)) {
5907                    markerOpts.id
5908                        .then(function(res) {
5909                            const id = res?.id || res;
5910                            markerOpts.el.id = id ? 'point-' + id : '';
5911                        })
5912                        .catch(function(err) {
5913                            console.warn("Erreur lors de la résolution de markerOpts.id :", err);
5914                            markerOpts.el.id = ''; // fallback raisonnable
5915                        });
5916                } else {
5917                    markerOpts.el.id = ('' + markerOpts.id).length ? 'point-' + markerOpts.id : '';
5918                }
5919
5920                var marker = new maplibregl.Marker({ element: markerOpts.el, anchor: markerOpts.anchor || 'bottom', offset: markerOpts.offset })
5921
5922                if(markerOpts.text.length){
5923                    $(marker.getElement()).html($('<span/>', {
5924                        'text': markerOpts.text
5925                    }).prop('outerHTML'))
5926                }
5927
5928                if($.isArray(markerOpts.coordinates) && markerOpts.coordinates.length === 2){
5929                    marker.setLngLat(markerOpts.coordinates)
5930                }
5931
5932                if(markerOpts.attachToMap){
5933                    marker.addTo(_this.mapGl)
5934                }
5935
5936                return marker;
5937            },
5938            createOnce: function(coordinates, id, className){
5939
5940                var marker = null,
5941                    alreadyExists = false;
5942
5943                if(!!this.list[id]){
5944                    alreadyExists = true
5945                    marker = this.list[id]
5946                }else{
5947                    // Vive l'ES5...pourrave
5948                    this.list[id] = marker = this.create({
5949                        className: className,
5950                        id: id,
5951                        anchor: 'center', // dot indicateur centré sur le point
5952                        offset: [0, 0]    // pas de compensation globe pour le dot
5953                    })
5954                }
5955
5956                marker.setLngLat(coordinates);
5957
5958                if(!alreadyExists) marker.addTo(_this.mapGl);
5959
5960                return marker;
5961            },
5962            deleteById: function(marker, fromList, id){
5963                if(!!fromList){
5964                    if(!this.list[id]) return
5965                    this.list[id].remove()
5966                    delete this.list[id]
5967                }else{
5968                    marker.remove()
5969                }
5970            }
5971        },
5972        updateTopBar: function() {
5973            $.each(settings.groupActions.find('.nav__dropdown > *'), function() {
5974                priorityNav.toMenu(settings.groupActions.get(0), settings.groupActions.attr('instance'))
5975            });
5976
5977            var toggleButton = settings.groupActions.find('.priority-nav__dropdown-toggle')
5978            if (toggleButton.is('.is-open')) {
5979                toggleButton.trigger('click')
5980            }
5981
5982            tippy.hideAll({duration: 0})
5983
5984            var catchEndFit = new $.Deferred()
5985            priorityNav.doesItFit(settings.groupActions.get(0), catchEndFit)
5986
5987            catchEndFit.then(function () {
5988                settings.groupActions.removeClass('mod')
5989                _this.mapGl.resize()
5990                $.each(settings.groupActions.find('.nav > *'), function () {
5991                    var newProps = {
5992                        placement: 'bottom'
5993                    }
5994                    this._tippy.setProps(newProps)
5995                    tippySingleton.setProps(newProps)
5996                })
5997            })
5998        },
5999
6000        // Formate les coordonées selon les paramètres enregistrés ds le profil utilisateur
6001        getFormattedCoordinates: function(coordinates){
6002
6003            switch (settings.coordinateFormat) {
6004                // DMS
6005                case 1:
6006                    var toDMS = new dms(coordinates.lat, coordinates.lng),
6007                        lng = toDMS.dmsArrays.longitude,
6008                        lat = toDMS.dmsArrays.latitude;
6009
6010                    return lng[0]+'° '+lng[1]+'\' '+Number.parseFloat(lng[2]).toFixed(4)+'" '+lng[3]
6011                        +' - '+lat[0]+'° '+lat[1]+'\' '+Number.parseFloat(lat[2]).toFixed(4)+'" '+lat[3];
6012                    break;
6013                // UTM 220
6014                case 2:
6015                    var _utm = UTM.fromLatLon(coordinates.lat, coordinates.lng)
6016                    return 'X : '+_utm.easting.toFixed(2)+' - Y : '+_utm.northing.toFixed(2)+' - '+_utm.zoneNum+_utm.zoneLetter
6017                    break;
6018                // DD 220
6019                default:
6020                    var $lng = $('<span />', {
6021                        class: 'lng',
6022                        text : Number.parseFloat(coordinates.lng).toFixed(5)
6023                    })
6024
6025                    var $lat= $('<span />', {
6026                        class: 'lat',
6027                        text : Number.parseFloat(coordinates.lat).toFixed(5)
6028                    })
6029
6030                    return 'Lng : ' + $lng.html() + ' - '+'Lat : ' + $lat.html()
6031            }
6032        },
6033        setCookie: function(object){
6034            Cookies.set('software.opts', Object.assign(Cookies.getJSON('software.opts'), object), { expires: 365 })
6035        },
6036        addAsyncTask: function(promise){
6037            // Nous pouvons vouloir ajouter une promesse existante ou non
6038            var p = promise || new $.Deferred()
6039            this.asyncTasks.push(p)
6040            return p
6041        },
6042        /**
6043         * Appelle Valhalla via votre proxy, décode la polyline
6044         * et renvoie un format unifié.
6045         *
6046         * @param {Array<{lat:number,lon:number}>} locations
6047         * @returns {Promise<{ success: boolean, points?: [number,number][], metaData?: object, error?: string }>}
6048         */
6049        callRoute: async function (locations, costing, layer = null) {
6050            if (!Array.isArray(locations) || locations.length < 2) {
6051                return { success: false, error: i18n("api_error_need_two_points") };
6052            }
6053            const payload = {
6054                locations,
6055                costing,
6056                ...(layer && { layer })
6057            };
6058
6059            const url = (settings.routingProxyUrl || '/index.php?component=software&task=proxyRouting');
6060
6061            try {
6062                const response = await fetch(url, {
6063                    method: 'POST',
6064                    headers: { 'Content-Type': 'application/json' },
6065                    body: JSON.stringify(payload)
6066                });
6067
6068                const text = await response.text();
6069                const data = JSON.parse(text);
6070
6071                if (!response.ok || !data.success) {
6072                    console.error(`Erreur API: `, data);
6073                    return {
6074                        success: false,
6075                        error: data.error || `HTTP ${response.status}`,
6076                        metaData: data.body || data.metaData
6077                    };
6078                }
6079
6080                if (typeof polyline === 'undefined' || typeof polyline.decode !== 'function') {
6081                    return { success: false, error: i18n("api_error_polyline_module_missing") };
6082                }
6083
6084                if (typeof data.legs === 'undefined') {
6085                    return { success: false, error: i18n("api_error_polyline_data_missing") };
6086                }
6087                let legs = [];
6088                data.legs.forEach(leg => {
6089                    let shape = polyline.decode(leg.shape, 6);
6090                    // Le serveur peut joindre les altitudes (DP 3D) alignées sur les points du shape :
6091                    // on les porte comme 3e composante [lon, lat, alt] pour éviter un appel MNT côté client.
6092                    const altitudes = Array.isArray(leg.altitudes) ? leg.altitudes : null;
6093                    shape = shape.map(([lat, lon], i) => {
6094                        const coord = [
6095                            parseFloat(lon.toFixed(6)),
6096                            parseFloat(lat.toFixed(6))
6097                        ];
6098                        if (altitudes && typeof altitudes[i] === 'number') {
6099                            coord[2] = altitudes[i];
6100                        }
6101                        return coord;
6102                    });
6103                    legs.push(shape);
6104                });
6105
6106
6107                return {
6108                    success: true,
6109                    legs,
6110                    metaData: data.metaData || {},
6111                    sourceService: data.sourceService
6112                };
6113            } catch (err) {
6114                console.error("Erreur lors de l'appel au service :", err);
6115                return { success: false, error: err.message };
6116            }
6117        },
6118
6119
6120        /**
6121         * Enregistre ou met à jour un point en BDD
6122         * @param {string} idPath - ID du tracé
6123         * @param {Array} indexes - Indexes des points
6124         * @returns {Promise<Object>} - Données du point enregistré
6125         */
6126        getAltitudeForPoints: async function(idPath, indexes) {
6127            const points = [];
6128                indexes.forEach(index => {
6129                    if (!this.store[idPath] || !this.store[idPath][index]) {
6130                        console.warn(`Point introuvable: chemin ${idPath}, index ${index}`);
6131                        return;
6132                    }
6133
6134                    const point = this.store[idPath][index];
6135
6136                    const data = {
6137                        lng: point.lng,
6138                        lat: point.lat,
6139                        id: point.id
6140                    };
6141                    points.push(data)
6142                });
6143                const datas = {};
6144                datas.points = points;
6145
6146                async function getAltitudeFromMNT(datas) {
6147                    let responseText;
6148                    try {
6149                        const fetchResponse = await fetch('/index.php?component=software&task=getAltitudeFromMNT', {
6150                            method: 'POST',
6151                            headers: {
6152                                'Content-Type': 'application/json'
6153                            },
6154                            body: JSON.stringify(datas),
6155                        });
6156                        if (!fetchResponse.ok) {
6157                            throw new Error(sprintf(
6158                                i18n("api_error_http_status"),
6159                                fetchResponse.status
6160                            ));
6161                        }
6162                        responseText = await fetchResponse.text();
6163                    } catch(error) {
6164                        console.error(`Erreur AJAX pour les points:`, error.message);
6165                        throw new Error(i18n("api_error_invalid_server_response"));
6166                    }
6167                    let response;
6168                    try {
6169                        response = JSON.parse(responseText);
6170                    } catch (e) {
6171                        console.error("Erreur JSON.parse sur réponse serveur pour les points");
6172                        console.error("Réponse brute :", responseText);
6173                        throw new Error(i18n("api_error_json_parse"));
6174                    }
6175
6176                    if (response.points && Array.isArray(response.points)) {
6177                        return response.points;
6178                    } else {
6179                        throw new Error(i18n("api_error_invalid_points"));
6180                    }
6181                }
6182                try {
6183                    const responsePoints = await getAltitudeFromMNT(datas);
6184                    responsePoints.forEach((pointResponse) => {
6185                        if (pointResponse.id) {
6186                            let path = _this.store[idPath];
6187                            let index = path.findIndex(function(p){
6188                                return p.id == pointResponse.id;
6189                                })
6190                            if(index !== -1) {
6191                                _this.path.updatePoint(idPath, index, {
6192                                    altitude: pointResponse.altitude,
6193                                })
6194                            } else {
6195                                throw new Error(sprintf(
6196                                    i18n("api_error_incompatible_point"),
6197                                    pointResponse
6198                                ));
6199                            }
6200                        }
6201                    });
6202                    console.info('Altitudes calculées avec succès');
6203                } catch (error) {
6204                    console.error(error);
6205                }
6206        },
6207
6208        updateWaypoints: function (idPath) {
6209            const points = _this.store[idPath];
6210            if (!points || !Array.isArray(points) || !points.length) return;
6211            const wpts = _this.markers?.list?.wpts || [];
6212            if (!Array.isArray(wpts) || !wpts.length) return;
6213
6214            points.forEach((point) => {
6215                for (let j = 0; j < wpts.length; j++) {
6216                    const el = wpts[j].getElement?.();
6217                    if (!el) continue;
6218
6219                    if (`point-${point.id}` !== el.id) continue;
6220
6221                    const distance = Number(this.convertDistance(point?.distance ?? 0)).toFixed(2);
6222                    const altitude = this.convertAltitude(point?.altitude ?? 0);
6223                    const popupMessage =
6224                        "<div><img height='28' width='28' src='/img/fiches/distance.svg' /> " +
6225                        settings.currentUnits.distance + " " + distance + "</div>" +
6226                        "<div><img height='28' width='28' src='/img/fiches/altitude.svg' /> alt " +
6227                        altitude + settings.currentUnits.altitude + "</div>";
6228
6229                    const wptInfo = [];
6230
6231                    if (typeof point.textWP === 'string' && point.textWP.trim() !== '') {
6232                        wptInfo.push(
6233                            "<div><img height='28' width='28' src='/img/fiches/indication.svg' /> " +
6234                            point.textWP.trim() +
6235                            "</div>"
6236                        );
6237                    }
6238
6239                    if (typeof point.htmlEG === 'string') {
6240                        const eg = point.htmlEG.trim();
6241                        if (eg !== '') {
6242                            wptInfo.push(eg);
6243                        }
6244                    }
6245
6246                    const html = popupMessage + wptInfo.join('');
6247
6248                    wpts[j].setPopup(
6249                        new maplibregl.Popup({
6250                            focusAfterOpen: false,
6251                            offset: { bottom: [0, -50] }
6252                        }).setHTML(html)
6253                    );
6254
6255                    break;
6256                }
6257            });
6258        },
6259        /**
6260         * met à jour le diagramme et les infos du tracé
6261         * @param {string} idPath - ID du tracé
6262         */
6263        updateTracePostOperation: async function(idPath, storeInLocalStorage = true) {
6264            if (!idPath || !_this.store[idPath] || _this.store[idPath].length < 2) {
6265                return;
6266            }
6267            // Accès au bon chemin
6268            const path = _this.store[idPath];
6269
6270            // Génération des données pour le diagramme (distance cumulée + altitude)
6271            const altitudeChartData = [];
6272            let cumulativeDist = 0;
6273            let lastPointWithAltitude = null; // dernier point avec altitude traité
6274
6275            path.forEach((point, i) => {
6276                if (typeof point.altitude !== 'number') return;
6277                if (lastPointWithAltitude !== null) {
6278                    // Calcul de la distance depuis le dernier point avec altitude connu
6279                    // (et non plus depuis path[i-1] qui peut ne pas avoir d'altitude)
6280                    const segmentDist = calculateDistanceApprox(
6281                        [lastPointWithAltitude.lng, lastPointWithAltitude.lat],
6282                        [point.lng, point.lat]
6283                    );
6284                    cumulativeDist += segmentDist;
6285                }
6286                _this.store[idPath][i].distance = cumulativeDist;
6287                lastPointWithAltitude = point;
6288
6289                altitudeChartData.push([+_this.convertDistance(cumulativeDist.toFixed(3)), Math.round(_this.convertAltitude(point.altitude))]);
6290
6291            })
6292            // Mise à jour des métadonnées à partir des points enrichis
6293            const data = {};
6294            data.metaData = _this.computeMetaDataFromPoints(_this.store[idPath]);
6295            data.altitudeChartData = altitudeChartData;
6296            // Mise à jour du diagramme
6297            _this.tab.update('diagrams', idPath, data, _this.swiper.diagrams);
6298            _this.tab.update('infos', idPath, data, _this.swiper.infos);
6299            _this.updateWaypoints(idPath);
6300            if (storeInLocalStorage) {
6301                _this.storePathsLocalStorage();
6302            }
6303        },
6304
6305        /**
6306         * Calcule les métadonnées à partir d'une liste de points
6307         * @param {Array} points - Liste de points [lng, lat, altitude]
6308         * @returns {Object} - Métadonnées calculées
6309         */
6310        computeMetaDataFromPoints: function (points) {
6311            let pos = 0, neg = 0, dist = 0, altitudeReference = 0;
6312            let high = -Infinity, low = Infinity;
6313            let prev = null;
6314            let countPts = 0;
6315            let deltaList = '';
6316            points.forEach((pt) => {
6317                if (typeof pt.altitude !== 'number') return; // ignore les points sans altitude
6318
6319                high = Math.max(high, pt.altitude);
6320                low = Math.min(low, pt.altitude);
6321
6322                if (prev) {
6323                    const delta = pt.altitude - altitudeReference;
6324                    if (Math.abs(delta) >= settings.heightDifferenceLimit) {
6325                        altitudeReference = pt.altitude;
6326                        if (delta > 0) pos += delta;
6327                        else if (delta < 0) neg += Math.abs(delta);
6328                    }
6329                    dist += calculateDistanceApprox(
6330                        [pt.lng, pt.lat],
6331                        [prev.lng, prev.lat]
6332                    ) * 1000;
6333                } else {
6334                    altitudeReference = pt.altitude;
6335                }
6336
6337                prev = pt;
6338                countPts++;
6339            });
6340
6341            const durationMinutes = Math.round((dist / 1000 + pos / 100) / 3.5 * 60);
6342            let h = Math.floor(durationMinutes / 60);
6343            let m = 5 * Math.round((durationMinutes % 60) / 5);
6344            if (m === 0) m = '00';
6345            else if (m === 5) m = '05';
6346            else if (m === 60) {
6347                m = '00';
6348                h++;
6349            }
6350
6351            const duration = `${h > 0 ? h + 'h ' : ''}${m}${h === 0 && m > 0 ? 'min' : ''}`;
6352
6353            return {
6354                'duration': duration,
6355                'distance': this.convertDistance(dist / 1000).toFixed(2),
6356                'pos-elevation': this.convertAltitude(Math.round(pos)),
6357                'neg-elevation': this.convertAltitude(Math.round(neg)),
6358                'high-point': this.convertAltitude(Math.round(high)),
6359                'low-point': this.convertAltitude(Math.round(low)),
6360                'count-point': countPts
6361            };
6362        },
6363        storePathsLocalStorage: function () {
6364            try {
6365                const pathCopy = JSON.parse(JSON.stringify(this.store));
6366                let colors = new Object();
6367                Object.keys(this.store).forEach(function (idPath) {
6368                    const feature = _this.drawGl.get(idPath);
6369                    colors[idPath] = feature?.properties?.user_color || feature?.properties?.color || settings.hexColor;
6370                });
6371
6372                // BUGFIX : Appel de cleanLocalStorage() au lieu de localStorage.clear()
6373                // qui effacerait tout le localStorage du domaine
6374                _this.cleanLocalStorage();
6375
6376                // Puis réécriture des nouvelles valeurs
6377                localStorage.setItem('visorando_paths', JSON.stringify(pathCopy));
6378                localStorage.setItem('visorando_paths_colors', JSON.stringify(colors));
6379                localStorage.setItem('visorando_paths_idRando', settings.idRando);
6380                localStorage.setItem('visorando_paths_idRandoHash', settings.idRandoHash);
6381                localStorage.setItem('visorando_paths_time', Date.now());
6382            } catch (e) {
6383                console.error("Erreur lors de la sauvegarde des tracés:", e);
6384                iziToast.error({
6385                    message: i18n("toast_msg_trace_too_long")
6386                });
6387            }
6388        },
6389
6390        cleanLocalStorage: function () {
6391            try {
6392                localStorage.removeItem('visorando_paths');
6393                localStorage.removeItem('visorando_paths_colors');
6394                localStorage.removeItem('visorando_paths_idRando');
6395                localStorage.removeItem('visorando_paths_idRandoHash');
6396                localStorage.removeItem('visorando_paths_time');
6397                sessionStorage.removeItem("stoGpxTitle");
6398                sessionStorage.removeItem("stoGpxDescription");
6399            } catch (e) {
6400                console.error("Erreur lors du nettoyage du localStorage:", e);
6401            }
6402        },
6403
6404        getPathsLocalStorage: async function (mapReady, fetchGeoJSON) {
6405            try {
6406                const localPaths = localStorage.getItem('visorando_paths');
6407                if (localPaths === null || localPaths === 'null') {
6408                    _this.emptyPathsFromLocalStorage(mapReady, fetchGeoJSON);
6409                    return;
6410                }
6411                const paths = JSON.parse(localPaths);
6412                if (paths.length === 'undefined' || paths.length === 0) {
6413                    _this.emptyPathsFromLocalStorage(mapReady, fetchGeoJSON);
6414                    return;
6415                }
6416
6417                const colors = JSON.parse(localStorage.getItem('visorando_paths_colors'));
6418
6419                const id = localStorage.getItem('visorando_paths_idRando');
6420
6421                settings.idRando = (!localPaths || JSON.parse(localPaths).length === 0 || ['null', null].includes(id) || isNaN(+id)) ? null : +id;
6422
6423                if (settings.idRando && settings.idRando > 0) {
6424                    const response = await fetch('index.php?component=software&task=isAuthor&idRandonnee=' + settings.idRando, {
6425                        method: 'GET'
6426                    });
6427                    const json = await response.json();
6428                    if (!json || !json.isAuthor) {
6429                        _this.emptyPathsFromLocalStorage(mapReady, fetchGeoJSON);
6430                        return;
6431                    }
6432                }
6433                settings.idRandoHash = localStorage.getItem('visorando_paths_idRandoHash');
6434                if (settings.idRandoHash === null || settings.idRandoHash === 'null') {
6435                    settings.idRandoHash = '';
6436                }
6437                let dateLocalStorage = localStorage.getItem('visorando_paths_time');
6438                if (dateLocalStorage === null || dateLocalStorage === 'null') {
6439                    dateLocalStorage = 0;
6440                }
6441                if (dateLocalStorage && dateLocalStorage > 0) {
6442                    const now = Date.now();
6443                    if ((now - dateLocalStorage) > settings.localStorageExpiration) {
6444                        const confirmOpts = Object.assign({
6445                            title: i18n("toast_title_get_routes_from_local"),
6446                            message: i18n("toast_msg_get_routes_from_local"),
6447                            buttons: [
6448                                [`<button class="btn s">${i18n("toast_button_yes")}</button>`,
6449                                    function(instance, toast) {
6450                                        _this.processInitPaths(paths, [], mapReady, fetchGeoJSON);
6451                                        instance.hide({}, toast);
6452                                    },
6453                                    true
6454                                ],
6455                                [`<button class="btn e">${i18n("toast_button_no")}</button>`,
6456                                    function(instance, toast) {
6457                                        _this.emptyPathsFromLocalStorage(mapReady, fetchGeoJSON);
6458                                        instance.hide({transitionOut: 'fadeOut'}, toast);
6459                                    },
6460                                    false
6461                                ]
6462                            ]
6463                        }, _this.commonConfirmOpts);
6464
6465                        iziToast.show(confirmOpts)
6466                    } else {
6467                        _this.processInitPaths(paths, colors, mapReady, fetchGeoJSON);
6468                    }
6469                } else {
6470                    _this.processInitPaths([], [], mapReady, fetchGeoJSON);
6471                }
6472
6473            } catch (e) {
6474                console.error("Erreur lors de la récupération des tracés:", e);
6475                _this.processInitPaths([], [], mapReady, fetchGeoJSON);
6476            }
6477        },
6478        emptyPathsFromLocalStorage: function (mapReady, fetchGeoJSON) {
6479            settings.idRando = 0;
6480            settings.idRandoHash = '';
6481            _this.cleanLocalStorage();
6482            _this.processInitPaths([], [], mapReady, fetchGeoJSON);
6483        },
6484        processInitPaths: function(paths, colors, mapReady, fetchGeoJSON, storeInLocalStorage = true) {
6485            if (!paths || typeof paths !== 'object') {
6486                console.warn('processInitPaths: paths est null ou invalide', paths);
6487                return;
6488            }
6489
6490            var nbPath = Object.keys(paths).length,
6491                nbPoints = 0;
6492
6493            if(nbPath > 1 && settings.isSimpleVersion){
6494
6495                iziToast.show({
6496                    message: settings.user.hasPremiumAccess ?
6497                        i18n("toast_msg_advanced_mode_required") :
6498                        i18n("toast_msg_premium_needed"),
6499                    closeOnClick: false,
6500                    color: 'white',
6501                    progressBar: false,
6502                    drag: false,
6503                    timeout: false,
6504                    close: false,
6505                    overlay: true,
6506                    position: 'center',
6507                    buttons: settings.user.hasPremiumAccess ?
6508                        [
6509                            [`<button class="btn s">${i18n("toast_button_activate_advanced")}</button>`,
6510                                function(instance, toast) {
6511                                    instance.hide({transitionOut: 'fadeOut'}, toast)
6512                                    _this.activeAction('switch-version')
6513                                },
6514                                true
6515                            ]
6516                        ] : [
6517                            [`<button class="btn s">${i18n("toast_button_subscribe_premium")}</button>`,
6518                                function(instance, toast) {
6519                                    window.location = settings.clubUrl
6520                                },
6521                                true
6522                            ]
6523                        ]
6524                })
6525
6526                if(!settings.user.hasPremiumAccess){
6527                    return
6528                }
6529            }
6530
6531            mapReady.done(function(){
6532                $.each(paths, function(pathId, points){
6533                    var path = turf.feature({
6534                        type: 'LineString',
6535                        coordinates: []
6536                    });
6537
6538                    // skip iteration
6539                    if(points.length < 2) {
6540                        nbPath--;
6541                        return true
6542                    }
6543
6544                    // Par convention et pour maintenir une unicité dans drawGl
6545                    path.id = pathId
6546                    nbPoints += points.length
6547                    // Pour chaque points du tracé courant...
6548                    $.each(points, function(order, point){
6549                        point.order = +order
6550                        _this.path.insertPoint(pathId, order, point)
6551
6552                        path.geometry.coordinates.push([point.lng, point.lat])
6553                    })
6554
6555                    _this.drawGl.add(path);
6556                });
6557
6558                _this.path.handleMarkersPath(false)
6559                _this.setInitialDescriptionWaypointNumbers();
6560
6561                if(_this.path.getAll().length){
6562                    _this.actions.center(null, 1)
6563                    _this.history.historyStore[0] = {
6564                        store: Immutable.fromJS(_this.store)
6565                    }
6566                }
6567
6568                if (nbPoints) {
6569                    const nbPathNum = Number(nbPath);
6570                    const nbPointsNum = Number(nbPoints);
6571
6572                    iziToast.success({
6573                        message: sprintf(
6574                            i18n("toast_msg_trace_points_loaded_success"),
6575                            nbPathNum
6576                        )
6577                    });
6578                }
6579
6580                if(nbPath) {
6581                    _this.drawGl.changeMode('simple_select', {
6582                        featureIds: _this.path.getAll()
6583                    })
6584                    _this.activeMode('simple_select');
6585                    _this.updateGroupActions()
6586                    _this.updateHelper()
6587                } else {
6588                    _this.drawGl.changeMode('draw_line_string')
6589                    _this.activeMode('draw_line_string')
6590                }
6591
6592                _this.path.getAll().forEach((pathId) => {
6593                    let color = settings.hexColor;
6594                    if (colors !== null && colors !== 'null' && colors[pathId] !== undefined && colors[pathId] !== null) {
6595                        color = colors[pathId];
6596                    }
6597                    _this.changePathColor(pathId, color);
6598                    _this.drawGl.renderCanvas();
6599                    _this.updateTracePostOperation(pathId, storeInLocalStorage);
6600                })
6601
6602                // Synchroniser le color picker avec la couleur du premier tracé chargé
6603                const firstPathId = _this.path.getAll()[0];
6604                if (firstPathId) {
6605                    const firstFeature = _this.drawGl.get(firstPathId);
6606                    const firstColor = firstFeature?.properties?.user_color || firstFeature?.properties?.color;
6607                    if (firstColor) {
6608                        $('#color-picker-dropdown ul li').removeClass('active');
6609                        $('#color-picker-dropdown ul li a').each(function() {
6610                            if ($(this).data('color') === firstColor) {
6611                                $(this).parent().addClass('active');
6612                                $('#color-picker-dropdown .dropdown-toggle .color-circle').
6612css('background-color', firstColor);
6613                            }
6614                        });
6615                    }
6616                }
6617
6618                fetchGeoJSON.resolve()
6619
6620            })
6621        },
6622        changePathColor: function(pathId, color) {
6623            const feature = _this.drawGl.get(pathId);
6624            if (!feature) return;
6625
6626            feature.properties = feature.properties || {};
6627            feature.properties.color = color;
6628            feature.properties.user_color = color;
6629
6630            if (typeof _this.drawGl.setFeatureProperty === 'function') {
6631                _this.drawGl.setFeatureProperty(pathId, 'color', color);
6632                _this.drawGl.setFeatureProperty(pathId, 'user_color', color);
6633            }
6634        },
6635        convertDistance: function(km) {
6636            if (settings.currentUnits.distance=='mi') {
6637                const MILES_PER_KM = 0.621371;
6638                return km * MILES_PER_KM;
6639            }
6640            return km;
6641        },
6642
6643        convertAltitude: function(meters) {
6644            if (settings.currentUnits.altitude=='ft') {
6645                const FEET_PER_METER = 3.28084;
6646                return Math.round(meters * FEET_PER_METER);
6647            }
6648            return Math.round(meters);
6649        },
6650        projectPointOnSegment: function(clickPoint, segmentStart, segmentEnd) {
6651            const A = { x: segmentStart[0], y: segmentStart[1] };
6652            const B = { x: segmentEnd[0], y: segmentEnd[1] };
6653            const P = { x: clickPoint.lng, y: clickPoint.lat };
6654
6655            const AB = { x: B.x - A.x, y: B.y - A.y };
6656            const AP = { x: P.x - A.x, y: P.y - A.y };
6657
6658            const AB_squared = AB.x * AB.x + AB.y * AB.y;
6659            const AP_AB = AP.x * AB.x + AP.y * AB.y;
6660
6661            let t = AB_squared !== 0 ? AP_AB / AB_squared : 0;
6662            t = Math.max(0, Math.min(1, t)); // Contraindre t entre 0 et 1
6663
6664            return {
6665                lng: A.x + t * AB.x,
6666                lat: A.y + t * AB.y
6667            };
6668        }
6669    }
6670    // ───────────────────────────────────────────────
6671    // COLOR PICKER LOGIC
6672    // ───────────────────────────────────────────────
6673    $('#color-picker-dropdown .dropdown-item').on('click', function (e) {
6674
6675        var selectedColor = $(this).data('color');
6676        $('#color-picker-dropdown li').removeClass('active');
6677        $(this).closest('li').addClass('active');
6678        $('#color-picker-dropdown .dropdown-toggle .color-circle').
6678css('background-color', selectedColor);
6679
6680        let activeId = _this.path.getActivePathId();
6681
6682        // Si aucun tracé actif (ex : modification d'un circuit existant sans sélection explicite),
6683        // appliquer la couleur à tous les tracés disponibles
6684        if (!activeId || activeId.length === 0) {
6685            activeId = _this.path.getAll();
6686        }
6687
6688        try {
6689            activeId.forEach((id) => {
6690                _this.changePathColor(id, selectedColor);
6691                $('.swiper-pagination-bullet[data-path-id=' + id + ']').css('background-color', selectedColor);
6692            })
6693            _this.drawGl.renderCanvas();
6694
6695            _this.path.getAll().forEach((pathId) => {
6696                _this.updateTracePostOperation(pathId);
6697            })
6698
6699            if (typeof _this.path?.handleMarkersPath === 'function') {
6700                _this.path.handleMarkersPath(true);
6701            }
6702
6703            if (activeId.length > 0) {
6704                iziToast.success({
6705                    message: i18n("toast_msg_color_applied"),
6706                    position: 'bottomCenter',
6707                    timeout: 2000
6708                });
6709            }
6710
6711            _paq.push(['trackEvent',
6712                'Route planner',
6713                'Change path color',
6714                'Set path color to ' + selectedColor,
6715            ]);
6716
6717        } catch (err) {
6718            console.error('Erreur couleur:', err);
6719            iziToast.error({
6720                message: sprintf(
6721                    i18n("toast_msg_color_change_failed"),
6722                    err.message
6723                ),
6724                position: 'bottomCenter',
6725                timeout: 3000
6726            });
6727        }
6728    });
6729
6730    return _this
6731})()
6732
6733
6734    $.fn.hasScrollBar = function() {
6735        return this.prop('scrollHeight') > this.prop('clientHeight');
6736    }
6737
6738    $.fn.alterClass = function ( removals, additions ) {
6739
6740        var self = this;
6741        if ( removals.indexOf( '*' ) === -1 ) {
6742            // Use native jQuery methods if there is no wildcard matching
6743            self.removeClass( removals );
6744            return !additions ? self : self.addClass( additions );
6745        }
6746        var patt = new RegExp( '\\s' +
6747            removals.
6748            replace( /\*/g, '[A-Za-z0-9-_]+' ).
6749            split( ' ' ).
6750            join( '\\s|\\s' ) +
6751            '\\s', 'g' );
6752        self.each( function ( i, it ) {
6753            var cn = ' ' + it.className + ' ';
6754            while ( patt.test( cn ) ) {
6755                cn = cn.replace( patt, ' ' );
6756            }
6757            it.className = $.trim( cn );
6758        });
6759        return !additions ? self : self.addClass( additions );
6760    };
6761
6762    /**
6763     * Calcule la distance approximative entre deux points en kilomètres
6764     * @param {Array} point1 - Coordonnées [lng, lat]
6765     * @param {Array} point2 - Coordonnées [lng, lat]
6766     * @returns {number} - Distance en kilomètres
6767     */
6768    function calculateDistanceApprox(point1, point2) {
6769        const toRad = value => value * Math.PI / 180;
6770        const R = 6371; // Rayon de la Terre en km
6771
6772        const dLat = toRad(point2[1] - point1[1]);
6773        const dLon = toRad(point2[0] - point1[0]);
6774
6775        const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
6776            Math.cos(toRad(point1[1])) * Math.cos(toRad(point2[1])) *
6777            Math.sin(dLon/2) * Math.sin(dLon/2);
6778
6779        const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
6780        return R * c;
6781    }
6782
6783    function computeMapDim(){
6784        var dim = _module.mapGl.getContainer().getBoundingClientRect()
6785        return { w: dim.width, h: dim.height };
6786    }
6787
6788    function throttleWithoutTrailing(callback, delay) {
6789        var throttleTimeout = null;
6790        var storedEvent = null;
6791
6792        var throttledEventHandler = function(e) {
6793            storedEvent = e;
6794
6795            var shouldHandleEvent = !throttleTimeout;
6796
6797            if (shouldHandleEvent) {
6798                callback(storedEvent);
6799
6800                storedEvent = null;
6801
6802                throttleTimeout = setTimeout(function() {
6803                    throttleTimeout = null;
6804
6805                    if (storedEvent) {
6806                        throttledEventHandler(storedEvent);
6807                    }
6808                }, delay);
6809            }
6810        };
6811
6812        return throttledEventHandler;
6813    };
6814
6815    function throttle(func, limit) {
6816        var lastFunc;
6817        var lastRan;
6818        return function () {
6819            var context = this;
6820            var args = arguments;
6821
6822            if (!lastRan) {
6823                func.apply(context, args);
6824                lastRan = Date.now();
6825            } else {
6826                clearTimeout(lastFunc);
6827                lastFunc = setTimeout(function () {
6828                    if (Date.now() - lastRan >= limit) {
6829                        func.apply(context, args);
6830                        lastRan = Date.now();
6831                    }
6832                }, limit - (Date.now() - lastRan));
6833            }
6834        };
6835    };
6836
6837    /**
6838     * Fonction pour récupérer le mode de transport sélectionné
6839     * @returns {string} - Le mode de transport sélectionné (pédestre ou cycliste)
6840     */
6841    function getSelectedMode() {
6842        // Disable selector for now
6843        return 'pedestrian';
6844        // const activeItem = document.querySelector('#auto-trace-modes .dropdown-item.active');
6845        // return activeItem ? activeItem.dataset.mode : 'pedestrian'; // fallback safe
6846    }
6847
6848    function isPromise(promise){
6849        return promise && typeof promise.then == 'function'
6850    }
6851
6852    function _get(obj, prop, defaultValue){
6853        if(obj.hasOwnProperty(prop)){
6854            return obj[prop]
6855        }
6856        return defaultValue || null
6857    }
6858
6859    $.extend({
6860        getCSS: function(urls, callback, nocache){
6861            $.when.apply($,
6862                $.map(urls, function(url){
6863                    if (!!nocache) url += '?_ts=' + new Date().getTime();
6864                    return $.get(url, function(){
6865                        $('<link>', {rel:'stylesheet', type:'text/css', 'href':url}).appendTo('head');
6866                    });
6867                })
6868            ).always(function(){
6869                if (typeof callback=='function') callback();
6870            });
6871        },
6872    });
6873
6874    return _module.init()
6875}));
6876    
6877
6878// iOS workaround: MapLibre v5 uses Pointer Events + touch-action:none on canvas,
6879// which prevents iOS from synthesizing click events on sibling elements.
6880// Fix: listen to pointerup globally and trigger click() on UI elements outside the map canvas.
6881(function() {
6882    if (!(/iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1))) return;
6883
6884    var UI_SELECTORS = 'a, button, label, input, [role="button"], .tab, .expand-handler';
6885    var MAP_UI_SELECTORS = '.maplibregl-ctrl button, .maplibregl-ctrl a, .vrmap-close-panel, .vrmap-baselayer-thumbnail, .vrmap-map-layers-panel label, .vrmap-map-layers-panel input';
6886
6887    document.addEventListener('pointerup', function(e) {
6888        if (e.button !== 0) return;
6889        var target = e.target.closest(UI_SELECTORS) || e.target.closest(MAP_UI_SELECTORS);
6890        if (target && !target.closest('.maplibregl-canvas-container')) {
6891            target.click();
6892        }
6893    });
6894})();
6895
6896$(function () {
6897  const $toolbar = $('.responsive-toolbar');
6898  let isDragging = false;
6899  let offsetX = 0;
6900  let offsetY = 0;
6901  let toolbarMoved = false;
6902  let autoTraceEnabled = $('#auto-trace-toggle').prop('checked');
6903  let selectedColor = '#FF7F00';
6904  let selectedMode = 'pédestre';
6905
6906  // ───────────────────────────────────────────────
6907  // CENTER TOOLBAR INITIALLY (accurate centering)
6908  // ───────────────────────────────────────────────
6909  function centerToolbar() {
6910    if (toolbarMoved) return;
6911
6912    const winWidth = $(window).width();
6913    const tbWidth = $toolbar.outerWidth();
6914    const centeredLeft = (winWidth - tbWidth) / 2;
6915
6916    $toolbar.css({
6917      left: `${centeredLeft}px`,
6918      top: '60px',
6919      transform: 'none',
6920      width: ''
6921    });
6922  }
6923
6924  centerToolbar();
6925
6926  // ───────────────────────────────────────────────
6927  // MODE BUTTON VISIBILITY CONTROL
6928  // ───────────────────────────────────────────────
6929  function refreshModeToggleVisibility() {
6930    if (autoTraceEnabled) {
6931      $('#auto-trace-mode-toggle').show();
6932    } else {
6933      $('#auto-trace-mode-toggle').hide();
6934      $('.modes-dropdown').hide();
6935    }
6936  }
6937
6938  refreshModeToggleVisibility();
6939
6940  // ───────────────────────────────────────────────
6941  // CLAMPING TOOLBAR INSIDE WINDOW
6942  // ───────────────────────────────────────────────
6943  function clampPosition(left, top) {
6944    const winWidth = $(window).width();
6945    const winHeight = $(window).height();
6946    const tbWidth = $toolbar.outerWidth();
6947    const tbHeight = $toolbar.outerHeight();
6948    return {
6949      left: Math.max(0, Math.min(left, winWidth - tbWidth)),
6950      top: Math.max(0, Math.min(top, winHeight - tbHeight))
6951    };
6952  }
6953
6954  // ───────────────────────────────────────────────
6955  // DRAG START (Mouse & Touch)
6956  // ───────────────────────────────────────────────
6957  function startDrag(pageX, pageY) {
6958    isDragging = true;
6959    toolbarMoved = true;
6960
6961    // Cacher les dropdowns pour éviter des sauts de layout
6962    $('.dropdown').removeClass('open');
6963
6964    // Supprimer les transitions pendant le drag
6965    $toolbar.css({
6966      transition: 'none',
6967      transform: 'none'
6968    });
6969
6970    // Forcer le reflow
6971    void $toolbar[0].offsetWidth;
6972
6973    // Fixer la largeur actuelle pour éviter les sauts
6974    const currentWidth = $toolbar.outerWidth();
6975    $toolbar.css('width', currentWidth + 'px');
6976
6977    // Fixer position actuelle
6978    const offset = $toolbar.offset();
6979    offsetX = pageX - offset.left;
6980    offsetY = pageY - offset.top;
6981
6982    $toolbar
6983        .addClass('dragging')
6984        .css({
6985          position: 'fixed',
6986          left: offset.left + 'px',
6987          top: offset.top + 'px'
6988        });
6989  }
6990
6991  function endDrag() {
6992    isDragging = false;
6993
6994    const left = parseInt($toolbar.css('left'), 10);
6995    const top = parseInt($toolbar.css('top'), 10);
6996
6997    $toolbar.removeClass('dragging');
6998
6999    // Remet en place la transition douce
7000    $toolbar.css({
7001      left: `${left}px`,
7002      top: `${top}px`,
7003      transform: 'none'
7004    });
7005
7006    // Délaye la remise du width à auto (évite glitch visuel immédiat)
7007    setTimeout(() => {
7008      $toolbar.css({
7009        width: '',
7010        transition: ''
7011      });
7012    }, 50);
7013  }
7014
7015  // DRAG HANDLE ONLY — Mouse
7016  $toolbar.on('mousedown', '.drag-handle', function (e) {
7017    e.preventDefault();
7018    e.stopPropagation();
7019    startDrag(e.pageX, e.pageY);
7020  });
7021
7022  // DRAG HANDLE ONLY — Touch
7023  $toolbar.on('touchstart', '.drag-handle', function (e) {
7024    const touch = e.originalEvent.touches[0];
7025    e.preventDefault();
7026    e.stopPropagation();
7027    startDrag(touch.pageX, touch.pageY);
7028  });
7029
7030  // ───────────────────────────────────────────────
7031  // 🖱 MOUSE DRAG EVENTS
7032  // ───────────────────────────────────────────────
7033  $toolbar.on('mousedown', '.drag-handle', function (e) {
7034    e.preventDefault();
7035    e.stopPropagation();
7036    startDrag(e.pageX, e.pageY);
7037  });
7038
7039  $(document).on('mousemove', function (e) {
7040    if (isDragging) {
7041      e.preventDefault();
7042      const { left, top } = clampPosition(e.pageX - offsetX, e.pageY - offsetY);
7043      $toolbar.css({ left: `${left}px`, top: `${top}px` });
7044    }
7045  });
7046
7047  $(document).on('mouseup', function () {
7048    if (isDragging) endDrag();
7049  });
7050
7051  // ───────────────────────────────────────────────
7052  // TOUCH DRAG EVENTS
7053  // ───────────────────────────────────────────────
7054  $toolbar.on('touchstart', '.drag-handle', function (e) {
7055    const touch = e.originalEvent.touches[0];
7056    startDrag(touch.pageX, touch.pageY);
7057  });
7058
7059  $(document).on('touchmove', function (e) {
7060    if (isDragging) {
7061      e.preventDefault();
7062      const touch = e.originalEvent.touches[0];
7063      const { left, top } = clampPosition(touch.pageX - offsetX, touch.pageY - offsetY);
7064      $toolbar.css({ left: `${left}px`, top: `${top}px` });
7065    }
7066  });
7067
7068  $(document).on('touchend', function () {
7069    if (isDragging) endDrag();
7070  });
7071
7072  // ───────────────────────────────────────────────
7073  // TOGGLES & DROPDOWNS
7074  // ───────────────────────────────────────────────
7075  // $('#auto-trace-mode-toggle').on('click', function (e) {
7076  //   if (!autoTraceEnabled) return;
7077  //   e.stopPropagation();
7078  //   $('.dropdown-menu').not('#auto-trace-modes').hide();
7079  //   $('#auto-trace-modes').toggle();
7080  // });
7081
7082  $('#auto-trace-toggle').on('click', function () {
7083
7084    autoTraceEnabled = !autoTraceEnabled;
7085
7086    $(this).closest('.responsive-toolbar').toggleClass('auto-trace-toolbar-enabled auto-trace-toolbar-disabled');
7087
7088    if (typeof VISORANDO_SOFTWARE?.toggleAutoTracing === 'function') {
7089      VISORANDO_SOFTWARE.toggleAutoTracing(this);
7090    }
7091    refreshModeToggleVisibility();
7092  });
7093
7094  // $('#more-menu-toggle').on('click', function (e) {
7095  //   e.stopPropagation();
7096  //   $('.dropdown-menu').not('#more-menu-dropdown').hide();
7097  //   $('#more-menu-dropdown').toggle();
7098  // });
7099  //
7100  // $('#color-picker-toggle').on('click', function (e) {
7101  //   e.stopPropagation();
7102  //   $('.dropdown-menu').not('#color-picker-dropdown').hide();
7103  //   $('#color-picker-dropdown').toggle();
7104  // });
7105
7106  $(document).on('mousedown', function (e) {
7107    if (!$(e.target).closest('.responsive-toolbar').length) {
7108      $('.dropdown').removeClass('open');
7109    }
7110  });
7111
7112  // ───────────────────────────────────────────────
7113  // MODE SELECTOR
7114  // ───────────────────────────────────────────────
7115  $('#auto-trace-modes .dropdown-item').on('click', function () {
7116
7117    selectedMode = $(this).data('mode');
7118    $('#auto-trace-modes li').removeClass('active');
7119    $(this).closest('li').addClass('active');
7120    $('#auto-trace-modes .dropdown-toggle .auto-trace-mode-text').text('Mode : ' + $(this).text());
7121
7122    if (typeof VISORANDO_SOFTWARE !== 'undefined') {
7123      VISORANDO_SOFTWARE.selectedRoutingService = () => selectedRoutingService;
7124      VISORANDO_SOFTWARE.selectedRoutingMode = () => selectedMode;
7125    }
7126
7127    console.log('Mode de routage sélectionné :', selectedMode);
7128  });
7129
7130  // ───────────────────────────────────────────────
7131  // AUTO-TRACE INIT PATCH
7132  // ───────────────────────────────────────────────
7133  if (typeof VISORANDO_SOFTWARE?.toggleAutoTracing === 'function') {
7134    if (!$('#auto-trace').hasClass('active')) {
7135      VISORANDO_SOFTWARE.toggleAutoTracing(document.getElementById('auto-trace-toggle'));
7136    }
7137  }
7138
7139  // ───────────────────────────────────────────────
7140  // OPTIONAL: RECENTER ON WINDOW RESIZE
7141  // ───────────────────────────────────────────────
7142  $(window).on('resize', function () {
7143    centerToolbar();
7144  });
7145});
7146$("#goToDonationIcon").click(function () {
7147    
7148    _paq.push(['trackEvent',
7149        'Redirect to donation',
7150        'Redirect to donation page',
7151        'Click on map button that redirect to donation page information',
7152        
7153
7154    ]);    return true;
7155});
7156            });
7157
7158            <!-- Icone de chargement de la page de recherche -->
7159            $('.searchDiv').find('form').submit(function (ev) {
7160                ev.preventDefault();
7161                $(this).css("display", "none").parent().find('span').css('display', 'table');
7162                var that = this;
7163                setTimeout(function () {
7164                    that.submit();
7165                }, 1);
7166            });
7167        </script>
7167
7168    </body>
7169</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.