ol-elevation-profile 0.5.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,26 +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)
56
- maxLegendClasses: 8,
58
+ maxClasses: 8, // maximum number of slope classes (colours + legend)
57
59
  xTicks: null,
58
60
  yTicks: null,
59
61
  show: 'click',
@@ -62,9 +64,11 @@ import * as d3 from 'd3';
62
64
  followMap: true,
63
65
  marker: true,
64
66
  hideOnMapClick: true,
65
- responsive: true, // adapte la largeur/placement, mobile inclus
66
- mobileBreakpoint: 640, // <= largeur écran -> mode mobile (100% largeur, top/bottom)
67
- zoom: false, // boutons début/fin pour recadrer carte + profil sur A..B
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)
68
72
  tooltipItems: ['distance', 'elevation'],
69
73
  headerItems: ['distance', 'ascent', 'descent', 'minmax'],
70
74
  titleProperty: 'name',
@@ -72,7 +76,10 @@ import * as d3 from 'd3';
72
76
  labels: {
73
77
  distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
74
78
  ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
75
- zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir'
79
+ time: 'Temps', duration: 'Durée',
80
+ durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
81
+ zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
82
+ loading: 'Chargement du profil altimétrique'
76
83
  }
77
84
  };
78
85
 
@@ -89,6 +96,59 @@ import * as d3 from 'd3';
89
96
  const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
90
97
  const isUrl = (v) => typeof v === 'string' && /^(https?:)?\/\/|^mailto:/i.test(v);
