ol-elevation-profile 0.6.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,8 @@
2
2
  import Control from 'ol/control/Control.js';
3
3
  import Overlay from 'ol/Overlay.js';
4
4
  import { unByKey } from 'ol/Observable.js';
5
- import { toLonLat } from 'ol/proj.js';
5
+ import { toLonLat, get, transform, fromLonLat } from 'ol/proj.js';
6
+ import TileState from 'ol/TileState.js';
6
7
  import { getDistance } from 'ol/sphere.js';
7
8
  import { boundingExtent } from 'ol/extent.js';
8
9
  import * as d3 from 'd3';
@@ -10,9 +11,11 @@ import * as d3 from 'd3';
10
11
  /**
11
12
  * Synchronized elevation profile control for OpenLayers, rendered with d3.
12
13
  *
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.
14
+ * Reads elevation (Z) directly from 3D line geometries (`[lon, lat, z]`). A track without
15
+ * Z is completed from a terrain model - keyless AWS Terrain Tiles by default, which means
16
+ * the control fetches tiles on its own; set `dem: null` to keep it entirely offline.
17
+ * Clicking (or hovering) a track shows its profile; a marker stays synchronized on both
18
+ * the map and the chart.
16
19
  *
17
20
  * Peer dependencies (provided by the host application, not bundled):
18
21
  * - OpenLayers >= 6 (https://openlayers.org/)
@@ -36,25 +39,26 @@ import * as d3 from 'd3';
36
39
  const DEFAULTS = {
37
40
  immersion: 'docked',
38
41
  position: 'bottom',
39
- width: 520, // nombre (px, plafonné à la largeur de la carte) ou 'auto'/'100%'/'full' = largeur de la carte
42
+ width: 520, // number (px, capped to the map width) or 'auto'/'100%'/'full' = map width
40
43
  height: 180,
41
44
  margins: { unit: 'px', top: 20, right: 24, bottom: 30, left: 48 },
42
45
  units: 'meters',
43
46
  dataProjection: null,
47
+ dem: 'terrarium', // AWS tiles by default; null = off; or { url, encoding, zoom, maxTiles }
44
48
  maxPoints: 2000,
45
- smoothing: 0, // lissage de l'altitude : fenêtre en MÈTRES (0 = aucun)
49
+ smoothing: 0, // elevation smoothing: window in METRES (0 = none)
46
50
  theme: 'steelblue',
47
- color: null, // null=thème, 'auto'=couleur de la trace, ou couleur CSS
51
+ color: null, // null = theme, 'auto' = track colour, or a CSS colour
48
52
  trackLayer: null,
49
53
  transparency: false, // false | true | nombre 0..1
50
54
  transparencyLevel: 0.45,
51
55
  grid: true,
52
56
  slope: false,
53
57
  slopeClassSize: 2.5,
54
- slopeColors: null, // null = dégradé bleu->rouge (HSL) ; sinon tableau interpolé
55
- slopeSeparators: true, // ligne verticale à chaque changement de classe
58
+ slopeColors: null, // null = blue->red ramp (HSL); otherwise an interpolated array
59
+ slopeSeparators: true, // vertical line at each class change
56
60
  slopeLegend: true,
57
- maxClasses: 8, // nombre maximal de classes de pente (couleurs + légende)
61
+ maxClasses: 8, // maximum number of slope classes (colours + legend)
58
62
  xTicks: null,
59
63
  yTicks: null,
60
64
  show: 'click',
@@ -63,11 +67,12 @@ import * as d3 from 'd3';
63
67
  followMap: true,
64
68
  marker: true,
65
69
  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)
70
+ responsive: true, // adapts width/placement, mobile included
71
+ mobileBreakpoint: 640, // <= screen width -> mobile mode (100% width, top/bottom)
72
+ zoom: false, // start/end buttons cropping map + profile to A..B
73
+ exportPng: false, // toolbar button exporting the panel as a PNG
74
+ ignoreStops: true, // duration = moving time (stops excluded)
75
+ stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
71
76
  tooltipItems: ['distance', 'elevation'],
72
77
  headerItems: ['distance', 'ascent', 'descent', 'minmax'],
73
78
  titleProperty: 'name',
@@ -77,7 +82,9 @@ import * as d3 from 'd3';
77
82
  ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
78
83
  time: 'Temps', duration: 'Durée',
79
84
  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'
85
+ zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
86
+ exportPng: 'Exporter en PNG',
87
+ loading: 'Chargement du profil altimétrique'
81
88
  }
82
89
  };
83
90
 
@@ -94,15 +101,44 @@ import * as d3 from 'd3';
94
101
  const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
95
102
  const isUrl = (v) => typeof v === 'string' && /^(https?:)?\/\/|^mailto:/i.test(v);
96
103
 
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).
104
+ /**
105
+ * Coordinate segments to profile, whatever the geometry type: an array of point arrays.
106
+ *
107
+ * A polygon is profiled along its **outer ring only**; the holes are not part of the
108
+ * outline itself. The ring being closed, the profile returns to its starting point -
109
+ * that is the outline, faithfully, not a defect.
110
+ *
111
+ * One place for the five that used to test `getType()` themselves: they all wrapped
112
+ * `getCoordinates()` in one extra array unless it was a MultiLineString, which silently
113
+ * made a polygon iterate over its rings as if they were points.
114
+ */
115
+ function geomLines(geom) {
116
+ if (!geom || !geom.getCoordinates) return [];
117
+ const c = geom.getCoordinates();
118
+ switch (geom.getType()) {
119
+ case 'LineString':
120
+ case 'LinearRing': return [c];
121
+ case 'MultiLineString': return c;
122
+ case 'Polygon': return c.length ? [c[0]] : [];
123
+ case 'MultiPolygon': return c.map((poly) => poly[0]).filter(Boolean);
124
+ default: return [];
125
+ }
126
+ }
127
+
128
+ /** Whether a geometry can be profiled at all - the map-selection filter and nothing more. */
129
+ function isProfilable(geom) {
130
+ return !!geom && /^(Multi)?(LineString|Polygon)$|^LinearRing$/.test(geom.getType());
131
+ }
132
+
133
+ // Flat array of timestamps (epoch ms) aligned on the flat coordinate order, read from
134
+ // properties.coordTimes (ISO or number), coordinateProperties.times, or the 4th (M) dimension.
99
135
  function extractTimes(feature, lines) {
100
136
  const props = (feature && feature.getProperties) ? feature.getProperties() : {};
101
137
  let raw = props.coordTimes;
102
138
  if (raw == null && props.coordinateProperties) raw = props.coordinateProperties.times || props.coordinateProperties.coordTimes;
103
139
  let flat = null;
104
140
  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)
