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