ol-elevation-profile 1.0.0 → 2.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.
@@ -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';
@@ -35,6 +36,53 @@ import * as d3 from 'd3';
35
36
 
36
37
  const POSITIONS = ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
37
38
 
39
+ /**
40
+ * Jeux de libellés livrés avec la librairie. `en` est le défaut et la retombée : une
41
+ * langue inconnue rend l'anglais plutôt que des clés vides ou un mélange.
42
+ *
43
+ * Un jeu est complet, jamais partiel. Une traduction à trous laisserait deux langues
44
+ * cohabiter dans le même panneau, ce qui se voit ; `labels` reste là pour corriger une
45
+ * clé isolée sans avoir à redéclarer les autres.
46
+ *
47
+ * `ascent` et `descent` valent `D+` / `D-` partout : la notation est celle des cartes et
48
+ * des topos, pas un mot à traduire.
49
+ */
50
+ const LOCALES = {
51
+ en: {
52
+ distance: 'Distance', elevation: 'Elevation', slope: 'Slope',
53
+ ascent: 'D+', descent: 'D-', empty: 'Click a track',
54
+ time: 'Time', duration: 'Duration',
55
+ durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'd' },
56
+ zoomStart: 'Set start (A)', zoomEnd: 'Set end (B)', zoomAll: 'Show all',
57
+ zoomBack: 'Back one level',
58
+ exportPng: 'Export as PNG',
59
+ collapse: 'Collapse the profile', expand: 'Expand the profile',
60
+ loading: 'Loading the elevation profile'
61
+ },
62
+ fr: {
63
+ distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
64
+ ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
65
+ time: 'Temps', duration: 'Durée',
66
+ durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
67
+ zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
68
+ zoomBack: 'Revenir au niveau précédent',
69
+ exportPng: 'Exporter en PNG',
70
+ collapse: 'Réduire le profil', expand: 'Agrandir le profil',
71
+ loading: 'Chargement du profil altimétrique'
72
+ },
73
+ es: {
74
+ distance: 'Distancia', elevation: 'Altitud', slope: 'Pendiente',
75
+ ascent: 'D+', descent: 'D-', empty: 'Haga clic en una traza',
76
+ time: 'Tiempo', duration: 'Duración',
77
+ durationUnits: { s: 'seg', m: 'min', h: 'h', d: 'd' },
78
+ zoomStart: 'Definir el inicio (A)', zoomEnd: 'Definir el final (B)', zoomAll: 'Ver todo',
79
+ zoomBack: 'Volver al nivel anterior',
80
+ exportPng: 'Exportar como PNG',
81
+ collapse: 'Contraer el perfil', expand: 'Desplegar el perfil',
82
+ loading: 'Cargando el perfil de elevación'
83
+ }
84
+ };
85
+
38
86
  const DEFAULTS = {
39
87
  immersion: 'docked',
40
88
  position: 'bottom',
@@ -60,6 +108,8 @@ import * as d3 from 'd3';
60
108
  maxClasses: 8, // maximum number of slope classes (colours + legend)
61
109
  xTicks: null,
62
110
  yTicks: null,
111
+ verticalScale: 'auto', // 'auto' : le profil remplit la hauteur ;
112
+ // un nombre : mètres par centimètre physique
63
113
  show: 'click',
64
114
  collapsable: true,
65
115
  collapsed: false,
@@ -69,22 +119,23 @@ import * as d3 from 'd3';
69
119
  responsive: true, // adapts width/placement, mobile included
70
120
  mobileBreakpoint: 640, // <= screen width -> mobile mode (100% width, top/bottom)
71
121
  zoom: false, // start/end buttons cropping map + profile to A..B
122
+ zoomLevels: 3, // niveaux de recadrage imbriqués (1 = l'ancien niveau unique)
123
+ exportPng: false, // toolbar button exporting the panel as a PNG
72
124
  ignoreStops: true, // duration = moving time (stops excluded)
73
125
  stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
74
126
  tooltipItems: ['distance', 'elevation'],
75
127
  headerItems: ['distance', 'ascent', 'descent', 'minmax'],
76
128
  titleProperty: 'name',
77
129
  titleLink: null,
78
- labels: {
79
- distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
80
- ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
81
- time: 'Temps', duration: 'Durée',
82
- durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
83
- zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
84
- loading: 'Chargement du profil altimétrique'
85
- }
130
+ lang: 'en', // 'en' | 'fr' | 'es' : jeu de libellés livré
131
+ labels: {} // surcharges clé à clé, appliquées par-dessus la langue
86
132
  };
87
133
 
134
+ /** Libellés de la langue [lang], corrigés par les surcharges [over]. */
135
+ function resolveLabels(lang, over) {
136
+ return deepMerge(LOCALES[lang] || LOCALES.en, over || null);
137
+ }
138
+
88
139
  // ----- helpers ---------------------------------------------------------
89
140
  function deepMerge(base, over) {
90
141
  const out = {};
@@ -186,15 +237,21 @@ import * as d3 from 'd3';
186
237
  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>';
187
238
  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>';
188
239
  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>';
240
+ 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>';
241
+ const ICON_BACK = '<svg viewBox="0 0 24 24"><path d="M10 6 4 12l6 6M4 12h10a6 6 0 0 1 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
189
242
  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>';
190
243
 
244
+ // Recherche du segment contenant une abscisse. Construit une fois : le survol la sollicite
245
+ // à chaque mouvement de souris.
246
+ const bisectX = d3.bisector((d) => d.x).left;
247
+
191
248
  // ----- digital elevation model (DEM) -----------------------------------
192
249
  /**
193
250
  * Known terrain-tile sources.
194
251
  *
195
252
  * A PNG tile carries elevation in its R/G/B channels; it is read on a canvas rather
196
253
  * than queried point by point from an API. That is what makes a 10 000-point track a
197
- * handful of requests, with no key, no quota and no rate limit where the free
254
+ * handful of requests, with no key, no quota and no rate limit, where the free
198
255
  * elevation APIs cap out at 100 or 200 points per call.
199
256
  *
200
257
  * Attribution is not automatic: the library does not own the map. It is up to the
@@ -208,17 +265,335 @@ import * as d3 from 'd3';
208
265
  encoding: 'terrarium',
209
266
  maxZoom: 14,
210
267
  attributions: 'Elevation: <a href="https://registry.opendata.aws/terrain-tiles/">Terrain Tiles</a> (AWS Open Data)'
268
+ },
269
+ /**
270
+ * IGN Géoplateforme, serving the RGE ALTI over France and its overseas territories.
271
+ *
272
+ * A point API rather than tiles, hence its own sampler: it answers 200 points per
273
+ * request and announces one request per second, so a 10 000 point track takes about
274
+ * fifty calls spread over as many seconds. In exchange it is metre-accurate where the
275
+ * world models are at ninety.
276
+ *
277
+ * **No key is required** on the public endpoint - verified against the live service.
278
+ * `apiKey` exists for a deployment that does demand one; it is appended as a query
279
+ * parameter, named by `apiKeyParam`.
280
+ *
281
+ * Outside its coverage the service does not error: it answers OUT_OF_COVERAGE for the
282
+ * point. No border is coded here - one asks, and it says itself where it does not know.
283
+ */
284
+ ign: {
285
+ api: 'ign',
286
+ url: 'https://data.geopf.fr/altimetrie/1.0/calcul/alti/rest/elevation.json',
287
+ resource: 'ign_rge_alti_wld',
288
+ batch: 200, // more fits, but the URL grows ~20 bytes a point and hits 414
289
+ minInterval: 1100, // ms between calls; the service announces 1 req/s
290
+ attributions: 'Elevation: <a href="https://geoservices.ign.fr/rgealti">RGE ALTI</a> (IGN)'
211
291
  }
212
292
  };
213
293
 
294
+ /** Value the IGN service returns where it holds no measurement. */
295
+ const IGN_NO_DATA = -99999;
296
+
297
+ /**
298
+ * Request URL for one batch, in the order given: the response returns the elevations in
299
+ * the same one.
300
+ *
301
+ * `zonly=true` cuts the response down to the elevations alone, without echoing the
302
+ * coordinates - a third of the weight for the same information. Coordinates rounded to
303
+ * the millionth of a degree, about ten centimetres: beyond that one only adds digits to
304
+ * the URL.
305
+ */
306
+ function ignUrl(cfg, pts) {
307
+ const f = (v) => v.toFixed(6);
308
+ const sep = cfg.url.indexOf('?') >= 0 ? '&' : '?';
309
+ let u = cfg.url + sep + 'resource=' + encodeURIComponent(cfg.resource) +
310
+ '&delimiter=|&zonly=true' +
311
+ '&lon=' + pts.map((p) => f(p[0])).join('|') +
312
+ '&lat=' + pts.map((p) => f(p[1])).join('|');
313
+ if (cfg.apiKey) u += '&' + (cfg.apiKeyParam || 'apikey') + '=' + encodeURIComponent(cfg.apiKey);
314
+ return u;
315
+ }
316
+
317
+ /**
318
+ * Sampler for the IGN service: batches, paces, and maps out-of-coverage to null.
319
+ *
320
+ * A batch whose response does not carry exactly as many elevations as points were asked
321
+ * for fails the whole fill. Elevations one can no longer map to their points are worse
322
+ * than absent ones: they would land on the wrong places, silently.
323
+ */
324
+ function ignSampler(cfg) {
325
+ const size = Math.max(1, cfg.batch || 200);
326
+ const gap = cfg.minInterval || 0;
327
+ return (lonlats) => {
328
+ const out = [];
329
+ let last = 0;
330
+ const step = (i) => {
331
+ if (i >= lonlats.length) return Promise.resolve(out);
332
+ const chunk = lonlats.slice(i, i + size);
333
+ const wait = Math.max(0, gap - (Date.now() - last));
334
+ return new Promise((r) => setTimeout(r, wait))
335
+ .then(() => { last = Date.now(); return fetch(ignUrl(cfg, chunk)); })
336
+ .then((r) => (r && r.ok) ? r.json() : null)
337
+ .then((body) => {
338
+ const z = body && body.elevations;
339
+ if (!Array.isArray(z) || z.length !== chunk.length) return null;
340
+ for (const v of z) out.push((v == null || v <= IGN_NO_DATA) ? null : v);
341
+ return step(i + size);
342
+ });
343
+ };
344
+ return step(0);
345
+ };
346
+ }
347
+
214
348
  const DEM_DEFAULTS = { source: 'terrarium', zoom: 'auto', maxZoom: 14, maxTiles: 32, concurrency: 6, tileSize: 256 };