141
+ if (!flat && lines) { // fallback: 4th M dimension (XYZM layout)
106
142
  const tmp = []; let any = false;
107
143
  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
144
  flat = any ? tmp : null;
@@ -153,8 +189,541 @@ import * as d3 from 'd3';
153
189
  const ICON_EXPAND = '<svg viewBox="0 0 24 24"><path d="M3 19l5-7 4 4 4-6 5 9z" fill="currentColor" opacity=".85"/></svg>';
154
190
  const ICON_A = '<svg viewBox="0 0 24 24"><path d="M7 5v14M11 12h8m0 0-3-3m3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
155
191
  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>';
192
+ const ICON_PNG = '<svg viewBox="0 0 24 24"><path d="M12 4v10m0 0 4-4m-4 4-4-4M5 18h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
156
193
  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
194
 
195
+ // ----- digital elevation model (DEM) -----------------------------------
196
+ /**
197
+ * Known terrain-tile sources.
198
+ *
199
+ * A PNG tile carries elevation in its R/G/B channels; it is read on a canvas rather
200
+ * than queried point by point from an API. That is what makes a 10 000-point track a
201
+ * handful of requests, with no key, no quota and no rate limit — where the free
202
+ * elevation APIs cap out at 100 or 200 points per call.
203
+ *
204
+ * Attribution is not automatic: the library does not own the map. It is up to the
205
+ * application to carry `attributions` in its own basemap source - and since the DEM is
206
+ * on by default, that obligation arrives without having been asked for. `dem: null`
207
+ * turns the whole thing off.
208
+ */
209
+ const DEM_PRESETS = {
210
+ terrarium: {
211
+ url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
212
+ encoding: 'terrarium',
213
+ maxZoom: 14,
214
+ attributions: 'Elevation: <a href="https://registry.opendata.aws/terrain-tiles/">Terrain Tiles</a> (AWS Open Data)'
215
+ },
216
+ /**
217
+ * IGN Géoplateforme, serving the RGE ALTI over France and its overseas territories.
218
+ *
219
+ * A point API rather than tiles, hence its own sampler: it answers 200 points per
220
+ * request and announces one request per second, so a 10 000 point track takes about
221
+ * fifty calls spread over as many seconds. In exchange it is metre-accurate where the
222
+ * world models are at ninety.
223
+ *
224
+ * **No key is required** on the public endpoint - verified against the live service.
225
+ * `apiKey` exists for a deployment that does demand one; it is appended as a query
226
+ * parameter, named by `apiKeyParam`.
227
+ *
228
+ * Outside its coverage the service does not error: it answers OUT_OF_COVERAGE for the
229
+ * point. No border is coded here - one asks, and it says itself where it does not know.
230
+ */
231
+ ign: {
232
+ api: 'ign',
233
+ url: 'https://data.geopf.fr/altimetrie/1.0/calcul/alti/rest/elevation.json',
234
+ resource: 'ign_rge_alti_wld',
235
+ batch: 200, // more fits, but the URL grows ~20 bytes a point and hits 414
236
+ minInterval: 1100, // ms between calls; the service announces 1 req/s
237
+ attributions: 'Elevation: <a href="https://geoservices.ign.fr/rgealti">RGE ALTI</a> (IGN)'
238
+ }
239
+ };
240
+
241
+ /** Value the IGN service returns where it holds no measurement. */
242
+ const IGN_NO_DATA = -99999;
243
+
244
+ /**
245
+ * Request URL for one batch, in the order given: the response returns the elevations in
246
+ * the same one.
247
+ *
248
+ * `zonly=true` cuts the response down to the elevations alone, without echoing the
249
+ * coordinates - a third of the weight for the same information. Coordinates rounded to
250
+ * the millionth of a degree, about ten centimetres: beyond that one only adds digits to
251
+ * the URL.
252
+ */
253
+ function ignUrl(cfg, pts) {
254
+ const f = (v) => v.toFixed(6);
255
+ const sep = cfg.url.indexOf('?') >= 0 ? '&' : '?';
256
+ let u = cfg.url + sep + 'resource=' + encodeURIComponent(cfg.resource) +
257
+ '&delimiter=|&zonly=true' +
258
+ '&lon=' + pts.map((p) => f(p[0])).join('|') +
259
+ '&lat=' + pts.map((p) => f(p[1])).join('|');
260
+ if (cfg.apiKey) u += '&' + (cfg.apiKeyParam || 'apikey') + '=' + encodeURIComponent(cfg.apiKey);
261
+ return u;
262
+ }
263
+
264
+ /**
265
+ * Sampler for the IGN service: batches, paces, and maps out-of-coverage to null.
266
+ *
267
+ * A batch whose response does not carry exactly as many elevations as points were asked
268
+ * for fails the whole fill. Elevations one can no longer map to their points are worse
269
+ * than absent ones: they would land on the wrong places, silently.
270
+ */
271
+ function ignSampler(cfg) {
272
+ const size = Math.max(1, cfg.batch || 200);
273
+ const gap = cfg.minInterval || 0;
274
+ return (lonlats) => {
275
+ const out = [];
276
+ let last = 0;
277
+ const step = (i) => {
278
+ if (i >= lonlats.length) return Promise.resolve(out);
279
+ const chunk = lonlats.slice(i, i + size);
280
+ const wait = Math.max(0, gap - (Date.now() - last));
281
+ return new Promise((r) => setTimeout(r, wait))
282
+ .then(() => { last = Date.now(); return fetch(ignUrl(cfg, chunk)); })
283
+ .then((r) => (r && r.ok) ? r.json() : null)
284
+ .then((body) => {
285
+ const z = body && body.elevations;
286
+ if (!Array.isArray(z) || z.length !== chunk.length) return null;
287
+ for (const v of z) out.push((v == null || v <= IGN_NO_DATA) ? null : v);
288
+ return step(i + size);
289
+ });
290
+ };
291
+ return step(0);
292
+ };
293
+ }
294
+
295
+ const DEM_DEFAULTS = { source: 'terrarium', zoom: 'auto', maxZoom: 14, maxTiles: 32, concurrency: 6, tileSize: 256 };
296
+
297
+ /**
298
+ * RGB → metres decoders, one per encoding convention.
299
+ *
300
+ * `encoding` also takes a function `(r, g, b, a) => metres`, for a tile set that packs
301
+ * elevation its own way. Return null where the pixel carries no measurement: the fill is
302
+ * abandoned rather than guessed.
303
+ *
304
+ * A GeoServer greyscale DEM is NOT decoded here. What a WMS returns is a rendered image,
305
+ * stretched and quantised by its style, not the coverage values; reading it back would
306
+ * be reading the rendering. Those go through `featureInfo`, which asks the server for the
307
+ * band value itself (see demConfig).
308
+ */
309
+ const DEM_DECODERS = {
310
+ terrarium: (r, g, b) => (r * 256 + g + b / 256) - 32768,
311
+ mapbox: (r, g, b) => -1e4 + (r * 65536 + g * 256 + b) * 0.1
312
+ };
313
+
314
+ /** Half the Web Mercator world, in metres: the bound of EPSG:3857. */
315
+ const MERC_HALF = 20037508.342789244;
316
+
317
+ /** Extent of an XYZ tile in EPSG:3857, for the WMS requests built below. */
318
+ function tileExtent(z, x, y) {
319
+ const span = 2 * MERC_HALF / Math.pow(2, z);
320
+ const minX = -MERC_HALF + x * span, maxY = MERC_HALF - y * span;
321
+ return [minX, maxY - span, minX + span, maxY];
322
+ }
323
+
324
+ function query(base, params) {
325
+ const sep = base.indexOf('?') >= 0 ? '&' : '?';
326
+ return base.replace(/[?&]$/, '') + sep +
327
+ Object.keys(params).map((k) => k + '=' + encodeURIComponent(params[k])).join('&');
328
+ }
329
+
330
+ /**
331
+ * WMS GetMap URL covering one XYZ tile, so a WMS behaves like any other tile source.
332
+ *
333
+ * The axis-order trap: WMS 1.3.0 names the reference system `CRS`, 1.1.1 names it `SRS`,
334
+ * and sending the wrong one gets the request rejected or silently mis-georeferenced.
335
+ * The version actually in force decides, after the caller's params are merged in.
336
+ */
337
+ function wmsTileUrl(w, z, x, y, tileSize) {
338
+ const p = Object.assign({
339
+ SERVICE: 'WMS', REQUEST: 'GetMap', VERSION: '1.3.0', FORMAT: 'image/png',
340
+ TRANSPARENT: 'false', STYLES: ''
341
+ }, w.params || {});
342
+ const key = String(p.VERSION).indexOf('1.1') === 0 ? 'SRS' : 'CRS';
343
+ delete p.SRS; delete p.CRS;
344
+ p[key] = w.projection || 'EPSG:3857';
345
+ p.LAYERS = w.layers;
346
+ p.WIDTH = tileSize; p.HEIGHT = tileSize;
347
+ p.BBOX = tileExtent(z, x, y).join(',');
348
+ return query(w.url, p);
349
+ }
350
+
351
+ /**
352
+ * Where a tile comes from: an OpenLayers source, a WMS, or an XYZ template.
353
+ *
354
+ * Handing an `ol/source/TileImage` (XYZ, TileWMS, or any subclass) delegates URL building
355
+ * to OpenLayers itself, which already knows that source's quirks - subdomains, custom
356
+ * params, tile grid. Cheaper and safer than reimplementing each one here.
357
+ */
358
+ function tileUrlFn(cfg) {
359
+ const src = cfg.olSource;
360
+ if (src && typeof src.getTileUrlFunction === 'function') {
361
+ const fn = src.getTileUrlFunction();
362
+ const proj = get('EPSG:3857');
363
+ return (z, x, y) => fn([z, x, y], 1, proj);
364
+ }
365
+ if (cfg.wms) return (z, x, y) => wmsTileUrl(cfg.wms, z, x, y, cfg.tileSize);
366
+ return (z, x, y) => cfg.url.replace('{z}', z).replace('{x}', x).replace('{y}', y);
367
+ }
368
+
369
+ /**
370
+ * Values of one DataTile, or null if it never arrived.
371
+ *
372
+ * A DataTile carries no URL: it is loaded by the source itself and its values are read
373
+ * back with getData(). The state has to be watched rather than awaited - OpenLayers
374
+ * announces it through a `change` event, not a promise.
375
+ */
376
+ function loadDataTile(tile) {
377
+ return new Promise((resolve) => {
378
+ const settle = () => {
379
+ const st = tile.getState();
380
+ if (st === TileState.LOADED) { resolve(tile.getData ? tile.getData() : null); return true; }
381
+ if (st === TileState.ERROR || st === TileState.EMPTY) { resolve(null); return true; }
382
+ return false;
383
+ };
384
+ if (settle()) return;
385
+ const onChange = () => { if (settle()) tile.removeEventListener('change', onChange); };
386
+ tile.addEventListener('change', onChange);
387
+ if (tile.load) tile.load();
388
+ });
389
+ }
390
+
391
+ /**
392
+ * Sampler for an `ol/source/DataTile` - `ol/source/GeoTIFF` above all.
393
+ *
394
+ * Its own path because such a source shares nothing with the XYZ one: no URL, no Web
395
+ * Mercator, no 256 pixel tiles. A GeoTIFF keeps **its own projection and its own tile
396
+ * grid**, both only known once `getView()` has resolved, and its values are real numbers
397
+ * rather than colours - so nothing here goes through a decoder.
398
+ *
399
+ * Positions are held in grid pixels, as with the XYZ tiles and for the same reason: the
400
+ * four neighbours of a point straddle two tiles as soon as it runs along an edge.
401
+ *
402
+ * `normalize: false` matters on the source, otherwise OpenLayers rescales the values to
403
+ * 0..1 and the profile comes out in fractions of nothing.
404
+ */
405
+ function dataTileSampler(cfg) {
406
+ const src = cfg.olSource;
407
+ const band = cfg.band || 0;
408
+ return (lonlats) => {
409
+ // getView() is only awaited when the grid is not known yet - which is the GeoTIFF
410
+ // case, where it settles once the metadata has been read. On a plain DataTile that
411
+ // promise never settles at all, and awaiting it unconditionally hangs the fill.
412
+ const known = src.getTileGrid && src.getTileGrid();
413
+ const ready = known ? Promise.resolve(null) : Promise.resolve(src.getView ? src.getView() : null);
414
+ return ready.then((view) => {
415
+ const grid = known || (src.getTileGrid && src.getTileGrid()) || (view && view.tileGrid);
416
+ if (!grid) return null;
417
+ const proj = get((view && view.projection) || (src.getProjection && src.getProjection()) || 'EPSG:3857');
418
+ const zs = grid.getResolutions ? grid.getResolutions().length - 1 : 0;
419
+ const bands = src.bandCount || 1;
420
+ const coords = lonlats.map((ll) => transform([ll[0], ll[1]], 'EPSG:4326', proj));
421
+
422
+ // Finest level whose tile count stays within maxTiles, as for the XYZ tiles: it is
423
+ // the tiling that widens with the track, not the model that degrades.
424
+ // Pixel bounds of the coverage, when it declares an extent. A point inside the raster
425
+ // but within half a pixel of its edge lands on a neighbour that does not exist: its
426
+ // tile would come back empty and the all-or-nothing rule would drop the whole track.
427
+ // Half a pixel of tolerance at the borders, as on any regular grid.
428
+ const ext = (grid.getExtent && grid.getExtent()) || (view && view.extent) || null;
429
+ const plan = (z) => {
430
+ const res = grid.getResolution(z), origin = grid.getOrigin(z);
431
+ let ts = grid.getTileSize(z); ts = Array.isArray(ts) ? ts : [ts, ts];
432
+ const cols = ext ? Math.round((ext[2] - ext[0]) / res) : Infinity;
433
+ const rows = ext ? Math.round((ext[3] - ext[1]) / res) : Infinity;
434
+ const clampI = (i) => Math.min(cols - 1, Math.max(0, i));
435
+ const clampJ = (j) => Math.min(rows - 1, Math.max(0, j));
436
+ const need = new Map();
437
+ const at = coords.map((c) => {
438
+ const px = (c[0] - origin[0]) / res - 0.5, py = (origin[1] - c[1]) / res - 0.5;
439
+ const i0 = clampI(Math.floor(px)), j0 = clampJ(Math.floor(py));
440
+ for (let di = 0; di < 2; di++) for (let dj = 0; dj < 2; dj++) {
441
+ const tx = Math.floor(clampI(i0 + di) / ts[0]), ty = Math.floor(clampJ(j0 + dj) / ts[1]);
442
+ need.set(tx + '/' + ty, [tx, ty]);
443
+ }
444
+ // Interpolation fractions clamped too: a point beyond the last pixel centre keeps
445
+ // that pixel's value rather than extrapolating past the edge of the data.
446
+ return { i0, j0, tx: Math.min(1, Math.max(0, px - i0)), ty: Math.min(1, Math.max(0, py - j0)), clampI, clampJ };
447
+ });
448
+ return { res, origin, ts, need, at, clampI, clampJ };
449
+ };
450
+ let z = zs, p = plan(z);
451
+ while (z > 0 && p.need.size > cfg.maxTiles) { z--; p = plan(z); }
452
+ if (p.need.size > cfg.maxTiles) return null;
453
+
454
+ const keys = Array.from(p.need.keys());
455
+ return Promise.all(keys.map((k) => {
456
+ const t = p.need.get(k);
457
+ return loadDataTile(src.getTile(z, t[0], t[1], 1, proj)).then((d) => [k, d]);
458
+ })).then((pairs) => {
459
+ const tiles = new Map(pairs);
460
+ const value = (i, j) => {
461
+ const tx = Math.floor(p.clampI(i) / p.ts[0]), ty = Math.floor(p.clampJ(j) / p.ts[1]);
462
+ i = p.clampI(i); j = p.clampJ(j);
463
+ const d = tiles.get(tx + '/' + ty);
464
+ if (!d) return null;
465
+ const col = i - tx * p.ts[0], row = j - ty * p.ts[1];
466
+ const v = d[(row * p.ts[0] + col) * bands + band];
467
+ return (v == null || !isFinite(v)) ? null : v;
468
+ };
469
+ return p.at.map((n) => {
470
+ const a = value(n.i0, n.j0), b = value(n.i0 + 1, n.j0);
471
+ const c = value(n.i0, n.j0 + 1), e = value(n.i0 + 1, n.j0 + 1);
472
+ if (a == null || b == null || c == null || e == null) return null;
473
+ const north = a + (b - a) * n.tx, south = c + (e - c) * n.tx;
474
+ return north + (south - north) * n.ty;
475
+ });
476
+ });
477
+ });
478
+ };
479
+ }
480
+
481
+ /**
482
+ * WMS GetFeatureInfo sampler: one request per point, for a coverage served as a rendered
483
+ * greyscale that cannot be decoded from its pixels.
484
+ *
485
+ * **It is slow**, unavoidably: a thousand-point track is a thousand round trips, where a
486
+ * tile source needs a handful. Worth it only when nothing else can reach the data - and
487
+ * `concurrency` is what keeps it bearable.
488
+ */
489
+ function featureInfoSampler(cfg) {
490
+ const fi = cfg.featureInfo;
491
+ const lanes = Math.max(1, cfg.concurrency || 6);
492
+ return (lonlats) => {
493
+ const out = new Array(lonlats.length);
494
+ let next = 0, broken = false;
495
+ const one = () => {
496
+ if (broken || next >= lonlats.length) return Promise.resolve();
497
+ const i = next++;
498
+ return fetch(featureInfoUrl(fi, lonlats[i]))
499
+ .then((r) => (r && r.ok) ? r.json() : null)
500
+ .then((body) => { out[i] = bandValue(body, fi.property); return one(); })
501
+ .catch(() => { broken = true; });
502
+ };
503
+ const running = [];
504
+ for (let i = 0; i < Math.min(lanes, lonlats.length); i++) running.push(one());
505
+ return Promise.all(running).then(() => broken ? null : out);
506
+ };
507
+ }
508
+
509
+ /** GetFeatureInfo URL for one point: a one-pixel image centred on it. */
510
+ function featureInfoUrl(fi, ll) {
511
+ const c = fromLonLat([ll[0], ll[1]]);
512
+ const d = fi.resolution || 1; // half-size of the box, in metres
513
+ const p = Object.assign({
514
+ SERVICE: 'WMS', REQUEST: 'GetFeatureInfo', VERSION: '1.3.0',
515
+ INFO_FORMAT: 'application/json', STYLES: '', FEATURE_COUNT: 1
516
+ }, fi.params || {});
517
+ const key = String(p.VERSION).indexOf('1.1') === 0 ? 'SRS' : 'CRS';
518
+ delete p.SRS; delete p.CRS;
519
+ p[key] = fi.projection || 'EPSG:3857';
520
+ p.LAYERS = fi.layers;
521
+ p.QUERY_LAYERS = fi.queryLayers || fi.layers;
522
+ p.WIDTH = 1; p.HEIGHT = 1; p.I = 0; p.J = 0; p.X = 0; p.Y = 0; // I/J is 1.3.0, X/Y is 1.1.1
523
+ p.BBOX = [c[0] - d, c[1] - d, c[0] + d, c[1] + d].join(',');
524
+ return query(fi.url, p);
525
+ }
526
+
527
+ /**
528
+ * Band value in a GetFeatureInfo response.
529
+ *
530
+ * The band's property name is not standard - GeoServer calls it GRAY_INDEX for a
531
+ * single-band coverage, but a styled or renamed layer answers otherwise. Without an
532
+ * explicit `property` the first finite number wins, which is right whenever the coverage
533
+ * carries one band, and is why `property` exists for when it does not.
534
+ */
535
+ function bandValue(body, property) {
536
+ const f = body && body.features && body.features[0];
537
+ const props = f && f.properties;
538
+ if (!props) return null;
539
+ if (property) { const v = Number(props[property]); return isFinite(v) ? v : null; }
540
+ for (const k of Object.keys(props)) { const v = Number(props[k]); if (props[k] !== null && props[k] !== '' && isFinite(v)) return v; }
541
+ return null;
542
+ }
543
+
544
+ /** Web Mercator pixel coordinates at zoom `z`, origin at the top-left corner of the world. */
545
+ function worldPixel(lon, lat, z, tileSize) {
546
+ const n = tileSize * Math.pow(2, z);
547
+ const s = Math.sin(lat * Math.PI / 180);
548
+ return [(lon + 180) / 360 * n, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n];
549
+ }
550
+
551
+ /**
552
+ * Reads elevations from a set of terrain tiles.
553
+ *
554
+ * Positions are held in **world pixels** rather than tile by tile: the four neighbours
555
+ * of a point can fall in two different tiles whenever it runs along an edge, which is
556
+ * the common case on a track. Converting to world pixels first, then resolving the
557
+ * tile, makes the edge case disappear instead of handling it.
558
+ */
559
+ class DemSampler {
560
+ /** @param {Object} cfg `url`, `encoding`, `tileSize`, `concurrency`. */
561
+ constructor(cfg) {
562
+ this.cfg = cfg;
563
+ this.decode = (typeof cfg.encoding === 'function') ? cfg.encoding
564
+ : (DEM_DECODERS[cfg.encoding] || DEM_DECODERS.terrarium);
565
+ this.tileUrl = tileUrlFn(cfg);
566
+ this.tiles = new Map(); // "z/x/y" -> RGBA pixels, or null if the tile was lost
567
+ }
568
+
569
+ /**
570
+ * North-west neighbour of the point plus the interpolation fractions, in world pixels.
571
+ *
572
+ * The half-pixel subtracted is not a fudge: pixel centres fall on half-integers, and
573
+ * without that shift `floor()` would return the cell containing the point rather than
574
+ * its western neighbour, offsetting the whole profile by half a pixel.
575
+ */
576
+ _neighbours(lon, lat, z) {
577
+ const wp = worldPixel(lon, lat, z, this.cfg.tileSize);
578
+ const px = wp[0] - 0.5, py = wp[1] - 0.5;
579
+ const i0 = Math.floor(px), j0 = Math.floor(py);
580
+ return { i0, j0, tx: px - i0, ty: py - j0 };
581
+ }
582
+
583
+ /** Tile key of a world pixel; x wraps around the globe, y clamps at the poles. */
584
+ _key(i, j, z) {
585
+ const ts = this.cfg.tileSize, span = ts * Math.pow(2, z);
586
+ const jj = Math.min(span - 1, Math.max(0, j)), ii = ((i % span) + span) % span;
587
+ return { key: z + '/' + Math.floor(ii / ts) + '/' + Math.floor(jj / ts), ii, jj };
588
+ }
589
+
590
+ /** Elevation of one pixel, or null if its tile is missing. */
591
+ _at(i, j, z) {
592
+ const ts = this.cfg.tileSize;
593
+ const r = this._key(i, j, z);
594
+ const data = this.tiles.get(r.key);
595
+ if (!data) return null;
596
+ const o = ((r.jj % ts) * ts + (r.ii % ts)) * 4;
597
+ const v = this.decode(data[o], data[o + 1], data[o + 2], data[o + 3]);
598
+ return (v == null || !isFinite(v)) ? null : v;
599
+ }
600
+
601
+ /**
602
+ * Elevation **bilinearly interpolated** between the four surrounding pixels; null as
603
+ * soon as a single one is missing.
604
+ *
605
+ * Bilinear rather than "the pixel containing the point": on a 30 or 90 m model, taking
606
+ * the pixel value makes the profile advance in stairs, and every stair counts as a
607
+ * climb then a descent in the ascent total. The interpolation invents no relief — it
608
+ * renders the same surface, without the steps of the sampling grid.
609
+ */
610
+ sample(lon, lat, z) {
611
+ const n = this._neighbours(lon, lat, z);
612
+ const a = this._at(n.i0, n.j0, z), b = this._at(n.i0 + 1, n.j0, z);
613
+ const c = this._at(n.i0, n.j0 + 1, z), d = this._at(n.i0 + 1, n.j0 + 1, z);
614
+ if (a == null || b == null || c == null || d == null) return null;
615
+ const north = a + (b - a) * n.tx, south = c + (d - c) * n.tx;
616
+ return north + (south - north) * n.ty;
617
+ }
618
+
619
+ /** Keys of the tiles needed by the four neighbours of each of the points. */
620
+ tilesFor(lonlats, z) {
621
+ const keys = new Set();
622
+ for (const ll of lonlats) {
623
+ const n = this._neighbours(ll[0], ll[1], z);
624
+ 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);
625
+ }
626
+ return keys;
627
+ }
628
+
629
+ /** Pixels of one tile, or null if it could not be read. */
630
+ _fetch(key) {
631
+ const parts = key.split('/');
632
+ const url = this.tileUrl(+parts[0], +parts[1], +parts[2]);
633
+ return new Promise((resolve) => {
634
+ const img = new Image();
635
+ // Without this attribute the canvas is tainted and getImageData throws: the tile
636
+ // would display, but stay unreadable. The intended sources answer with
637
+ // Access-Control-Allow-Origin: *.
638
+ img.crossOrigin = 'anonymous';
639
+ img.onload = () => {
640
+ try {
641
+ const ts = this.cfg.tileSize;
642
+ const cv = document.createElement('canvas');
643
+ cv.width = ts; cv.height = ts;
644
+ const ctx = cv.getContext('2d', { willReadFrequently: true });
645
+ ctx.drawImage(img, 0, 0, ts, ts);
646
+ resolve(ctx.getImageData(0, 0, ts, ts).data);
647
+ } catch (e) { resolve(null); }
648
+ };
649
+ img.onerror = () => resolve(null);
650
+ img.src = url;
651
+ });
652
+ }
653
+
654
+ /**
655
+ * Loads the missing tiles, `concurrency` in flight. Resolves false if one is missing.
656
+ *
657
+ * Lost tiles are remembered as such: without that, a second pass over the same track
658
+ * would fire the same requests, doomed to fail again.
659
+ */
660
+ load(keys) {
661
+ const todo = Array.from(keys).filter((k) => !this.tiles.has(k));
662
+ let next = 0, ok = true;
663
+ const worker = () => {
664
+ if (next >= todo.length) return Promise.resolve();
665
+ const k = todo[next++];
666
+ return this._fetch(k).then((data) => { this.tiles.set(k, data); if (!data) ok = false; return worker(); });
667
+ };
668
+ const lanes = [];
669
+ for (let i = 0; i < Math.min(this.cfg.concurrency, todo.length); i++) lanes.push(worker());
670
+ return Promise.all(lanes).then(() => ok);
671
+ }
672
+ }
673
+
674
+ /**
675
+ * The `dem` option (true | source name | sampling function | object) → a full
676
+ * configuration, or null.
677
+ *
678
+ * A `sample` function short-circuits the tile machinery entirely: the application
679
+ * answers with the elevations itself, from wherever it can reach them - a GeoServer
680
+ * WCS coverage, a GeoTIFF decoded with geotiff.js, a national elevation API. Only the
681
+ * transport is delegated; the control keeps the sequencing, the all-or-nothing rule,
682
+ * the spinner and the `demload` event, which is where the subtle parts live.
683
+ *
684
+ * That extension point rather than a WMS/WCS/GeoTIFF mode per protocol: reading a
685
+ * GeoTIFF means a full decoder, and this library has no runtime dependency to spend
686
+ * on one that most users would never load.
687
+ */
688
+ function demConfig(opt) {
689
+ if (!opt) return null;
690
+ if (typeof opt === 'function') return Object.assign({}, DEM_DEFAULTS, { sample: opt });
691
+ const raw = (opt === true || typeof opt === 'string') ? { source: opt === true ? 'terrarium' : opt } : Object.assign({}, opt);
692
+ if (typeof raw.sample === 'function') return Object.assign({}, DEM_DEFAULTS, raw);
693
+ // A point-query source: no tile grid, no pixel decoding.
694
+ if (raw.featureInfo) {
695
+ const fiCfg = Object.assign({}, DEM_DEFAULTS, raw);
696
+ fiCfg.sample = featureInfoSampler(fiCfg);
697
+ return fiCfg;
698
+ }
699
+ // An ol/source/DataTile (GeoTIFF above all) carries values, not URLs: its own path.
700
+ if (raw.olSource && typeof raw.olSource.getTileUrlFunction !== 'function' &&
701
+ typeof raw.olSource.getTile === 'function') {
702
+ const dtCfg = Object.assign({}, DEM_DEFAULTS, raw);
703
+ dtCfg.sample = dataTileSampler(dtCfg);
704
+ return dtCfg;
705
+ }
706
+ // Tiles: an OpenLayers source or a WMS both stand in for the url template.
707
+ const hasTiles = raw.url || raw.wms || raw.olSource;
708
+ const preset = DEM_PRESETS[raw.source] || (hasTiles ? {} : DEM_PRESETS.terrarium);
709
+ const cfg = Object.assign({}, DEM_DEFAULTS, preset, raw);
710
+ if (cfg.api === 'ign') { cfg.sample = ignSampler(cfg); return cfg; } // a point API
711
+ return (cfg.url || cfg.wms || cfg.olSource) ? cfg : null;
712
+ }
713
+
714
+ /**
715
+ * Zoom at which to download the model: the finest one that fits within `maxTiles`.
716
+ *
717
+ * It is the tiling that widens as the track grows, not a model that degrades: a 10 km
718
+ * track is read at the finest step available, a 300 km one at a coarser step rather
719
+ * than in three hundred requests.
720
+ */
721
+ function demZoomFor(sampler, lonlats, cfg) {
722
+ if (typeof cfg.zoom === 'number') return cfg.zoom;
723
+ for (let z = cfg.maxZoom; z > 0; z--) if (sampler.tilesFor(lonlats, z).size <= cfg.maxTiles) return z;
724
+ return 1;
725
+ }
726
+
158
727
  // =======================================================================
