ol-elevation-profile 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,53 @@ import * as d3 from 'd3';
36
36
 
37
37
  const POSITIONS = ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
38
38
 
39
+ /**
40
+ * Jeux de libellés livrés avec la librairie. `en` est le défaut et la retombée : une
41
+ * langue inconnue rend l'anglais plutôt que des clés vides ou un mélange.
42
+ *
43
+ * Un jeu est complet, jamais partiel. Une traduction à trous laisserait deux langues
44
+ * cohabiter dans le même panneau, ce qui se voit ; `labels` reste là pour corriger une
45
+ * clé isolée sans avoir à redéclarer les autres.
46
+ *
47
+ * `ascent` et `descent` valent `D+` / `D-` partout : la notation est celle des cartes et
48
+ * des topos, pas un mot à traduire.
49
+ */
50
+ const LOCALES = {
51
+ en: {
52
+ distance: 'Distance', elevation: 'Elevation', slope: 'Slope',
53
+ ascent: 'D+', descent: 'D-', empty: 'Click a track',
54
+ time: 'Time', duration: 'Duration',
55
+ durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'd' },
56
+ zoomStart: 'Set start (A)', zoomEnd: 'Set end (B)', zoomAll: 'Show all',
57
+ zoomBack: 'Back one level',
58
+ exportPng: 'Export as PNG',
59
+ collapse: 'Collapse the profile', expand: 'Expand the profile',
60
+ loading: 'Loading the elevation profile'
61
+ },
62
+ fr: {
63
+ distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
64
+ ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
65
+ time: 'Temps', duration: 'Durée',
66
+ durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
67
+ zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
68
+ zoomBack: 'Revenir au niveau précédent',
69
+ exportPng: 'Exporter en PNG',
70
+ collapse: 'Réduire le profil', expand: 'Agrandir le profil',
71
+ loading: 'Chargement du profil altimétrique'
72
+ },
73
+ es: {
74
+ distance: 'Distancia', elevation: 'Altitud', slope: 'Pendiente',
75
+ ascent: 'D+', descent: 'D-', empty: 'Haga clic en una traza',
76
+ time: 'Tiempo', duration: 'Duración',
77
+ durationUnits: { s: 'seg', m: 'min', h: 'h', d: 'd' },
78
+ zoomStart: 'Definir el inicio (A)', zoomEnd: 'Definir el final (B)', zoomAll: 'Ver todo',
79
+ zoomBack: 'Volver al nivel anterior',
80
+ exportPng: 'Exportar como PNG',
81
+ collapse: 'Contraer el perfil', expand: 'Desplegar el perfil',
82
+ loading: 'Cargando el perfil de elevación'
83
+ }
84
+ };
85
+
39
86
  const DEFAULTS = {
40
87
  immersion: 'docked',
41
88
  position: 'bottom',
@@ -61,6 +108,8 @@ import * as d3 from 'd3';
61
108
  maxClasses: 8, // maximum number of slope classes (colours + legend)
62
109
  xTicks: null,
63
110
  yTicks: null,
111
+ verticalScale: 'auto', // 'auto' : le profil remplit la hauteur ;
112
+ // un nombre : mètres par centimètre physique
64
113
  show: 'click',
65
114
  collapsable: true,
66
115
  collapsed: false,
@@ -70,6 +119,7 @@ import * as d3 from 'd3';
70
119
  responsive: true, // adapts width/placement, mobile included
71
120
  mobileBreakpoint: 640, // <= screen width -> mobile mode (100% width, top/bottom)
72
121
  zoom: false, // start/end buttons cropping map + profile to A..B
122
+ zoomLevels: 3, // niveaux de recadrage imbriqués (1 = l'ancien niveau unique)
73
123
  exportPng: false, // toolbar button exporting the panel as a PNG
74
124
  ignoreStops: true, // duration = moving time (stops excluded)
75
125
  stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
@@ -77,17 +127,15 @@ import * as d3 from 'd3';
77
127
  headerItems: ['distance', 'ascent', 'descent', 'minmax'],
78
128
  titleProperty: 'name',
79
129
  titleLink: null,
80
- labels: {
81
- distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
82
- ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
83
- time: 'Temps', duration: 'Durée',
84
- durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
85
- zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
86
- exportPng: 'Exporter en PNG',
87
- loading: 'Chargement du profil altimétrique'
88
- }
130
+ lang: 'en', // 'en' | 'fr' | 'es' : jeu de libellés livré
131
+ labels: {} // surcharges clé à clé, appliquées par-dessus la langue
89
132
  };
90
133
 
134
+ /** Libellés de la langue [lang], corrigés par les surcharges [over]. */
135
+ function resolveLabels(lang, over) {
136
+ return deepMerge(LOCALES[lang] || LOCALES.en, over || null);
137
+ }
138
+
91
139
  // ----- helpers ---------------------------------------------------------
