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