215
349
 
216
- /** RGB → metres decoders, one per encoding convention. */
350
+ /**
351
+ * RGB to metres decoders, one per encoding convention.
352
+ *
353
+ * `encoding` also takes a function `(r, g, b, a) => metres`, for a tile set that packs
354
+ * elevation its own way. Return null where the pixel carries no measurement: the fill is
355
+ * abandoned rather than guessed.
356
+ *
357
+ * A GeoServer greyscale DEM is NOT decoded here. What a WMS returns is a rendered image,
358
+ * stretched and quantised by its style, not the coverage values; reading it back would
359
+ * be reading the rendering. Those go through `featureInfo`, which asks the server for the
360
+ * band value itself (see demConfig).
361
+ */
217
362
  const DEM_DECODERS = {
218
363
  terrarium: (r, g, b) => (r * 256 + g + b / 256) - 32768,
219
364
  mapbox: (r, g, b) => -1e4 + (r * 65536 + g * 256 + b) * 0.1
220
365
  };
221
366
 
367
+ /** Half the Web Mercator world, in metres: the bound of EPSG:3857. */
368
+ const MERC_HALF = 20037508.342789244;
369
+
370
+ /** Extent of an XYZ tile in EPSG:3857, for the WMS requests built below. */
371
+ function tileExtent(z, x, y) {
372
+ const span = 2 * MERC_HALF / Math.pow(2, z);
373
+ const minX = -MERC_HALF + x * span, maxY = MERC_HALF - y * span;
374
+ return [minX, maxY - span, minX + span, maxY];
375
+ }
376
+
377
+ function query(base, params) {
378
+ const sep = base.indexOf('?') >= 0 ? '&' : '?';
379
+ return base.replace(/[?&]$/, '') + sep +
380
+ Object.keys(params).map((k) => k + '=' + encodeURIComponent(params[k])).join('&');
381
+ }
382
+
383
+ /**
384
+ * WMS GetMap URL covering one XYZ tile, so a WMS behaves like any other tile source.
385
+ *
386
+ * The axis-order trap: WMS 1.3.0 names the reference system `CRS`, 1.1.1 names it `SRS`,
387
+ * and sending the wrong one gets the request rejected or silently mis-georeferenced.
388
+ * The version actually in force decides, after the caller's params are merged in.
389
+ */
390
+ function wmsTileUrl(w, z, x, y, tileSize) {
391
+ const p = Object.assign({
392
+ SERVICE: 'WMS', REQUEST: 'GetMap', VERSION: '1.3.0', FORMAT: 'image/png',
393
+ TRANSPARENT: 'false', STYLES: ''
394
+ }, w.params || {});
395
+ const key = String(p.VERSION).indexOf('1.1') === 0 ? 'SRS' : 'CRS';
396
+ delete p.SRS; delete p.CRS;
397
+ p[key] = w.projection || 'EPSG:3857';
398
+ p.LAYERS = w.layers;
399
+ p.WIDTH = tileSize; p.HEIGHT = tileSize;
400
+ p.BBOX = tileExtent(z, x, y).join(',');
401
+ return query(w.url, p);
402
+ }
403
+
404
+ /**
405
+ * Where a tile comes from: an OpenLayers source, a WMS, or an XYZ template.
406
+ *
407
+ * Handing an `ol/source/TileImage` (XYZ, TileWMS, or any subclass) delegates URL building
408
+ * to OpenLayers itself, which already knows that source's quirks - subdomains, custom
409
+ * params, tile grid. Cheaper and safer than reimplementing each one here.
410
+ */
411
+ function tileUrlFn(cfg) {
412
+ const src = cfg.olSource;
413
+ if (src && typeof src.getTileUrlFunction === 'function') {
414
+ const fn = src.getTileUrlFunction();
415
+ const proj = get('EPSG:3857');
416
+ return (z, x, y) => fn([z, x, y], 1, proj);
417
+ }
418
+ if (cfg.wms) return (z, x, y) => wmsTileUrl(cfg.wms, z, x, y, cfg.tileSize);
419
+ return (z, x, y) => cfg.url.replace('{z}', z).replace('{x}', x).replace('{y}', y);
420
+ }
421
+
422
+ /**
423
+ * Values of one DataTile, or null if it never arrived.
424
+ *
425
+ * A DataTile carries no URL: it is loaded by the source itself and its values are read
426
+ * back with getData(). The state has to be watched rather than awaited - OpenLayers
427
+ * announces it through a `change` event, not a promise.
428
+ */
429
+ function loadDataTile(tile) {
430
+ return new Promise((resolve) => {
431
+ const settle = () => {
432
+ const st = tile.getState();
433
+ if (st === TileState.LOADED) { resolve(tile.getData ? tile.getData() : null); return true; }
434
+ if (st === TileState.ERROR || st === TileState.EMPTY) { resolve(null); return true; }
435
+ return false;
436
+ };
437
+ if (settle()) return;
438
+ const onChange = () => { if (settle()) tile.removeEventListener('change', onChange); };
439
+ tile.addEventListener('change', onChange);
440
+ if (tile.load) tile.load();
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Sampler for an `ol/source/DataTile` - `ol/source/GeoTIFF` above all.
446
+ *
447
+ * Its own path because such a source shares nothing with the XYZ one: no URL, no Web
448
+ * Mercator, no 256 pixel tiles. A GeoTIFF keeps **its own projection and its own tile
449
+ * grid**, both only known once `getView()` has resolved, and its values are real numbers
450
+ * rather than colours - so nothing here goes through a decoder.
451
+ *
452
+ * Positions are held in grid pixels, as with the XYZ tiles and for the same reason: the
453
+ * four neighbours of a point straddle two tiles as soon as it runs along an edge.
454
+ *
455
+ * `normalize: false` matters on the source, otherwise OpenLayers rescales the values to
456
+ * 0..1 and the profile comes out in fractions of nothing.
457
+ */
458
+ function dataTileSampler(cfg) {
459
+ const src = cfg.olSource;
460
+ const band = cfg.band || 0;
461
+ return (lonlats) => {
462
+ // getView() is only awaited when the grid is not known yet - which is the GeoTIFF
463
+ // case, where it settles once the metadata has been read. On a plain DataTile that
464
+ // promise never settles at all, and awaiting it unconditionally hangs the fill.
465
+ const known = src.getTileGrid && src.getTileGrid();
466
+ const ready = known ? Promise.resolve(null) : Promise.resolve(src.getView ? src.getView() : null);
467
+ return ready.then((view) => {
468
+ const grid = known || (src.getTileGrid && src.getTileGrid()) || (view && view.tileGrid);
469
+ if (!grid) return null;
470
+ const proj = get((view && view.projection) || (src.getProjection && src.getProjection()) || 'EPSG:3857');
471
+ const zs = grid.getResolutions ? grid.getResolutions().length - 1 : 0;
472
+ const bands = src.bandCount || 1;
473
+ const coords = lonlats.map((ll) => transform([ll[0], ll[1]], 'EPSG:4326', proj));
474
+
475
+ // Finest level whose tile count stays within maxTiles, as for the XYZ tiles: it is
476
+ // the tiling that widens with the track, not the model that degrades.
477
+ // Pixel bounds of the coverage, when it declares an extent. A point inside the raster
478
+ // but within half a pixel of its edge lands on a neighbour that does not exist: its
479
+ // tile would come back empty and the all-or-nothing rule would drop the whole track.
480
+ // Half a pixel of tolerance at the borders, as on any regular grid.
481
+ const ext = (grid.getExtent && grid.getExtent()) || (view && view.extent) || null;
482
+ const plan = (z) => {
483
+ const res = grid.getResolution(z), origin = grid.getOrigin(z);
484
+ let ts = grid.getTileSize(z); ts = Array.isArray(ts) ? ts : [ts, ts];
485
+ const cols = ext ? Math.round((ext[2] - ext[0]) / res) : Infinity;
486
+ const rows = ext ? Math.round((ext[3] - ext[1]) / res) : Infinity;
487
+ const clampI = (i) => Math.min(cols - 1, Math.max(0, i));
488
+ const clampJ = (j) => Math.min(rows - 1, Math.max(0, j));
489
+ const need = new Map();
490
+ const at = coords.map((c) => {
491
+ const px = (c[0] - origin[0]) / res - 0.5, py = (origin[1] - c[1]) / res - 0.5;
492
+ const i0 = clampI(Math.floor(px)), j0 = clampJ(Math.floor(py));
493
+ for (let di = 0; di < 2; di++) for (let dj = 0; dj < 2; dj++) {
494
+ const tx = Math.floor(clampI(i0 + di) / ts[0]), ty = Math.floor(clampJ(j0 + dj) / ts[1]);
495
+ need.set(tx + '/' + ty, [tx, ty]);
496
+ }
497
+ // Interpolation fractions clamped too: a point beyond the last pixel centre keeps
498
+ // that pixel's value rather than extrapolating past the edge of the data.
499
+ return { i0, j0, tx: Math.min(1, Math.max(0, px - i0)), ty: Math.min(1, Math.max(0, py - j0)), clampI, clampJ };
500
+ });
501
+ return { res, origin, ts, need, at, clampI, clampJ };
502
+ };
503
+ let z = zs, p = plan(z);
504
+ while (z > 0 && p.need.size > cfg.maxTiles) { z--; p = plan(z); }
505
+ if (p.need.size > cfg.maxTiles) return null;
506
+
507
+ const keys = Array.from(p.need.keys());
508
+ return Promise.all(keys.map((k) => {
509
+ const t = p.need.get(k);
510
+ return loadDataTile(src.getTile(z, t[0], t[1], 1, proj)).then((d) => [k, d]);
511
+ })).then((pairs) => {
512
+ const tiles = new Map(pairs);
513
+ const value = (i, j) => {
514
+ const tx = Math.floor(p.clampI(i) / p.ts[0]), ty = Math.floor(p.clampJ(j) / p.ts[1]);
515
+ i = p.clampI(i); j = p.clampJ(j);
516
+ const d = tiles.get(tx + '/' + ty);
517
+ if (!d) return null;
518
+ const col = i - tx * p.ts[0], row = j - ty * p.ts[1];
519
+ const v = d[(row * p.ts[0] + col) * bands + band];
520
+ return (v == null || !isFinite(v)) ? null : v;
521
+ };
522
+ return p.at.map((n) => {
523
+ const a = value(n.i0, n.j0), b = value(n.i0 + 1, n.j0);
524
+ const c = value(n.i0, n.j0 + 1), e = value(n.i0 + 1, n.j0 + 1);
525
+ if (a == null || b == null || c == null || e == null) return null;
526
+ const north = a + (b - a) * n.tx, south = c + (e - c) * n.tx;
527
+ return north + (south - north) * n.ty;
528
+ });
529
+ });
530
+ });
531
+ };
532
+ }
533
+
534
+ /**
535
+ * WMS GetFeatureInfo sampler: one request per point, for a coverage served as a rendered
536
+ * greyscale that cannot be decoded from its pixels.
537
+ *
538
+ * **It is slow**, unavoidably: a thousand-point track is a thousand round trips, where a
539
+ * tile source needs a handful. Worth it only when nothing else can reach the data - and
540
+ * `concurrency` is what keeps it bearable.
541
+ */
542
+ function featureInfoSampler(cfg) {
543
+ const fi = cfg.featureInfo;
544
+ const lanes = Math.max(1, cfg.concurrency || 6);
545
+ return (lonlats) => {
546
+ const out = new Array(lonlats.length);
547
+ let next = 0, broken = false;
548
+ const one = () => {
549
+ if (broken || next >= lonlats.length) return Promise.resolve();
550
+ const i = next++;
551
+ return fetch(featureInfoUrl(fi, lonlats[i]))
552
+ .then((r) => (r && r.ok) ? r.json() : null)
553
+ .then((body) => { out[i] = bandValue(body, fi.property); return one(); })
554
+ .catch(() => { broken = true; });
555
+ };
556
+ const running = [];
557
+ for (let i = 0; i < Math.min(lanes, lonlats.length); i++) running.push(one());
558
+ return Promise.all(running).then(() => broken ? null : out);
559
+ };
560
+ }
561
+
562
+ /** GetFeatureInfo URL for one point: a one-pixel image centred on it. */
563
+ function featureInfoUrl(fi, ll) {
564
+ const c = fromLonLat([ll[0], ll[1]]);
565
+ const d = fi.resolution || 1; // half-size of the box, in metres
566
+ const p = Object.assign({
567
+ SERVICE: 'WMS', REQUEST: 'GetFeatureInfo', VERSION: '1.3.0',
568
+ INFO_FORMAT: 'application/json', STYLES: '', FEATURE_COUNT: 1
569
+ }, fi.params || {});
570
+ const key = String(p.VERSION).indexOf('1.1') === 0 ? 'SRS' : 'CRS';
571
+ delete p.SRS; delete p.CRS;
572
+ p[key] = fi.projection || 'EPSG:3857';
573
+ p.LAYERS = fi.layers;
574
+ p.QUERY_LAYERS = fi.queryLayers || fi.layers;
575
+ 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
576
+ p.BBOX = [c[0] - d, c[1] - d, c[0] + d, c[1] + d].join(',');
577
+ return query(fi.url, p);
578
+ }
579
+
580
+ /**
581
+ * Band value in a GetFeatureInfo response.
582
+ *
583
+ * The band's property name is not standard - GeoServer calls it GRAY_INDEX for a
584
+ * single-band coverage, but a styled or renamed layer answers otherwise. Without an
585
+ * explicit `property` the first finite number wins, which is right whenever the coverage
586
+ * carries one band, and is why `property` exists for when it does not.
587
+ */
588
+ function bandValue(body, property) {
589
+ const f = body && body.features && body.features[0];
590
+ const props = f && f.properties;
591
+ if (!props) return null;
592
+ if (property) { const v = Number(props[property]); return isFinite(v) ? v : null; }
593
+ for (const k of Object.keys(props)) { const v = Number(props[k]); if (props[k] !== null && props[k] !== '' && isFinite(v)) return v; }
594
+ return null;
595
+ }
596
+
222
597
  /** Web Mercator pixel coordinates at zoom `z`, origin at the top-left corner of the world. */
223
598
  function worldPixel(lon, lat, z, tileSize) {
224
599
  const n = tileSize * Math.pow(2, z);
@@ -238,7 +613,9 @@ import * as d3 from 'd3';
238
613
  /** @param {Object} cfg `url`, `encoding`, `tileSize`, `concurrency`. */
239
614
  constructor(cfg) {
240
615
  this.cfg = cfg;
241
- this.decode = DEM_DECODERS[cfg.encoding] || DEM_DECODERS.terrarium;
616
+ this.decode = (typeof cfg.encoding === 'function') ? cfg.encoding
617
+ : (DEM_DECODERS[cfg.encoding] || DEM_DECODERS.terrarium);
618
+ this.tileUrl = tileUrlFn(cfg);
242
619
  this.tiles = new Map(); // "z/x/y" -> RGBA pixels, or null if the tile was lost
243
620
  }
244
621
 
@@ -270,7 +647,8 @@ import * as d3 from 'd3';
270
647
  const data = this.tiles.get(r.key);
271
648
  if (!data) return null;
272
649
  const o = ((r.jj % ts) * ts + (r.ii % ts)) * 4;
273
- return this.decode(data[o], data[o + 1], data[o + 2]);
650
+ const v = this.decode(data[o], data[o + 1], data[o + 2], data[o + 3]);
651
+ return (v == null || !isFinite(v)) ? null : v;
274
652
  }
275
653
 
276
654
  /**
@@ -279,7 +657,7 @@ import * as d3 from 'd3';
279
657
  *
280
658
  * Bilinear rather than "the pixel containing the point": on a 30 or 90 m model, taking
281
659
  * the pixel value makes the profile advance in stairs, and every stair counts as a
282
- * climb then a descent in the ascent total. The interpolation invents no relief it
660
+ * climb then a descent in the ascent total. The interpolation invents no relief: it
283
661
  * renders the same surface, without the steps of the sampling grid.
284
662
  */
285
663
  sample(lon, lat, z) {
@@ -304,7 +682,7 @@ import * as d3 from 'd3';
304
682
  /** Pixels of one tile, or null if it could not be read. */
305
683
  _fetch(key) {
306
684
  const parts = key.split('/');
307
- const url = this.cfg.url.replace('{z}', parts[0]).replace('{x}', parts[1]).replace('{y}', parts[2]);
685
+ const url = this.tileUrl(+parts[0], +parts[1], +parts[2]);
308
686
  return new Promise((resolve) => {
309
687
  const img = new Image();
310
688
  // Without this attribute the canvas is tainted and getImageData throws: the tile
@@ -346,13 +724,44 @@ import * as d3 from 'd3';
346
724
  }
347
725
  }
348
726
 
349
- /** The `dem` option (true | source name | object) → a full configuration, or null. */
727
+ /**
728
+ * The `dem` option (true | source name | sampling function | object) into
729
+ * a full configuration, or null.
730
+ *
731
+ * A `sample` function short-circuits the tile machinery entirely: the application
732
+ * answers with the elevations itself, from wherever it can reach them - a GeoServer
733
+ * WCS coverage, a GeoTIFF decoded with geotiff.js, a national elevation API. Only the
734
+ * transport is delegated; the control keeps the sequencing, the all-or-nothing rule,
735
+ * the spinner and the `demload` event, which is where the subtle parts live.
736
+ *
737
+ * That extension point rather than a WMS/WCS/GeoTIFF mode per protocol: reading a
738
+ * GeoTIFF means a full decoder, and this library has no runtime dependency to spend
739
+ * on one that most users would never load.
740
+ */
350
741
  function demConfig(opt) {
351
742
  if (!opt) return null;
743
+ if (typeof opt === 'function') return Object.assign({}, DEM_DEFAULTS, { sample: opt });
352
744
  const raw = (opt === true || typeof opt === 'string') ? { source: opt === true ? 'terrarium' : opt } : Object.assign({}, opt);
353
- const preset = DEM_PRESETS[raw.source] || (raw.url ? {} : DEM_PRESETS.terrarium);
745
+ if (typeof raw.sample === 'function') return Object.assign({}, DEM_DEFAULTS, raw);
746
+ // A point-query source: no tile grid, no pixel decoding.
747
+ if (raw.featureInfo) {
748
+ const fiCfg = Object.assign({}, DEM_DEFAULTS, raw);
749
+ fiCfg.sample = featureInfoSampler(fiCfg);
750
+ return fiCfg;
751
+ }
752
+ // An ol/source/DataTile (GeoTIFF above all) carries values, not URLs: its own path.
753
+ if (raw.olSource && typeof raw.olSource.getTileUrlFunction !== 'function' &&
754
+ typeof raw.olSource.getTile === 'function') {
755
+ const dtCfg = Object.assign({}, DEM_DEFAULTS, raw);
756
+ dtCfg.sample = dataTileSampler(dtCfg);
757
+ return dtCfg;
758
+ }
759
+ // Tiles: an OpenLayers source or a WMS both stand in for the url template.
760
+ const hasTiles = raw.url || raw.wms || raw.olSource;
761
+ const preset = DEM_PRESETS[raw.source] || (hasTiles ? {} : DEM_PRESETS.terrarium);
354
762
  const cfg = Object.assign({}, DEM_DEFAULTS, preset, raw);
355
- return cfg.url ? cfg : null;
763
+ if (cfg.api === 'ign') { cfg.sample = ignSampler(cfg); return cfg; } // a point API
764
+ return (cfg.url || cfg.wms || cfg.olSource) ? cfg : null;
356
765
  }
357
766
 
358
767
  /**
@@ -386,12 +795,19 @@ import * as d3 from 'd3';
386
795
  * @property {boolean} [slope=false] Split the profile into slope-class colored portions.
387
796
  * @property {number} [slopeClassSize=2.5] Slope class width, in percent.
388
797
  * @property {number} [maxClasses=8] Maximum number of slope classes (colors + legend).
389
- * @property {?string[]} [slopeColors=null] `null` = bluered ramp; otherwise an interpolated color array.
798
+ * @property {?string[]} [slopeColors=null] `null` = blue-to-red ramp; otherwise an interpolated color array.
390
799
  * @property {boolean} [slopeSeparators=true] Vertical separator at each slope-class change.
391
800
  * @property {boolean} [slopeLegend=true] Color legend under the title.
392
801
  * @property {number} [smoothing=0] Elevation smoothing window, in METERS (0 = none).
393
802
  * @property {?number} [xTicks=null] X axis ticks (null = auto from width).
394
803
  * @property {?number} [yTicks=null] Y axis ticks (null = auto from height).
804
+ * @property {('auto'|number)} [verticalScale='auto'] `'auto'`: the profile fills the
805
+ * height, which is legible but changes scale from one track to the next, so a 2 % ramp
806
+ * looks like a wall and two profiles cannot be compared. A number fixes the metres
807
+ * covered per physical centimetre. It is a **floor**, not a cage: a track whose range
808
+ * exceeds what the height can show would spill out of the frame, which is worse than
809
+ * losing comparability: the scale then widens silently, nothing being drawn on the
810
+ * chart to say so.
395
811
  * @property {'click'|'mouseover'} [show='click'] How a track is selected on the map.
396
812
  * @property {boolean} [hideOnMapClick=true] Click on empty map hides the profile.
397
813
  * @property {boolean} [collapsable=true] Show the collapse/expand button.
@@ -401,15 +817,32 @@ import * as d3 from 'd3';
401
817
  * @property {boolean} [responsive=true] Adapt width/placement; mobile included.
402
818
  * @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
403
819
  * @property {boolean} [zoom=false] A/B buttons to crop map + profile to a sub-range.
820
+ * @property {number} [zoomLevels=3] Nested crops allowed: a crop can itself be cropped,
821
+ * down to this depth. `1` restores the former single level. A "back" button appears
822
+ * from the second level; "show all" empties the stack whatever the depth.
823
+ * @property {boolean} [exportPng=false] Toolbar button exporting the whole panel as a PNG.
404
824
  * @property {boolean} [ignoreStops=true] When computing time, ignore stopped segments (moving time).
405
825
  * @property {number} [stopSpeed=0.5] Speed threshold (m/s) below which a segment counts as a stop.
406
826
  * @property {Array<'distance'|'elevation'|'slope'|'time'>} [tooltipItems=['distance','elevation']] Tooltip content (`'time'` = elapsed time at the cursor, if the track has time data).
407
827
  * @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).
828
+ * @property {('en'|'fr'|'es')} [lang='en'] Language of the shipped labels. An unknown
829
+ * code falls back to English rather than leaving keys empty.
830
+ * @property {Object} [labels={}] Per-key overrides applied on top of `lang`. They survive
831
+ * a later language change, so a corrected key stays corrected.
408
832
  * @property {string} [titleProperty='name'] Feature property used as the title.
409
- * @property {?string} [titleLink=null] Feature property holding a URL clickable title.
833
+ * @property {?string} [titleLink=null] Feature property holding a URL, making the title a link.
410
834
  * @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
411
835
  * @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
412
- * @property {?(boolean|string|Object)} [dem='terrarium'] Fill missing elevations from a terrain model. `'terrarium'` (default) or `true` = AWS Terrain Tiles (keyless); `null` disables it; or `{url,encoding:'terrarium'|'mapbox',zoom,maxZoom,maxTiles,tileSize,concurrency}`.
836
+ * @property {?(boolean|string|Function|Object)} [dem='terrarium'] Fill missing elevations
837
+ * from a terrain model. Sources: `'terrarium'` (default) or `true` = AWS Terrain Tiles;
838
+ * `'ign'` = IGN Géoplateforme RGE ALTI (France, keyless, `apiKey` optional);
839
+ * `{url}` = XYZ template; `{wms:{url,layers,params}}` = WMS tiles;
840
+ * `{olSource}` = any `ol/source/TileImage` (XYZ, TileWMS, and so on);
841
+ * `{featureInfo:{url,layers,property}}` = WMS GetFeatureInfo, one request per point,
842
+ * for a greyscale coverage that cannot be decoded from its pixels (slow);
843
+ * a function or `{sample}` `(lonlats, ctx) => number[]|Promise<number[]>` to source them
844
+ * yourself; `null` disables the whole thing.
845
+ * Decoding: `encoding` is `'terrarium'`, `'mapbox'`, or a function `(r,g,b,a) => metres`.
413
846
  */
414
847
 
415
848
  /**
@@ -426,23 +859,37 @@ import * as d3 from 'd3';
426
859
  constructor(opts) {
427
860
  const o = deepMerge(DEFAULTS, opts || {});
428
861
  if (POSITIONS.indexOf(o.position) === -1) o.position = 'bottom';
862
+ // Les surcharges sont gardées à part de leur résultat : changer de langue plus tard
863
+ // doit reprendre le jeu neuf de la nouvelle langue sans perdre ce que l'appelant a
864
+ // corrigé, ce que le seul jeu résolu ne permettrait plus de démêler.
865
+ const userLabels = o.labels;
866
+ o.labels = resolveLabels(o.lang, userLabels);
429
867
  const element = buildElement(o);
430
868
  super({ element, target: opts && opts.target });
431
869
 
432
870
  this.options = o;
871
+ this._userLabels = userLabels;
433
872
  this.slopeColors = o.slopeColors || null;
434
873
  this._feature = null;
435
874
  this._fullSamples = null; this._fullStats = null;
436
875
  this._samples = null; this._stats = null;
437
876
  this._marker = null;
438
877
  this._collapsed = !!o.collapsed;
439
- this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
878
+ this._crops = []; this._off = 0;
879
+ this._zoomA = null; this._zoomB = null; this._armed = null;
440
880
  this._demZ = null; this._demFor = null; this._demSeq = 0; this._demLoading = false;
441
881
  this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
442
882
  this._buildDom(element);
443
883
  element.style.display = 'none';
444
884
  }
445
885
 
886
+ /** Recadré dès qu'un niveau est empilé. Lu partout où le booléen l'était. */
887
+ get _cropMode() { return this._crops.length > 0; }
888
+ /** Profondeur courante ; 0 quand on voit la trace entière. */
889
+ get _cropDepth() { return this._crops.length; }
890
+ /** Niveaux imbriqués autorisés, au moins un. */
891
+ get _cropMax() { const n = this.options.zoomLevels; return n == null ? 3 : Math.max(1, n | 0); }
892
+
446
893
  // ---------- static helpers ----------------------------------------
447
894
  /**
448
895
  * Whether a feature has any Z (elevation) coordinate.
@@ -489,16 +936,19 @@ import * as d3 from 'd3';
489
936
  };
490
937
  this._btnA = mkBtn('oep-a', ICON_A, o.labels.zoomStart, () => this._arm('A'));
491
938
  this._btnB = mkBtn('oep-b', ICON_B, o.labels.zoomEnd, () => this._arm('B'));
939
+ this._btnBack = mkBtn('oep-back', ICON_BACK, o.labels.zoomBack, () => this._popCrop());
492
940
  this._btnAll = mkBtn('oep-all', ICON_ALL, o.labels.zoomAll, () => this._exitZoom());
941
+ // Last of the toolbar, so it sits to the right of the zoom buttons.
942
+ this._btnPng = mkBtn('oep-png', ICON_PNG, o.labels.exportPng, () => this.exportPNG());
493
943
  header.appendChild(this._toolbar);
494
944
 
495
945
  const btn = document.createElement('button');
496
946
  btn.className = 'oep-toggle'; btn.type = 'button';
497
- btn.setAttribute('aria-label', 'Réduire ou agrandir le profil');
498
947
  btn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
499
948
  btn.addEventListener('click', () => this.toggleCollapsed());
500
949
  header.appendChild(btn);
501
950
  this._toggleBtn = btn;
951
+ this._applyToggleLabel();
502
952
 
503
953
  this._legendEl = document.createElement('div'); this._legendEl.className = 'oep-legend'; this._legendEl.style.display = 'none';
504
954
  this._body = document.createElement('div'); this._body.className = 'oep-body';
@@ -509,6 +959,34 @@ import * as d3 from 'd3';
509
959
  if (this._collapsed) root.classList.add('oep-collapsed');
510
960
  }
511
961
 
962
+ /**
963
+ * Reporte les libellés courants sur ce que le rendu ne réécrit pas.
964
+ *
965
+ * Les boutons et le bouton de repli sont construits une fois pour toutes ; sans cela,
966
+ * changer de langue ne toucherait que le graphe et laisserait les infobulles dans
967
+ * l'ancienne. Le titre n'est repris que faute de tracé : sinon il porte le nom du tracé,
968
+ * qui n'est pas un libellé de la librairie.
969
+ */
970
+ _applyLabels() {
971
+ const l = this.options.labels;
972
+ const set = (b, t) => { if (b && t) { b.title = t; b.setAttribute('aria-label', t); } };
973
+ set(this._btnA, l.zoomStart); set(this._btnB, l.zoomEnd);
974
+ set(this._btnBack, l.zoomBack); set(this._btnAll, l.zoomAll); set(this._btnPng, l.exportPng);
975
+ this._applyToggleLabel();
976
+ if (this._titleEl && !this._feature) this._titleEl.textContent = l.empty;
977
+ }
978
+ /**
979
+ * Le bouton de repli dit ce qu'il va faire, non ce qu'il montre.
980
+ *
981
+ * Un seul libellé pour les deux états ("réduire ou agrandir") laisse deviner lequel
982
+ * des deux le clic déclenche ; l'icône, elle, a déjà basculé. Le titre suit donc l'état,
983
+ * et l'infobulle visible dit la même chose que le nom accessible.
984
+ */
985
+ _applyToggleLabel() {
986
+ const l = this.options.labels, t = this._collapsed ? l.expand : l.collapse;
987
+ if (!this._toggleBtn || !t) return;
988
+ this._toggleBtn.title = t; this._toggleBtn.setAttribute('aria-label', t);
989
+ }
512
990
  _resolveColor() { const o = this.options; if (!o.color) return null; if (o.color === 'auto') return this._featureColor(); return o.color; }
513
991
  _featureColor() {
514
992
  const f = this._feature; if (!f) return null;
@@ -549,6 +1027,7 @@ import * as d3 from 'd3';
549
1027
  this._collapsed = (typeof force === 'boolean') ? force : !this._collapsed;
550
1028
  this.element.classList.toggle('oep-collapsed', this._collapsed);
551
1029
  this._toggleBtn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
1030
+ this._applyToggleLabel();
552
1031
  if (!this._collapsed && this._feature) this._render();
553
1032
  this._adjustAttribution();
554
1033
  }
@@ -613,19 +1092,32 @@ import * as d3 from 'd3';
613
1092
  });
614
1093
 
615
1094
  this._mapKeys = [];
616
- this._mapKeys.push(map.on(o.show === 'mouseover' ? 'pointermove' : 'click', (evt) => {
617
- const feature = lineAt(evt.pixel); if (feature && feature !== this._feature) this.setFeature(feature);
1095
+ // `show`, `hideOnMapClick` et `followMap` sont relus à l'événement, non à
1096
+ // l'abonnement. Les abonnements sont posés une fois pour toutes, ici ; les figer
1097
+ // rendait ces trois options muettes dès que `setOptions` les changeait ensuite,
1098
+ // basculer en survol ne faisait rien, sans que rien ne le dise. Les deux types
1099
+ // d'événement sont donc écoutés en permanence, et c'est l'option qui tranche.
1100
+ this._mapKeys.push(map.on('click', (evt) => {
1101
+ const feature = lineAt(evt.pixel);
1102
+ if (this.options.show !== 'mouseover' && feature && feature !== this._feature) this.setFeature(feature);
1103
+ if (this.options.hideOnMapClick && !feature && this._feature) this.clear();
618
1104
  }));
619
- if (o.hideOnMapClick) this._mapKeys.push(map.on('click', (evt) => { if (!lineAt(evt.pixel) && this._feature) this.clear(); }));
620
- if (o.followMap) this._mapKeys.push(map.on('pointermove', (evt) => {
621
- if (!this._feature || this._collapsed) return;
1105
+ this._mapKeys.push(map.on('pointermove', (evt) => {
1106
+ if (this.options.show === 'mouseover') {
1107
+ const feature = lineAt(evt.pixel);
1108
+ if (feature && feature !== this._feature) this.setFeature(feature);
1109
+ }
1110
+ // Le survol coûte un `lineAt` par déplacement : rien n'est cherché tant qu'aucune
1111
+ // des deux options ne le demande.
1112
+ if (!this.options.followMap || !this._feature || this._collapsed) return;
622
1113
  const cp = this._closestOnProfile(evt.coordinate); if (!cp) return;
623
1114
  const px = map.getPixelFromCoordinate(cp); if (!px) return;
624
1115
  if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
625
1116
  }));
626
1117
  // leave the A/B crop when the map is zoomed out
627
1118
  this._mapKeys.push(map.on('moveend', () => {
628
- if (this._cropMode && this._fitRes && map.getView().getResolution() > this._fitRes * 1.25) this._exitZoom();
1119
+ const cur = this._crops[this._crops.length - 1];
1120
+ if (cur && cur.fitRes && map.getView().getResolution() > cur.fitRes * 1.25) this._popCrop();
629
1121
  }));
630
1122
  this._mapKeys.push(map.on('change:size', this._onResize));
631
1123
  }
@@ -640,7 +1132,7 @@ import * as d3 from 'd3';
640
1132
  */
641
1133
  setFeature(feature) {
642
1134
  this._feature = feature || null;
643
- this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null;
1135
+ this._crops = []; this._off = 0; this._zoomA = null; this._zoomB = null; this._armed = null;
644
1136
  if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._clear(); return this; }
645
1137
  this.element.style.display = '';
646
1138
  this._compute();
@@ -674,7 +1166,14 @@ import * as d3 from 'd3';
674
1166
  if (patch && (patch.theme || 'color' in patch)) this._applyTheme();
675
1167
  if (patch && ('transparency' in patch || 'transparencyLevel' in patch)) this._applyTransparency();
676
1168
  if (patch && 'collapsable' in patch) this._applyCollapsable();
677
- if (patch && 'zoom' in patch) this._updateZoomButtons();
1169
+ if (patch && 'labels' in patch) this._userLabels = deepMerge(this._userLabels || {}, patch.labels);
1170
+ // Recalculé depuis la langue, jamais empilé sur le jeu précédent : passer de 'fr' à
1171
+ // 'es' ne doit rien laisser en français derrière lui, hors surcharges de l'appelant.
1172
+ if (patch && ('lang' in patch || 'labels' in patch)) {
1173
+ this.options.labels = resolveLabels(this.options.lang, this._userLabels);
1174
+ this._applyLabels();
1175
+ }
1176
+ if (patch && ('zoom' in patch || 'exportPng' in patch)) this._updateZoomButtons();
678
1177
  if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
679
1178
  if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
680
1179
  if (this._feature) { this._compute(); this._fillFromDem(this._feature); this._updateZoomButtons(); if (this._collapsed) this._renderTitle(); else this._render(); }
@@ -690,7 +1189,7 @@ import * as d3 from 'd3';
690
1189
  * and the ascent total becomes absurd. Better the flat profile we would have had
691
1190
  * without the DEM.
692
1191
  *
693
- * Nothing is reported to the user on failure the fill is a supplement, it has no
1192
+ * Nothing is reported to the user on failure: the fill is a supplement, it has no
694
1193
  * business breaking a display that succeeded without it. The `demload` event lets the
695
1194
  * application know if it wants to.
696
1195
  *
@@ -702,7 +1201,9 @@ import * as d3 from 'd3';
702
1201
  const cfg = demConfig(this.options.dem);
703
1202
  if (!cfg || !feature || this._demFor === feature) return;
704
1203
  if (ElevationProfile.featureHasZ(feature)) return; // the track already carries its Z
705
- if (typeof Image === 'undefined' || typeof document === 'undefined') return;
1204
+ // Canvas decoding needs a browser; a `sample` function does not, and must stay
1205
+ // usable where there is no DOM.
1206
+ if (!cfg.sample && (typeof Image === 'undefined' || typeof document === 'undefined')) return;
706
1207
 
707
1208
  const geom = feature.getGeometry && feature.getGeometry();
708
1209
  if (!geom) return;
@@ -715,28 +1216,67 @@ import * as d3 from 'd3';
715
1216
  // Sequence number: a track clicked while another is loading must win, otherwise the
716
1217
  // slowest response would overwrite the profile on screen.
717
1218
  const seq = ++this._demSeq;
1219
+ this._demLoading = true;
1220
+
1221
+ // A misconfigured source must not take the profile down with it. _fillFromDem runs
1222
+ // inside setFeature, so anything thrown here would reach the caller and leave no
1223
+ // chart at all - where the whole point is that the fill is a supplement.
1224
+ try {
1225
+ this._startFill(cfg, seq, feature, lonlats, dataProj);
1226
+ } catch (e) {
1227
+ this._demDone(seq, feature, null, lonlats.length, null, 0);
1228
+ }
1229
+ }
1230
+
1231
+ /** @private */
1232
+ _startFill(cfg, seq, feature, lonlats, dataProj) {
1233
+ if (cfg.sample) {
1234
+ // Wrapped in a promise chain so a function that throws synchronously fails the
1235
+ // same way as one that rejects - a source that misbehaves must not leave the
1236
+ // spinner turning forever.
1237
+ Promise.resolve()
1238
+ .then(() => cfg.sample(lonlats, { feature, projection: dataProj }))
1239
+ .then((zs) => this._demDone(seq, feature, zs, lonlats.length, null, 0))
1240
+ .catch(() => this._demDone(seq, feature, null, lonlats.length, null, 0));
1241
+ return;
1242
+ }
1243
+
718
1244
  const sampler = new DemSampler(cfg);
719
1245
  const z = demZoomFor(sampler, lonlats, cfg);
720
- this._demLoading = true;
721
1246
  sampler.load(sampler.tilesFor(lonlats, z)).then((ok) => {
722
- // A newer fill has taken over: it owns _demLoading and will clear it itself.
723
- if (seq !== this._demSeq || this._feature !== feature) return;
724
1247
  const zs = ok ? lonlats.map((ll) => sampler.sample(ll[0], ll[1], z)) : null;
725
- const complete = !!zs && zs.every((v) => v != null && isFinite(v));
726
- this._demLoading = false;
727
- if (complete) { this._demZ = zs; this._demFor = feature; this._compute(); }
728
- // Redrawn even on failure: the spinner has to give way to the flat profile.
729
- this._updateZoomButtons();
730
- if (!this._collapsed) this._render();
731
- /**
732
- * Fired once a terrain-model fill has completed (or failed).
733
- * @event demload
734
- * @property {boolean} ok Whether every point could be sampled.
735
- * @property {number} zoom Tile zoom level used.
736
- * @property {number} tiles Tiles fetched.
737
- */
738
- this.dispatchEvent({ type: 'demload', ok: complete, zoom: z, tiles: sampler.tiles.size });
739
- });
1248
+ this._demDone(seq, feature, zs, lonlats.length, z, sampler.tiles.size);
1249
+ }).catch(() => this._demDone(seq, feature, null, lonlats.length, z, 0));
1250
+ }
1251
+
1252
+ /**
1253
+ * Outcome of a fill, whatever produced it: adopt the elevations, drop the spinner,
1254
+ * redraw, announce.
1255
+ *
1256
+ * A result of the wrong length is refused outright rather than padded. Elevations one
1257
+ * can no longer map to their points are worse than absent ones: they would land on the
1258
+ * wrong places, and nothing downstream would notice.
1259
+ *
1260
+ * @fires demload
1261
+ * @private
1262
+ */
1263
+ _demDone(seq, feature, zs, expected, zoom, tiles) {
1264
+ // A newer fill has taken over: it owns _demLoading and will clear it itself.
1265
+ if (seq !== this._demSeq || this._feature !== feature) return;
1266
+ const usable = Array.isArray(zs) && zs.length === expected && zs.every((v) => v != null && isFinite(v));
1267
+ this._demLoading = false;
1268
+ if (usable) { this._demZ = zs; this._demFor = feature; this._compute(); }
1269
+ // Redrawn even on failure: the spinner has to give way to the flat profile.
1270
+ this._updateZoomButtons();
1271
+ if (!this._collapsed) this._render();
1272
+ /**
1273
+ * Fired once a terrain-model fill has completed (or failed).
1274
+ * @event demload
1275
+ * @property {boolean} ok Whether every point could be sampled.
1276
+ * @property {?number} zoom Tile zoom level used, `null` for a custom sampler.
1277
+ * @property {number} tiles Tiles fetched, `0` for a custom sampler.
1278
+ */
1279
+ this.dispatchEvent({ type: 'demload', ok: usable, zoom, tiles });
740
1280
  }
741
1281
 
742
1282
  // ---------- computation -------------------------------------------
@@ -779,7 +1319,60 @@ import * as d3 from 'd3';
779
1319
  this._addSlope(samples);
780
1320
  this._fullSamples = samples;
781
1321
  this._fullStats = this._statsOf(samples, true);
782
- this._samples = samples; this._stats = this._fullStats;
1322
+ // Un recalcul (option changée, altitudes arrivées du MNT) ne doit pas défaire le
1323
+ // recadrage : la pile est rejouée sur les nouveaux échantillons. Pile vide, on
1324
+ // retombe sur la trace entière, ce que faisait l'affectation directe d'avant.
1325
+ this._applyCrops();
1326
+ }
1327
+ /**
1328
+ * Pixels CSS d'un centimètre physique, mesurés plutôt que déduits.
1329
+ *
1330
+ * Le rapport nominal est de 96 px par pouce, soit 37,8 px par centimètre, mais le zoom
1331
+ * du navigateur le déplace : mesurer un élément d'un centimètre suit ce zoom là où une
1332
+ * constante mentirait. Non mémorisé - la mesure force un calcul de disposition, mais un
1333
+ * rendu est rare et un cache se périmerait au premier Ctrl+molette.
1334
+ */
1335
+ _pxPerCm() {
1336
+ try {
1337
+ const temoin = document.createElement('div');
1338
+ temoin.style.cssText = 'position:absolute;left:-9999px;top:0;width:1cm;height:1cm';
1339
+ (this.element || document.body).appendChild(temoin);
1340
+ const px = temoin.getBoundingClientRect().height;
1341
+ temoin.remove();
1342
+ if (px > 0) return px;
1343
+ } catch (e) { /* document indisponible : on retombe sur la valeur nominale */ }
1344
+ return 96 / 2.54;
1345
+ }
1346
+
1347
+ /**
1348
+ * Échelle verticale du graphe.
1349
+ *
1350
+ * En `'auto'`, le profil remplit la hauteur : c'est lisible, mais l'échelle change d'une
1351
+ * trace à l'autre, si bien qu'une pente de 2 % y prend l'allure d'un mur et que deux
1352
+ * profils ne se comparent pas. Un nombre fixe au contraire les mètres couverts par
1353
+ * centimètre physique.
1354
+ *
1355
+ * La valeur demandée est un **plancher**, non un carcan : une trace dont l'amplitude
1356
+ * dépasse ce que la hauteur peut montrer déborderait du cadre, ce qui est pire que de
1357
+ * perdre la comparabilité. Le graphe n'en dit rien : l'échelle n'est pas une donnée de
1358
+ * la trace et n'a pas à encombrer le dessin ; l'échelle réellement appliquée est
1359
+ * seulement publiée dans `_vScale`, à qui veut la connaître.
1360
+ *
1361
+ * `nice()` n'est pas appliqué en échelle absolue : il arrondit le domaine vers
1362
+ * l'extérieur, donc il fausserait le rapport qu'on vient de fixer.
1363
+ */
1364
+ _yScale(s, zpad, innerH) {
1365
+ const o = this.options, bas = s.min - zpad, haut = s.max + zpad;
1366
+ const vs = typeof o.verticalScale === 'number' ? o.verticalScale : 0;
1367
+ if (!(vs > 0)) {
1368
+ this._vScale = null;
1369
+ return d3.scaleLinear().domain([bas, haut]).range([innerH, 0]).nice();
1370
+ }
1371
+ const cm = innerH / this._pxPerCm(); // hauteur du graphe, en centimètres
1372
+ const etendue = Math.max(vs * cm, haut - bas); // jamais moins qu'il n'en faut
1373
+ const milieu = (bas + haut) / 2;
1374
+ this._vScale = etendue / cm; // m/cm effectivement appliqués
1375
+ return d3.scaleLinear().domain([milieu - etendue / 2, milieu + etendue / 2]).range([innerH, 0]);
783
1376
  }
784
1377
  _statsOf(samples, fromTotal) {
785
1378
  let ascent = 0, descent = 0, zmin = Infinity, zmax = -Infinity, maxAbs = 0;
@@ -851,7 +1444,7 @@ import * as d3 from 'd3';
851
1444
  // 7 sec | 26 min | 1 h 48 min | 2 d 3 h (days + hours normalised)
852
1445
  _fmtDuration(sec) {
853
1446
  if (sec == null || !isFinite(sec)) return '';
854
- const u = (this.options.labels && this.options.labels.durationUnits) || { s: 'sec', m: 'min', h: 'h', d: 'j' };
1447
+ const u = (this.options.labels && this.options.labels.durationUnits) || LOCALES.en.durationUnits;
855
1448
  sec = Math.max(0, Math.round(sec));
856
1449
  if (sec < 60) return sec + ' ' + u.s;
857
1450
  if (sec < 3600) return Math.round(sec / 60) + ' ' + u.m;
@@ -870,7 +1463,7 @@ import * as d3 from 'd3';
870
1463
  * Track title, and its optional link.
871
1464
  *
872
1465
  * Its own method because it is the only part of the header that stays visible once
873
- * collapsed the CSS hides the body, the stats, the legend and the toolbar. `_render`
1466
+ * collapsed: the CSS hides the body, the stats, the legend and the toolbar. `_render`
874
1467
  * is skipped while collapsed, so without a separate entry point the title would keep
875
1468
  * naming the previous track after a change of feature.
876
1469
  */
@@ -903,7 +1496,7 @@ import * as d3 from 'd3';
903
1496
  else if (it === 'descent') { html.push(`<span class="oep-down">${o.labels.descent} ${fmtElevation(s.descent, o.units)}</span>`); text.push(`${o.labels.descent} ${fmtElevation(s.descent, o.units)}`); }
904
1497
  else if (it === 'min') { html.push(fmtElevation(s.min, o.units)); text.push(fmtElevation(s.min, o.units)); }
905
1498
  else if (it === 'max') { html.push(fmtElevation(s.max, o.units)); text.push(fmtElevation(s.max, o.units)); }
906
- else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}–${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
1499
+ else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}-${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
907
1500
  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}`); } }
908
1501
  } else if (it && it.property) {
909
1502
  const val = f.get && f.get(it.property); if (val == null || val === '') return;
@@ -920,7 +1513,7 @@ import * as d3 from 'd3';
920
1513
  const sc = this._slopeScale();
921
1514
  for (let idx = 0; idx <= sc.maxIdx; idx++) {
922
1515
  const color = sc.colorByIndex(idx);
923
- const label = (sc.capped && idx === sc.maxIdx) ? `≥ ${idx * sc.classSize} %` : `${idx * sc.classSize}–${(idx + 1) * sc.classSize} %`;
1516
+ const label = (sc.capped && idx === sc.maxIdx) ? `≥ ${idx * sc.classSize} %` : `${idx * sc.classSize}-${(idx + 1) * sc.classSize} %`;
924
1517
  const item = document.createElement('span'); item.className = 'oep-leg-it';
925
1518
  item.innerHTML = `<i class="oep-sw" style="background:${color}"></i>${label}`;
926
1519
  this._legendEl.appendChild(item);
@@ -983,7 +1576,7 @@ import * as d3 from 'd3';
983
1576
 
984
1577
  const x = d3.scaleLinear().domain([0, s.distance]).range([0, innerW]);
985
1578
  const zpad = (s.max - s.min) * 0.1 || 10;
986
- const y = d3.scaleLinear().domain([s.min - zpad, s.max + zpad]).range([innerH, 0]).nice();
1579
+ const y = this._yScale(s, zpad, innerH);
987
1580
  this._x = x; this._y = y; this._dims = { innerW, innerH };
988
1581
 
989
1582
  if (o.grid) g.append('g').attr('class', 'oep-grid').call(d3.axisLeft(y).ticks(yTicks).tickSize(-innerW).tickFormat(''));
@@ -1016,7 +1609,7 @@ import * as d3 from 'd3';
1016
1609
  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));
1017
1610
 
1018
1611
  // A / B markers while a range is being picked
1019
- if (o.zoom && !this._cropMode) {
1612
+ if (o.zoom && this._cropDepth < this._cropMax) {
1020
1613
  [['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
1021
1614
  if (val == null) return;
1022
1615
  const px = x(val);
@@ -1031,61 +1624,264 @@ import * as d3 from 'd3';
1031
1624
  const lbl = focus.append('g').attr('class', 'oep-focus-label'); lbl.append('rect').attr('class', 'oep-focus-bg'); lbl.append('text').attr('class', 'oep-focus-txt');
1032
1625
  this._focus = focus;
1033
1626
 
1034
- const bisect = d3.bisector((d) => d.x).left;
1035
1627
  const pick = (event) => { const mx = d3.pointer(event, g.node())[0]; return Math.max(0, Math.min(s.distance, x.invert(mx))); };
1036
1628
  const overlay = svg.append('rect').attr('class', 'oep-overlay').attr('x', ml).attr('y', mt).attr('width', innerW).attr('height', innerH);
1037
1629
  overlay.on('mousemove', (event) => {
1038
- const x0 = pick(event); const idx = bisect(data, x0, 1);
1039
- const d0 = data[idx - 1], d1 = data[idx] || d0; const d = (x0 - d0.x) > (d1.x - x0) ? d1 : d0;
1630
+ const d = this._sampleAt(pick(event)); if (!d) return;
1040
1631
  this._setFocus(d); if (this._marker) this._marker.setPosition(d.coord);
1041
1632
  }).on('mouseout', () => this._clearFocus());
1042
- overlay.on('click', (event) => {
1043
- if (!this._armed || this._cropMode) return;
1044
- const x0 = pick(event);
1045
- if (this._armed === 'A') this._zoomA = x0; else this._zoomB = x0;
1046
- this._armed = null; this._updateZoomButtons();
1047
- if (this._zoomA != null && this._zoomB != null) this._applyZoom(); else this._render();
1048
- });
1633
+ overlay.on('click', (event) => this._placeBound(pick(event)));
1049
1634
  if (this._armed) overlay.style('cursor', 'col-resize');
1050
1635
  this._adjustAttribution();
1051
1636
  }
1052
1637
 
1638
+ // ---------- PNG export ---------------------------------------------
1639
+ /**
1640
+ * Properties frozen onto the exported clone.
1641
+ *
1642
+ * A serialized SVG carries no stylesheet: rules that live in the CSS file, and every
1643
+ * `var(--oep-*)` they resolve, vanish the moment the markup leaves the document. The
1644
+ * export would come out as black shapes on nothing. So the computed value of each
1645
+ * painting property is written inline, node by node.
1646
+ */
1647
+ static get _EXPORT_PROPS() {
1648
+ return ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray',
1649
+ 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-family', 'font-size',
1650
+ 'font-weight', 'text-anchor', 'shape-rendering'];
1651
+ }
1652
+
1653
+ /** Copies the computed painting styles of `src` onto `dst`, recursively. */
1654
+ _freezeStyles(src, dst) {
1655
+ const props = ElevationProfile._EXPORT_PROPS;
1656
+ const cs = getComputedStyle(src);
1657
+ let inline = '';
1658
+ for (const p of props) { const v = cs.getPropertyValue(p); if (v) inline += `${p}:${v};`; }
1659
+ dst.setAttribute('style', inline);
1660
+ const a = src.children, b = dst.children;
1661
+ for (let i = 0; i < a.length && i < b.length; i++) this._freezeStyles(a[i], b[i]);
1662
+ }
1663
+
1664
+ /**
1665
+ * The whole panel as an SVG string: background, title, stats, legend, chart.
1666
+ *
1667
+ * Rebuilt rather than screenshotted. The header is HTML and the chart is SVG, and the
1668
+ * only way to put HTML in an SVG is a `foreignObject`, which browsers refuse to
1669
+ * rasterise consistently. Redrawing the two text lines as `<text>` is a handful of
1670
+ * lines and works everywhere.
1671
+ *
1672
+ * The current-position indicator is dropped: it marks where the pointer happens to be,
1673
+ * which means nothing once the image is saved.
1674
+ */
1675
+ _exportSvg() {
1676
+ const chart = this._body.querySelector('svg');
1677
+ if (!chart) return null;
1678
+ const cs = getComputedStyle(this.element);
1679
+ const bg = cs.getPropertyValue('--oep-bg').trim() || '#fff';
1680
+ const fg = cs.getPropertyValue('--oep-text').trim() || '#222';
1681
+ const font = cs.fontFamily || 'system-ui, sans-serif';
1682
+ const W = +chart.getAttribute('width'), H = +chart.getAttribute('height');
1683
+ const pad = 8, titleH = 20, statsH = this._statsEl.textContent ? 15 : 0;
1684
+ const legend = (this._legendEl.style.display !== 'none') ? this._legendEl : null;
1685
+ const legH = legend ? 18 : 0;
1686
+ const top = pad + titleH + statsH + legH;
1687
+
1688
+ const clone = chart.cloneNode(true);
1689
+ // The pointer indicator and the hit-test overlay have no place in a saved image.
1690
+ clone.querySelectorAll('.oep-focus, .oep-overlay').forEach((n) => n.remove());
1691
+ this._freezeStyles(chart, clone);
1692
+ // width/height are kept: a nested <svg> without them fills the parent viewport, so
1693
+ // the chart would be stretched over the header's height as well.
1694
+
1695
+ const esc2 = (t) => esc(String(t));
1696
+ const parts = [];
1697
+ parts.push(`<rect width="${W + 2 * pad}" height="${H + top + pad}" fill="${esc2(bg)}"/>`);
1698
+ 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>`);
1699
+ 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>`);
1700
+ if (legend) {
1701
+ let x = pad;
1702
+ const y = pad + titleH + statsH + 12;
1703
+ for (const item of legend.querySelectorAll('.oep-leg-it')) {
1704
+ const sw = item.querySelector('.oep-sw');
1705
+ const color = sw ? getComputedStyle(sw).backgroundColor : 'none';
1706
+ const label = item.textContent.trim();
1707
+ parts.push(`<rect x="${x}" y="${y - 8}" width="10" height="10" fill="${esc2(color)}"/>`);
1708
+ parts.push(`<text x="${x + 14}" y="${y}" style="font-family:${esc2(font)};font-size:10px;fill:${esc2(fg)}">${esc2(label)}</text>`);
1709
+ x += 14 + label.length * 5.6 + 10;
1710
+ }
1711
+ }
1712
+ parts.push(`<g transform="translate(${pad},${top})">${new XMLSerializer().serializeToString(clone)}</g>`);
1713
+ return {
1714
+ width: W + 2 * pad, height: H + top + pad,
1715
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W + 2 * pad}" height="${H + top + pad}" ` +
1716
+ `viewBox="0 0 ${W + 2 * pad} ${H + top + pad}">${parts.join('')}</svg>`
1717
+ };
1718
+ }
1719
+
1720
+ /**
1721
+ * Export the whole panel - title, stats, legend and chart - as a PNG.
1722
+ *
1723
+ * Resolves with the Blob. Unless `download` is false, it also saves the file, which is
1724
+ * what the toolbar button does.
1725
+ *
1726
+ * @param {{scale?:number, filename?:string, download?:boolean}} [opts]
1727
+ * `scale` defaults to the device pixel ratio, so the image is not soft on a retina
1728
+ * screen. `filename` defaults to the track title.
1729
+ * @returns {Promise<Blob>}
1730
+ */
1731
+ exportPNG(opts) {
1732
+ const o = opts || {};
1733
+ const built = this._exportSvg();
1734
+ if (!built) return Promise.reject(new Error('ol-elevation-profile: nothing to export'));
1735
+ const scale = o.scale || (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
1736
+ return new Promise((resolve, reject) => {
1737
+ const img = new Image();
1738
+ img.onload = () => {
1739
+ try {
1740
+ const cv = document.createElement('canvas');
1741
+ cv.width = Math.round(built.width * scale);
1742
+ cv.height = Math.round(built.height * scale);
1743
+ const ctx = cv.getContext('2d');
1744
+ ctx.scale(scale, scale);
1745
+ ctx.drawImage(img, 0, 0);
1746
+ cv.toBlob((blob) => {
1747
+ if (!blob) { reject(new Error('ol-elevation-profile: PNG encoding failed')); return; }
1748
+ if (o.download !== false) this._save(blob, o.filename);
1749
+ resolve(blob);
1750
+ }, 'image/png');
1751
+ } catch (e) { reject(e); }
1752
+ };
1753
+ img.onerror = () => reject(new Error('ol-elevation-profile: SVG could not be rasterised'));
1754
+ // Encoded as a data URL rather than a blob: URL - a blob: source taints the canvas
1755
+ // in some browsers, and toBlob would then throw a security error.
1756
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(built.svg);
1757
+ });
1758
+ }
1759
+
1760
+ /** @private */
1761
+ _save(blob, filename) {
1762
+ const name = filename || `${(this._titleEl.textContent || 'profile').replace(/[\\/:*?"<>|]+/g, '-').trim()}.png`;
1763
+ const url = URL.createObjectURL(blob);
1764
+ const a = document.createElement('a');
1765
+ a.href = url; a.download = name;
1766
+ document.body.appendChild(a); a.click(); a.remove();
1767
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1768
+ }
1769
+
1053
1770
  // ---------- zoom A/B ----------------------------------------------
1054
1771
  _arm(which) { this._armed = (this._armed === which) ? null : which; this._updateZoomButtons(); if (this._focus) this._render(); }
1772
+ /**
1773
+ * Pose la borne armée à l'abscisse [x0], puis arme l'autre tant qu'elle manque.
1774
+ *
1775
+ * Poser A n'a d'intérêt que pour poser B ensuite : enchaîner d'un clic sur l'autre
1776
+ * évite l'aller-retour par la barre d'outils entre les deux. Les deux bornes réunies,
1777
+ * le recadrage part et rien ne reste armé.
1778
+ */
1779
+ _placeBound(x0) {
1780
+ if (!this._armed || this._cropDepth >= this._cropMax) return;
1781
+ const autre = this._armed === 'A' ? 'B' : 'A';
1782
+ if (this._armed === 'A') this._zoomA = x0; else this._zoomB = x0;
1783
+ const complet = this._zoomA != null && this._zoomB != null;
1784
+ this._armed = complet ? null : autre;
1785
+ this._updateZoomButtons();
1786
+ if (complet) this._pushCrop(this._zoomA, this._zoomB); else this._render();
1787
+ }
1055
1788
  _updateZoomButtons() {
1056
- const show = !!this.options.zoom && !!this._feature;
1057
- this._toolbar.style.display = show ? '' : 'none';
1058
- const crop = this._cropMode;
1059
- this._btnA.style.display = show && !crop ? '' : 'none';
1060
- this._btnB.style.display = show && !crop ? '' : 'none';
1061
- this._btnAll.style.display = show && crop ? '' : 'none';
1789
+ const o = this.options, has = !!this._feature;
1790
+ const show = !!o.zoom && has;
1791
+ const png = !!o.exportPng && has;
1792
+ // The toolbar carries both: it stays visible as long as either has something to show.
1793
+ this._toolbar.style.display = (show || png) ? '' : 'none';
1794
+ this._btnPng.style.display = png ? '' : 'none';
1795
+ const deep = this._cropDepth, peutDescendre = deep < this._cropMax;
1796
+ this._btnA.style.display = show && peutDescendre ? '' : 'none';
1797
+ this._btnB.style.display = show && peutDescendre ? '' : 'none';
1798
+ // Au premier niveau, revenir et tout voir sont le même geste : seul "tout voir"
1799
+ // paraît, ce qui laisse le niveau unique (zoomLevels: 1) exactement tel qu'il était.
1800
+ this._btnBack.style.display = show && deep > 1 ? '' : 'none';
1801
+ this._btnAll.style.display = show && deep > 0 ? '' : 'none';
1062
1802
  this._btnA.classList.toggle('armed', this._armed === 'A');
1063
1803
  this._btnB.classList.toggle('armed', this._armed === 'B');
1064
1804
  }
1065
- _applyZoom() {
1066
- const a = Math.min(this._zoomA, this._zoomB), b = Math.max(this._zoomA, this._zoomB);
1805
+ /**
1806
+ * Recadre sur le sommet de la pile : échantillons, statistiques, origine des abscisses.
1807
+ *
1808
+ * Les bornes empilées sont gardées en abscisse de la trace **entière**, jamais dans le
1809
+ * repère du niveau courant : sinon chaque cran se lirait dans celui du précédent et
1810
+ * l'erreur de rebasage se composerait de niveau en niveau. Rend les coordonnées
1811
+ * cartographiques du niveau, dont le cadrage a besoin.
1812
+ */
1813
+ _applyCrops() {
1814
+ if (!this._crops.length) {
1815
+ this._off = 0; this._samples = this._fullSamples; this._stats = this._fullStats;
1816
+ return null;
1817
+ }
1818
+ const { a, b } = this._crops[this._crops.length - 1];
1067
1819
  const raw = this._fullSamples.filter((p) => p.x >= a && p.x <= b);
1068
- if (raw.length < 2) return;
1069
- const off = raw[0].x; // A devient 0
1070
- const tOff = raw[0].t != null ? raw[0].t : 0;
1071
- 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) }));
1072
- const coords = raw.map((p) => p.coord);
1073
- this._samples = cropped; this._stats = this._statsOf(cropped, false); this._cropMode = true;
1074
- this._updateZoomButtons(); this._render();
1075
- const map = this.getMap();
1076
- if (map) {
1077
- const ext = boundingExtent(coords);
1078
- const view = map.getView();
1079
- try { this._fitRes = view.getResolutionForExtent(ext, map.getSize()); } catch (e) { this._fitRes = null; }
1080
- view.fit(ext, { padding: [40, 40, 40, 40], duration: 400 });
1820
+ if (raw.length < 2) { this._crops.pop(); return this._applyCrops(); }
1821
+ const off = raw[0].x, tOff = raw[0].t != null ? raw[0].t : 0;
1822
+ this._off = off;
1823
+ this._samples = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope, t: (p.t != null ? p.t - tOff : null) }));
1824
+ this._stats = this._statsOf(this._samples, false);
1825
+ return raw.map((p) => p.coord);
1826
+ }
1827
+
1828
+ /** Cadre la carte sur un niveau, ou sur la trace entière si [coords] est nul. */
1829
+ _fitMap(coords) {
1830
+ const map = this.getMap(); if (!map) return null;
1831
+ let ext = null;
1832
+ if (coords && coords.length) ext = boundingExtent(coords);
1833
+ else if (this._feature) ext = this._feature.getGeometry().getExtent();
1834
+ if (!ext) return null;
1835
+ const view = map.getView();
1836
+ let res = null;
1837
+ try { res = view.getResolutionForExtent(ext, map.getSize()); } catch (e) { res = null; }
1838
+ view.fit(ext, { padding: [40, 40, 40, 40], duration: 400 });
1839
+ return res;
1840
+ }
1841
+
1842
+ /** Empile un niveau. [a0] et [b0] sont lus dans le repère du niveau courant. */
1843
+ _pushCrop(a0, b0) {
1844
+ this._zoomA = null; this._zoomB = null; this._armed = null;
1845
+ if (this._cropDepth >= this._cropMax) { this._updateZoomButtons(); this._render(); return; }
1846
+ const a = this._off + Math.min(a0, b0), b = this._off + Math.max(a0, b0);
1847
+ let n = 0;
1848
+ for (let k = 0; k < this._fullSamples.length; k++) {
1849
+ const px = this._fullSamples[k].x; if (px >= a && px <= b) n++;
1081
1850
  }
1851
+ if (n < 2) { this._updateZoomButtons(); this._render(); return; }
1852
+ this._crops.push({ a, b, fitRes: null });
1853
+ const coords = this._applyCrops();
1854
+ this._updateZoomButtons(); this._render();
1855
+ const cur = this._crops[this._crops.length - 1];
1856
+ if (cur) cur.fitRes = this._fitMap(coords);
1082
1857
  }
