ol-elevation-profile 1.1.0 → 2.0.0

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