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