1858
+
1859
+ /**
1860
+ * Recadre sur A..B déjà posés. Conservé : c'était le point d'entrée du niveau unique,
1861
+ * et le supprimer casserait ce qui l'appelle sans rien apporter.
1862
+ */
1863
+ _applyZoom() {
1864
+ if (this._zoomA == null || this._zoomB == null) return;
1865
+ this._pushCrop(this._zoomA, this._zoomB);
1866
+ }
1867
+
1868
+ /** Remonte d'un niveau. */
1869
+ _popCrop() {
1870
+ if (!this._crops.length) return;
1871
+ this._crops.pop();
1872
+ this._zoomA = null; this._zoomB = null; this._armed = null;
1873
+ const coords = this._applyCrops();
1874
+ this._updateZoomButtons(); if (!this._collapsed) this._render();
1875
+ this._fitMap(coords);
1876
+ }
1877
+
1878
+ /** Vide la pile d'un coup, quel que soit le nombre de niveaux. */
1083
1879
  _exitZoom() {
1084
- this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
1085
- this._samples = this._fullSamples; this._stats = this._fullStats;
1880
+ this._crops.length = 0;
1881
+ this._zoomA = null; this._zoomB = null; this._armed = null;
1882
+ this._applyCrops();
1086
1883
  this._updateZoomButtons(); if (!this._collapsed) this._render();
1087
- const map = this.getMap();
1088
- if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
1884
+ this._fitMap(null);
1089
1885
  }