91
98
 
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.
130
+ function extractTimes(feature, lines) {
131
+ const props = (feature && feature.getProperties) ? feature.getProperties() : {};
132
+ let raw = props.coordTimes;
133
+ if (raw == null && props.coordinateProperties) raw = props.coordinateProperties.times || props.coordinateProperties.coordTimes;
134
+ let flat = null;
135
+ if (Array.isArray(raw)) flat = Array.isArray(raw[0]) ? raw.reduce((a, b) => a.concat(b), []) : raw.slice();
136
+ if (!flat && lines) { // fallback: 4th M dimension (XYZM layout)
137
+ const tmp = []; let any = false;
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; }
139
+ flat = any ? tmp : null;
140
+ }
141
+ if (!flat) return null;
142
+ let valid = 0;
143
+ const ms = flat.map((v) => {
144
+ if (v == null || v === '') return null;
145
+ const t = (typeof v === 'number') ? (v > 1e12 ? v : v * 1000) : Date.parse(v); // epoch ms / epoch s / ISO
146
+ if (!isFinite(t)) return null;
147
+ valid++; return t;
148
+ });
149
+ return valid >= 2 ? ms : null;
150
+ }
151
+
92
152
  function colorToCss(c) {
93
153
  if (c == null) return null;
94
154
  if (typeof c === 'string') return c;
@@ -126,6 +186,186 @@ import * as d3 from 'd3';
126
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>';
127
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>';
128
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
+
129
369
  // =======================================================================
130
370
  /**
131
371
  * @typedef {Object} ElevationProfileOptions
@@ -159,12 +399,15 @@ import * as d3 from 'd3';
159
399
  * @property {boolean} [responsive=true] Adapt width/placement; mobile included.
160
400
  * @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
161
401
  * @property {boolean} [zoom=false] A/B buttons to crop map + profile to a sub-range.
162
- * @property {Array<'distance'|'elevation'|'slope'>} [tooltipItems=['distance','elevation']] Tooltip content.
163
- * @property {Array<string|{property:string,label?:string,asLink?:boolean,linkText?:string}>} [headerItems] Header content.
402
+ * @property {boolean} [ignoreStops=true] When computing time, ignore stopped segments (moving time).
403
+ * @property {number} [stopSpeed=0.5] Speed threshold (m/s) below which a segment counts as a stop.
404
+ * @property {Array<'distance'|'elevation'|'slope'|'time'>} [tooltipItems=['distance','elevation']] Tooltip content (`'time'` = elapsed time at the cursor, if the track has time data).
405
+ * @property {Array<string|{property:string,label?:string,asLink?:boolean,linkText?:string}>} [headerItems] Header content (string tokens: distance, ascent, descent, min, max, minmax, `'duration'` = total elapsed time).
164
406
  * @property {string} [titleProperty='name'] Feature property used as the title.
165
407
  * @property {?string} [titleLink=null] Feature property holding a URL → clickable title.
166
408
  * @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
167
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}`.
168
411
  */
169
412
 
170
413
  /**
@@ -192,12 +435,13 @@ import * as d3 from 'd3';
192
435
  this._marker = null;
193
436
  this._collapsed = !!o.collapsed;
194
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;
195
439
  this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
196
440
  this._buildDom(element);
197
441
  element.style.display = 'none';
198
442
  }
199
443
 
200
- // ---------- util statique ----------------------------------------
444
+ // ---------- static helpers ----------------------------------------
201
445
  /**
202
446
  * Whether a feature has any Z (elevation) coordinate.
203
447
  * @param {import('ol/Feature').default} feature
@@ -206,11 +450,21 @@ import * as d3 from 'd3';
206
450
  static featureHasZ(feature) {
207
451
  const g = feature && feature.getGeometry && feature.getGeometry();
208
452
  if (!g) return false;
209
- const coords = g.getType() === 'MultiLineString' ? g.getCoordinates() : [g.getCoordinates()];
210
- 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;
211
454
  return false;
212
455
  }
213
456
 
457
+ /**
458
+ * Whether a feature carries per-point time data (coordTimes / XYZM).
459
+ * @param {import('ol/Feature').default} feature
460
+ * @returns {boolean}
461
+ */
462
+ static featureHasTime(feature) {
463
+ const g = feature && feature.getGeometry && feature.getGeometry();
464
+ if (!g) return false;
465
+ return !!extractTimes(feature, geomLines(g));
466
+ }
467
+
214
468
  // ---------- DOM ---------------------------------------------------
215
469
  _buildDom(root) {
216
470
  const o = this.options;
@@ -297,7 +551,7 @@ import * as d3 from 'd3';
297
551
  this._adjustAttribution();
298
552
  }
299
553
 
300
- // ---------- responsive -------------------------------------------
554
+ // ---------- responsive --------------------------------------------
301
555
  _availWidth() {
302
556
  const map = this.getMap();
303
557
  const t = map && map.getTargetElement && map.getTargetElement();
@@ -313,7 +567,7 @@ import * as d3 from 'd3';
313
567
  return mobile;
314
568
  }
315
569
 
316
- // 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
317
571
  _adjustAttribution() {
318
572
  const map = this.getMap();
319
573
  const target = map && map.getTargetElement && map.getTargetElement();
@@ -324,17 +578,17 @@ import * as d3 from 'd3';
324
578
  const mapR = target.getBoundingClientRect();
325
579
  const pR = this.element.getBoundingClientRect();
326
580
  if (!pR.height) return;
327
- 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
328
582
  const rightGap = mapR.right - pR.right;
329
- const atBottom = bottomGap < pR.height; // profil dans la bande basse
330
- 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)
331
585
  if (atBottom && reachesRight) {
332
586
  attr.style.right = `${Math.max(0, Math.round(rightGap))}px`;
333
- 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
334
588
  }
335
589
  }
336
590
 
