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