92
140
  function deepMerge(base, over) {
93
141
  const out = {};
@@ -190,15 +238,20 @@ import * as d3 from 'd3';
190
238
  const ICON_A = '<svg viewBox="0 0 24 24"><path d="M7 5v14M11 12h8m0 0-3-3m3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
191
239
  const ICON_B = '<svg viewBox="0 0 24 24"><path d="M17 5v14M13 12H5m0 0 3-3m-3 3 3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
192
240
  const ICON_PNG = '<svg viewBox="0 0 24 24"><path d="M12 4v10m0 0 4-4m-4 4-4-4M5 18h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
241
+ const ICON_BACK = '<svg viewBox="0 0 24 24"><path d="M10 6 4 12l6 6M4 12h10a6 6 0 0 1 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
193
242
  const ICON_ALL = '<svg viewBox="0 0 24 24"><path d="M4 12h16M4 12l4-4M4 12l4 4M20 12l-4-4M20 12l-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
194
243
 
244
+ // Recherche du segment contenant une abscisse. Construit une fois : le survol la sollicite
245
+ // à chaque mouvement de souris.
246
+ const bisectX = d3.bisector((d) => d.x).left;
247
+
195
248
  // ----- digital elevation model (DEM) -----------------------------------
196
249
  /**
197
250
  * Known terrain-tile sources.
198
251
  *
199
252
  * A PNG tile carries elevation in its R/G/B channels; it is read on a canvas rather
200
253
  * than queried point by point from an API. That is what makes a 10 000-point track a
201
- * handful of requests, with no key, no quota and no rate limit where the free
254
+ * handful of requests, with no key, no quota and no rate limit, where the free
202
255
  * elevation APIs cap out at 100 or 200 points per call.
203
256
  *
204
257
  * Attribution is not automatic: the library does not own the map. It is up to the
@@ -295,7 +348,7 @@ import * as d3 from 'd3';
295
348
  const DEM_DEFAULTS = { source: 'terrarium', zoom: 'auto', maxZoom: 14, maxTiles: 32, concurrency: 6, tileSize: 256 };
296
349
 
297
350
  /**
298
- * RGB metres decoders, one per encoding convention.
351
+ * RGB to metres decoders, one per encoding convention.
299
352
  *
300
353
  * `encoding` also takes a function `(r, g, b, a) => metres`, for a tile set that packs
301
354
  * elevation its own way. Return null where the pixel carries no measurement: the fill is
@@ -604,7 +657,7 @@ import * as d3 from 'd3';
604
657
  *
605
658
  * Bilinear rather than "the pixel containing the point": on a 30 or 90 m model, taking
606
659
  * the pixel value makes the profile advance in stairs, and every stair counts as a
607
- * climb then a descent in the ascent total. The interpolation invents no relief it
660
+ * climb then a descent in the ascent total. The interpolation invents no relief: it
608
661
  * renders the same surface, without the steps of the sampling grid.
609
662
  */
610
663
  sample(lon, lat, z) {
@@ -672,8 +725,8 @@ import * as d3 from 'd3';
672
725
  }
673
726
 
674
727
  /**
675
- * The `dem` option (true | source name | sampling function | object) → a full
676
- * configuration, or null.
728
+ * The `dem` option (true | source name | sampling function | object) into
729
+ * a full configuration, or null.
677
730
  *
678
731
  * A `sample` function short-circuits the tile machinery entirely: the application
679
732
  * answers with the elevations itself, from wherever it can reach them - a GeoServer
@@ -742,12 +795,19 @@ import * as d3 from 'd3';
742
795
  * @property {boolean} [slope=false] Split the profile into slope-class colored portions.
743
796
  * @property {number} [slopeClassSize=2.5] Slope class width, in percent.
744
797
  * @property {number} [maxClasses=8] Maximum number of slope classes (colors + legend).
745
- * @property {?string[]} [slopeColors=null] `null` = bluered ramp; otherwise an interpolated color array.
798
+ * @property {?string[]} [slopeColors=null] `null` = blue-to-red ramp; otherwise an interpolated color array.
746
799
  * @property {boolean} [slopeSeparators=true] Vertical separator at each slope-class change.
747
800
  * @property {boolean} [slopeLegend=true] Color legend under the title.
748
801
  * @property {number} [smoothing=0] Elevation smoothing window, in METERS (0 = none).
749
802
  * @property {?number} [xTicks=null] X axis ticks (null = auto from width).
750
803
  * @property {?number} [yTicks=null] Y axis ticks (null = auto from height).
804
+ * @property {('auto'|number)} [verticalScale='auto'] `'auto'`: the profile fills the
805
+ * height, which is legible but changes scale from one track to the next, so a 2 % ramp
806
+ * looks like a wall and two profiles cannot be compared. A number fixes the metres
807
+ * covered per physical centimetre. It is a **floor**, not a cage: a track whose range
808
+ * exceeds what the height can show would spill out of the frame, which is worse than
809
+ * losing comparability: the scale then widens silently, nothing being drawn on the
810
+ * chart to say so.
751
811
  * @property {'click'|'mouseover'} [show='click'] How a track is selected on the map.
752
812
  * @property {boolean} [hideOnMapClick=true] Click on empty map hides the profile.
753
813
  * @property {boolean} [collapsable=true] Show the collapse/expand button.
@@ -757,20 +817,27 @@ import * as d3 from 'd3';
757
817
  * @property {boolean} [responsive=true] Adapt width/placement; mobile included.
758
818
  * @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
759
819
  * @property {boolean} [zoom=false] A/B buttons to crop map + profile to a sub-range.
820
+ * @property {number} [zoomLevels=3] Nested crops allowed: a crop can itself be cropped,
821
+ * down to this depth. `1` restores the former single level. A "back" button appears
822
+ * from the second level; "show all" empties the stack whatever the depth.
760
823
  * @property {boolean} [exportPng=false] Toolbar button exporting the whole panel as a PNG.
761
824
  * @property {boolean} [ignoreStops=true] When computing time, ignore stopped segments (moving time).
762
825
  * @property {number} [stopSpeed=0.5] Speed threshold (m/s) below which a segment counts as a stop.
763
826
  * @property {Array<'distance'|'elevation'|'slope'|'time'>} [tooltipItems=['distance','elevation']] Tooltip content (`'time'` = elapsed time at the cursor, if the track has time data).
764
827
  * @property {Array<string|{property:string,label?:string,asLink?:boolean,linkText?:string}>} [headerItems] Header content (string tokens: distance, ascent, descent, min, max, minmax, `'duration'` = total elapsed time).
828
+ * @property {('en'|'fr'|'es')} [lang='en'] Language of the shipped labels. An unknown
829
+ * code falls back to English rather than leaving keys empty.
830
+ * @property {Object} [labels={}] Per-key overrides applied on top of `lang`. They survive
831
+ * a later language change, so a corrected key stays corrected.
765
832
  * @property {string} [titleProperty='name'] Feature property used as the title.
766
- * @property {?string} [titleLink=null] Feature property holding a URL clickable title.
833
+ * @property {?string} [titleLink=null] Feature property holding a URL, making the title a link.
767
834
  * @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
768
835
  * @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
769
836
  * @property {?(boolean|string|Function|Object)} [dem='terrarium'] Fill missing elevations
770
837
  * from a terrain model. Sources: `'terrarium'` (default) or `true` = AWS Terrain Tiles;
771
838
  * `'ign'` = IGN Géoplateforme RGE ALTI (France, keyless, `apiKey` optional);
772
839
  * `{url}` = XYZ template; `{wms:{url,layers,params}}` = WMS tiles;
773
- * `{olSource}` = any `ol/source/TileImage` (XYZ, TileWMS, );
840
+ * `{olSource}` = any `ol/source/TileImage` (XYZ, TileWMS, and so on);
774
841
  * `{featureInfo:{url,layers,property}}` = WMS GetFeatureInfo, one request per point,
775
842
  * for a greyscale coverage that cannot be decoded from its pixels (slow);
776
843
  * a function or `{sample}` `(lonlats, ctx) => number[]|Promise<number[]>` to source them
@@ -792,23 +859,37 @@ import * as d3 from 'd3';
792
859
  constructor(opts) {
793
860
  const o = deepMerge(DEFAULTS, opts || {});
794
861
  if (POSITIONS.indexOf(o.position) === -1) o.position = 'bottom';
862
+ // Les surcharges sont gardées à part de leur résultat : changer de langue plus tard
863
+ // doit reprendre le jeu neuf de la nouvelle langue sans perdre ce que l'appelant a
864
+ // corrigé, ce que le seul jeu résolu ne permettrait plus de démêler.
865
+ const userLabels = o.labels;
866
+ o.labels = resolveLabels(o.lang, userLabels);
795
867
  const element = buildElement(o);
796
868
  super({ element, target: opts && opts.target });
797
869
 
798
870
  this.options = o;
871
+ this._userLabels = userLabels;
799
872
  this.slopeColors = o.slopeColors || null;
800
873
  this._feature = null;
801
874
  this._fullSamples = null; this._fullStats = null;
802
875
  this._samples = null; this._stats = null;
803
876
  this._marker = null;
804
877
  this._collapsed = !!o.collapsed;
805
- this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
878
+ this._crops = []; this._off = 0;
879
+ this._zoomA = null; this._zoomB = null; this._armed = null;
806
880
  this._demZ = null; this._demFor = null; this._demSeq = 0; this._demLoading = false;
807
881
  this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
808
882
  this._buildDom(element);
809
883
  element.style.display = 'none';
810
884
  }
811
885
 
886
+ /** Recadré dès qu'un niveau est empilé. Lu partout où le booléen l'était. */
887
+ get _cropMode() { return this._crops.length > 0; }
888
+ /** Profondeur courante ; 0 quand on voit la trace entière. */
889
+ get _cropDepth() { return this._crops.length; }
890
+ /** Niveaux imbriqués autorisés, au moins un. */
891
+ get _cropMax() { const n = this.options.zoomLevels; return n == null ? 3 : Math.max(1, n | 0); }
892
+
812
893
  // ---------- static helpers ----------------------------------------
813
894
  /**
814
895
  * Whether a feature has any Z (elevation) coordinate.
@@ -855,6 +936,7 @@ import * as d3 from 'd3';
855
936
  };
856
937
  this._btnA = mkBtn('oep-a', ICON_A, o.labels.zoomStart, () => this._arm('A'));
857
938
  this._btnB = mkBtn('oep-b', ICON_B, o.labels.zoomEnd, () => this._arm('B'));
939
+ this._btnBack = mkBtn('oep-back', ICON_BACK, o.labels.zoomBack, () => this._popCrop());
858
940
  this._btnAll = mkBtn('oep-all', ICON_ALL, o.labels.zoomAll, () => this._exitZoom());
859
941
  // Last of the toolbar, so it sits to the right of the zoom buttons.
860
942
  this._btnPng = mkBtn('oep-png', ICON_PNG, o.labels.exportPng, () => this.exportPNG());
@@ -862,11 +944,11 @@ import * as d3 from 'd3';
862
944
 
863
945
  const btn = document.createElement('button');
864
946
  btn.className = 'oep-toggle'; btn.type = 'button';
865
- btn.setAttribute('aria-label', 'Réduire ou agrandir le profil');
866
947
  btn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
867
948
  btn.addEventListener('click', () => this.toggleCollapsed());
868
949
  header.appendChild(btn);
869
950
  this._toggleBtn = btn;
951
+ this._applyToggleLabel();
870
952
 
871
953
  this._legendEl = document.createElement('div'); this._legendEl.className = 'oep-legend'; this._legendEl.style.display = 'none';
872
954
  this._body = document.createElement('div'); this._body.className = 'oep-body';
@@ -877,6 +959,34 @@ import * as d3 from 'd3';
877
959
  if (this._collapsed) root.classList.add('oep-collapsed');
878
960
  }
879
961
 
962
+ /**
963
+ * Reporte les libellés courants sur ce que le rendu ne réécrit pas.
964
+ *
965
+ * Les boutons et le bouton de repli sont construits une fois pour toutes ; sans cela,
966
+ * changer de langue ne toucherait que le graphe et laisserait les infobulles dans
967
+ * l'ancienne. Le titre n'est repris que faute de tracé : sinon il porte le nom du tracé,
968
+ * qui n'est pas un libellé de la librairie.
969
+ */
970
+ _applyLabels() {
971
+ const l = this.options.labels;
972
+ const set = (b, t) => { if (b && t) { b.title = t; b.setAttribute('aria-label', t); } };
973
+ set(this._btnA, l.zoomStart); set(this._btnB, l.zoomEnd);
974
+ set(this._btnBack, l.zoomBack); set(this._btnAll, l.zoomAll); set(this._btnPng, l.exportPng);
975
+ this._applyToggleLabel();
976
+ if (this._titleEl && !this._feature) this._titleEl.textContent = l.empty;
977
+ }
978
+ /**
979
+ * Le bouton de repli dit ce qu'il va faire, non ce qu'il montre.
980
+ *
981
+ * Un seul libellé pour les deux états ("réduire ou agrandir") laisse deviner lequel
982
+ * des deux le clic déclenche ; l'icône, elle, a déjà basculé. Le titre suit donc l'état,
983
+ * et l'infobulle visible dit la même chose que le nom accessible.
984
+ */
985
+ _applyToggleLabel() {
986
+ const l = this.options.labels, t = this._collapsed ? l.expand : l.collapse;
987
+ if (!this._toggleBtn || !t) return;
988
+ this._toggleBtn.title = t; this._toggleBtn.setAttribute('aria-label', t);
989
+ }
880
990
  _resolveColor() { const o = this.options; if (!o.color) return null; if (o.color === 'auto') return this._featureColor(); return o.color; }
881
991
  _featureColor() {
882
992
  const f = this._feature; if (!f) return null;
@@ -917,6 +1027,7 @@ import * as d3 from 'd3';
917
1027
  this._collapsed = (typeof force === 'boolean') ? force : !this._collapsed;
918
1028
  this.element.classList.toggle('oep-collapsed', this._collapsed);
919
1029
  this._toggleBtn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
1030
+ this._applyToggleLabel();
920
1031
  if (!this._collapsed && this._feature) this._render();
921
1032
  this._adjustAttribution();
922
1033
  }
@@ -981,19 +1092,32 @@ import * as d3 from 'd3';
981
1092
  });