1090
1886
 
1091
1887
  /**
@@ -1100,13 +1896,31 @@ import * as d3 from 'd3';
1100
1896
  const g = this._feature && this._feature.getGeometry();
1101
1897
  if (!g) return null;
1102
1898
  if (!/Polygon/.test(g.getType())) return g.getClosestPoint(coordinate);
1103
- const data = this._fullSamples;
1899
+ const hit = this._projectOn(this._fullSamples, coordinate);
1900
+ return hit ? hit.coord : null;
1901
+ }
1902
+
1903
+ /**
1904
+ * Projeté d'une coordonnée sur la ligne brisée [data] : le point, et son abscisse
1905
+ * curviligne dans le repère de [data].
1906
+ *
1907
+ * Retenir l'échantillon le plus proche ferait sauter le marqueur de sommet en sommet,
1908
+ * l'écart entre deux points de trace étant souvent bien plus grand que le pas du
1909
+ * pointeur. La projection sur les segments donne le point réellement visé.
1910
+ */
1911
+ _projectOn(data, coord) {
1104
1912
  if (!data || !data.length) return null;
1913
+ if (data.length === 1) return { x: data[0].x, coord: data[0].coord.slice(0, 2) };
1105
1914
  let best = null, bd = Infinity;
1106
- for (const p of data) {
1107
- const dx = p.coord[0] - coordinate[0], dy = p.coord[1] - coordinate[1];
1108
- const dd = dx * dx + dy * dy;
1109
- if (dd < bd) { bd = dd; best = p.coord; }
1915
+ for (let i = 1; i < data.length; i++) {
1916
+ const a = data[i - 1].coord, b = data[i].coord;
1917
+ const abx = b[0] - a[0], aby = b[1] - a[1], len2 = abx * abx + aby * aby;
1918
+ // Segment dégénéré (deux points confondus) : le projeté est le point lui-même.
1919
+ let r = len2 > 0 ? ((coord[0] - a[0]) * abx + (coord[1] - a[1]) * aby) / len2 : 0;
1920
+ r = r < 0 ? 0 : (r > 1 ? 1 : r);
1921
+ const px = a[0] + abx * r, py = a[1] + aby * r;
1922
+ const dx = px - coord[0], dy = py - coord[1], dd = dx * dx + dy * dy;
1923
+ if (dd < bd) { bd = dd; best = { x: data[i - 1].x + (data[i].x - data[i - 1].x) * r, coord: [px, py] }; }
1110
1924
  }
1111
1925
  return best;
1112
1926
  }
@@ -1136,11 +1950,42 @@ import * as d3 from 'd3';
1136
1950
  else if (x(d.x) - bb.width / 2 < 0) t.attr('text-anchor', 'start');
1137
1951
  else t.attr('text-anchor', 'middle');
1138
1952
  }