337
- // ---------- carte -------------------------------------------------
591
+ // ---------- map ---------------------------------------------------
338
592
  setMap(map) {
339
593
  const prev = this.getMap();
340
594
  super.setMap(map);
@@ -353,7 +607,7 @@ import * as d3 from 'd3';
353
607
  if (typeof window !== 'undefined') window.addEventListener('resize', this._onResize);
354
608
 
355
609
  const lineAt = (pixel) => map.forEachFeatureAtPixel(pixel, (f) => {
356
- const g = f.getGeometry(); return (g && /LineString/.test(g.getType())) ? f : undefined;
610
+ const g = f.getGeometry(); return isProfilable(g) ? f : undefined;
357
611
  });
358
612
 
359
613
  this._mapKeys = [];
@@ -363,11 +617,11 @@ import * as d3 from 'd3';
363
617
  if (o.hideOnMapClick) this._mapKeys.push(map.on('click', (evt) => { if (!lineAt(evt.pixel) && this._feature) this.clear(); }));
364
618
  if (o.followMap) this._mapKeys.push(map.on('pointermove', (evt) => {
365
619
  if (!this._feature || this._collapsed) return;
366
- const cp = this._feature.getGeometry().getClosestPoint(evt.coordinate);
620
+ const cp = this._closestOnProfile(evt.coordinate); if (!cp) return;
367
621
  const px = map.getPixelFromCoordinate(cp); if (!px) return;
368
622
  if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
369
623
  }));
370
- // sortie du zoom au dézoom de la carte
624
+ // leave the A/B crop when the map is zoomed out
371
625
  this._mapKeys.push(map.on('moveend', () => {
372
626
  if (this._cropMode && this._fitRes && map.getView().getResolution() > this._fitRes * 1.25) this._exitZoom();
373
627
  }));
@@ -376,7 +630,8 @@ import * as d3 from 'd3';
376
630
 
377
631
  // ---------- API ---------------------------------------------------
378
632
  /**
379
- * 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.
380
635
  * Passing a falsy value hides the control.
381
636
  * @param {?import('ol/Feature').default} feature
382
637
  * @returns {this}
@@ -384,17 +639,21 @@ import * as d3 from 'd3';
384
639
  setFeature(feature) {
385
640
  this._feature = feature || null;
386
641
  this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null;
387
- 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; }
388
643
  this.element.style.display = '';
389
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);
390
648
  this._updateZoomButtons();
391
- if (!this._collapsed) this._render();
649
+ if (this._collapsed) this._renderTitle(); else this._render();
392
650
  return this;
393
651
  }
394
652
  /** Hide the profile and clear the current feature. @returns {this} */
395
653
  clear() { return this.setFeature(null); }
396
654
  /**
397
- * @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.
398
657
  */
399
658
  getStats() { return this._stats; }
400
659
  /** @param {string|Object} t Theme name or colors object. @returns {this} */
@@ -415,24 +674,102 @@ import * as d3 from 'd3';
415
674
  if (patch && 'collapsable' in patch) this._applyCollapsable();
416
675
  if (patch && 'zoom' in patch) this._updateZoomButtons();
417
676
  if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
418
- 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(); }
419
679
  return this;
420
680
  }
421
681
 