159
728
  /**
160
729
  * @typedef {Object} ElevationProfileOptions
@@ -188,6 +757,7 @@ import * as d3 from 'd3';
188
757
  * @property {boolean} [responsive=true] Adapt width/placement; mobile included.
189
758
  * @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
190
759
  * @property {boolean} [zoom=false] A/B buttons to crop map + profile to a sub-range.
760
+ * @property {boolean} [exportPng=false] Toolbar button exporting the whole panel as a PNG.
191
761
  * @property {boolean} [ignoreStops=true] When computing time, ignore stopped segments (moving time).
192
762
  * @property {number} [stopSpeed=0.5] Speed threshold (m/s) below which a segment counts as a stop.
193
763
  * @property {Array<'distance'|'elevation'|'slope'|'time'>} [tooltipItems=['distance','elevation']] Tooltip content (`'time'` = elapsed time at the cursor, if the track has time data).
@@ -196,6 +766,16 @@ import * as d3 from 'd3';
196
766
  * @property {?string} [titleLink=null] Feature property holding a URL → clickable title.
197
767
  * @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
198
768
  * @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
769
+ * @property {?(boolean|string|Function|Object)} [dem='terrarium'] Fill missing elevations
770
+ * from a terrain model. Sources: `'terrarium'` (default) or `true` = AWS Terrain Tiles;
771
+ * `'ign'` = IGN Géoplateforme RGE ALTI (France, keyless, `apiKey` optional);
772
+ * `{url}` = XYZ template; `{wms:{url,layers,params}}` = WMS tiles;
773
+ * `{olSource}` = any `ol/source/TileImage` (XYZ, TileWMS, …);
774
+ * `{featureInfo:{url,layers,property}}` = WMS GetFeatureInfo, one request per point,
775
+ * for a greyscale coverage that cannot be decoded from its pixels (slow);
776
+ * a function or `{sample}` `(lonlats, ctx) => number[]|Promise<number[]>` to source them
777
+ * yourself; `null` disables the whole thing.
778
+ * Decoding: `encoding` is `'terrarium'`, `'mapbox'`, or a function `(r,g,b,a) => metres`.
199
779
  */
