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