422
- // ---------- 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 -------------------------------------------
423
741
  _compute() {
424
742
  const o = this.options;
425
743
  const geom = this._feature.getGeometry();
426
- const lines = geom.getType() === 'MultiLineString' ? geom.getCoordinates() : [geom.getCoordinates()];
744
+ const lines = geomLines(geom);
427
745
  const dataProj = o.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
428
746
 
747
+ // Terrain-model elevations, if they were loaded for THIS feature.
748
+ const demZ = (this._demFor === this._feature) ? this._demZ : null;
749
+
750
+ const times = extractTimes(this._feature, lines);
751
+ this._hasTime = !!times;
752
+ const ignoreStops = o.ignoreStops !== false; // default: stops are excluded
753
+ const stopSpeed = (o.stopSpeed != null ? o.stopSpeed : 0.5); // m/s
429
754
  const pts = [];
430
- let cum = 0, prev = null;
755
+ let cum = 0, prev = null, i = -1, tAcc = 0, prevMs = null;
431
756
  for (const seg of lines) for (const c of seg) {
757
+ i++;
432
758
  const ll = toLonLat(c, dataProj);
433
- if (prev) cum += getDistance(prev, ll);
759
+ let dseg = 0;
760
+ if (prev) { dseg = getDistance(prev, ll); cum += dseg; }
761
+ let t = null;
762
+ if (times && times[i] != null) {
763
+ const ms = times[i];
764
+ if (prevMs != null) {
765
+ const dt = (ms - prevMs) / 1000; // seconds over the segment
766
+ if (dt > 0 && (!ignoreStops || (dseg / dt) >= stopSpeed)) tAcc += dt;
767
+ }
768
+ t = tAcc; prevMs = ms;
769
+ }
434
770
  prev = ll;
435
- pts.push({ x: cum, z: (c.length > 2 && isFinite(c[2])) ? c[2] : 0, coord: c });
771
+ const z = (c.length > 2 && isFinite(c[2])) ? c[2] : (demZ ? demZ[i] : 0);
772
+ pts.push({ x: cum, z, coord: c, t });
436
773
  }
437
774
  if (o.smoothing > 0) this._smooth(pts, o.smoothing);
438
775
 
@@ -450,11 +787,15 @@ import * as d3 from 'd3';
450
787
  const s = Math.abs(samples[k].slope || 0); if (s > maxAbs) maxAbs = s;
451
788
  }
452
789
  const distance = samples.length ? samples[samples.length - 1].x - samples[0].x : 0;
453
- return { distance, ascent, descent, min: isFinite(zmin) ? zmin : 0, max: isFinite(zmax) ? zmax : 0, maxAbsSlope: maxAbs, points: samples.length };
790
+ const ta = samples.length ? samples[0].t : null, tb = samples.length ? samples[samples.length - 1].t : null;
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.
794
+ return { distance, duration, ascent, descent, min: isFinite(zmin) ? zmin : 0, max: isFinite(zmax) ? zmax : 0, maxAbsSlope: maxAbs, points: samples.length };
454
795
  }
455
796
  _smooth(pts, meters) {
456
797
  if (!(meters > 0) || pts.length < 3) return;
457
- const half = meters / 2; // fenêtre = ±(meters/2) le long du tracé
798
+ const half = meters / 2; // window = +/-(meters/2) along the track
458
799
  const z = pts.map((p) => p.z);
459
800
  let lo = 0, hi = 0, sum = 0;
460
801
  for (let i = 0; i < pts.length; i++) {
@@ -465,7 +806,7 @@ import * as d3 from 'd3';
465
806
  }
466
807
  }
467
808
  _decimate(pts, maxPoints) {
468
- const copy = (p) => ({ x: p.x, z: p.z, coord: p.coord });
809
+ const copy = (p) => ({ x: p.x, z: p.z, coord: p.coord, t: p.t });
469
810
  if (!maxPoints || pts.length <= maxPoints) return pts.map(copy);
470
811
  const step = pts.length / maxPoints, out = [];
471
812
  for (let i = 0; i < maxPoints; i++) out.push(copy(pts[Math.floor(i * step)]));
@@ -479,20 +820,20 @@ import * as d3 from 'd3';
479
820
  if (samples.length) samples[0].slope = samples.length > 1 ? samples[1].slope : 0;
480
821
  }
481
822
 