200
780
 
201
781
  /**
@@ -223,12 +803,13 @@ import * as d3 from 'd3';
223
803
  this._marker = null;
224
804
  this._collapsed = !!o.collapsed;
225
805
  this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
806
+ this._demZ = null; this._demFor = null; this._demSeq = 0; this._demLoading = false;
226
807
  this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
227
808
  this._buildDom(element);
228
809
  element.style.display = 'none';
229
810
  }
230
811
 
231
- // ---------- util statique ----------------------------------------
812
+ // ---------- static helpers ----------------------------------------
232
813
  /**
233
814
  * Whether a feature has any Z (elevation) coordinate.
234
815
  * @param {import('ol/Feature').default} feature
@@ -237,8 +818,7 @@ import * as d3 from 'd3';
237
818
  static featureHasZ(feature) {
238
819
  const g = feature && feature.getGeometry && feature.getGeometry();
239
820
  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;
821
+ for (const seg of geomLines(g)) for (const c of seg) if (c.length > 2 && isFinite(c[2])) return true;
242
822
  return false;
243
823
  }
244
824
 
@@ -250,8 +830,7 @@ import * as d3 from 'd3';
250
830
  static featureHasTime(feature) {
251
831
  const g = feature && feature.getGeometry && feature.getGeometry();
252
832
  if (!g) return false;
253
- const lines = g.getType() === 'MultiLineString' ? g.getCoordinates() : [g.getCoordinates()];
254
- return !!extractTimes(feature, lines);
833
+ return !!extractTimes(feature, geomLines(g));
255
834
  }
256
835
 
257
836
  // ---------- DOM ---------------------------------------------------
@@ -277,6 +856,8 @@ import * as d3 from 'd3';
277
856
  this._btnA = mkBtn('oep-a', ICON_A, o.labels.zoomStart, () => this._arm('A'));
278
857
  this._btnB = mkBtn('oep-b', ICON_B, o.labels.zoomEnd, () => this._arm('B'));
279
858
  this._btnAll = mkBtn('oep-all', ICON_ALL, o.labels.zoomAll, () => this._exitZoom());
859
+ // Last of the toolbar, so it sits to the right of the zoom buttons.
860
+ this._btnPng = mkBtn('oep-png', ICON_PNG, o.labels.exportPng, () => this.exportPNG());
280
861
  header.appendChild(this._toolbar);
281
862
 
282
863
  const btn = document.createElement('button');
@@ -340,7 +921,7 @@ import * as d3 from 'd3';
340
921
  this._adjustAttribution();
341
922
  }
342
923
 
343
- // ---------- responsive -------------------------------------------
924
+ // ---------- responsive --------------------------------------------
344
925
  _availWidth() {
345
926
  const map = this.getMap();
346
927
  const t = map && map.getTargetElement && map.getTargetElement();
@@ -356,7 +937,7 @@ import * as d3 from 'd3';
356
937
  return mobile;
357
938
  }
358
939
 
359
- // remonte les attributions OL au-dessus du profil quand celui-ci occupe le coin bas-droite
940
+ // Lift the OL attribution above the profile when the profile takes the bottom-right corner
360
941
  _adjustAttribution() {
361
942
  const map = this.getMap();
362
943
  const target = map && map.getTargetElement && map.getTargetElement();
@@ -367,17 +948,17 @@ import * as d3 from 'd3';
367
948
  const mapR = target.getBoundingClientRect();
368
949
  const pR = this.element.getBoundingClientRect();
369
950
  if (!pR.height) return;
370
- const bottomGap = mapR.bottom - pR.bottom; // bord bas de carte <-> bas du profil
951
+ const bottomGap = mapR.bottom - pR.bottom; // map bottom edge <-> profile bottom
371
952
  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)
953
+ const atBottom = bottomGap < pR.height; // profile sits in the bottom band
954
+ const reachesRight = rightGap < 24; // reaches the bottom-right corner (attributions)
374
955
  if (atBottom && reachesRight) {
375
956
  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
957
+ attr.style.bottom = `${Math.round(pR.height + 2 * bottomGap)}px`; // same gap above the profile
377
958
  }
378
959
  }
379
960
 
380
- // ---------- carte -------------------------------------------------
961
+ // ---------- map ---------------------------------------------------
381
962
  setMap(map) {
382
963
  const prev = this.getMap();
383
964
  super.setMap(map);
@@ -396,7 +977,7 @@ import * as d3 from 'd3';
396
977
  if (typeof window !== 'undefined') window.addEventListener('resize', this._onResize);
397
978
 
398
979
  const lineAt = (pixel) => map.forEachFeatureAtPixel(pixel, (f) => {
399
- const g = f.getGeometry(); return (g && /LineString/.test(g.getType())) ? f : undefined;
980
+ const g = f.getGeometry(); return isProfilable(g) ? f : undefined;
400
981
  });
401
982
 
402
983
  this._mapKeys = [];
@@ -406,11 +987,11 @@ import * as d3 from 'd3';
406
987
  if (o.hideOnMapClick) this._mapKeys.push(map.on('click', (evt) => { if (!lineAt(evt.pixel) && this._feature) this.clear(); }));
407
988
  if (o.followMap) this._mapKeys.push(map.on('pointermove', (evt) => {
408
989
  if (!this._feature || this._collapsed) return;
409
- const cp = this._feature.getGeometry().getClosestPoint(evt.coordinate);
990
+ const cp = this._closestOnProfile(evt.coordinate); if (!cp) return;
410
991
  const px = map.getPixelFromCoordinate(cp); if (!px) return;
411
992
  if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
412
993
  }));
413
- // sortie du zoom au dézoom de la carte
994
+ // leave the A/B crop when the map is zoomed out
414
995
  this._mapKeys.push(map.on('moveend', () => {
415
996
  if (this._cropMode && this._fitRes && map.getView().getResolution() > this._fitRes * 1.25) this._exitZoom();
416
997
  }));
@@ -419,7 +1000,8 @@ import * as d3 from 'd3';
419
1000
 
420
1001
  // ---------- API ---------------------------------------------------
421
1002
  /**
422
- * Show the profile for the given feature (LineString/MultiLineString, ideally 3D).
1003
+ * Show the profile for the given feature (LineString, MultiLineString, or a Polygon /
1004
+ * MultiPolygon profiled along its outer ring), ideally 3D.
423
1005
  * Passing a falsy value hides the control.
424
1006
  * @param {?import('ol/Feature').default} feature
425
1007
  * @returns {this}
@@ -427,17 +1009,21 @@ import * as d3 from 'd3';
427
1009
  setFeature(feature) {
428
1010
  this._feature = feature || null;
429
1011
  this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null;
430
- if (!feature) { this._fullSamples = this._samples = null; this._clear(); return this; }
1012
+ if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._clear(); return this; }
431
1013
  this.element.style.display = '';
432
1014
  this._compute();
1015
+ // Before the render, not after: it is _fillFromDem that knows whether a fill is
1016
+ // starting, and the render needs that to draw the spinner instead of a flat profile.
1017
+ this._fillFromDem(feature);
433
1018
  this._updateZoomButtons();
434
- if (!this._collapsed) this._render();
1019
+ if (this._collapsed) this._renderTitle(); else this._render();
435
1020
  return this;
436
1021
  }
437
1022
  /** Hide the profile and clear the current feature. @returns {this} */