982
1093
 
983
1094
  this._mapKeys = [];
984
- this._mapKeys.push(map.on(o.show === 'mouseover' ? 'pointermove' : 'click', (evt) => {
985
- const feature = lineAt(evt.pixel); if (feature && feature !== this._feature) this.setFeature(feature);
1095
+ // `show`, `hideOnMapClick` et `followMap` sont relus à l'événement, non à
1096
+ // l'abonnement. Les abonnements sont posés une fois pour toutes, ici ; les figer
1097
+ // rendait ces trois options muettes dès que `setOptions` les changeait ensuite,
1098
+ // basculer en survol ne faisait rien, sans que rien ne le dise. Les deux types
1099
+ // d'événement sont donc écoutés en permanence, et c'est l'option qui tranche.
1100
+ this._mapKeys.push(map.on('click', (evt) => {
1101
+ const feature = lineAt(evt.pixel);
1102
+ if (this.options.show !== 'mouseover' && feature && feature !== this._feature) this.setFeature(feature);
1103
+ if (this.options.hideOnMapClick && !feature && this._feature) this.clear();
986
1104
  }));
987
- if (o.hideOnMapClick) this._mapKeys.push(map.on('click', (evt) => { if (!lineAt(evt.pixel) && this._feature) this.clear(); }));
988
- if (o.followMap) this._mapKeys.push(map.on('pointermove', (evt) => {
989
- if (!this._feature || this._collapsed) return;
1105
+ this._mapKeys.push(map.on('pointermove', (evt) => {
1106
+ if (this.options.show === 'mouseover') {
1107
+ const feature = lineAt(evt.pixel);
1108
+ if (feature && feature !== this._feature) this.setFeature(feature);
1109
+ }
1110
+ // Le survol coûte un `lineAt` par déplacement : rien n'est cherché tant qu'aucune
1111
+ // des deux options ne le demande.
1112
+ if (!this.options.followMap || !this._feature || this._collapsed) return;
990
1113
  const cp = this._closestOnProfile(evt.coordinate); if (!cp) return;
991
1114
  const px = map.getPixelFromCoordinate(cp); if (!px) return;
992
1115
  if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
993
1116
  }));
994
1117
  // leave the A/B crop when the map is zoomed out
995
1118
  this._mapKeys.push(map.on('moveend', () => {
996
- if (this._cropMode && this._fitRes && map.getView().getResolution() > this._fitRes * 1.25) this._exitZoom();
1119
+ const cur = this._crops[this._crops.length - 1];
1120
+ if (cur && cur.fitRes && map.getView().getResolution() > cur.fitRes * 1.25) this._popCrop();
997
1121
  }));
998
1122
  this._mapKeys.push(map.on('change:size', this._onResize));
999
1123
  }
@@ -1008,7 +1132,7 @@ import * as d3 from 'd3';
1008
1132
  */
1009
1133
  setFeature(feature) {
1010
1134
  this._feature = feature || null;
1011
- this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null;
1135
+ this._crops = []; this._off = 0; this._zoomA = null; this._zoomB = null; this._armed = null;
1012
1136
  if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._clear(); return this; }
1013
1137
  this.element.style.display = '';
1014
1138
  this._compute();
@@ -1042,6 +1166,13 @@ import * as d3 from 'd3';
1042
1166
  if (patch && (patch.theme || 'color' in patch)) this._applyTheme();
1043
1167
  if (patch && ('transparency' in patch || 'transparencyLevel' in patch)) this._applyTransparency();
1044
1168
  if (patch && 'collapsable' in patch) this._applyCollapsable();
1169
+ if (patch && 'labels' in patch) this._userLabels = deepMerge(this._userLabels || {}, patch.labels);
1170
+ // Recalculé depuis la langue, jamais empilé sur le jeu précédent : passer de 'fr' à
1171
+ // 'es' ne doit rien laisser en français derrière lui, hors surcharges de l'appelant.
1172
+ if (patch && ('lang' in patch || 'labels' in patch)) {
1173
+ this.options.labels = resolveLabels(this.options.lang, this._userLabels);
1174
+ this._applyLabels();
1175
+ }
1045
1176
  if (patch && ('zoom' in patch || 'exportPng' in patch)) this._updateZoomButtons();
1046
1177
  if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
1047
1178
  if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
@@ -1058,7 +1189,7 @@ import * as d3 from 'd3';
1058
1189
  * and the ascent total becomes absurd. Better the flat profile we would have had
1059
1190
  * without the DEM.
1060
1191
  *
1061
- * Nothing is reported to the user on failure the fill is a supplement, it has no
1192
+ * Nothing is reported to the user on failure: the fill is a supplement, it has no
1062
1193
  * business breaking a display that succeeded without it. The `demload` event lets the
1063
1194
  * application know if it wants to.
1064
1195
  *
@@ -1188,7 +1319,60 @@ import * as d3 from 'd3';
1188
1319
  this._addSlope(samples);
1189
1320
  this._fullSamples = samples;
1190
1321
  this._fullStats = this._statsOf(samples, true);
1191
- this._samples = samples; this._stats = this._fullStats;
1322
+ // Un recalcul (option changée, altitudes arrivées du MNT) ne doit pas défaire le
1323
+ // recadrage : la pile est rejouée sur les nouveaux échantillons. Pile vide, on
1324
+ // retombe sur la trace entière, ce que faisait l'affectation directe d'avant.
1325
+ this._applyCrops();
1326
+ }
1327
+ /**
1328
+ * Pixels CSS d'un centimètre physique, mesurés plutôt que déduits.
1329
+ *
1330
+ * Le rapport nominal est de 96 px par pouce, soit 37,8 px par centimètre, mais le zoom
1331
+ * du navigateur le déplace : mesurer un élément d'un centimètre suit ce zoom là où une
1332
+ * constante mentirait. Non mémorisé - la mesure force un calcul de disposition, mais un
1333
+ * rendu est rare et un cache se périmerait au premier Ctrl+molette.
1334
+ */
1335
+ _pxPerCm() {
1336
+ try {
1337
+ const temoin = document.createElement('div');
1338
+ temoin.style.cssText = 'position:absolute;left:-9999px;top:0;width:1cm;height:1cm';
1339
+ (this.element || document.body).appendChild(temoin);
1340
+ const px = temoin.getBoundingClientRect().height;
1341
+ temoin.remove();
1342
+ if (px > 0) return px;
1343
+ } catch (e) { /* document indisponible : on retombe sur la valeur nominale */ }
1344
+ return 96 / 2.54;
1345
+ }
1346
+
1347
+ /**
1348
+ * Échelle verticale du graphe.
1349
+ *
1350
+ * En `'auto'`, le profil remplit la hauteur : c'est lisible, mais l'échelle change d'une
1351
+ * trace à l'autre, si bien qu'une pente de 2 % y prend l'allure d'un mur et que deux
1352
+ * profils ne se comparent pas. Un nombre fixe au contraire les mètres couverts par
1353
+ * centimètre physique.
1354
+ *
1355
+ * La valeur demandée est un **plancher**, non un carcan : une trace dont l'amplitude
1356
+ * dépasse ce que la hauteur peut montrer déborderait du cadre, ce qui est pire que de
1357
+ * perdre la comparabilité. Le graphe n'en dit rien : l'échelle n'est pas une donnée de
1358
+ * la trace et n'a pas à encombrer le dessin ; l'échelle réellement appliquée est
1359
+ * seulement publiée dans `_vScale`, à qui veut la connaître.
1360
+ *
1361
+ * `nice()` n'est pas appliqué en échelle absolue : il arrondit le domaine vers
1362
+ * l'extérieur, donc il fausserait le rapport qu'on vient de fixer.
1363
+ */
1364
+ _yScale(s, zpad, innerH) {
1365
+ const o = this.options, bas = s.min - zpad, haut = s.max + zpad;
1366
+ const vs = typeof o.verticalScale === 'number' ? o.verticalScale : 0;
1367
+ if (!(vs > 0)) {
1368
+ this._vScale = null;
1369
+ return d3.scaleLinear().domain([bas, haut]).range([innerH, 0]).nice();
1370
+ }
1371
+ const cm = innerH / this._pxPerCm(); // hauteur du graphe, en centimètres
1372
+ const etendue = Math.max(vs * cm, haut - bas); // jamais moins qu'il n'en faut
1373
+ const milieu = (bas + haut) / 2;
1374
+ this._vScale = etendue / cm; // m/cm effectivement appliqués
1375
+ return d3.scaleLinear().domain([milieu - etendue / 2, milieu + etendue / 2]).range([innerH, 0]);
1192
1376
  }
1193
1377
  _statsOf(samples, fromTotal) {
1194
1378
  let ascent = 0, descent = 0, zmin = Infinity, zmax = -Infinity, maxAbs = 0;
@@ -1260,7 +1444,7 @@ import * as d3 from 'd3';
1260
1444
  // 7 sec | 26 min | 1 h 48 min | 2 d 3 h (days + hours normalised)
1261
1445
  _fmtDuration(sec) {
1262
1446
  if (sec == null || !isFinite(sec)) return '';
1263
- const u = (this.options.labels && this.options.labels.durationUnits) || { s: 'sec', m: 'min', h: 'h', d: 'j' };
1447
+ const u = (this.options.labels && this.options.labels.durationUnits) || LOCALES.en.durationUnits;
1264
1448
  sec = Math.max(0, Math.round(sec));
1265
1449
  if (sec < 60) return sec + ' ' + u.s;
1266
1450
  if (sec < 3600) return Math.round(sec / 60) + ' ' + u.m;
@@ -1279,7 +1463,7 @@ import * as d3 from 'd3';
1279
1463
  * Track title, and its optional link.
1280
1464
  *
1281
1465
  * Its own method because it is the only part of the header that stays visible once
1282
- * collapsed the CSS hides the body, the stats, the legend and the toolbar. `_render`
1466
+ * collapsed: the CSS hides the body, the stats, the legend and the toolbar. `_render`
1283
1467
  * is skipped while collapsed, so without a separate entry point the title would keep
1284
1468
  * naming the previous track after a change of feature.
1285
1469
  */
@@ -1312,7 +1496,7 @@ import * as d3 from 'd3';
1312
1496
  else if (it === 'descent') { html.push(`<span class="oep-down">${o.labels.descent} ${fmtElevation(s.descent, o.units)}</span>`); text.push(`${o.labels.descent} ${fmtElevation(s.descent, o.units)}`); }
1313
1497
  else if (it === 'min') { html.push(fmtElevation(s.min, o.units)); text.push(fmtElevation(s.min, o.units)); }
1314
1498
  else if (it === 'max') { html.push(fmtElevation(s.max, o.units)); text.push(fmtElevation(s.max, o.units)); }
1315
- else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}–${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
1499
+ else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}-${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
1316
1500
  else if (it === 'duration') { if (s.duration != null) { const v = this._fmtDuration(s.duration); html.push(`<span class="oep-time">${esc(o.labels.duration)} ${v}</span>`); text.push(`${o.labels.duration} ${v}`); } }
1317
1501
  } else if (it && it.property) {
1318
1502
  const val = f.get && f.get(it.property); if (val == null || val === '') return;
@@ -1329,7 +1513,7 @@ import * as d3 from 'd3';
1329
1513
  const sc = this._slopeScale();
1330
1514
  for (let idx = 0; idx <= sc.maxIdx; idx++) {
1331
1515
  const color = sc.colorByIndex(idx);
1332
- const label = (sc.capped && idx === sc.maxIdx) ? `≥ ${idx * sc.classSize} %` : `${idx * sc.classSize}–${(idx + 1) * sc.classSize} %`;
1516
+ const label = (sc.capped && idx === sc.maxIdx) ? `≥ ${idx * sc.classSize} %` : `${idx * sc.classSize}-${(idx + 1) * sc.classSize} %`;
1333
1517
  const item = document.createElement('span'); item.className = 'oep-leg-it';
1334
1518
  item.innerHTML = `<i class="oep-sw" style="background:${color}"></i>${label}`;
1335
1519
  this._legendEl.appendChild(item);
@@ -1392,7 +1576,7 @@ import * as d3 from 'd3';
1392
1576
 
1393
1577
  const x = d3.scaleLinear().domain([0, s.distance]).range([0, innerW]);
1394
1578
  const zpad = (s.max - s.min) * 0.1 || 10;
1395
- const y = d3.scaleLinear().domain([s.min - zpad, s.max + zpad]).range([innerH, 0]).nice();
1579
+ const y = this._yScale(s, zpad, innerH);
1396
1580
  this._x = x; this._y = y; this._dims = { innerW, innerH };
1397
1581
 
1398
1582
  if (o.grid) g.append('g').attr('class', 'oep-grid').call(d3.axisLeft(y).ticks(yTicks).tickSize(-innerW).tickFormat(''));
@@ -1425,7 +1609,7 @@ import * as d3 from 'd3';
1425
1609
  g.append('g').attr('class', 'oep-axis oep-axis-y').call(d3.axisLeft(y).ticks(yTicks).tickFormat((d) => o.units === 'imperial' ? Math.round(d * 3.28084) : d));
1426
1610
 
1427
1611
  // A / B markers while a range is being picked
1428
- if (o.zoom && !this._cropMode) {
1612
+ if (o.zoom && this._cropDepth < this._cropMax) {
1429
1613
  [['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
1430
1614
  if (val == null) return;
1431
1615
  const px = x(val);
@@ -1440,21 +1624,13 @@ import * as d3 from 'd3';
1440
1624
  const lbl = focus.append('g').attr('class', 'oep-focus-label'); lbl.append('rect').attr('class', 'oep-focus-bg'); lbl.append('text').attr('class', 'oep-focus-txt');
1441
1625
  this._focus = focus;
1442
1626
 
1443
- const bisect = d3.bisector((d) => d.x).left;
1444
1627
  const pick = (event) => { const mx = d3.pointer(event, g.node())[0]; return Math.max(0, Math.min(s.distance, x.invert(mx))); };
1445
1628
  const overlay = svg.append('rect').attr('class', 'oep-overlay').attr('x', ml).attr('y', mt).attr('width', innerW).attr('height', innerH);
1446
1629
  overlay.on('mousemove', (event) => {
1447
- const x0 = pick(event); const idx = bisect(data, x0, 1);
1448
- const d0 = data[idx - 1], d1 = data[idx] || d0; const d = (x0 - d0.x) > (d1.x - x0) ? d1 : d0;
1630
+ const d = this._sampleAt(pick(event)); if (!d) return;
1449
1631
  this._setFocus(d); if (this._marker) this._marker.setPosition(d.coord);
1450
1632
  }).on('mouseout', () => this._clearFocus());
1451
- overlay.on('click', (event) => {
1452
- if (!this._armed || this._cropMode) return;
1453
- const x0 = pick(event);
1454
- if (this._armed === 'A') this._zoomA = x0; else this._zoomB = x0;
1455
- this._armed = null; this._updateZoomButtons();
1456
- if (this._zoomA != null && this._zoomB != null) this._applyZoom(); else this._render();
1457
- });
1633
+ overlay.on('click', (event) => this._placeBound(pick(event)));
1458
1634
  if (this._armed) overlay.style('cursor', 'col-resize');
1459
1635
  this._adjustAttribution();
1460
1636
  }
@@ -1593,6 +1769,22 @@ import * as d3 from 'd3';
1593
1769
 
1594
1770
  // ---------- zoom A/B ----------------------------------------------
1595
1771
  _arm(which) { this._armed = (this._armed === which) ? null : which; this._updateZoomButtons(); if (this._focus) this._render(); }
1772
+ /**
1773
+ * Pose la borne armée à l'abscisse [x0], puis arme l'autre tant qu'elle manque.
1774
+ *
1775
+ * Poser A n'a d'intérêt que pour poser B ensuite : enchaîner d'un clic sur l'autre
1776
+ * évite l'aller-retour par la barre d'outils entre les deux. Les deux bornes réunies,
1777
+ * le recadrage part et rien ne reste armé.
1778
+ */
1779
+ _placeBound(x0) {
1780
+ if (!this._armed || this._cropDepth >= this._cropMax) return;
1781
+ const autre = this._armed === 'A' ? 'B' : 'A';
1782
+ if (this._armed === 'A') this._zoomA = x0; else this._zoomB = x0;
1783
+ const complet = this._zoomA != null && this._zoomB != null;
1784
+ this._armed = complet ? null : autre;
1785
+ this._updateZoomButtons();
1786
+ if (complet) this._pushCrop(this._zoomA, this._zoomB); else this._render();
1787
+ }
1596
1788
  _updateZoomButtons() {
1597
1789
  const o = this.options, has = !!this._feature;
1598
1790
  const show = !!o.zoom && has;
@@ -1600,37 +1792,96 @@ import * as d3 from 'd3';
1600
1792
  // The toolbar carries both: it stays visible as long as either has something to show.
1601
1793
  this._toolbar.style.display = (show || png) ? '' : 'none';
1602
1794
  this._btnPng.style.display = png ? '' : 'none';
1603
- const crop = this._cropMode;
1604
- this._btnA.style.display = show && !crop ? '' : 'none';
1605
- this._btnB.style.display = show && !crop ? '' : 'none';
1606
- this._btnAll.style.display = show && crop ? '' : 'none';
1795
+ const deep = this._cropDepth, peutDescendre = deep < this._cropMax;
1796
+ this._btnA.style.display = show && peutDescendre ? '' : 'none';
1797
+ this._btnB.style.display = show && peutDescendre ? '' : 'none';
1798
+ // Au premier niveau, revenir et tout voir sont le même geste : seul "tout voir"
1799
+ // paraît, ce qui laisse le niveau unique (zoomLevels: 1) exactement tel qu'il était.
1800
+ this._btnBack.style.display = show && deep > 1 ? '' : 'none';
1801
+ this._btnAll.style.display = show && deep > 0 ? '' : 'none';
1607
1802
  this._btnA.classList.toggle('armed', this._armed === 'A');
1608
1803
  this._btnB.classList.toggle('armed', this._armed === 'B');
1609
1804
  }
1610
- _applyZoom() {
1611
- const a = Math.min(this._zoomA, this._zoomB), b = Math.max(this._zoomA, this._zoomB);
1805
+ /**
1806
+ * Recadre sur le sommet de la pile : échantillons, statistiques, origine des abscisses.
1807
+ *
1808
+ * Les bornes empilées sont gardées en abscisse de la trace **entière**, jamais dans le
1809
+ * repère du niveau courant : sinon chaque cran se lirait dans celui du précédent et
1810
+ * l'erreur de rebasage se composerait de niveau en niveau. Rend les coordonnées
1811
+ * cartographiques du niveau, dont le cadrage a besoin.
1812
+ */
1813
+ _applyCrops() {
1814
+ if (!this._crops.length) {
1815
+ this._off = 0; this._samples = this._fullSamples; this._stats = this._fullStats;
1816
+ return null;
1817
+ }
1818
+ const { a, b } = this._crops[this._crops.length - 1];
1612
1819
  const raw = this._fullSamples.filter((p) => p.x >= a && p.x <= b);
1613
- if (raw.length < 2) return;
1614
- const off = raw[0].x; // A devient 0
1615
- const tOff = raw[0].t != null ? raw[0].t : 0;
1616
- const cropped = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope, t: (p.t != null ? p.t - tOff : null) }));
1617
- const coords = raw.map((p) => p.coord);
1618
- this._samples = cropped; this._stats = this._statsOf(cropped, false); this._cropMode = true;
1619
- this._updateZoomButtons(); this._render();
1620
- const map = this.getMap();
1621
- if (map) {
1622
- const ext = boundingExtent(coords);
1623
- const view = map.getView();
1624
- try { this._fitRes = view.getResolutionForExtent(ext, map.getSize()); } catch (e) { this._fitRes = null; }
1625
- view.fit(ext, { padding: [40, 40, 40, 40], duration: 400 });
1820
+ if (raw.length < 2) { this._crops.pop(); return this._applyCrops(); }
1821
+ const off = raw[0].x, tOff = raw[0].t != null ? raw[0].t : 0;
1822
+ this._off = off;
1823
+ this._samples = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope, t: (p.t != null ? p.t - tOff : null) }));
1824
+ this._stats = this._statsOf(this._samples, false);
1825
+ return raw.map((p) => p.coord);
1826
+ }
1827
+
1828
+ /** Cadre la carte sur un niveau, ou sur la trace entière si [coords] est nul. */
1829
+ _fitMap(coords) {
1830
+ const map = this.getMap(); if (!map) return null;
1831
+ let ext = null;
1832
+ if (coords && coords.length) ext = boundingExtent(coords);
1833
+ else if (this._feature) ext = this._feature.getGeometry().getExtent();
1834
+ if (!ext) return null;
1835
+ const view = map.getView();
1836
+ let res = null;
1837
+ try { res = view.getResolutionForExtent(ext, map.getSize()); } catch (e) { res = null; }
1838
+ view.fit(ext, { padding: [40, 40, 40, 40], duration: 400 });
1839
+ return res;
1840
+ }
1841
+
1842
+ /** Empile un niveau. [a0] et [b0] sont lus dans le repère du niveau courant. */
1843
+ _pushCrop(a0, b0) {
1844
+ this._zoomA = null; this._zoomB = null; this._armed = null;
1845
+ if (this._cropDepth >= this._cropMax) { this._updateZoomButtons(); this._render(); return; }
1846
+ const a = this._off + Math.min(a0, b0), b = this._off + Math.max(a0, b0);
1847
+ let n = 0;
1848
+ for (let k = 0; k < this._fullSamples.length; k++) {
1849
+ const px = this._fullSamples[k].x; if (px >= a && px <= b) n++;
1626
1850
  }
1851
+ if (n < 2) { this._updateZoomButtons(); this._render(); return; }
1852
+ this._crops.push({ a, b, fitRes: null });
1853
+ const coords = this._applyCrops();
1854
+ this._updateZoomButtons(); this._render();
1855
+ const cur = this._crops[this._crops.length - 1];
1856
+ if (cur) cur.fitRes = this._fitMap(coords);
1857
+ }
1858
+
1859
+ /**
1860
+ * Recadre sur A..B déjà posés. Conservé : c'était le point d'entrée du niveau unique,
1861
+ * et le supprimer casserait ce qui l'appelle sans rien apporter.
1862
+ */
1863
+ _applyZoom() {
1864
+ if (this._zoomA == null || this._zoomB == null) return;
1865
+ this._pushCrop(this._zoomA, this._zoomB);
1866
+ }
1867
+
1868
+ /** Remonte d'un niveau. */
1869
+ _popCrop() {
1870
+ if (!this._crops.length) return;
1871
+ this._crops.pop();
1872
+ this._zoomA = null; this._zoomB = null; this._armed = null;
1873
+ const coords = this._applyCrops();
1874
+ this._updateZoomButtons(); if (!this._collapsed) this._render();
1875
+ this._fitMap(coords);
1627
1876
  }
1877
+
1878
+ /** Vide la pile d'un coup, quel que soit le nombre de niveaux. */
1628
1879
  _exitZoom() {
1629
- this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
1630
- this._samples = this._fullSamples; this._stats = this._fullStats;
1880
+ this._crops.length = 0;
1881
+ this._zoomA = null; this._zoomB = null; this._armed = null;
1882
+ this._applyCrops();
1631
1883
  this._updateZoomButtons(); if (!this._collapsed) this._render();
1632
- const map = this.getMap();
1633
- if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
1884
+ this._fitMap(null);
1634
1885
  }
1635
1886
 
1636
1887
  /**
@@ -1645,13 +1896,31 @@ import * as d3 from 'd3';
1645
1896
  const g = this._feature && this._feature.getGeometry();
1646
1897
  if (!g) return null;
1647
1898
  if (!/Polygon/.test(g.getType())) return g.getClosestPoint(coordinate);
1648
- const data = this._fullSamples;
1899
+ const hit = this._projectOn(this._fullSamples, coordinate);
1900
+ return hit ? hit.coord : null;
1901
+ }
1902
+
1903
+ /**
1904
+ * Projeté d'une coordonnée sur la ligne brisée [data] : le point, et son abscisse
1905
+ * curviligne dans le repère de [data].
1906
+ *
1907
+ * Retenir l'échantillon le plus proche ferait sauter le marqueur de sommet en sommet,
1908
+ * l'écart entre deux points de trace étant souvent bien plus grand que le pas du
1909
+ * pointeur. La projection sur les segments donne le point réellement visé.
1910
+ */
1911
+ _projectOn(data, coord) {
1649
1912
  if (!data || !data.length) return null;
1913
+ if (data.length === 1) return { x: data[0].x, coord: data[0].coord.slice(0, 2) };
1650
1914
  let best = null, bd = Infinity;
1651
- for (const p of data) {
1652
- const dx = p.coord[0] - coordinate[0], dy = p.coord[1] - coordinate[1];
1653
- const dd = dx * dx + dy * dy;
1654
- if (dd < bd) { bd = dd; best = p.coord; }
1915
+ for (let i = 1; i < data.length; i++) {
1916
+ const a = data[i - 1].coord, b = data[i].coord;
1917
+ const abx = b[0] - a[0], aby = b[1] - a[1], len2 = abx * abx + aby * aby;
1918
+ // Segment dégénéré (deux points confondus) : le projeté est le point lui-même.
1919
+ let r = len2 > 0 ? ((coord[0] - a[0]) * abx + (coord[1] - a[1]) * aby) / len2 : 0;
1920
+ r = r < 0 ? 0 : (r > 1 ? 1 : r);
1921
+ const px = a[0] + abx * r, py = a[1] + aby * r;
1922
+ const dx = px - coord[0], dy = py - coord[1], dd = dx * dx + dy * dy;
1923
+ if (dd < bd) { bd = dd; best = { x: data[i - 1].x + (data[i].x - data[i - 1].x) * r, coord: [px, py] }; }
1655
1924
  }
1656
1925
  return best;
1657
1926
  }
@@ -1681,11 +1950,42 @@ import * as d3 from 'd3';
1681
1950
  else if (x(d.x) - bb.width / 2 < 0) t.attr('text-anchor', 'start');
1682
1951
  else t.attr('text-anchor', 'middle');
1683
1952
  }
1953
+ /**
1954
+ * Point du profil à une abscisse quelconque, interpolé entre les deux échantillons qui
1955
+ * l'encadrent.
1956
+ *
1957
+ * Se caler sur l'échantillon le plus proche ferait avancer le curseur de sommet en
1958
+ * sommet : sur un tracé peu dense, ou décimé par `maxPoints`, le saut est franc et le
1959
+ * survol perd sa continuité. Les échantillons sont les points *mesurés*, non les seules
1960
+ * positions que le curseur ait le droit d'occuper.
1961
+ *
1962
+ * La pente n'est pas interpolée : `_addSlope` la pose sur le segment qui précède chaque
1963
+ * échantillon, elle est donc constante sur ce segment et vaut celle de son extrémité.
1964
+ * Le temps l'est, mais seulement si les deux bornes en portent un.
1965
+ */
1966
+ _sampleAt(x0) {
1967
+ const data = this._samples;
1968
+ if (!data || !data.length) return null;
1969
+ if (data.length === 1) return data[0];
1970
+ const xa = data[0].x, xb = data[data.length - 1].x;
1971
+ const xc = Math.max(xa, Math.min(xb, x0));
1972
+ const i = Math.min(Math.max(bisectX(data, xc, 1), 1), data.length - 1);
1973
+ const d0 = data[i - 1], d1 = data[i];
1974
+ const span = d1.x - d0.x, r = span > 0 ? (xc - d0.x) / span : 0;
1975
+ const lerp = (a, b) => a + (b - a) * r;
1976
+ return {
1977
+ x: xc,
1978
+ z: lerp(d0.z, d1.z),
1979
+ coord: [lerp(d0.coord[0], d1.coord[0]), lerp(d0.coord[1], d1.coord[1])],
1980
+ slope: d1.slope,
1981
+ t: (d0.t != null && d1.t != null) ? lerp(d0.t, d1.t) : null
1982
+ };
1983
+ }
1984
+ /** Suit une coordonnée de la carte, projetée sur le tracé courant. */
1684
1985
  _focusByCoord(coord) {
1685
- const data = this._samples; if (!data) return;
1686
- let best = null, bd = Infinity;
1687
- for (const p of data) { const dx = p.coord[0] - coord[0], dy = p.coord[1] - coord[1]; const dd = dx * dx + dy * dy; if (dd < bd) { bd = dd; best = p; } }
1688
- if (best) { this._setFocus(best); if (this._marker) this._marker.setPosition(best.coord); }
1986
+ const hit = this._projectOn(this._samples, coord); if (!hit) return;
1987
+ const d = this._sampleAt(hit.x); if (!d) return;
1988
+ this._setFocus(d); if (this._marker) this._marker.setPosition(d.coord);
1689
1989
  }
1690
1990
  _clearFocus() { if (this._focus) this._focus.style('display', 'none'); if (this._marker) this._marker.setPosition(undefined); }
1691
1991
  _clear() {
@@ -1693,7 +1993,7 @@ import * as d3 from 'd3';
1693
1993
  this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
1694
1994
  this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
1695
1995
  this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
1696
- this._cropMode = false; this._demLoading = false; this._updateZoomButtons();
1996
+ this._crops = []; this._off = 0; this._demLoading = false; this._updateZoomButtons();
1697
1997
  this._clearFocus();
1698
1998
  this.element.style.display = 'none';
1699
1999
  this._adjustAttribution();