ol-elevation-profile 2.0.0 → 2.1.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.
@@ -1,8 +1,8 @@
1
- /*! ol-elevation-profile | MIT License | https://github.com/lc-4918/ol-elevation-profile */
1
+ /*! ol-elevation-profile 2.1.0 | MIT License | https://github.com/lc-4918/ol-elevation-profile */
2
2
  import Control from 'ol/control/Control.js';
3
3
  import Overlay from 'ol/Overlay.js';
4
4
  import { unByKey } from 'ol/Observable.js';
5
- import { toLonLat, get, transform, fromLonLat } from 'ol/proj.js';
5
+ import { toLonLat, transform, get, fromLonLat } from 'ol/proj.js';
6
6
  import TileState from 'ol/TileState.js';
7
7
  import { getDistance } from 'ol/sphere.js';
8
8
  import { boundingExtent } from 'ol/extent.js';
@@ -51,6 +51,8 @@ import * as d3 from 'd3';
51
51
  en: {
52
52
  distance: 'Distance', elevation: 'Elevation', slope: 'Slope',
53
53
  ascent: 'D+', descent: 'D-', empty: 'Click a track',
54
+ noElevation: 'No elevation data',
55
+ untitled: 'Profile',
54
56
  time: 'Time', duration: 'Duration',
55
57
  durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'd' },
56
58
  zoomStart: 'Set start (A)', zoomEnd: 'Set end (B)', zoomAll: 'Show all',