438
1023
  clear() { return this.setFeature(null); }
439
1024
  /**
440
- * @returns {?{distance:number,ascent:number,descent:number,min:number,max:number,maxAbsSlope:number,points:number}}
1025
+ * @returns {?{distance:number,duration:?number,ascent:number,descent:number,min:number,max:number,maxAbsSlope:number,points:number}}
1026
+ * `duration` is `null` when the track carries no time data.
441
1027
  */
442
1028
  getStats() { return this._stats; }
443
1029
  /** @param {string|Object} t Theme name or colors object. @returns {this} */
@@ -456,22 +1042,125 @@ import * as d3 from 'd3';
456
1042
  if (patch && (patch.theme || 'color' in patch)) this._applyTheme();
457
1043
  if (patch && ('transparency' in patch || 'transparencyLevel' in patch)) this._applyTransparency();
458
1044
  if (patch && 'collapsable' in patch) this._applyCollapsable();
459
- if (patch && 'zoom' in patch) this._updateZoomButtons();
1045
+ if (patch && ('zoom' in patch || 'exportPng' in patch)) this._updateZoomButtons();
460
1046
  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(); }
1047
+ if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
1048
+ if (this._feature) { this._compute(); this._fillFromDem(this._feature); this._updateZoomButtons(); if (this._collapsed) this._renderTitle(); else this._render(); }
462
1049
  return this;