482
- // ---------- pente : couleurs --------------------------------------
823
+ // ---------- slope: colours ----------------------------------------
483
824
  _slopeScale() {
484
825
  const cs = this.options.slopeClassSize || 2.5;
485
826
  const maxClasses = this.options.maxClasses || 8;
486
827
  const realIdx = Math.max(1, Math.floor((this._stats.maxAbsSlope || 0) / cs));
487
- const maxIdx = Math.min(realIdx, maxClasses - 1); // au plus `maxClasses` classes (défaut 8)
488
- 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
489
830
  let colorByIndex;
490
831
  if (this.slopeColors && this.slopeColors.length) {
491
832
  const interp = d3.interpolateRgbBasis(this.slopeColors);
492
833
  colorByIndex = (idx) => interp(maxIdx ? idx / maxIdx : 0);
493
834
  } else {
494
- // rampe à arrêts non uniformes : partie froide (bleu-vert, peu lisible) compressée,
495
- // 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.
496
837
  const ramp = d3.scaleLinear()
497
838
  .domain([0, 0.16, 0.42, 0.68, 1])
498
839
  .range(['#2166ac', '#27a35a', '#ffe000', '#f4791f', '#d7191c'])
@@ -504,14 +845,53 @@ import * as d3 from 'd3';
504
845
  }
505
846
  _classIndex(slope, sc) { return Math.min(sc.maxIdx, Math.floor(Math.abs(slope) / sc.classSize)); }
506
847
 
507
- // ---------- entête + légende --------------------------------------
508
- _renderHeader() {
509
- const o = this.options, s = this._stats, f = this._feature;
848
+ // ---------- time: adaptive format ---------------------------------
849
+ // 7 sec | 26 min | 1 h 48 min | 2 d 3 h (days + hours normalised)
850
+ _fmtDuration(sec) {
851
+ if (sec == null || !isFinite(sec)) return '';
852
+ const u = (this.options.labels && this.options.labels.durationUnits) || { s: 'sec', m: 'min', h: 'h', d: 'j' };
853
+ sec = Math.max(0, Math.round(sec));
854
+ if (sec < 60) return sec + ' ' + u.s;
855
+ if (sec < 3600) return Math.round(sec / 60) + ' ' + u.m;
856
+ if (sec < 86400) {
857
+ let h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
858
+ if (m === 60) { h++; m = 0; }
859
+ return m ? `${h} ${u.h} ${m} ${u.m}` : `${h} ${u.h}`;
860
+ }
861
+ let d = Math.floor(sec / 86400), h = Math.round((sec % 86400) / 3600);
862
+ if (h === 24) { d++; h = 0; }
863
+ return h ? `${d} ${u.d} ${h} ${u.h}` : `${d} ${u.d}`;
864
+ }
865
+
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;
510
878
  const name = (f.get && f.get(o.titleProperty)) || 'Profil';
511
879
  const linkUrl = o.titleLink && f.get && f.get(o.titleLink);
512
880
  if (isUrl(linkUrl)) this._titleEl.innerHTML = `<a href="${esc(linkUrl)}" target="_blank" rel="noopener">${esc(name)}</a>`;
513
881
  else this._titleEl.textContent = name;
514
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
+ }
515
895
 
516
896
  const html = [], text = [];
517
897
  o.headerItems.forEach((it) => {
@@ -522,6 +902,7 @@ import * as d3 from 'd3';
522
902
  else if (it === 'min') { html.push(fmtElevation(s.min, o.units)); text.push(fmtElevation(s.min, o.units)); }
523
903
  else if (it === 'max') { html.push(fmtElevation(s.max, o.units)); text.push(fmtElevation(s.max, o.units)); }
524
904
  else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}–${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
905
+ else if (it === 'duration') { if (s.duration != null) { const v = this._fmtDuration(s.duration); html.push(`<span class="oep-time">${esc(o.labels.duration)} ${v}</span>`); text.push(`${o.labels.duration} ${v}`); } }
525
906
  } else if (it && it.property) {
526
907
  const val = f.get && f.get(it.property); if (val == null || val === '') return;
527
908
  const lbl = it.label ? `${it.label} ` : '';
@@ -546,18 +927,40 @@ import * as d3 from 'd3';
546
927
  } else this._legendEl.style.display = 'none';
547
928
  }
548
929
 
549
- // ---------- 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 ---------------------------------------------
550
952
  _render() {
551
953
  const o = this.options, s = this._stats, data = this._samples;
552
954
  if (!data || !data.length) return;
553
955
  this._applyTheme();
554
956
  const mobile = this._applyPlacement();
555
957
  this._renderHeader();
958
+ if (this._demLoading) { this._renderSpinner(); return; }
556
959
 
557
960
  const m = o.margins, u = m.unit || 'px';
558
961
  const toPx = (v) => u === 'px' ? v : v * (parseFloat(getComputedStyle(this.element).fontSize) || 16);
559
962
 
560
- // 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
561
964
  const avail = this._availWidth();
562
965
  const isAuto = (o.width === 'auto' || o.width === '100%' || o.width === 'full');
563
966
  const desktopW = isAuto ? avail : Math.min(typeof o.width === 'number' ? o.width : (parseFloat(o.width) || avail), avail);
@@ -594,7 +997,7 @@ import * as d3 from 'd3';
594
997
  const cls = this._classIndex(data[i].slope, sc);
595
998
  let j = i; while (j + 1 < data.length && this._classIndex(data[j + 1].slope, sc) === cls) j++;
596
999
  g.append('path').datum(data.slice(i - 1, j + 1)).attr('class', 'oep-area-slope').attr('fill', sc.colorByIndex(cls)).attr('d', areaGen);
597
- 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
598
1001
  i = j + 1;
599
1002
  }
600
1003
  if (o.slopeSeparators) seps.forEach((d) => {
@@ -610,7 +1013,7 @@ import * as d3 from 'd3';
610
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));
611
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));
612
1015
 