@@ -62,6 +64,8 @@ import * as d3 from 'd3';
62
64
  fr: {
63
65
  distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
64
66
  ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
67
+ noElevation: 'Aucune altimétrie',
68
+ untitled: 'Profil',
65
69
  time: 'Temps', duration: 'Durée',
66
70
  durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
67
71
  zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
@@ -73,6 +77,8 @@ import * as d3 from 'd3';
73
77
  es: {
74
78
  distance: 'Distancia', elevation: 'Altitud', slope: 'Pendiente',
75
79
  ascent: 'D+', descent: 'D-', empty: 'Haga clic en una traza',
80
+ noElevation: 'Sin datos de altitud',
81
+ untitled: 'Perfil',
76
82
  time: 'Tiempo', duration: 'Duración',
77
83
  durationUnits: { s: 'seg', m: 'min', h: 'h', d: 'd' },
78
84
  zoomStart: 'Definir el inicio (A)', zoomEnd: 'Definir el final (B)', zoomAll: 'Ver todo',
@@ -109,8 +115,11 @@ import * as d3 from 'd3';
109
115
  xTicks: null,
110
116
  yTicks: null,
111
117
  verticalScale: 'auto', // 'auto' : le profil remplit la hauteur ;
112
- // un nombre : mètres par centimètre physique
118
+ // un nombre : mètres par centimètre physique ;
119
+ // { exaggeration } : rapport fixe vertical/horizontal
113
120
  show: 'click',
121
+ showWithoutElevation: true, // trace sans Z, et aucun MNT n'a pu en fournir :
122
+ // le panneau paraît quand même, avec un message
114
123
  collapsable: true,
115
124
  collapsed: false,
116
125
  followMap: true,
@@ -125,8 +134,8 @@ import * as d3 from 'd3';
125
134
  stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
126
135
  tooltipItems: ['distance', 'elevation'],
127
136
  headerItems: ['distance', 'ascent', 'descent', 'minmax'],
128
- titleProperty: 'name',
129
- titleLink: null,
137
+ titleProperty: 'name', // ou une liste, essayée dans l'ordre
138
+ titleLink: null, // ou un nom, ou une liste : la première vraie URL gagne
130
139
  lang: 'en', // 'en' | 'fr' | 'es' : jeu de libellés livré
131
140
  labels: {} // surcharges clé à clé, appliquées par-dessus la langue
132
141
  };
@@ -743,6 +752,15 @@ import * as d3 from 'd3';
743
752
  if (typeof opt === 'function') return Object.assign({}, DEM_DEFAULTS, { sample: opt });
744
753
  const raw = (opt === true || typeof opt === 'string') ? { source: opt === true ? 'terrarium' : opt } : Object.assign({}, opt);
745
754
  if (typeof raw.sample === 'function') return Object.assign({}, DEM_DEFAULTS, raw);
755
+ // A precomputed profile, held by the application and keyed by the track. It is the one
756
+ // source that answers with the WHOLE track rather than one value per point: no grid to
757
+ // choose, no pixels to decode, and nothing to sample.
758
+ if (raw.track) {
759
+ const trCfg = Object.assign({}, DEM_DEFAULTS, raw);
760
+ trCfg.track = Object.assign({ coords: 'coords', parse: null, fetchOptions: null },
761
+ typeof raw.track === 'string' ? { url: raw.track } : raw.track);
762
+ return trCfg.track.url ? trCfg : null;
763
+ }
746
764
  // A point-query source: no tile grid, no pixel decoding.
747
765
  if (raw.featureInfo) {
748
766
  const fiCfg = Object.assign({}, DEM_DEFAULTS, raw);
@@ -764,6 +782,25 @@ import * as d3 from 'd3';
764
782
  return (cfg.url || cfg.wms || cfg.olSource) ? cfg : null;
765
783
  }
766
784
 
785
+ /**
786
+ * L'URL du profil d'une trace : un gabarit `{propriete}` lu sur l'entité, ou une fonction.
787
+ *
788
+ * Une propriété absente rend `null` plutôt qu'une URL trouée : demander `/profils/.json`
789
+ * ferait répondre le serveur - souvent par un 404, parfois par autre chose - là où la
790
+ * question n'a simplement pas de sens pour cette trace.
791
+ */
792
+ function trackUrl(tpl, feature) {
793
+ if (typeof tpl === 'function') return tpl(feature) || null;
794
+ if (typeof tpl !== 'string' || !feature) return null;
795
+ let complet = true;
796
+ const url = tpl.replace(/\{(\w+)\}/g, (_, cle) => {
797
+ const v = feature.get(cle);
798
+ if (v == null || v === '') { complet = false; return ''; }
799
+ return encodeURIComponent(String(v));
800
+ });
801
+ return complet ? url : null;
802
+ }
803
+
767
804
  /**
768
805
  * Zoom at which to download the model: the finest one that fits within `maxTiles`.
769
806
  *
@@ -801,14 +838,23 @@ import * as d3 from 'd3';
801
838
  * @property {number} [smoothing=0] Elevation smoothing window, in METERS (0 = none).
802
839
  * @property {?number} [xTicks=null] X axis ticks (null = auto from width).
803
840
  * @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.
841
+ * @property {('auto'|number|{exaggeration:number})} [verticalScale='auto'] `'auto'`: the
842
+ * profile fills the height, which is legible but changes scale from one track to the
843
+ * next, so a 2 % ramp looks like a wall and two profiles cannot be compared. A number
844
+ * fixes the metres covered per physical centimetre, which makes RANGES comparable.
845
+ * `{exaggeration}` fixes the ratio between the two axes instead, which makes SLOPES
846
+ * comparable: the gradient read off the chart is the real one, multiplied by the same
847
+ * factor on every track, and the control recomputes it per track and per A/B crop.
848
+ * Either form is a **floor**, not a cage: a track whose range exceeds what the height
849
+ * can show would spill out of the frame, which is worse than losing comparability, so
850
+ * the scale then widens silently, nothing being drawn on the chart to say so.
811
851
  * @property {'click'|'mouseover'} [show='click'] How a track is selected on the map.
852
+ * @property {boolean} [showWithoutElevation=true] What to do with a track that ends up
853
+ * with no elevation at all: none in its geometry, and none the terrain model could
854
+ * supply either (`dem: null`, or a fill that failed). `true` shows the panel with the
855
+ * track's title and the `noElevation` message where the chart would be; `false` hides
856
+ * the panel outright. Either way no chart is drawn: a flat line at zero under a D+ of
857
+ * 0 m is not a missing figure, it is a wrong one.
812
858
  * @property {boolean} [hideOnMapClick=true] Click on empty map hides the profile.
813
859
  * @property {boolean} [collapsable=true] Show the collapse/expand button.
814
860
  * @property {boolean} [collapsed=false] Initial collapsed state.
@@ -829,8 +875,14 @@ import * as d3 from 'd3';
829
875
  * code falls back to English rather than leaving keys empty.
830
876
  * @property {Object} [labels={}] Per-key overrides applied on top of `lang`. They survive
831
877
  * a later language change, so a corrected key stays corrected.
832
- * @property {string} [titleProperty='name'] Feature property used as the title.
833
- * @property {?string} [titleLink=null] Feature property holding a URL, making the title a link.
878
+ * @property {(string|string[])} [titleProperty='name'] Feature property used as the title,
879
+ * or a list of them tried in order - the first one holding a value wins. A feature with
880
+ * none falls back on the `untitled` label.
881
+ * @property {?(string|string[])} [titleLink=null] Feature property holding a URL, which
882
+ * makes the title a link, or a list tried in order - the first one holding an actual
883
+ * URL wins, so a property carrying something else is stepped over rather than rendered
884
+ * as a broken link. `null`, the default, never links the title: a dataset is not asked
885
+ * to explain that its `url` column is not the one meant for the reader.
834
886
  * @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
835
887
  * @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
836
888
  * @property {?(boolean|string|Function|Object)} [dem='terrarium'] Fill missing elevations
@@ -840,6 +892,11 @@ import * as d3 from 'd3';
840
892
  * `{olSource}` = any `ol/source/TileImage` (XYZ, TileWMS, and so on);
841
893
  * `{featureInfo:{url,layers,property}}` = WMS GetFeatureInfo, one request per point,
842
894
  * for a greyscale coverage that cannot be decoded from its pixels (slow);
895
+ * `{track:{url,coords,parse,fetchOptions}}` = a profile the application computed
896
+ * beforehand and serves per track, `url` being a `{property}` template read off the
897
+ * feature (or a function). Unlike every other source it answers with the whole track:
898
+ * `[[lon,lat,z],…]` replaces the geometry, so a decimated profile is accepted, while a
899
+ * plain `[z,…]` still lines up with the geometry's own points;
843
900
  * a function or `{sample}` `(lonlats, ctx) => number[]|Promise<number[]>` to source them
844
901
  * yourself; `null` disables the whole thing.
845
902
  * Decoding: `encoding` is `'terrarium'`, `'mapbox'`, or a function `(r,g,b,a) => metres`.
@@ -878,6 +935,9 @@ import * as d3 from 'd3';
878
935
  this._crops = []; this._off = 0;
879
936
  this._zoomA = null; this._zoomB = null; this._armed = null;
880
937
  this._demZ = null; this._demFor = null; this._demSeq = 0; this._demLoading = false;
938
+ // Un profil précalculé apporte sa propre géométrie : elle remplace celle de l'entité
939
+ // le temps du calcul, sans jamais la modifier - l'entité appartient à la carte.
940
+ this._trackLines = null; this._linesFor = null;
881
941
  this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
882
942
  this._buildDom(element);
883
943
  element.style.display = 'none';
@@ -1133,7 +1193,7 @@ import * as d3 from 'd3';
1133
1193
  setFeature(feature) {
1134
1194
  this._feature = feature || null;
1135
1195
  this._crops = []; this._off = 0; this._zoomA = null; this._zoomB = null; this._armed = null;
1136
- if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._clear(); return this; }
1196
+ if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._trackLines = this._linesFor = null; this._clear(); return this; }
1137
1197
  this.element.style.display = '';
1138
1198
  this._compute();
1139
1199
  // Before the render, not after: it is _fillFromDem that knows whether a fill is
@@ -1175,7 +1235,7 @@ import * as d3 from 'd3';
1175
1235
  }
1176
1236
  if (patch && ('zoom' in patch || 'exportPng' in patch)) this._updateZoomButtons();
1177
1237
  if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
1178
- if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
1238
+ if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; this._trackLines = null; this._linesFor = null; }
1179
1239
  if (this._feature) { this._compute(); this._fillFromDem(this._feature); this._updateZoomButtons(); if (this._collapsed) this._renderTitle(); else this._render(); }
1180
1240
  return this;
1181
1241
  }
@@ -1199,19 +1259,22 @@ import * as d3 from 'd3';
1199
1259
  */
1200
1260
  _fillFromDem(feature) {
1201
1261
  const cfg = demConfig(this.options.dem);
1202
- if (!cfg || !feature || this._demFor === feature) return;
1262
+ if (!cfg || !feature || this._demFor === feature || this._linesFor === feature) return;
1203
1263
  if (ElevationProfile.featureHasZ(feature)) return; // the track already carries its Z
1204
1264
  // Canvas decoding needs a browser; a `sample` function does not, and must stay
1205
1265
  // usable where there is no DOM.
1206
1266
  if (!cfg.sample && (typeof Image === 'undefined' || typeof document === 'undefined')) return;
1207
1267
 
1208
1268
  const geom = feature.getGeometry && feature.getGeometry();
1209
- if (!geom) return;
1210
- const lines = geomLines(geom);
1269
+ const lines = geom ? geomLines(geom) : [];
1211
1270
  const dataProj = this.options.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
1212
1271
  const lonlats = [];
1213
1272
  for (const seg of lines) for (const c of seg) lonlats.push(toLonLat(c, dataProj));
1214
- if (!lonlats.length) return;
1273
+ // Un profil précalculé rapporte sa propre géométrie : il lui suffit de savoir DE
1274
+ // QUELLE trace il s'agit. C'est ce qui le rend utilisable sur une entité de tuile
1275
+ // vectorielle, dont les coordonnées sont dans le repère de la tuile et qu'aucune
1276
+ // source échantillonnée ne saurait interroger.
1277
+ if (!lonlats.length && !cfg.track) return;
1215
1278
 
1216
1279
  // Sequence number: a track clicked while another is loading must win, otherwise the
1217
1280
  // slowest response would overwrite the profile on screen.
@@ -1230,6 +1293,7 @@ import * as d3 from 'd3';
1230
1293
 
1231
1294
  /** @private */
1232
1295
  _startFill(cfg, seq, feature, lonlats, dataProj) {
1296
+ if (cfg.track) { this._startTrackFill(cfg, seq, feature, lonlats.length, dataProj); return; }
1233
1297
  if (cfg.sample) {
1234
1298
  // Wrapped in a promise chain so a function that throws synchronously fails the
1235
1299
  // same way as one that rejects - a source that misbehaves must not leave the
@@ -1249,6 +1313,75 @@ import * as d3 from 'd3';
1249
1313
  }).catch(() => this._demDone(seq, feature, null, lonlats.length, z, 0));
1250
1314
  }
1251
1315
 
1316
+ /**
1317
+ * Va chercher un profil déjà calculé, que l'application tient prêt pour cette trace.
1318
+ *
1319
+ * Les dix autres sources échantillonnent un modèle de terrain aux points de la trace.
1320
+ * Celle-ci ne fait rien de tel : elle demande à l'application le profil qu'elle a déjà,
1321
+ * calculé une fois à l'ingestion sur le modèle qu'elle voulait, décimé comme elle
1322
+ * l'entendait. C'est le cas d'une application qui a fait son altimétrie en amont et ne
1323
+ * veut ni la refaire dans le navigateur ni dépendre d'un service au clic.
1324
+ *
1325
+ * La réponse peut prendre deux formes, et elles ne veulent pas dire la même chose :
1326
+ * un tableau de nombres est une altitude par point de la géométrie, qui rejoint le
1327
+ * chemin ordinaire ; un tableau de triplets `[lon, lat, z]` porte SA propre géométrie
1328
+ * et remplace celle de la trace, ce qui est le seul moyen d'accepter un profil décimé.
1329
+ *
1330
+ * @private
1331
+ */
1332
+ _startTrackFill(cfg, seq, feature, expected, dataProj) {
1333
+ const url = trackUrl(cfg.track.url, feature);
1334
+ if (!url) { this._demDone(seq, feature, null, expected, null, 0); return; }
1335
+ Promise.resolve()
1336
+ .then(() => fetch(url, cfg.track.fetchOptions || undefined))
1337
+ .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
1338
+ .then((json) => {
1339
+ const data = cfg.track.parse ? cfg.track.parse(json, feature)
1340
+ : (Array.isArray(json) ? json : (json && json[cfg.track.coords]));
1341
+ this._trackDone(seq, feature, data, expected, dataProj);
1342
+ })
1343
+ // Une trace sans profil répond 404, et c'est un fait, pas une panne : elle retombe
1344
+ // sur le même sort qu'un remplissage manqué.
1345
+ .catch(() => this._demDone(seq, feature, null, expected, null, 0));
1346
+ }
1347
+
1348
+ /**
1349
+ * Adopte un profil précalculé, sous l'une ou l'autre de ses deux formes.
1350
+ *
1351
+ * Un triplet incomplet fait tout refuser, par la même règle que `_demDone` : un profil
1352
+ * auquel il manque des points n'est pas un profil incomplet, c'est un profil faux.
1353
+ *
1354
+ * @fires demload
1355
+ * @private
1356
+ */
1357
+ _trackDone(seq, feature, data, expected, dataProj) {
1358
+ const rate = () => this._demDone(seq, feature, null, expected, null, 0);
1359
+ if (!Array.isArray(data) || !data.length) return rate();
1360
+ // Des nombres : une altitude par point, exactement ce qu'un échantillonneur rend.
1361
+ if (typeof data[0] === 'number') return void this._demDone(seq, feature, data, expected, null, 0);
1362
+
1363
+ if (seq !== this._demSeq || this._feature !== feature) return; // un clic plus récent a gagné
1364
+ // `isFinite(null)` vaut true - Number(null) est 0 : sans le test de nullité, une
1365
+ // altitude absente passerait pour une altitude au niveau de la mer. Même règle que
1366
+ // `_demDone`, pour la même raison.
1367
+ const fini = (v) => v != null && isFinite(v);
1368
+ const lignes = [];
1369
+ for (const c of data) {
1370
+ if (!c || c.length < 3 || !fini(c[0]) || !fini(c[1]) || !fini(c[2])) return rate();
1371
+ const xy = transform([c[0], c[1]], 'EPSG:4326', dataProj);
1372
+ lignes.push([xy[0], xy[1], c[2]]);
1373
+ }
1374
+ if (lignes.length < 2) return rate();
1375
+
1376
+ this._demLoading = false;
1377
+ this._trackLines = [lignes]; this._linesFor = feature;
1378
+ this._demZ = null; this._demFor = null;
1379
+ this._compute();
1380
+ this._updateZoomButtons();
1381
+ if (!this._collapsed) this._render();
1382
+ this.dispatchEvent({ type: 'demload', ok: true, zoom: null, tiles: 0 });
1383
+ }
1384
+
1252
1385
  /**
1253
1386
  * Outcome of a fill, whatever produced it: adopt the elevations, drop the spinner,
1254
1387
  * redraw, announce.
@@ -1282,14 +1415,18 @@ import * as d3 from 'd3';
1282
1415
  // ---------- computation -------------------------------------------
1283
1416
  _compute() {
1284
1417
  const o = this.options;
1285
- const geom = this._feature.getGeometry();
1286
- const lines = geomLines(geom);
1418
+ // Un profil précalculé apporte sa géométrie ; sinon, celle de l'entité.
1419
+ const substitue = this._linesFor === this._feature && this._trackLines;
1420
+ const geom = this._feature.getGeometry && this._feature.getGeometry();
1421
+ const lines = substitue ? this._trackLines : (geom ? geomLines(geom) : []);
1287
1422
  const dataProj = o.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
1288
1423
 
1289
1424
  // Terrain-model elevations, if they were loaded for THIS feature.
1290
1425
  const demZ = (this._demFor === this._feature) ? this._demZ : null;
1291
1426
 
1292
- const times = extractTimes(this._feature, lines);
1427
+ // Les horodatages sont indexés sur les points de l'ENTITÉ : sur une géométrie
1428
+ // substituée ils tomberaient à côté, et un temps faux vaut moins que pas de temps.
1429
+ const times = substitue ? null : extractTimes(this._feature, lines);
1293
1430
  this._hasTime = !!times;
1294
1431
  const ignoreStops = o.ignoreStops !== false; // default: stops are excluded
1295
1432
  const stopSpeed = (o.stopSpeed != null ? o.stopSpeed : 0.5); // m/s
@@ -1361,19 +1498,57 @@ import * as d3 from 'd3';
1361
1498
  * `nice()` n'est pas appliqué en échelle absolue : il arrondit le domaine vers
1362
1499
  * l'extérieur, donc il fausserait le rapport qu'on vient de fixer.
1363
1500
  */
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;
1501
+ _yScale(s, zpad, innerH, innerW) {
1502
+ const bas = s.min - zpad, haut = s.max + zpad;
1503
+ const cm = innerH / this._pxPerCm(); // hauteur du graphe, en centimètres
1504
+ const cmH = innerW / this._pxPerCm(); // largeur du graphe, en centimètres
1505
+ // Le rapport entre les deux axes, quel que soit le mode : c'est la grandeur qu'on
1506
+ // tient fixe sous `{ exaggeration }`, et celle qui dérive librement en `'auto'`.
1507
+ // La publier partout est le seul moyen de voir la différence plutôt que d'y croire.
1508
+ const rapport = (etendue) => (cmH > 0 && etendue > 0) ? (s.distance / cmH) / (etendue / cm) : null;
1509
+
1510
+ const vs = this._verticalScaleFor(s, innerW);
1367
1511
  if (!(vs > 0)) {
1368
- this._vScale = null;
1369
- return d3.scaleLinear().domain([bas, haut]).range([innerH, 0]).nice();
1512
+ // `'auto'` : la hauteur est remplie, donc c'est l'amplitude qui décide de tout.
1513
+ // `nice()` arrondit le domaine vers l'extérieur, d'où la lecture APRÈS coup.
1514
+ const echelle = d3.scaleLinear().domain([bas, haut]).range([innerH, 0]).nice();
1515
+ const dom = echelle.domain();
1516
+ this._vScale = null; // aucune échelle absolue demandée
1517
+ this._vExaggeration = rapport(dom[1] - dom[0]);
1518
+ return echelle;
1370
1519
  }
1371
- const cm = innerH / this._pxPerCm(); // hauteur du graphe, en centimètres
1372
1520
  const etendue = Math.max(vs * cm, haut - bas); // jamais moins qu'il n'en faut
1373
1521
  const milieu = (bas + haut) / 2;
1374
1522
  this._vScale = etendue / cm; // m/cm effectivement appliqués
1523
+ // Sous le plancher, le rapport retombe sous celui demandé : c'est la seule façon de
1524
+ // savoir que c'est arrivé.
1525
+ this._vExaggeration = rapport(etendue);
1375
1526
  return d3.scaleLinear().domain([milieu - etendue / 2, milieu + etendue / 2]).range([innerH, 0]);
1376
1527
  }
1528
+
1529
+ /**
1530
+ * Les mètres par centimètre demandés, sous l'une ou l'autre forme de `verticalScale`.
1531
+ *
1532
+ * Une échelle absolue rend les AMPLITUDES comparables, pas les pentes : l'axe
1533
+ * horizontal s'étire toujours sur toute la trace, si bien qu'une même pente de 5 %
1534
+ * paraît trois fois plus raide sur une boucle de 3 km que sur une traversée de 30. Une
1535
+ * exagération fixe le RAPPORT entre les deux axes : la pente lue sur le dessin est
1536
+ * alors la pente réelle, multipliée par un facteur constant d'une trace à l'autre.
1537
+ *
1538
+ * Elle se calcule ici et pas chez l'appelant parce qu'elle demande deux choses que lui
1539
+ * n'a pas : la largeur du graphe une fois les marges retirées, et la distance
1540
+ * réellement affichée - celle du recadrage A/B en cours, non celle de la trace.
1541
+ *
1542
+ * @private
1543
+ */
1544
+ _verticalScaleFor(s, innerW) {
1545
+ const v = this.options.verticalScale;
1546
+ if (typeof v === 'number') return v;
1547
+ const ex = (v && typeof v === 'object') ? Number(v.exaggeration) : 0;
1548
+ if (!(ex > 0) || !(s.distance > 0) || !(innerW > 0)) return 0;
1549
+ const cm = innerW / this._pxPerCm(); // largeur du graphe, en centimètres
1550
+ return (s.distance / cm) / ex; // horizontale ÷ exagération
1551
+ }
1377
1552
  _statsOf(samples, fromTotal) {
1378
1553
  let ascent = 0, descent = 0, zmin = Infinity, zmax = -Infinity, maxAbs = 0;
1379
1554
  for (let k = 0; k < samples.length; k++) {
@@ -1467,17 +1642,80 @@ import * as d3 from 'd3';
1467
1642
  * is skipped while collapsed, so without a separate entry point the title would keep
1468
1643
  * naming the previous track after a change of feature.
1469
1644
  */
1645
+ /**
1646
+ * Cette trace a-t-elle une altitude, d'où qu'elle vienne ?
1647
+ *
1648
+ * Trois sources possibles, et une seule suffit : le Z de la géométrie, les altitudes
1649
+ * rapportées par un modèle de terrain, ou le profil précalculé d'une source `track`.
1650
+ * Aucune des trois, et il n'y a rien à dessiner - ce qui ne veut pas dire zéro.
1651
+ *
1652
+ * @private
1653
+ */
1654
+ _hasElevation() {
1655
+ const f = this._feature;
1656
+ if (!f) return false;
1657
+ if (ElevationProfile.featureHasZ(f)) return true;
1658
+ if (this._demFor === f && this._demZ) return true;
1659
+ if (this._linesFor === f && this._trackLines) return true;
1660
+ return false;
1661
+ }
1662
+
1663
+ /**
1664
+ * Ce qui prend la place du graphe quand la trace n'a aucune altitude.
1665
+ *
1666
+ * Même forme que le spinner, et pour la même raison : la hauteur du graphe est tenue,
1667
+ * si bien que le panneau ne saute pas si un profil finit par arriver. Le titre reste,
1668
+ * lui : c'est la trace cliquée, et l'utilisateur a besoin de savoir laquelle.
1669
+ *
1670
+ * @private
1671
+ */
1672
+ _renderNoElevation() {
1673
+ if (!this.options.showWithoutElevation) { this.element.style.display = 'none'; return; }
1674
+ this.element.style.display = '';
1675
+ this._applyTheme();
1676
+ this._applyPlacement();
1677
+ this._renderHeader(true);
1678
+ const H = typeof this.options.height === 'number' ? this.options.height : 180;
1679
+ this._body.innerHTML = '';
1680
+ const box = document.createElement('div');
1681
+ box.className = 'oep-noelev';
1682
+ box.style.height = `${H}px`;
1683
+ box.setAttribute('role', 'status');
1684
+ box.textContent = this.options.labels.noElevation;
1685
+ this._body.appendChild(box);
1686
+ this._clearFocus();
1687
+ }
1688
+
1689
+ /**
1690
+ * La première propriété qui répond, parmi celles proposées.
1691
+ *
1692
+ * Un nom de propriété unique reste un nom de propriété unique ; une liste est essayée
1693
+ * dans l'ordre. `garde` filtre ce qu'on accepte : n'importe quelle valeur non vide pour
1694
+ * un titre, une URL véritable pour un lien - sans quoi une propriété `url` contenant
1695
+ * autre chose ferait un lien mort plutôt que pas de lien du tout.
1696
+ *
1697
+ * @private
1698
+ */
1699
+ _pickProperty(f, spec, garde) {
1700
+ if (!spec || !f || !f.get) return null;
1701
+ for (const cle of (Array.isArray(spec) ? spec : [spec])) {
1702
+ const v = f.get(cle);
1703
+ if (v != null && v !== '' && (!garde || garde(v))) return v;
1704
+ }
1705
+ return null;
1706
+ }
1707
+
1470
1708
  _renderTitle() {
1471
1709
  const o = this.options, f = this._feature;
1472
1710
  if (!f) return;
1473
- const name = (f.get && f.get(o.titleProperty)) || 'Profil';
1474
- const linkUrl = o.titleLink && f.get && f.get(o.titleLink);
1711
+ const name = this._pickProperty(f, o.titleProperty) || o.labels.untitled;
1712
+ const linkUrl = this._pickProperty(f, o.titleLink, isUrl);
1475
1713
  if (isUrl(linkUrl)) this._titleEl.innerHTML = `<a href="${esc(linkUrl)}" target="_blank" rel="noopener">${esc(name)}</a>`;
1476
1714
  else this._titleEl.textContent = name;
1477
1715
  this._titleEl.setAttribute('title', name);
1478
1716
  }
1479
1717
 
1480
- _renderHeader() {
1718
+ _renderHeader(sansAltimetrie) {
1481
1719
  const o = this.options, s = this._stats, f = this._feature;
1482
1720
  this._renderTitle();
1483
1721
  // While the elevations are still unknown, every figure would read zero: a D+ of 0 m
@@ -1491,7 +1729,10 @@ import * as d3 from 'd3';
1491
1729
  const html = [], text = [];
1492
1730
  o.headerItems.forEach((it) => {
1493
1731
  if (typeof it === 'string') {
1494
- if (it === 'distance') { html.push(`<b>${fmtDistance(s.distance, o.units)}</b>`); text.push(fmtDistance(s.distance, o.units)); }
1732
+ // Sans altitude, il reste ce que la géométrie sait dire : la longueur, la durée.
1733
+ // Le dénivelé et les altitudes extrêmes, eux, vaudraient zéro - on les tait.
1734
+ if (sansAltimetrie && (it === 'ascent' || it === 'descent' || it === 'min' || it === 'max' || it === 'minmax')) return;
1735
+ if (it === 'distance') { if (!(s && s.distance > 0)) return; html.push(`<b>${fmtDistance(s.distance, o.units)}</b>`); text.push(fmtDistance(s.distance, o.units)); }
1495
1736
  else if (it === 'ascent') { html.push(`<span class="oep-up">${o.labels.ascent} ${fmtElevation(s.ascent, o.units)}</span>`); text.push(`${o.labels.ascent} ${fmtElevation(s.ascent, o.units)}`); }
1496
1737
  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)}`); }
1497
1738
  else if (it === 'min') { html.push(fmtElevation(s.min, o.units)); text.push(fmtElevation(s.min, o.units)); }
@@ -1546,6 +1787,10 @@ import * as d3 from 'd3';
1546
1787
  // ---------- rendering ---------------------------------------------
1547
1788
  _render() {
1548
1789
  const o = this.options, s = this._stats, data = this._samples;
1790
+ // Aucune altitude par aucune voie, et plus rien en chemin : le graphe n'a pas lieu
1791
+ // d'être. Le tracer quand même poserait une ligne plate au niveau zéro sous un D+ de
1792
+ // 0 m, ce qui n'est pas un chiffre manquant mais un chiffre faux.
1793
+ if (!this._demLoading && !this._hasElevation()) { this._renderNoElevation(); return; }
1549
1794
  if (!data || !data.length) return;
1550
1795
  this._applyTheme();
1551
1796
  const mobile = this._applyPlacement();
@@ -1576,7 +1821,7 @@ import * as d3 from 'd3';
1576
1821
 
1577
1822
  const x = d3.scaleLinear().domain([0, s.distance]).range([0, innerW]);
1578
1823
  const zpad = (s.max - s.min) * 0.1 || 10;
1579
- const y = this._yScale(s, zpad, innerH);
1824
+ const y = this._yScale(s, zpad, innerH, innerW);
1580
1825
  this._x = x; this._y = y; this._dims = { innerW, innerH };
1581
1826
 
1582
1827
  if (o.grid) g.append('g').attr('class', 'oep-grid').call(d3.axisLeft(y).ticks(yTicks).tickSize(-innerW).tickFormat(''));
@@ -2011,6 +2256,9 @@ import * as d3 from 'd3';
2011
2256
  ElevationProfile.DEM_PRESETS = DEM_PRESETS;
2012
2257
  ElevationProfile.DemSampler = DemSampler;
2013
2258
  ElevationProfile.POSITIONS = POSITIONS;
2014
- ElevationProfile.version = '0.6.0';
2259
+ // Stamped at build time from package.json - see rollup.config.mjs, which fails the build
2260
+ // if this placeholder ever stops matching. Read from the source rather than the bundle,
2261
+ // it says just that: a development copy, of no released version.
2262
+ ElevationProfile.version = '2.1.0';
2015
2263
 
2016
2264
  export { ElevationProfile as default };