463
1050
  }
464
1051
 
465
- // ---------- calcul ------------------------------------------------
1052
+ // ---------- digital elevation model -------------------------------
1053
+ /**
1054
+ * Fills the missing elevations from a terrain model, then redraws.
1055
+ *
1056
+ * **A track is filled entirely or not at all.** A profile missing a few points is not
1057
+ * an incomplete profile: points without Z count as zero, the line dives to sea level
1058
+ * and the ascent total becomes absurd. Better the flat profile we would have had
1059
+ * without the DEM.
1060
+ *
1061
+ * Nothing is reported to the user on failure — the fill is a supplement, it has no
1062
+ * business breaking a display that succeeded without it. The `demload` event lets the
1063
+ * application know if it wants to.
1064
+ *
1065
+ * @param {import('ol/Feature').default} feature
1066
+ * @fires demload
1067
+ * @private
1068
+ */
1069
+ _fillFromDem(feature) {
1070
+ const cfg = demConfig(this.options.dem);
1071
+ if (!cfg || !feature || this._demFor === feature) return;
1072
+ if (ElevationProfile.featureHasZ(feature)) return; // the track already carries its Z
1073
+ // Canvas decoding needs a browser; a `sample` function does not, and must stay
1074
+ // usable where there is no DOM.
1075
+ if (!cfg.sample && (typeof Image === 'undefined' || typeof document === 'undefined')) return;
1076
+
1077
+ const geom = feature.getGeometry && feature.getGeometry();
1078
+ if (!geom) return;
1079
+ const lines = geomLines(geom);
1080
+ const dataProj = this.options.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
1081
+ const lonlats = [];
1082
+ for (const seg of lines) for (const c of seg) lonlats.push(toLonLat(c, dataProj));
1083
+ if (!lonlats.length) return;
1084
+
1085
+ // Sequence number: a track clicked while another is loading must win, otherwise the
1086
+ // slowest response would overwrite the profile on screen.
1087
+ const seq = ++this._demSeq;
1088
+ this._demLoading = true;
1089
+
1090
+ // A misconfigured source must not take the profile down with it. _fillFromDem runs
1091
+ // inside setFeature, so anything thrown here would reach the caller and leave no
1092
+ // chart at all - where the whole point is that the fill is a supplement.
1093
+ try {
1094
+ this._startFill(cfg, seq, feature, lonlats, dataProj);
1095
+ } catch (e) {
1096
+ this._demDone(seq, feature, null, lonlats.length, null, 0);
1097
+ }
1098
+ }
1099
+
1100
+ /** @private */
1101
+ _startFill(cfg, seq, feature, lonlats, dataProj) {
1102
+ if (cfg.sample) {
1103
+ // Wrapped in a promise chain so a function that throws synchronously fails the
1104
+ // same way as one that rejects - a source that misbehaves must not leave the
1105
+ // spinner turning forever.
1106
+ Promise.resolve()
1107
+ .then(() => cfg.sample(lonlats, { feature, projection: dataProj }))
1108
+ .then((zs) => this._demDone(seq, feature, zs, lonlats.length, null, 0))
1109
+ .catch(() => this._demDone(seq, feature, null, lonlats.length, null, 0));
1110
+ return;
1111
+ }
1112
+
1113
+ const sampler = new DemSampler(cfg);
1114
+ const z = demZoomFor(sampler, lonlats, cfg);
1115
+ sampler.load(sampler.tilesFor(lonlats, z)).then((ok) => {
1116
+ const zs = ok ? lonlats.map((ll) => sampler.sample(ll[0], ll[1], z)) : null;
1117
+ this._demDone(seq, feature, zs, lonlats.length, z, sampler.tiles.size);
1118
+ }).catch(() => this._demDone(seq, feature, null, lonlats.length, z, 0));
1119
+ }
1120
+
1121
+ /**
1122
+ * Outcome of a fill, whatever produced it: adopt the elevations, drop the spinner,
1123
+ * redraw, announce.
1124
+ *
1125
+ * A result of the wrong length is refused outright rather than padded. Elevations one
1126
+ * can no longer map to their points are worse than absent ones: they would land on the
1127
+ * wrong places, and nothing downstream would notice.
1128
+ *
1129
+ * @fires demload
1130
+ * @private
1131
+ */
1132
+ _demDone(seq, feature, zs, expected, zoom, tiles) {
1133
+ // A newer fill has taken over: it owns _demLoading and will clear it itself.
1134
+ if (seq !== this._demSeq || this._feature !== feature) return;
1135
+ const usable = Array.isArray(zs) && zs.length === expected && zs.every((v) => v != null && isFinite(v));
1136
+ this._demLoading = false;
1137
+ if (usable) { this._demZ = zs; this._demFor = feature; this._compute(); }
1138
+ // Redrawn even on failure: the spinner has to give way to the flat profile.
1139
+ this._updateZoomButtons();
1140
+ if (!this._collapsed) this._render();
1141
+ /**
1142
+ * Fired once a terrain-model fill has completed (or failed).
1143
+ * @event demload
1144
+ * @property {boolean} ok Whether every point could be sampled.
1145
+ * @property {?number} zoom Tile zoom level used, `null` for a custom sampler.
1146
+ * @property {number} tiles Tiles fetched, `0` for a custom sampler.
1147
+ */
1148
+ this.dispatchEvent({ type: 'demload', ok: usable, zoom, tiles });
1149
+ }
1150
+
1151
+ // ---------- computation -------------------------------------------
466
1152
  _compute() {
467
1153
  const o = this.options;
468
1154
  const geom = this._feature.getGeometry();
469
- const lines = geom.getType() === 'MultiLineString' ? geom.getCoordinates() : [geom.getCoordinates()];
1155
+ const lines = geomLines(geom);
470
1156
  const dataProj = o.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
471
1157
 
1158
+ // Terrain-model elevations, if they were loaded for THIS feature.
1159
+ const demZ = (this._demFor === this._feature) ? this._demZ : null;
1160
+
472
1161
  const times = extractTimes(this._feature, lines);
473
1162
  this._hasTime = !!times;
474
- const ignoreStops = o.ignoreStops !== false; // défaut : ignore les arrêts
1163
+ const ignoreStops = o.ignoreStops !== false; // default: stops are excluded
475
1164
  const stopSpeed = (o.stopSpeed != null ? o.stopSpeed : 0.5); // m/s
476
1165
  const pts = [];
477
1166
  let cum = 0, prev = null, i = -1, tAcc = 0, prevMs = null;
@@ -484,13 +1173,14 @@ import * as d3 from 'd3';
484
1173
  if (times && times[i] != null) {
485
1174
  const ms = times[i];
486
1175
  if (prevMs != null) {
487
- const dt = (ms - prevMs) / 1000; // s sur le segment
1176
+ const dt = (ms - prevMs) / 1000; // seconds over the segment
488
1177
  if (dt > 0 && (!ignoreStops || (dseg / dt) >= stopSpeed)) tAcc += dt;
489
1178
  }
490
1179
  t = tAcc; prevMs = ms;
491
1180
  }
492
1181
  prev = ll;
493
- pts.push({ x: cum, z: (c.length > 2 && isFinite(c[2])) ? c[2] : 0, coord: c, t });
1182
+ const z = (c.length > 2 && isFinite(c[2])) ? c[2] : (demZ ? demZ[i] : 0);
1183
+ pts.push({ x: cum, z, coord: c, t });
494
1184
  }
495
1185
  if (o.smoothing > 0) this._smooth(pts, o.smoothing);
496
1186
 
@@ -510,11 +1200,13 @@ import * as d3 from 'd3';
510
1200
  const distance = samples.length ? samples[samples.length - 1].x - samples[0].x : 0;
511
1201
  const ta = samples.length ? samples[0].t : null, tb = samples.length ? samples[samples.length - 1].t : null;
512
1202
  const duration = (ta != null && tb != null) ? (tb - ta) : null;
1203
+ // Reported for a closed ring too, where they are equal by construction: on a loop,
1204
+ // D+ is exactly the figure one is after.
513
1205
  return { distance, duration, ascent, descent, min: isFinite(zmin) ? zmin : 0, max: isFinite(zmax) ? zmax : 0, maxAbsSlope: maxAbs, points: samples.length };
514
1206
  }