1953
+ /**
1954
+ * Point du profil à une abscisse quelconque, interpolé entre les deux échantillons qui
1955
+ * l'encadrent.
1956
+ *
1957
+ * Se caler sur l'échantillon le plus proche ferait avancer le curseur de sommet en
1958
+ * sommet : sur un tracé peu dense, ou décimé par `maxPoints`, le saut est franc et le
1959
+ * survol perd sa continuité. Les échantillons sont les points *mesurés*, non les seules
1960
+ * positions que le curseur ait le droit d'occuper.
1961
+ *
1962
+ * La pente n'est pas interpolée : `_addSlope` la pose sur le segment qui précède chaque
1963
+ * échantillon, elle est donc constante sur ce segment et vaut celle de son extrémité.
1964
+ * Le temps l'est, mais seulement si les deux bornes en portent un.
1965
+ */
1966
+ _sampleAt(x0) {
1967
+ const data = this._samples;
1968
+ if (!data || !data.length) return null;
1969
+ if (data.length === 1) return data[0];
1970
+ const xa = data[0].x, xb = data[data.length - 1].x;
1971
+ const xc = Math.max(xa, Math.min(xb, x0));
1972
+ const i = Math.min(Math.max(bisectX(data, xc, 1), 1), data.length - 1);
1973
+ const d0 = data[i - 1], d1 = data[i];
1974
+ const span = d1.x - d0.x, r = span > 0 ? (xc - d0.x) / span : 0;
1975
+ const lerp = (a, b) => a + (b - a) * r;
1976
+ return {
1977
+ x: xc,
1978
+ z: lerp(d0.z, d1.z),
1979
+ coord: [lerp(d0.coord[0], d1.coord[0]), lerp(d0.coord[1], d1.coord[1])],
1980
+ slope: d1.slope,
1981
+ t: (d0.t != null && d1.t != null) ? lerp(d0.t, d1.t) : null
1982
+ };
1983
+ }
1984
+ /** Suit une coordonnée de la carte, projetée sur le tracé courant. */
1139
1985
  _focusByCoord(coord) {
1140
- const data = this._samples; if (!data) return;
1141
- let best = null, bd = Infinity;
1142
- for (const p of data) { const dx = p.coord[0] - coord[0], dy = p.coord[1] - coord[1]; const dd = dx * dx + dy * dy; if (dd < bd) { bd = dd; best = p; } }
1143
- if (best) { this._setFocus(best); if (this._marker) this._marker.setPosition(best.coord); }
1986
+ const hit = this._projectOn(this._samples, coord); if (!hit) return;
1987
+ const d = this._sampleAt(hit.x); if (!d) return;
1988
+ this._setFocus(d); if (this._marker) this._marker.setPosition(d.coord);
1144
1989
  }
1145
1990
  _clearFocus() { if (this._focus) this._focus.style('display', 'none'); if (this._marker) this._marker.setPosition(undefined); }
1146
1991
  _clear() {
@@ -1148,7 +1993,7 @@ import * as d3 from 'd3';
1148
1993
  this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
1149
1994
  this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
1150
1995
  this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
1151
- this._cropMode = false; this._demLoading = false; this._updateZoomButtons();
1996
+ this._crops = []; this._off = 0; this._demLoading = false; this._updateZoomButtons();
1152
1997
  this._clearFocus();
1153
1998
  this.element.style.display = 'none';
1154
1999
  this._adjustAttribution();