ol-elevation-profile 0.6.0 → 1.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.
@@ -27,9 +27,11 @@
27
27
  /**
28
28
  * Synchronized elevation profile control for OpenLayers, rendered with d3.
29
29
  *
30
- * Reads elevation (Z) directly from 3D line geometries (`[lon, lat, z]`),
31
- * so no external elevation service is queried. Clicking (or hovering) a track
32
- * shows its profile; a marker stays synchronized on both the map and the chart.
30
+ * Reads elevation (Z) directly from 3D line geometries (`[lon, lat, z]`). A track without
31
+ * Z is completed from a terrain model - keyless AWS Terrain Tiles by default, which means
32
+ * the control fetches tiles on its own; set `dem: null` to keep it entirely offline.
33
+ * Clicking (or hovering) a track shows its profile; a marker stays synchronized on both
34
+ * the map and the chart.
33
35
  *
34
36
  * Peer dependencies (provided by the host application, not bundled):
35
37
  * - OpenLayers >= 6 (https://openlayers.org/)
@@ -53,25 +55,26 @@
53
55
  const DEFAULTS = {
54
56
  immersion: 'docked',
55
57
  position: 'bottom',
56
- width: 520, // nombre (px, plafonné à la largeur de la carte) ou 'auto'/'100%'/'full' = largeur de la carte
58
+ width: 520, // number (px, capped to the map width) or 'auto'/'100%'/'full' = map width
57
59
  height: 180,
58
60
  margins: { unit: 'px', top: 20, right: 24, bottom: 30, left: 48 },
59
61
  units: 'meters',
60
62
  dataProjection: null,
63
+ dem: 'terrarium', // AWS tiles by default; null = off; or { url, encoding, zoom, maxTiles }
61
64
  maxPoints: 2000,
62
- smoothing: 0, // lissage de l'altitude : fenêtre en MÈTRES (0 = aucun)
65
+ smoothing: 0, // elevation smoothing: window in METRES (0 = none)
63
66
  theme: 'steelblue',
64
- color: null, // null=thème, 'auto'=couleur de la trace, ou couleur CSS
67
+ color: null, // null = theme, 'auto' = track colour, or a CSS colour
65
68
  trackLayer: null,
66
69
  transparency: false, // false | true | nombre 0..1
67
70
  transparencyLevel: 0.45,
68
71
  grid: true,
69
72
  slope: false,
70
73
  slopeClassSize: 2.5,
71
- slopeColors: null, // null = dégradé bleu->rouge (HSL) ; sinon tableau interpolé
72
- slopeSeparators: true, // ligne verticale à chaque changement de classe
74
+ slopeColors: null, // null = blue->red ramp (HSL); otherwise an interpolated array
75
+ slopeSeparators: true, // vertical line at each class change
73
76
  slopeLegend: true,
74
- maxClasses: 8, // nombre maximal de classes de pente (couleurs + légende)
77
+ maxClasses: 8, // maximum number of slope classes (colours + legend)
75
78
  xTicks: null,
76
79
  yTicks: null,
77
80
  show: 'click',
@@ -80,11 +83,11 @@
80
83
  followMap: true,
81
84
  marker: true,
82
85
  hideOnMapClick: true,
83
- responsive: true, // adapte la largeur/placement, mobile inclus
84
- mobileBreakpoint: 640, // <= largeur écran -> mode mobile (100% largeur, top/bottom)
85
- zoom: false, // boutons début/fin pour recadrer carte + profil sur A..B
86
- ignoreStops: true, // durée = temps en mouvement (ignore les arrêts)
87
- stopSpeed: 0.5, // seuil d'arrêt en m/s (~1,8 km/h)
86
+ responsive: true, // adapts width/placement, mobile included
87
+ mobileBreakpoint: 640, // <= screen width -> mobile mode (100% width, top/bottom)
88
+ zoom: false, // start/end buttons cropping map + profile to A..B
89
+ ignoreStops: true, // duration = moving time (stops excluded)
90
+ stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
88
91
  tooltipItems: ['distance', 'elevation'],
89
92
  headerItems: ['distance', 'ascent', 'descent', 'minmax'],
90
93
  titleProperty: 'name',
@@ -94,7 +97,8 @@
94
97
  ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
95
98
  time: 'Temps', duration: 'Durée',
96
99
  durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
97
- zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir'
100
+ zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
101
+ loading: 'Chargement du profil altimétrique'
98
102
  }
99
103
  };
100
104
 
@@ -111,15 +115,44 @@
111
115
  const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
112
116
  const isUrl = (v) => typeof v === 'string' && /^(https?:)?\/\/|^mailto:/i.test(v);
113
117
 
114
- // Extrait un tableau plat de timestamps (ms epoch) aligné sur l'ordre plat des coordonnées,
115
- // depuis properties.coordTimes (ISO ou nombre), coordinateProperties.times, ou la 4e dimension (M).
118
+ /**
119
+ * Coordinate segments to profile, whatever the geometry type: an array of point arrays.
120
+ *
121
+ * A polygon is profiled along its **outer ring only**; the holes are not part of the
122
+ * outline itself. The ring being closed, the profile returns to its starting point -
123
+ * that is the outline, faithfully, not a defect.
124
+ *
125
+ * One place for the five that used to test `getType()` themselves: they all wrapped
126
+ * `getCoordinates()` in one extra array unless it was a MultiLineString, which silently
127
+ * made a polygon iterate over its rings as if they were points.
128
+ */
129
+ function geomLines(geom) {
130
+ if (!geom || !geom.getCoordinates) return [];
131
+ const c = geom.getCoordinates();
132
+ switch (geom.getType()) {
133
+ case 'LineString':
134
+ case 'LinearRing': return [c];
135
+ case 'MultiLineString': return c;
136
+ case 'Polygon': return c.length ? [c[0]] : [];
137
+ case 'MultiPolygon': return c.map((poly) => poly[0]).filter(Boolean);
138
+ default: return [];
139
+ }
140
+ }
141
+
142
+ /** Whether a geometry can be profiled at all - the map-selection filter and nothing more. */
143
+ function isProfilable(geom) {
144
+ return !!geom && /^(Multi)?(LineString|Polygon)$|^LinearRing$/.test(geom.getType());
145
+ }
146
+
147
+ // Flat array of timestamps (epoch ms) aligned on the flat coordinate order, read from
148
+ // properties.coordTimes (ISO or number), coordinateProperties.times, or the 4th (M) dimension.
116
149
  function extractTimes(feature, lines) {
117
150
  const props = (feature && feature.getProperties) ? feature.getProperties() : {};
118
151
  let raw = props.coordTimes;
119
152
  if (raw == null && props.coordinateProperties) raw = props.coordinateProperties.times || props.coordinateProperties.coordTimes;
120
153
  let flat = null;
121
154
  if (Array.isArray(raw)) flat = Array.isArray(raw[0]) ? raw.reduce((a, b) => a.concat(b), []) : raw.slice();
122
- if (!flat && lines) { // repli : 4e dimension M (layout XYZM)
155
+ if (!flat && lines) { // fallback: 4th M dimension (XYZM layout)
123
156
  const tmp = []; let any = false;
124
157
  for (const seg of lines) for (const c of seg) { const v = c.length > 3 ? c[3] : null; tmp.push(v); if (v != null && isFinite(v)) any = true; }
125
158
  flat = any ? tmp : null;
@@ -172,6 +205,186 @@
172
205
  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>';
173
206
  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>';
174
207
 
208
+ // ----- digital elevation model (DEM) -----------------------------------
209
+ /**
210
+ * Known terrain-tile sources.
211
+ *
212
+ * A PNG tile carries elevation in its R/G/B channels; it is read on a canvas rather
213
+ * than queried point by point from an API. That is what makes a 10 000-point track a
214
+ * handful of requests, with no key, no quota and no rate limit — where the free
215
+ * elevation APIs cap out at 100 or 200 points per call.
216
+ *
217
+ * Attribution is not automatic: the library does not own the map. It is up to the
218
+ * application to carry `attributions` in its own basemap source - and since the DEM is
219
+ * on by default, that obligation arrives without having been asked for. `dem: null`
220
+ * turns the whole thing off.
221
+ */
222
+ const DEM_PRESETS = {
223
+ terrarium: {
224
+ url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
225
+ encoding: 'terrarium',
226
+ maxZoom: 14,
227
+ attributions: 'Elevation: <a href="https://registry.opendata.aws/terrain-tiles/">Terrain Tiles</a> (AWS Open Data)'
228
+ }
229
+ };
230
+
231
+ const DEM_DEFAULTS = { source: 'terrarium', zoom: 'auto', maxZoom: 14, maxTiles: 32, concurrency: 6, tileSize: 256 };
232
+
233
+ /** RGB → metres decoders, one per encoding convention. */
234
+ const DEM_DECODERS = {
235
+ terrarium: (r, g, b) => (r * 256 + g + b / 256) - 32768,
236
+ mapbox: (r, g, b) => -1e4 + (r * 65536 + g * 256 + b) * 0.1
237
+ };
238
+
239
+ /** Web Mercator pixel coordinates at zoom `z`, origin at the top-left corner of the world. */
240
+ function worldPixel(lon, lat, z, tileSize) {
241
+ const n = tileSize * Math.pow(2, z);
242
+ const s = Math.sin(lat * Math.PI / 180);
243
+ return [(lon + 180) / 360 * n, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n];
244
+ }
245
+
246
+ /**
247
+ * Reads elevations from a set of terrain tiles.
248
+ *
249
+ * Positions are held in **world pixels** rather than tile by tile: the four neighbours
250
+ * of a point can fall in two different tiles whenever it runs along an edge, which is
251
+ * the common case on a track. Converting to world pixels first, then resolving the
252
+ * tile, makes the edge case disappear instead of handling it.
253
+ */
254
+ class DemSampler {
255
+ /** @param {Object} cfg `url`, `encoding`, `tileSize`, `concurrency`. */
256
+ constructor(cfg) {
257
+ this.cfg = cfg;
258
+ this.decode = DEM_DECODERS[cfg.encoding] || DEM_DECODERS.terrarium;
259
+ this.tiles = new Map(); // "z/x/y" -> RGBA pixels, or null if the tile was lost
260
+ }
261
+
262
+ /**
263
+ * North-west neighbour of the point plus the interpolation fractions, in world pixels.
264
+ *
265
+ * The half-pixel subtracted is not a fudge: pixel centres fall on half-integers, and
266
+ * without that shift `floor()` would return the cell containing the point rather than
267
+ * its western neighbour, offsetting the whole profile by half a pixel.
268
+ */
269
+ _neighbours(lon, lat, z) {
270
+ const wp = worldPixel(lon, lat, z, this.cfg.tileSize);
271
+ const px = wp[0] - 0.5, py = wp[1] - 0.5;
272
+ const i0 = Math.floor(px), j0 = Math.floor(py);
273
+ return { i0, j0, tx: px - i0, ty: py - j0 };
274
+ }
275
+
276
+ /** Tile key of a world pixel; x wraps around the globe, y clamps at the poles. */
277
+ _key(i, j, z) {
278
+ const ts = this.cfg.tileSize, span = ts * Math.pow(2, z);
279
+ const jj = Math.min(span - 1, Math.max(0, j)), ii = ((i % span) + span) % span;
280
+ return { key: z + '/' + Math.floor(ii / ts) + '/' + Math.floor(jj / ts), ii, jj };
281
+ }
282
+
283
+ /** Elevation of one pixel, or null if its tile is missing. */
284
+ _at(i, j, z) {
285
+ const ts = this.cfg.tileSize;
286
+ const r = this._key(i, j, z);
287
+ const data = this.tiles.get(r.key);
288
+ if (!data) return null;
289
+ const o = ((r.jj % ts) * ts + (r.ii % ts)) * 4;
290
+ return this.decode(data[o], data[o + 1], data[o + 2]);
291
+ }
292
+
293
+ /**
294
+ * Elevation **bilinearly interpolated** between the four surrounding pixels; null as
295
+ * soon as a single one is missing.
296
+ *
297
+ * Bilinear rather than "the pixel containing the point": on a 30 or 90 m model, taking
298
+ * the pixel value makes the profile advance in stairs, and every stair counts as a
299
+ * climb then a descent in the ascent total. The interpolation invents no relief — it
300
+ * renders the same surface, without the steps of the sampling grid.
301
+ */
302
+ sample(lon, lat, z) {
303
+ const n = this._neighbours(lon, lat, z);
304
+ const a = this._at(n.i0, n.j0, z), b = this._at(n.i0 + 1, n.j0, z);
305
+ const c = this._at(n.i0, n.j0 + 1, z), d = this._at(n.i0 + 1, n.j0 + 1, z);
306
+ if (a == null || b == null || c == null || d == null) return null;
307
+ const north = a + (b - a) * n.tx, south = c + (d - c) * n.tx;
308
+ return north + (south - north) * n.ty;
309
+ }
310
+
311
+ /** Keys of the tiles needed by the four neighbours of each of the points. */
312
+ tilesFor(lonlats, z) {
313
+ const keys = new Set();
314
+ for (const ll of lonlats) {
315
+ const n = this._neighbours(ll[0], ll[1], z);
316
+ for (let di = 0; di < 2; di++) for (let dj = 0; dj < 2; dj++) keys.add(this._key(n.i0 + di, n.j0 + dj, z).key);
317
+ }
318
+ return keys;
319
+ }
320
+
321
+ /** Pixels of one tile, or null if it could not be read. */
322
+ _fetch(key) {
323
+ const parts = key.split('/');
324
+ const url = this.cfg.url.replace('{z}', parts[0]).replace('{x}', parts[1]).replace('{y}', parts[2]);
325
+ return new Promise((resolve) => {
326
+ const img = new Image();
327
+ // Without this attribute the canvas is tainted and getImageData throws: the tile
328
+ // would display, but stay unreadable. The intended sources answer with
329
+ // Access-Control-Allow-Origin: *.
330
+ img.crossOrigin = 'anonymous';
331
+ img.onload = () => {
332
+ try {
333
+ const ts = this.cfg.tileSize;
334
+ const cv = document.createElement('canvas');
335
+ cv.width = ts; cv.height = ts;
336
+ const ctx = cv.getContext('2d', { willReadFrequently: true });
337
+ ctx.drawImage(img, 0, 0, ts, ts);
338
+ resolve(ctx.getImageData(0, 0, ts, ts).data);
339
+ } catch (e) { resolve(null); }
340
+ };
341
+ img.onerror = () => resolve(null);
342
+ img.src = url;
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Loads the missing tiles, `concurrency` in flight. Resolves false if one is missing.
348
+ *
349
+ * Lost tiles are remembered as such: without that, a second pass over the same track
350
+ * would fire the same requests, doomed to fail again.
351
+ */
352
+ load(keys) {
353
+ const todo = Array.from(keys).filter((k) => !this.tiles.has(k));
354
+ let next = 0, ok = true;
355
+ const worker = () => {
356
+ if (next >= todo.length) return Promise.resolve();
357
+ const k = todo[next++];
358
+ return this._fetch(k).then((data) => { this.tiles.set(k, data); if (!data) ok = false; return worker(); });
359
+ };
360
+ const lanes = [];
361
+ for (let i = 0; i < Math.min(this.cfg.concurrency, todo.length); i++) lanes.push(worker());
362
+ return Promise.all(lanes).then(() => ok);
363
+ }
364
+ }
365
+
366
+ /** The `dem` option (true | source name | object) → a full configuration, or null. */
367
+ function demConfig(opt) {
368
+ if (!opt) return null;
369
+ const raw = (opt === true || typeof opt === 'string') ? { source: opt === true ? 'terrarium' : opt } : Object.assign({}, opt);
370
+ const preset = DEM_PRESETS[raw.source] || (raw.url ? {} : DEM_PRESETS.terrarium);
371
+ const cfg = Object.assign({}, DEM_DEFAULTS, preset, raw);
372
+ return cfg.url ? cfg : null;
373
+ }
374
+
375
+ /**
376
+ * Zoom at which to download the model: the finest one that fits within `maxTiles`.
377
+ *
378
+ * It is the tiling that widens as the track grows, not a model that degrades: a 10 km
379
+ * track is read at the finest step available, a 300 km one at a coarser step rather
380
+ * than in three hundred requests.
381
+ */
382
+ function demZoomFor(sampler, lonlats, cfg) {
383
+ if (typeof cfg.zoom === 'number') return cfg.zoom;
384
+ for (let z = cfg.maxZoom; z > 0; z--) if (sampler.tilesFor(lonlats, z).size <= cfg.maxTiles) return z;
385
+ return 1;
386
+ }
387
+
175
388
  // =======================================================================
176
389
  /**
177
390
  * @typedef {Object} ElevationProfileOptions
@@ -213,6 +426,7 @@
213
426
  * @property {?string} [titleLink=null] Feature property holding a URL → clickable title.
214
427
  * @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
215
428
  * @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
429
+ * @property {?(boolean|string|Object)} [dem='terrarium'] Fill missing elevations from a terrain model. `'terrarium'` (default) or `true` = AWS Terrain Tiles (keyless); `null` disables it; or `{url,encoding:'terrarium'|'mapbox',zoom,maxZoom,maxTiles,tileSize,concurrency}`.
216
430
  */
217
431
 
218
432
  /**
@@ -240,12 +454,13 @@
240
454
  this._marker = null;
241
455
  this._collapsed = !!o.collapsed;
242
456
  this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
457
+ this._demZ = null; this._demFor = null; this._demSeq = 0; this._demLoading = false;
243
458
  this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
244
459
  this._buildDom(element);
245
460
  element.style.display = 'none';
246
461
  }
247
462
 
248
- // ---------- util statique ----------------------------------------
463
+ // ---------- static helpers ----------------------------------------
249
464
  /**
250
465
  * Whether a feature has any Z (elevation) coordinate.
251
466
  * @param {import('ol/Feature').default} feature
@@ -254,8 +469,7 @@
254
469
  static featureHasZ(feature) {
255
470
  const g = feature && feature.getGeometry && feature.getGeometry();
256
471
  if (!g) return false;
257
- const coords = g.getType() === 'MultiLineString' ? g.getCoordinates() : [g.getCoordinates()];
258
- for (const seg of coords) for (const c of seg) if (c.length > 2 && isFinite(c[2])) return true;
472
+ for (const seg of geomLines(g)) for (const c of seg) if (c.length > 2 && isFinite(c[2])) return true;
259
473
  return false;
260
474
  }
261
475
 
@@ -267,8 +481,7 @@
267
481
  static featureHasTime(feature) {
268
482
  const g = feature && feature.getGeometry && feature.getGeometry();
269
483
  if (!g) return false;
270
- const lines = g.getType() === 'MultiLineString' ? g.getCoordinates() : [g.getCoordinates()];
271
- return !!extractTimes(feature, lines);
484
+ return !!extractTimes(feature, geomLines(g));
272
485
  }
273
486
 
274
487
  // ---------- DOM ---------------------------------------------------
@@ -357,7 +570,7 @@
357
570
  this._adjustAttribution();
358
571
  }
359
572
 
360
- // ---------- responsive -------------------------------------------
573
+ // ---------- responsive --------------------------------------------
361
574
  _availWidth() {
362
575
  const map = this.getMap();
363
576
  const t = map && map.getTargetElement && map.getTargetElement();
@@ -373,7 +586,7 @@
373
586
  return mobile;
374
587
  }
375
588
 
376
- // remonte les attributions OL au-dessus du profil quand celui-ci occupe le coin bas-droite
589
+ // Lift the OL attribution above the profile when the profile takes the bottom-right corner
377
590
  _adjustAttribution() {
378
591
  const map = this.getMap();
379
592
  const target = map && map.getTargetElement && map.getTargetElement();
@@ -384,17 +597,17 @@
384
597
  const mapR = target.getBoundingClientRect();
385
598
  const pR = this.element.getBoundingClientRect();
386
599
  if (!pR.height) return;
387
- const bottomGap = mapR.bottom - pR.bottom; // bord bas de carte <-> bas du profil
600
+ const bottomGap = mapR.bottom - pR.bottom; // map bottom edge <-> profile bottom
388
601
  const rightGap = mapR.right - pR.right;
389
- const atBottom = bottomGap < pR.height; // profil dans la bande basse
390
- const reachesRight = rightGap < 24; // atteint le coin bas-droite (attributions)
602
+ const atBottom = bottomGap < pR.height; // profile sits in the bottom band
603
+ const reachesRight = rightGap < 24; // reaches the bottom-right corner (attributions)
391
604
  if (atBottom && reachesRight) {
392
605
  attr.style.right = `${Math.max(0, Math.round(rightGap))}px`;
393
- attr.style.bottom = `${Math.round(pR.height + 2 * bottomGap)}px`; // même écart au-dessus du profil
606
+ attr.style.bottom = `${Math.round(pR.height + 2 * bottomGap)}px`; // same gap above the profile
394
607
  }
395
608
  }
396
609
 
397
- // ---------- carte -------------------------------------------------
610
+ // ---------- map ---------------------------------------------------
398
611
  setMap(map) {
399
612
  const prev = this.getMap();
400
613
  super.setMap(map);
@@ -413,7 +626,7 @@
413
626
  if (typeof window !== 'undefined') window.addEventListener('resize', this._onResize);
414
627
 
415
628
  const lineAt = (pixel) => map.forEachFeatureAtPixel(pixel, (f) => {
416
- const g = f.getGeometry(); return (g && /LineString/.test(g.getType())) ? f : undefined;
629
+ const g = f.getGeometry(); return isProfilable(g) ? f : undefined;
417
630
  });
418
631
 
419
632
  this._mapKeys = [];
@@ -423,11 +636,11 @@
423
636
  if (o.hideOnMapClick) this._mapKeys.push(map.on('click', (evt) => { if (!lineAt(evt.pixel) && this._feature) this.clear(); }));
424
637
  if (o.followMap) this._mapKeys.push(map.on('pointermove', (evt) => {
425
638
  if (!this._feature || this._collapsed) return;
426
- const cp = this._feature.getGeometry().getClosestPoint(evt.coordinate);
639
+ const cp = this._closestOnProfile(evt.coordinate); if (!cp) return;
427
640
  const px = map.getPixelFromCoordinate(cp); if (!px) return;
428
641
  if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
429
642
  }));
430
- // sortie du zoom au dézoom de la carte
643
+ // leave the A/B crop when the map is zoomed out
431
644
  this._mapKeys.push(map.on('moveend', () => {
432
645
  if (this._cropMode && this._fitRes && map.getView().getResolution() > this._fitRes * 1.25) this._exitZoom();
433
646
  }));
@@ -436,7 +649,8 @@
436
649
 
437
650
  // ---------- API ---------------------------------------------------
438
651
  /**
439
- * Show the profile for the given feature (LineString/MultiLineString, ideally 3D).
652
+ * Show the profile for the given feature (LineString, MultiLineString, or a Polygon /
653
+ * MultiPolygon profiled along its outer ring), ideally 3D.
440
654
  * Passing a falsy value hides the control.
441
655
  * @param {?import('ol/Feature').default} feature
442
656
  * @returns {this}
@@ -444,17 +658,21 @@
444
658
  setFeature(feature) {
445
659
  this._feature = feature || null;
446
660
  this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null;
447
- if (!feature) { this._fullSamples = this._samples = null; this._clear(); return this; }
661
+ if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._clear(); return this; }
448
662
  this.element.style.display = '';
449
663
  this._compute();
664
+ // Before the render, not after: it is _fillFromDem that knows whether a fill is
665
+ // starting, and the render needs that to draw the spinner instead of a flat profile.
666
+ this._fillFromDem(feature);
450
667
  this._updateZoomButtons();
451
- if (!this._collapsed) this._render();
668
+ if (this._collapsed) this._renderTitle(); else this._render();
452
669
  return this;
453
670
  }
454
671
  /** Hide the profile and clear the current feature. @returns {this} */
455
672
  clear() { return this.setFeature(null); }
456
673
  /**
457
- * @returns {?{distance:number,ascent:number,descent:number,min:number,max:number,maxAbsSlope:number,points:number}}
674
+ * @returns {?{distance:number,duration:?number,ascent:number,descent:number,min:number,max:number,maxAbsSlope:number,points:number}}
675
+ * `duration` is `null` when the track carries no time data.
458
676
  */
459
677
  getStats() { return this._stats; }
460
678
  /** @param {string|Object} t Theme name or colors object. @returns {this} */
@@ -475,20 +693,82 @@
475
693
  if (patch && 'collapsable' in patch) this._applyCollapsable();
476
694
  if (patch && 'zoom' in patch) this._updateZoomButtons();
477
695
  if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
478
- if (this._feature) { this._compute(); this._updateZoomButtons(); if (!this._collapsed) this._render(); }
696
+ if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
697
+ if (this._feature) { this._compute(); this._fillFromDem(this._feature); this._updateZoomButtons(); if (this._collapsed) this._renderTitle(); else this._render(); }
479
698
  return this;
480
699
  }
481
700
 
482
- // ---------- calcul ------------------------------------------------
701
+ // ---------- digital elevation model -------------------------------
702
+ /**
703
+ * Fills the missing elevations from a terrain model, then redraws.
704
+ *
705
+ * **A track is filled entirely or not at all.** A profile missing a few points is not
706
+ * an incomplete profile: points without Z count as zero, the line dives to sea level
707
+ * and the ascent total becomes absurd. Better the flat profile we would have had
708
+ * without the DEM.
709
+ *
710
+ * Nothing is reported to the user on failure — the fill is a supplement, it has no
711
+ * business breaking a display that succeeded without it. The `demload` event lets the
712
+ * application know if it wants to.
713
+ *
714
+ * @param {import('ol/Feature').default} feature
715
+ * @fires demload
716
+ * @private
717
+ */
718
+ _fillFromDem(feature) {
719
+ const cfg = demConfig(this.options.dem);
720
+ if (!cfg || !feature || this._demFor === feature) return;
721
+ if (ElevationProfile.featureHasZ(feature)) return; // the track already carries its Z
722
+ if (typeof Image === 'undefined' || typeof document === 'undefined') return;
723
+
724
+ const geom = feature.getGeometry && feature.getGeometry();
725
+ if (!geom) return;
726
+ const lines = geomLines(geom);
727
+ const dataProj = this.options.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
728
+ const lonlats = [];
729
+ for (const seg of lines) for (const c of seg) lonlats.push(proj_js.toLonLat(c, dataProj));
730
+ if (!lonlats.length) return;
731
+
732
+ // Sequence number: a track clicked while another is loading must win, otherwise the
733
+ // slowest response would overwrite the profile on screen.
734
+ const seq = ++this._demSeq;
735
+ const sampler = new DemSampler(cfg);
736
+ const z = demZoomFor(sampler, lonlats, cfg);
737
+ this._demLoading = true;
738
+ sampler.load(sampler.tilesFor(lonlats, z)).then((ok) => {
739
+ // A newer fill has taken over: it owns _demLoading and will clear it itself.
740
+ if (seq !== this._demSeq || this._feature !== feature) return;
741
+ const zs = ok ? lonlats.map((ll) => sampler.sample(ll[0], ll[1], z)) : null;
742
+ const complete = !!zs && zs.every((v) => v != null && isFinite(v));
743
+ this._demLoading = false;
744
+ if (complete) { this._demZ = zs; this._demFor = feature; this._compute(); }
745
+ // Redrawn even on failure: the spinner has to give way to the flat profile.
746
+ this._updateZoomButtons();
747
+ if (!this._collapsed) this._render();
748
+ /**
749
+ * Fired once a terrain-model fill has completed (or failed).
750
+ * @event demload
751
+ * @property {boolean} ok Whether every point could be sampled.
752
+ * @property {number} zoom Tile zoom level used.
753
+ * @property {number} tiles Tiles fetched.
754
+ */
755
+ this.dispatchEvent({ type: 'demload', ok: complete, zoom: z, tiles: sampler.tiles.size });
756
+ });
757
+ }
758
+
759
+ // ---------- computation -------------------------------------------
483
760
  _compute() {
484
761
  const o = this.options;
485
762
  const geom = this._feature.getGeometry();
486
- const lines = geom.getType() === 'MultiLineString' ? geom.getCoordinates() : [geom.getCoordinates()];
763
+ const lines = geomLines(geom);
487
764
  const dataProj = o.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
488
765
 
766
+ // Terrain-model elevations, if they were loaded for THIS feature.
767
+ const demZ = (this._demFor === this._feature) ? this._demZ : null;
768
+
489
769
  const times = extractTimes(this._feature, lines);
490
770
  this._hasTime = !!times;
491
- const ignoreStops = o.ignoreStops !== false; // défaut : ignore les arrêts
771
+ const ignoreStops = o.ignoreStops !== false; // default: stops are excluded
492
772
  const stopSpeed = (o.stopSpeed != null ? o.stopSpeed : 0.5); // m/s
493
773
  const pts = [];
494
774
  let cum = 0, prev = null, i = -1, tAcc = 0, prevMs = null;
@@ -501,13 +781,14 @@
501
781
  if (times && times[i] != null) {
502
782
  const ms = times[i];
503
783
  if (prevMs != null) {
504
- const dt = (ms - prevMs) / 1000; // s sur le segment
784
+ const dt = (ms - prevMs) / 1000; // seconds over the segment
505
785
  if (dt > 0 && (!ignoreStops || (dseg / dt) >= stopSpeed)) tAcc += dt;
506
786
  }
507
787
  t = tAcc; prevMs = ms;
508
788
  }
509
789
  prev = ll;
510
- pts.push({ x: cum, z: (c.length > 2 && isFinite(c[2])) ? c[2] : 0, coord: c, t });
790
+ const z = (c.length > 2 && isFinite(c[2])) ? c[2] : (demZ ? demZ[i] : 0);
791
+ pts.push({ x: cum, z, coord: c, t });
511
792
  }
512
793
  if (o.smoothing > 0) this._smooth(pts, o.smoothing);
513
794
 
@@ -527,11 +808,13 @@
527
808
  const distance = samples.length ? samples[samples.length - 1].x - samples[0].x : 0;
528
809
  const ta = samples.length ? samples[0].t : null, tb = samples.length ? samples[samples.length - 1].t : null;
529
810
  const duration = (ta != null && tb != null) ? (tb - ta) : null;
811
+ // Reported for a closed ring too, where they are equal by construction: on a loop,
812
+ // D+ is exactly the figure one is after.
530
813
  return { distance, duration, ascent, descent, min: isFinite(zmin) ? zmin : 0, max: isFinite(zmax) ? zmax : 0, maxAbsSlope: maxAbs, points: samples.length };
531
814
  }
532
815
  _smooth(pts, meters) {
533
816
  if (!(meters > 0) || pts.length < 3) return;
534
- const half = meters / 2; // fenêtre = ±(meters/2) le long du tracé
817
+ const half = meters / 2; // window = +/-(meters/2) along the track
535
818
  const z = pts.map((p) => p.z);
536
819
  let lo = 0, hi = 0, sum = 0;
537
820
  for (let i = 0; i < pts.length; i++) {
@@ -556,20 +839,20 @@
556
839
  if (samples.length) samples[0].slope = samples.length > 1 ? samples[1].slope : 0;
557
840
  }
558
841
 
559
- // ---------- pente : couleurs --------------------------------------
842
+ // ---------- slope: colours ----------------------------------------
560
843
  _slopeScale() {
561
844
  const cs = this.options.slopeClassSize || 2.5;
562
845
  const maxClasses = this.options.maxClasses || 8;
563
846
  const realIdx = Math.max(1, Math.floor((this._stats.maxAbsSlope || 0) / cs));
564
- const maxIdx = Math.min(realIdx, maxClasses - 1); // au plus `maxClasses` classes (défaut 8)
565
- const capped = realIdx > maxIdx; // des pentes dépassent la dernière classe
847
+ const maxIdx = Math.min(realIdx, maxClasses - 1); // at most `maxClasses` classes (default 8)
848
+ const capped = realIdx > maxIdx; // some slopes overflow the last class
566
849
  let colorByIndex;
567
850
  if (this.slopeColors && this.slopeColors.length) {
568
851
  const interp = d3__namespace.interpolateRgbBasis(this.slopeColors);
569
852
  colorByIndex = (idx) => interp(maxIdx ? idx / maxIdx : 0);
570
853
  } else {
571
- // rampe à arrêts non uniformes : partie froide (bleu-vert, peu lisible) compressée,
572
- // jaune PUR au milieu, puis orange, rouge. classe 0 = bleu, classe max = rouge.
854
+ // Ramp with non-uniform stops: the cold end (blue-green, hard to read) is squeezed,
855
+ // PURE yellow in the middle, then orange, red. Class 0 = blue, top class = red.
573
856
  const ramp = d3__namespace.scaleLinear()
574
857
  .domain([0, 0.16, 0.42, 0.68, 1])
575
858
  .range(['#2166ac', '#27a35a', '#ffe000', '#f4791f', '#d7191c'])
@@ -581,8 +864,8 @@
581
864
  }
582
865
  _classIndex(slope, sc) { return Math.min(sc.maxIdx, Math.floor(Math.abs(slope) / sc.classSize)); }
583
866
 
584
- // ---------- temps : format adaptatif ------------------------------
585
- // 7 sec · 26 min · 1 h 48 min · 2 j 3 h (jours + heures normalisés)
867
+ // ---------- time: adaptive format ---------------------------------
868
+ // 7 sec | 26 min | 1 h 48 min | 2 d 3 h (days + hours normalised)
586
869
  _fmtDuration(sec) {
587
870
  if (sec == null || !isFinite(sec)) return '';
588
871
  const u = (this.options.labels && this.options.labels.durationUnits) || { s: 'sec', m: 'min', h: 'h', d: 'j' };
@@ -599,14 +882,35 @@
599
882
  return h ? `${d} ${u.d} ${h} ${u.h}` : `${d} ${u.d}`;
600
883
  }
601
884
 
602
- // ---------- entête + légende --------------------------------------
603
- _renderHeader() {
604
- const o = this.options, s = this._stats, f = this._feature;
885
+ // ---------- header + legend ---------------------------------------
886
+ /**
887
+ * Track title, and its optional link.
888
+ *
889
+ * Its own method because it is the only part of the header that stays visible once
890
+ * collapsed — the CSS hides the body, the stats, the legend and the toolbar. `_render`
891
+ * is skipped while collapsed, so without a separate entry point the title would keep
892
+ * naming the previous track after a change of feature.
893
+ */
894
+ _renderTitle() {
895
+ const o = this.options, f = this._feature;
896
+ if (!f) return;
605
897
  const name = (f.get && f.get(o.titleProperty)) || 'Profil';
606
898
  const linkUrl = o.titleLink && f.get && f.get(o.titleLink);
607
899
  if (isUrl(linkUrl)) this._titleEl.innerHTML = `<a href="${esc(linkUrl)}" target="_blank" rel="noopener">${esc(name)}</a>`;
608
900
  else this._titleEl.textContent = name;
609
901
  this._titleEl.setAttribute('title', name);
902
+ }
903
+
904
+ _renderHeader() {
905
+ const o = this.options, s = this._stats, f = this._feature;
906
+ this._renderTitle();
907
+ // While the elevations are still unknown, every figure would read zero: a D+ of 0 m
908
+ // then jumping to 1 200 is worse than no figure at all.
909
+ if (this._demLoading) {
910
+ this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
911
+ this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
912
+ return;
913
+ }
610
914
 
611
915
  const html = [], text = [];
612
916
  o.headerItems.forEach((it) => {
@@ -642,18 +946,40 @@
642
946
  } else this._legendEl.style.display = 'none';
643
947
  }
644
948
 
645
- // ---------- rendu -------------------------------------------------
949
+ /**
950
+ * Spinner shown in place of the chart while the terrain model loads.
951
+ *
952
+ * It keeps the chart's height so the panel does not jump when the profile replaces it,
953
+ * and it carries no colour of its own: the CSS takes `--oep-area`, which `_applyTheme`
954
+ * has just set - the theme colour, or the track colour under `color: 'auto'`.
955
+ */
956
+ _renderSpinner() {
957
+ const H = typeof this.options.height === 'number' ? this.options.height : 180;
958
+ this._body.innerHTML = '';
959
+ const box = document.createElement('div');
960
+ box.className = 'oep-loading';
961
+ box.style.height = `${H}px`;
962
+ box.setAttribute('role', 'status');
963
+ box.setAttribute('aria-label', this.options.labels.loading);
964
+ const sp = document.createElement('div');
965
+ sp.className = 'oep-spinner';
966
+ box.appendChild(sp);
967
+ this._body.appendChild(box);
968
+ }
969
+
970
+ // ---------- rendering ---------------------------------------------
646
971
  _render() {
647
972
  const o = this.options, s = this._stats, data = this._samples;
648
973
  if (!data || !data.length) return;
649
974
  this._applyTheme();
650
975
  const mobile = this._applyPlacement();
651
976
  this._renderHeader();
977
+ if (this._demLoading) { this._renderSpinner(); return; }
652
978
 
653
979
  const m = o.margins, u = m.unit || 'px';
654
980
  const toPx = (v) => u === 'px' ? v : v * (parseFloat(getComputedStyle(this.element).fontSize) || 16);
655
981
 
656
- // largeur : 100% en mobile ; 'auto'/'100%'/'full' = largeur de la carte ; sinon nombre plafonné à la carte
982
+ // Width: 100% on mobile; 'auto'/'100%'/'full' = map width; otherwise a number capped to the map
657
983
  const avail = this._availWidth();
658
984
  const isAuto = (o.width === 'auto' || o.width === '100%' || o.width === 'full');
659
985
  const desktopW = isAuto ? avail : Math.min(typeof o.width === 'number' ? o.width : (parseFloat(o.width) || avail), avail);
@@ -690,7 +1016,7 @@
690
1016
  const cls = this._classIndex(data[i].slope, sc);
691
1017
  let j = i; while (j + 1 < data.length && this._classIndex(data[j + 1].slope, sc) === cls) j++;
692
1018
  g.append('path').datum(data.slice(i - 1, j + 1)).attr('class', 'oep-area-slope').attr('fill', sc.colorByIndex(cls)).attr('d', areaGen);
693
- if (i > 1) seps.push(data[i - 1]); // changement de classe = frontière
1019
+ if (i > 1) seps.push(data[i - 1]); // a class change is a boundary
694
1020
  i = j + 1;
695
1021
  }
696
1022
  if (o.slopeSeparators) seps.forEach((d) => {
@@ -706,7 +1032,7 @@
706
1032
  g.append('text').attr('class', 'oep-axis-label').attr('x', innerW).attr('y', innerH + mb - 4).attr('text-anchor', 'end').text(distAxisLabel(o.units));
707
1033
  g.append('g').attr('class', 'oep-axis oep-axis-y').call(d3__namespace.axisLeft(y).ticks(yTicks).tickFormat((d) => o.units === 'imperial' ? Math.round(d * 3.28084) : d));
708
1034
 
709
- // marqueurs A / B en cours de sélection
1035
+ // A / B markers while a range is being picked
710
1036
  if (o.zoom && !this._cropMode) {
711
1037
  [['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
712
1038
  if (val == null) return;
@@ -779,6 +1105,29 @@
779
1105
  if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
780
1106
  }
781
1107
 
1108
+ /**
1109
+ * Point of the profiled outline closest to a map coordinate.
1110
+ *
1111
+ * A polygon's own `getClosestPoint` answers for its **surface**: with the cursor inside
1112
+ * the ring it returns the cursor itself, so the marker would follow the pointer across
1113
+ * the whole shape instead of sliding along the outline. Lines keep the geometry's own
1114
+ * answer, which is exact rather than limited to the decimated samples.
1115
+ */
1116
+ _closestOnProfile(coordinate) {
1117
+ const g = this._feature && this._feature.getGeometry();
1118
+ if (!g) return null;
1119
+ if (!/Polygon/.test(g.getType())) return g.getClosestPoint(coordinate);
1120
+ const data = this._fullSamples;
1121
+ if (!data || !data.length) return null;
1122
+ let best = null, bd = Infinity;
1123
+ for (const p of data) {
1124
+ const dx = p.coord[0] - coordinate[0], dy = p.coord[1] - coordinate[1];
1125
+ const dd = dx * dx + dy * dy;
1126
+ if (dd < bd) { bd = dd; best = p.coord; }
1127
+ }
1128
+ return best;
1129
+ }
1130
+
782
1131
  // ---------- focus -------------------------------------------------
783
1132
  _tooltipText(d) {
784
1133
  const o = this.options, parts = [];
@@ -816,7 +1165,7 @@
816
1165
  this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
817
1166
  this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
818
1167
  this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
819
- this._cropMode = false; this._updateZoomButtons();
1168
+ this._cropMode = false; this._demLoading = false; this._updateZoomButtons();
820
1169
  this._clearFocus();
821
1170
  this.element.style.display = 'none';
822
1171
  this._adjustAttribution();
@@ -830,6 +1179,9 @@
830
1179
  */
831
1180
  ElevationProfile.addTheme = (name, colors) => { THEMES[name] = colors; };
832
1181
  ElevationProfile.THEMES = THEMES;
1182
+ /** Known keyless terrain-tile sources, keyed by `dem.source` name. */
1183
+ ElevationProfile.DEM_PRESETS = DEM_PRESETS;
1184
+ ElevationProfile.DemSampler = DemSampler;
833
1185
  ElevationProfile.POSITIONS = POSITIONS;
834
1186
  ElevationProfile.version = '0.6.0';
835
1187