515
1207
  _smooth(pts, meters) {
516
1208
  if (!(meters > 0) || pts.length < 3) return;
517
- const half = meters / 2; // fenêtre = ±(meters/2) le long du tracé
1209
+ const half = meters / 2; // window = +/-(meters/2) along the track
518
1210
  const z = pts.map((p) => p.z);
519
1211
  let lo = 0, hi = 0, sum = 0;
520
1212
  for (let i = 0; i < pts.length; i++) {
@@ -539,20 +1231,20 @@ import * as d3 from 'd3';
539
1231
  if (samples.length) samples[0].slope = samples.length > 1 ? samples[1].slope : 0;
540
1232
  }
541
1233
 
542
- // ---------- pente : couleurs --------------------------------------
1234
+ // ---------- slope: colours ----------------------------------------
543
1235
  _slopeScale() {
544
1236
  const cs = this.options.slopeClassSize || 2.5;
545
1237
  const maxClasses = this.options.maxClasses || 8;
546
1238
  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
1239
+ const maxIdx = Math.min(realIdx, maxClasses - 1); // at most `maxClasses` classes (default 8)
1240
+ const capped = realIdx > maxIdx; // some slopes overflow the last class
549
1241
  let colorByIndex;
550
1242
  if (this.slopeColors && this.slopeColors.length) {
551
1243
  const interp = d3.interpolateRgbBasis(this.slopeColors);
552
1244
  colorByIndex = (idx) => interp(maxIdx ? idx / maxIdx : 0);
553
1245
  } 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.
1246
+ // Ramp with non-uniform stops: the cold end (blue-green, hard to read) is squeezed,
1247
+ // PURE yellow in the middle, then orange, red. Class 0 = blue, top class = red.
556
1248
  const ramp = d3.scaleLinear()
557
1249
  .domain([0, 0.16, 0.42, 0.68, 1])
558
1250
  .range(['#2166ac', '#27a35a', '#ffe000', '#f4791f', '#d7191c'])
@@ -564,8 +1256,8 @@ import * as d3 from 'd3';
564
1256
  }
565
1257
  _classIndex(slope, sc) { return Math.min(sc.maxIdx, Math.floor(Math.abs(slope) / sc.classSize)); }
566
1258
 
567
- // ---------- temps : format adaptatif ------------------------------
568
- // 7 sec · 26 min · 1 h 48 min · 2 j 3 h (jours + heures normalisés)
1259
+ // ---------- time: adaptive format ---------------------------------
1260
+ // 7 sec | 26 min | 1 h 48 min | 2 d 3 h (days + hours normalised)
569
1261
  _fmtDuration(sec) {
570
1262
  if (sec == null || !isFinite(sec)) return '';
571
1263
  const u = (this.options.labels && this.options.labels.durationUnits) || { s: 'sec', m: 'min', h: 'h', d: 'j' };
@@ -582,14 +1274,35 @@ import * as d3 from 'd3';
582
1274
  return h ? `${d} ${u.d} ${h} ${u.h}` : `${d} ${u.d}`;
583
1275
  }
584
1276
 
585
- // ---------- entête + légende --------------------------------------
586
- _renderHeader() {
587
- const o = this.options, s = this._stats, f = this._feature;
1277
+ // ---------- header + legend ---------------------------------------
1278
+ /**
1279
+ * Track title, and its optional link.
1280
+ *
1281
+ * Its own method because it is the only part of the header that stays visible once
1282
+ * collapsed — the CSS hides the body, the stats, the legend and the toolbar. `_render`
1283
+ * is skipped while collapsed, so without a separate entry point the title would keep
1284
+ * naming the previous track after a change of feature.
1285
+ */
1286
+ _renderTitle() {
1287
+ const o = this.options, f = this._feature;
1288
+ if (!f) return;
588
1289
  const name = (f.get && f.get(o.titleProperty)) || 'Profil';
589
1290
  const linkUrl = o.titleLink && f.get && f.get(o.titleLink);
590
1291
  if (isUrl(linkUrl)) this._titleEl.innerHTML = `<a href="${esc(linkUrl)}" target="_blank" rel="noopener">${esc(name)}</a>`;
591
1292
  else this._titleEl.textContent = name;
592
1293
  this._titleEl.setAttribute('title', name);
1294
+ }
1295
+
1296
+ _renderHeader() {
1297
+ const o = this.options, s = this._stats, f = this._feature;
1298
+ this._renderTitle();
1299
+ // While the elevations are still unknown, every figure would read zero: a D+ of 0 m
1300
+ // then jumping to 1 200 is worse than no figure at all.
1301
+ if (this._demLoading) {
1302
+ this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
1303
+ this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
1304
+ return;
1305
+ }
593
1306
 
594
1307
  const html = [], text = [];
595
1308
  o.headerItems.forEach((it) => {
@@ -625,18 +1338,40 @@ import * as d3 from 'd3';
625
1338
  } else this._legendEl.style.display = 'none';
626
1339
  }
627
1340
 
628
- // ---------- rendu -------------------------------------------------
1341
+ /**
1342
+ * Spinner shown in place of the chart while the terrain model loads.
1343
+ *
1344
+ * It keeps the chart's height so the panel does not jump when the profile replaces it,
1345
+ * and it carries no colour of its own: the CSS takes `--oep-area`, which `_applyTheme`
1346
+ * has just set - the theme colour, or the track colour under `color: 'auto'`.
1347
+ */
1348
+ _renderSpinner() {
1349
+ const H = typeof this.options.height === 'number' ? this.options.height : 180;
1350
+ this._body.innerHTML = '';
1351
+ const box = document.createElement('div');
1352
+ box.className = 'oep-loading';
1353
+ box.style.height = `${H}px`;
1354
+ box.setAttribute('role', 'status');
1355
+ box.setAttribute('aria-label', this.options.labels.loading);
1356
+ const sp = document.createElement('div');
1357
+ sp.className = 'oep-spinner';
1358
+ box.appendChild(sp);
1359
+ this._body.appendChild(box);
1360
+ }
1361
+
1362
+ // ---------- rendering ---------------------------------------------
629
1363
  _render() {
630
1364
  const o = this.options, s = this._stats, data = this._samples;
631
1365
  if (!data || !data.length) return;
632
1366
  this._applyTheme();
633
1367
  const mobile = this._applyPlacement();
634
1368
  this._renderHeader();
1369
+ if (this._demLoading) { this._renderSpinner(); return; }
635
1370
 
636
1371
  const m = o.margins, u = m.unit || 'px';
637
1372
  const toPx = (v) => u === 'px' ? v : v * (parseFloat(getComputedStyle(this.element).fontSize) || 16);
638
1373
 
639
- // largeur : 100% en mobile ; 'auto'/'100%'/'full' = largeur de la carte ; sinon nombre plafonné à la carte
1374
+ // Width: 100% on mobile; 'auto'/'100%'/'full' = map width; otherwise a number capped to the map
640
1375
  const avail = this._availWidth();
641
1376
  const isAuto = (o.width === 'auto' || o.width === '100%' || o.width === 'full');
642
1377
  const desktopW = isAuto ? avail : Math.min(typeof o.width === 'number' ? o.width : (parseFloat(o.width) || avail), avail);
@@ -673,7 +1408,7 @@ import * as d3 from 'd3';
673
1408
  const cls = this._classIndex(data[i].slope, sc);
674
1409
  let j = i; while (j + 1 < data.length && this._classIndex(data[j + 1].slope, sc) === cls) j++;
675
1410
  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
1411
+ if (i > 1) seps.push(data[i - 1]); // a class change is a boundary
677
1412
  i = j + 1;
678
1413
  }
679
1414
  if (o.slopeSeparators) seps.forEach((d) => {
@@ -689,7 +1424,7 @@ import * as d3 from 'd3';
689
1424
  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
1425
  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
1426
 
692
- // marqueurs A / B en cours de sélection
1427
+ // A / B markers while a range is being picked
693
1428
  if (o.zoom && !this._cropMode) {
694
1429
  [['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
695
1430
  if (val == null) return;
@@ -724,11 +1459,147 @@ import * as d3 from 'd3';
724
1459
  this._adjustAttribution();
725
1460
  }
726
1461
 
1462
+ // ---------- PNG export ---------------------------------------------
1463
+ /**
1464
+ * Properties frozen onto the exported clone.
1465
+ *
1466
+ * A serialized SVG carries no stylesheet: rules that live in the CSS file, and every
1467
+ * `var(--oep-*)` they resolve, vanish the moment the markup leaves the document. The
1468
+ * export would come out as black shapes on nothing. So the computed value of each
1469
+ * painting property is written inline, node by node.
1470
+ */
1471
+ static get _EXPORT_PROPS() {
1472
+ return ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray',
1473
+ 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-family', 'font-size',
1474
+ 'font-weight', 'text-anchor', 'shape-rendering'];
1475
+ }
1476
+
1477
+ /** Copies the computed painting styles of `src` onto `dst`, recursively. */
1478
+ _freezeStyles(src, dst) {
1479
+ const props = ElevationProfile._EXPORT_PROPS;
1480
+ const cs = getComputedStyle(src);
1481
+ let inline = '';
1482
+ for (const p of props) { const v = cs.getPropertyValue(p); if (v) inline += `${p}:${v};`; }
1483
+ dst.setAttribute('style', inline);
1484
+ const a = src.children, b = dst.children;
1485
+ for (let i = 0; i < a.length && i < b.length; i++) this._freezeStyles(a[i], b[i]);
1486
+ }
1487
+
1488
+ /**
1489
+ * The whole panel as an SVG string: background, title, stats, legend, chart.
1490
+ *
1491
+ * Rebuilt rather than screenshotted. The header is HTML and the chart is SVG, and the
1492
+ * only way to put HTML in an SVG is a `foreignObject`, which browsers refuse to
1493
+ * rasterise consistently. Redrawing the two text lines as `<text>` is a handful of
1494
+ * lines and works everywhere.
1495
+ *
1496
+ * The current-position indicator is dropped: it marks where the pointer happens to be,
1497
+ * which means nothing once the image is saved.
1498
+ */
1499
+ _exportSvg() {
1500
+ const chart = this._body.querySelector('svg');
1501
+ if (!chart) return null;
1502
+ const cs = getComputedStyle(this.element);
1503
+ const bg = cs.getPropertyValue('--oep-bg').trim() || '#fff';
1504
+ const fg = cs.getPropertyValue('--oep-text').trim() || '#222';
1505
+ const font = cs.fontFamily || 'system-ui, sans-serif';
1506
+ const W = +chart.getAttribute('width'), H = +chart.getAttribute('height');
1507
+ const pad = 8, titleH = 20, statsH = this._statsEl.textContent ? 15 : 0;
1508
+ const legend = (this._legendEl.style.display !== 'none') ? this._legendEl : null;
1509
+ const legH = legend ? 18 : 0;
1510
+ const top = pad + titleH + statsH + legH;
1511
+
1512
+ const clone = chart.cloneNode(true);
1513
+ // The pointer indicator and the hit-test overlay have no place in a saved image.
1514
+ clone.querySelectorAll('.oep-focus, .oep-overlay').forEach((n) => n.remove());
1515
+ this._freezeStyles(chart, clone);
1516
+ // width/height are kept: a nested <svg> without them fills the parent viewport, so
1517
+ // the chart would be stretched over the header's height as well.
1518
+
1519
+ const esc2 = (t) => esc(String(t));
1520
+ const parts = [];
1521
+ parts.push(`<rect width="${W + 2 * pad}" height="${H + top + pad}" fill="${esc2(bg)}"/>`);
1522
+ parts.push(`<text x="${pad}" y="${pad + 13}" style="font-family:${esc2(font)};font-size:13px;font-weight:600;fill:${esc2(fg)}">${esc2(this._titleEl.textContent)}</text>`);
1523
+ if (statsH) parts.push(`<text x="${pad}" y="${pad + titleH + 10}" style="font-family:${esc2(font)};font-size:11px;fill:${esc2(fg)};opacity:.85">${esc2(this._statsEl.textContent)}</text>`);
1524
+ if (legend) {
1525
+ let x = pad;
1526
+ const y = pad + titleH + statsH + 12;
1527
+ for (const item of legend.querySelectorAll('.oep-leg-it')) {
1528
+ const sw = item.querySelector('.oep-sw');
1529
+ const color = sw ? getComputedStyle(sw).backgroundColor : 'none';
1530
+ const label = item.textContent.trim();
1531
+ parts.push(`<rect x="${x}" y="${y - 8}" width="10" height="10" fill="${esc2(color)}"/>`);
1532
+ parts.push(`<text x="${x + 14}" y="${y}" style="font-family:${esc2(font)};font-size:10px;fill:${esc2(fg)}">${esc2(label)}</text>`);
1533
+ x += 14 + label.length * 5.6 + 10;
1534
+ }
1535
+ }
1536
+ parts.push(`<g transform="translate(${pad},${top})">${new XMLSerializer().serializeToString(clone)}</g>`);
1537
+ return {
1538
+ width: W + 2 * pad, height: H + top + pad,
1539
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W + 2 * pad}" height="${H + top + pad}" ` +
1540
+ `viewBox="0 0 ${W + 2 * pad} ${H + top + pad}">${parts.join('')}</svg>`
1541
+ };
1542
+ }
1543
+
1544
+ /**
1545
+ * Export the whole panel - title, stats, legend and chart - as a PNG.
1546
+ *
1547
+ * Resolves with the Blob. Unless `download` is false, it also saves the file, which is
1548
+ * what the toolbar button does.
1549
+ *
1550
+ * @param {{scale?:number, filename?:string, download?:boolean}} [opts]
1551
+ * `scale` defaults to the device pixel ratio, so the image is not soft on a retina
1552
+ * screen. `filename` defaults to the track title.
1553
+ * @returns {Promise<Blob>}
1554
+ */
1555
+ exportPNG(opts) {
1556
+ const o = opts || {};
1557
+ const built = this._exportSvg();
1558
+ if (!built) return Promise.reject(new Error('ol-elevation-profile: nothing to export'));
1559
+ const scale = o.scale || (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
1560
+ return new Promise((resolve, reject) => {
1561
+ const img = new Image();
1562
+ img.onload = () => {
1563
+ try {
1564
+ const cv = document.createElement('canvas');
1565
+ cv.width = Math.round(built.width * scale);
1566
+ cv.height = Math.round(built.height * scale);
1567
+ const ctx = cv.getContext('2d');
1568
+ ctx.scale(scale, scale);
1569
+ ctx.drawImage(img, 0, 0);
1570
+ cv.toBlob((blob) => {
1571
+ if (!blob) { reject(new Error('ol-elevation-profile: PNG encoding failed')); return; }
1572
+ if (o.download !== false) this._save(blob, o.filename);
1573
+ resolve(blob);
1574
+ }, 'image/png');
1575
+ } catch (e) { reject(e); }
1576
+ };
1577
+ img.onerror = () => reject(new Error('ol-elevation-profile: SVG could not be rasterised'));
1578
+ // Encoded as a data URL rather than a blob: URL - a blob: source taints the canvas
1579
+ // in some browsers, and toBlob would then throw a security error.
1580
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(built.svg);
1581
+ });
1582
+ }
1583
+
1584
+ /** @private */
1585
+ _save(blob, filename) {
1586
+ const name = filename || `${(this._titleEl.textContent || 'profile').replace(/[\\/:*?"<>|]+/g, '-').trim()}.png`;
1587
+ const url = URL.createObjectURL(blob);
1588
+ const a = document.createElement('a');
1589
+ a.href = url; a.download = name;
1590
+ document.body.appendChild(a); a.click(); a.remove();
1591
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1592
+ }
1593
+
727
1594
  // ---------- zoom A/B ----------------------------------------------
728
1595
  _arm(which) { this._armed = (this._armed === which) ? null : which; this._updateZoomButtons(); if (this._focus) this._render(); }
729
1596
  _updateZoomButtons() {
730
- const show = !!this.options.zoom && !!this._feature;
731
- this._toolbar.style.display = show ? '' : 'none';
1597
+ const o = this.options, has = !!this._feature;
1598
+ const show = !!o.zoom && has;
1599
+ const png = !!o.exportPng && has;
1600
+ // The toolbar carries both: it stays visible as long as either has something to show.
1601
+ this._toolbar.style.display = (show || png) ? '' : 'none';
1602
+ this._btnPng.style.display = png ? '' : 'none';
732
1603
  const crop = this._cropMode;
733
1604
  this._btnA.style.display = show && !crop ? '' : 'none';
734
1605
  this._btnB.style.display = show && !crop ? '' : 'none';
@@ -762,6 +1633,29 @@ import * as d3 from 'd3';
762
1633
  if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
763
1634
  }
764
1635
 
1636
+ /**
1637
+ * Point of the profiled outline closest to a map coordinate.
1638
+ *
1639
+ * A polygon's own `getClosestPoint` answers for its **surface**: with the cursor inside
1640
+ * the ring it returns the cursor itself, so the marker would follow the pointer across
1641
+ * the whole shape instead of sliding along the outline. Lines keep the geometry's own
1642
+ * answer, which is exact rather than limited to the decimated samples.
1643
+ */
1644
+ _closestOnProfile(coordinate) {
1645
+ const g = this._feature && this._feature.getGeometry();
1646
+ if (!g) return null;
1647
+ if (!/Polygon/.test(g.getType())) return g.getClosestPoint(coordinate);
1648
+ const data = this._fullSamples;
1649
+ if (!data || !data.length) return null;
1650
+ let best = null, bd = Infinity;
1651
+ for (const p of data) {
1652
+ const dx = p.coord[0] - coordinate[0], dy = p.coord[1] - coordinate[1];
1653
+ const dd = dx * dx + dy * dy;
1654
+ if (dd < bd) { bd = dd; best = p.coord; }
1655
+ }
1656
+ return best;
1657
+ }
1658
+
765
1659
  // ---------- focus -------------------------------------------------
766
1660
  _tooltipText(d) {
767
1661
  const o = this.options, parts = [];
@@ -799,7 +1693,7 @@ import * as d3 from 'd3';
799
1693
  this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
800
1694
  this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
801
1695
  this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
802
- this._cropMode = false; this._updateZoomButtons();
1696
+ this._cropMode = false; this._demLoading = false; this._updateZoomButtons();
803
1697
  this._clearFocus();
804
1698
  this.element.style.display = 'none';
805
1699
  this._adjustAttribution();
@@ -813,6 +1707,9 @@ import * as d3 from 'd3';
813
1707
  */
814
1708
  ElevationProfile.addTheme = (name, colors) => { THEMES[name] = colors; };
815
1709
  ElevationProfile.THEMES = THEMES;
1710
+ /** Known keyless terrain-tile sources, keyed by `dem.source` name. */
1711
+ ElevationProfile.DEM_PRESETS = DEM_PRESETS;
1712
+ ElevationProfile.DemSampler = DemSampler;
816
1713
  ElevationProfile.POSITIONS = POSITIONS;
817
1714
  ElevationProfile.version = '0.6.0';
818
1715