613
- // marqueurs A / B en cours de sélection
1016
+ // A / B markers while a range is being picked
614
1017
  if (o.zoom && !this._cropMode) {
615
1018
  [['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
616
1019
  if (val == null) return;
@@ -662,7 +1065,8 @@ import * as d3 from 'd3';
662
1065
  const raw = this._fullSamples.filter((p) => p.x >= a && p.x <= b);
663
1066
  if (raw.length < 2) return;
664
1067
  const off = raw[0].x; // A devient 0
665
- const cropped = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope }));
1068
+ const tOff = raw[0].t != null ? raw[0].t : 0;
1069
+ const cropped = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope, t: (p.t != null ? p.t - tOff : null) }));
666
1070
  const coords = raw.map((p) => p.coord);
667
1071
  this._samples = cropped; this._stats = this._statsOf(cropped, false); this._cropMode = true;
668
1072
  this._updateZoomButtons(); this._render();
@@ -682,6 +1086,29 @@ import * as d3 from 'd3';
682
1086
  if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
683
1087
  }
684
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
+
685
1112
  // ---------- focus -------------------------------------------------
686
1113
  _tooltipText(d) {
687
1114
  const o = this.options, parts = [];
@@ -689,6 +1116,7 @@ import * as d3 from 'd3';
689
1116
  if (it === 'distance') parts.push(fmtDistance(d.x, o.units));
690
1117
  else if (it === 'elevation') parts.push(fmtElevation(d.z, o.units));
691
1118
  else if (it === 'slope') parts.push(fmtSlope(d.slope || 0));
1119
+ else if (it === 'time') { if (d.t != null) parts.push(this._fmtDuration(d.t)); }
692
1120
  });
693
1121
  return parts.join(' · ');
694
1122
  }
@@ -718,7 +1146,7 @@ import * as d3 from 'd3';
718
1146
  this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
719
1147
  this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
720
1148
  this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
721
- this._cropMode = false; this._updateZoomButtons();
1149
+ this._cropMode = false; this._demLoading = false; this._updateZoomButtons();
722
1150
  this._clearFocus();
723
1151
  this.element.style.display = 'none';
724
1152
  this._adjustAttribution();
@@ -732,7 +1160,10 @@ import * as d3 from 'd3';
732
1160
  */
733
1161
  ElevationProfile.addTheme = (name, colors) => { THEMES[name] = colors; };
734
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;
735
1166
  ElevationProfile.POSITIONS = POSITIONS;
736
- ElevationProfile.version = '0.5.0';
1167
+ ElevationProfile.version = '0.6.0';
737
1168
 
738
1169
  export default ElevationProfile;