ol-elevation-profile 0.5.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/dist/ol-elevation-profile.css +24 -6
- package/dist/ol-elevation-profile.esm.js +488 -57
- package/dist/ol-elevation-profile.esm.min.js +7 -5
- package/dist/ol-elevation-profile.js +488 -57
- package/dist/ol-elevation-profile.min.js +7 -5
- package/package.json +3 -3
- package/src/ol-elevation-profile.css +24 -6
- package/src/ol-elevation-profile.js +488 -57
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Synchronized elevation profile control for OpenLayers, rendered with d3.
|
|
3
3
|
*
|
|
4
|
-
* Reads elevation (Z) directly from 3D line geometries (`[lon, lat, z]`)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Reads elevation (Z) directly from 3D line geometries (`[lon, lat, z]`). A track without
|
|
5
|
+
* Z is completed from a terrain model - keyless AWS Terrain Tiles by default, which means
|
|
6
|
+
* the control fetches tiles on its own; set `dem: null` to keep it entirely offline.
|
|
7
|
+
* Clicking (or hovering) a track shows its profile; a marker stays synchronized on both
|
|
8
|
+
* the map and the chart.
|
|
7
9
|
*
|
|
8
10
|
* Peer dependencies (provided by the host application, not bundled):
|
|
9
11
|
* - OpenLayers >= 6 (https://openlayers.org/)
|
|
@@ -34,26 +36,26 @@ import * as d3 from 'd3';
|
|
|
34
36
|
const DEFAULTS = {
|
|
35
37
|
immersion: 'docked',
|
|
36
38
|
position: 'bottom',
|
|
37
|
-
width: 520, //
|
|
39
|
+
width: 520, // number (px, capped to the map width) or 'auto'/'100%'/'full' = map width
|
|
38
40
|
height: 180,
|
|
39
41
|
margins: { unit: 'px', top: 20, right: 24, bottom: 30, left: 48 },
|
|
40
42
|
units: 'meters',
|
|
41
43
|
dataProjection: null,
|
|
44
|
+
dem: 'terrarium', // AWS tiles by default; null = off; or { url, encoding, zoom, maxTiles }
|
|
42
45
|
maxPoints: 2000,
|
|
43
|
-
smoothing: 0, //
|
|
46
|
+
smoothing: 0, // elevation smoothing: window in METRES (0 = none)
|
|
44
47
|
theme: 'steelblue',
|
|
45
|
-
color: null, // null=
|
|
48
|
+
color: null, // null = theme, 'auto' = track colour, or a CSS colour
|
|
46
49
|
trackLayer: null,
|
|
47
50
|
transparency: false, // false | true | nombre 0..1
|
|
48
51
|
transparencyLevel: 0.45,
|
|
49
52
|
grid: true,
|
|
50
53
|
slope: false,
|
|
51
54
|
slopeClassSize: 2.5,
|
|
52
|
-
slopeColors: null, // null =
|
|
53
|
-
slopeSeparators: true, //
|
|
55
|
+
slopeColors: null, // null = blue->red ramp (HSL); otherwise an interpolated array
|
|
56
|
+
slopeSeparators: true, // vertical line at each class change
|
|
54
57
|
slopeLegend: true,
|
|
55
|
-
maxClasses: 8, //
|
|
56
|
-
maxLegendClasses: 8,
|
|
58
|
+
maxClasses: 8, // maximum number of slope classes (colours + legend)
|
|
57
59
|
xTicks: null,
|
|
58
60
|
yTicks: null,
|
|
59
61
|
show: 'click',
|
|
@@ -62,9 +64,11 @@ import * as d3 from 'd3';
|
|
|
62
64
|
followMap: true,
|
|
63
65
|
marker: true,
|
|
64
66
|
hideOnMapClick: true,
|
|
65
|
-
responsive: true, //
|
|
66
|
-
mobileBreakpoint: 640, // <=
|
|
67
|
-
zoom: false, //
|
|
67
|
+
responsive: true, // adapts width/placement, mobile included
|
|
68
|
+
mobileBreakpoint: 640, // <= screen width -> mobile mode (100% width, top/bottom)
|
|
69
|
+
zoom: false, // start/end buttons cropping map + profile to A..B
|
|
70
|
+
ignoreStops: true, // duration = moving time (stops excluded)
|
|
71
|
+
stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
|
|
68
72
|
tooltipItems: ['distance', 'elevation'],
|
|
69
73
|
headerItems: ['distance', 'ascent', 'descent', 'minmax'],
|
|
70
74
|
titleProperty: 'name',
|
|
@@ -72,7 +76,10 @@ import * as d3 from 'd3';
|
|
|
72
76
|
labels: {
|
|
73
77
|
distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
|
|
74
78
|
ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
|
|
75
|
-
|
|
79
|
+
time: 'Temps', duration: 'Durée',
|
|
80
|
+
durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
|
|
81
|
+
zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
|
|
82
|
+
loading: 'Chargement du profil altimétrique'
|
|
76
83
|
}
|
|
77
84
|
};
|
|
78
85
|
|
|
@@ -89,6 +96,59 @@ import * as d3 from 'd3';
|
|
|
89
96
|
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
90
97
|
const isUrl = (v) => typeof v === 'string' && /^(https?:)?\/\/|^mailto:/i.test(v);
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Coordinate segments to profile, whatever the geometry type: an array of point arrays.
|
|
101
|
+
*
|
|
102
|
+
* A polygon is profiled along its **outer ring only**; the holes are not part of the
|
|
103
|
+
* outline itself. The ring being closed, the profile returns to its starting point -
|
|
104
|
+
* that is the outline, faithfully, not a defect.
|
|
105
|
+
*
|
|
106
|
+
* One place for the five that used to test `getType()` themselves: they all wrapped
|
|
107
|
+
* `getCoordinates()` in one extra array unless it was a MultiLineString, which silently
|
|
108
|
+
* made a polygon iterate over its rings as if they were points.
|
|
109
|
+
*/
|
|
110
|
+
function geomLines(geom) {
|
|
111
|
+
if (!geom || !geom.getCoordinates) return [];
|
|
112
|
+
const c = geom.getCoordinates();
|
|
113
|
+
switch (geom.getType()) {
|
|
114
|
+
case 'LineString':
|
|
115
|
+
case 'LinearRing': return [c];
|
|
116
|
+
case 'MultiLineString': return c;
|
|
117
|
+
case 'Polygon': return c.length ? [c[0]] : [];
|
|
118
|
+
case 'MultiPolygon': return c.map((poly) => poly[0]).filter(Boolean);
|
|
119
|
+
default: return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether a geometry can be profiled at all - the map-selection filter and nothing more. */
|
|
124
|
+
function isProfilable(geom) {
|
|
125
|
+
return !!geom && /^(Multi)?(LineString|Polygon)$|^LinearRing$/.test(geom.getType());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Flat array of timestamps (epoch ms) aligned on the flat coordinate order, read from
|
|
129
|
+
// properties.coordTimes (ISO or number), coordinateProperties.times, or the 4th (M) dimension.
|
|
130
|
+
function extractTimes(feature, lines) {
|
|
131
|
+
const props = (feature && feature.getProperties) ? feature.getProperties() : {};
|
|
132
|
+
let raw = props.coordTimes;
|
|
133
|
+
if (raw == null && props.coordinateProperties) raw = props.coordinateProperties.times || props.coordinateProperties.coordTimes;
|
|
134
|
+
let flat = null;
|
|
135
|
+
if (Array.isArray(raw)) flat = Array.isArray(raw[0]) ? raw.reduce((a, b) => a.concat(b), []) : raw.slice();
|
|
136
|
+
if (!flat && lines) { // fallback: 4th M dimension (XYZM layout)
|
|
137
|
+
const tmp = []; let any = false;
|
|
138
|
+
for (const seg of lines) for (const c of seg) { const v = c.length > 3 ? c[3] : null; tmp.push(v); if (v != null && isFinite(v)) any = true; }
|
|
139
|
+
flat = any ? tmp : null;
|
|
140
|
+
}
|
|
141
|
+
if (!flat) return null;
|
|
142
|
+
let valid = 0;
|
|
143
|
+
const ms = flat.map((v) => {
|
|
144
|
+
if (v == null || v === '') return null;
|
|
145
|
+
const t = (typeof v === 'number') ? (v > 1e12 ? v : v * 1000) : Date.parse(v); // epoch ms / epoch s / ISO
|
|
146
|
+
if (!isFinite(t)) return null;
|
|
147
|
+
valid++; return t;
|
|
148
|
+
});
|
|
149
|
+
return valid >= 2 ? ms : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
92
152
|
function colorToCss(c) {
|
|
93
153
|
if (c == null) return null;
|
|
94
154
|
if (typeof c === 'string') return c;
|
|
@@ -126,6 +186,186 @@ import * as d3 from 'd3';
|
|
|
126
186
|
const ICON_B = '<svg viewBox="0 0 24 24"><path d="M17 5v14M13 12H5m0 0 3-3m-3 3 3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
127
187
|
const ICON_ALL = '<svg viewBox="0 0 24 24"><path d="M4 12h16M4 12l4-4M4 12l4 4M20 12l-4-4M20 12l-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
128
188
|
|
|
189
|
+
// ----- digital elevation model (DEM) -----------------------------------
|
|
190
|
+
/**
|
|
191
|
+
* Known terrain-tile sources.
|
|
192
|
+
*
|
|
193
|
+
* A PNG tile carries elevation in its R/G/B channels; it is read on a canvas rather
|
|
194
|
+
* than queried point by point from an API. That is what makes a 10 000-point track a
|
|
195
|
+
* handful of requests, with no key, no quota and no rate limit — where the free
|
|
196
|
+
* elevation APIs cap out at 100 or 200 points per call.
|
|
197
|
+
*
|
|
198
|
+
* Attribution is not automatic: the library does not own the map. It is up to the
|
|
199
|
+
* application to carry `attributions` in its own basemap source - and since the DEM is
|
|
200
|
+
* on by default, that obligation arrives without having been asked for. `dem: null`
|
|
201
|
+
* turns the whole thing off.
|
|
202
|
+
*/
|
|
203
|
+
const DEM_PRESETS = {
|
|
204
|
+
terrarium: {
|
|
205
|
+
url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png',
|
|
206
|
+
encoding: 'terrarium',
|
|
207
|
+
maxZoom: 14,
|
|
208
|
+
attributions: 'Elevation: <a href="https://registry.opendata.aws/terrain-tiles/">Terrain Tiles</a> (AWS Open Data)'
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const DEM_DEFAULTS = { source: 'terrarium', zoom: 'auto', maxZoom: 14, maxTiles: 32, concurrency: 6, tileSize: 256 };
|
|
213
|
+
|
|
214
|
+
/** RGB → metres decoders, one per encoding convention. */
|
|
215
|
+
const DEM_DECODERS = {
|
|
216
|
+
terrarium: (r, g, b) => (r * 256 + g + b / 256) - 32768,
|
|
217
|
+
mapbox: (r, g, b) => -10000 + (r * 65536 + g * 256 + b) * 0.1
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Web Mercator pixel coordinates at zoom `z`, origin at the top-left corner of the world. */
|
|
221
|
+
function worldPixel(lon, lat, z, tileSize) {
|
|
222
|
+
const n = tileSize * Math.pow(2, z);
|
|
223
|
+
const s = Math.sin(lat * Math.PI / 180);
|
|
224
|
+
return [(lon + 180) / 360 * n, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Reads elevations from a set of terrain tiles.
|
|
229
|
+
*
|
|
230
|
+
* Positions are held in **world pixels** rather than tile by tile: the four neighbours
|
|
231
|
+
* of a point can fall in two different tiles whenever it runs along an edge, which is
|
|
232
|
+
* the common case on a track. Converting to world pixels first, then resolving the
|
|
233
|
+
* tile, makes the edge case disappear instead of handling it.
|
|
234
|
+
*/
|
|
235
|
+
class DemSampler {
|
|
236
|
+
/** @param {Object} cfg `url`, `encoding`, `tileSize`, `concurrency`. */
|
|
237
|
+
constructor(cfg) {
|
|
238
|
+
this.cfg = cfg;
|
|
239
|
+
this.decode = DEM_DECODERS[cfg.encoding] || DEM_DECODERS.terrarium;
|
|
240
|
+
this.tiles = new Map(); // "z/x/y" -> RGBA pixels, or null if the tile was lost
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* North-west neighbour of the point plus the interpolation fractions, in world pixels.
|
|
245
|
+
*
|
|
246
|
+
* The half-pixel subtracted is not a fudge: pixel centres fall on half-integers, and
|
|
247
|
+
* without that shift `floor()` would return the cell containing the point rather than
|
|
248
|
+
* its western neighbour, offsetting the whole profile by half a pixel.
|
|
249
|
+
*/
|
|
250
|
+
_neighbours(lon, lat, z) {
|
|
251
|
+
const wp = worldPixel(lon, lat, z, this.cfg.tileSize);
|
|
252
|
+
const px = wp[0] - 0.5, py = wp[1] - 0.5;
|
|
253
|
+
const i0 = Math.floor(px), j0 = Math.floor(py);
|
|
254
|
+
return { i0, j0, tx: px - i0, ty: py - j0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Tile key of a world pixel; x wraps around the globe, y clamps at the poles. */
|
|
258
|
+
_key(i, j, z) {
|
|
259
|
+
const ts = this.cfg.tileSize, span = ts * Math.pow(2, z);
|
|
260
|
+
const jj = Math.min(span - 1, Math.max(0, j)), ii = ((i % span) + span) % span;
|
|
261
|
+
return { key: z + '/' + Math.floor(ii / ts) + '/' + Math.floor(jj / ts), ii, jj };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Elevation of one pixel, or null if its tile is missing. */
|
|
265
|
+
_at(i, j, z) {
|
|
266
|
+
const ts = this.cfg.tileSize;
|
|
267
|
+
const r = this._key(i, j, z);
|
|
268
|
+
const data = this.tiles.get(r.key);
|
|
269
|
+
if (!data) return null;
|
|
270
|
+
const o = ((r.jj % ts) * ts + (r.ii % ts)) * 4;
|
|
271
|
+
return this.decode(data[o], data[o + 1], data[o + 2]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Elevation **bilinearly interpolated** between the four surrounding pixels; null as
|
|
276
|
+
* soon as a single one is missing.
|
|
277
|
+
*
|
|
278
|
+
* Bilinear rather than "the pixel containing the point": on a 30 or 90 m model, taking
|
|
279
|
+
* the pixel value makes the profile advance in stairs, and every stair counts as a
|
|
280
|
+
* climb then a descent in the ascent total. The interpolation invents no relief — it
|
|
281
|
+
* renders the same surface, without the steps of the sampling grid.
|
|
282
|
+
*/
|
|
283
|
+
sample(lon, lat, z) {
|
|
284
|
+
const n = this._neighbours(lon, lat, z);
|
|
285
|
+
const a = this._at(n.i0, n.j0, z), b = this._at(n.i0 + 1, n.j0, z);
|
|
286
|
+
const c = this._at(n.i0, n.j0 + 1, z), d = this._at(n.i0 + 1, n.j0 + 1, z);
|
|
287
|
+
if (a == null || b == null || c == null || d == null) return null;
|
|
288
|
+
const north = a + (b - a) * n.tx, south = c + (d - c) * n.tx;
|
|
289
|
+
return north + (south - north) * n.ty;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Keys of the tiles needed by the four neighbours of each of the points. */
|
|
293
|
+
tilesFor(lonlats, z) {
|
|
294
|
+
const keys = new Set();
|
|
295
|
+
for (const ll of lonlats) {
|
|
296
|
+
const n = this._neighbours(ll[0], ll[1], z);
|
|
297
|
+
for (let di = 0; di < 2; di++) for (let dj = 0; dj < 2; dj++) keys.add(this._key(n.i0 + di, n.j0 + dj, z).key);
|
|
298
|
+
}
|
|
299
|
+
return keys;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Pixels of one tile, or null if it could not be read. */
|
|
303
|
+
_fetch(key) {
|
|
304
|
+
const parts = key.split('/');
|
|
305
|
+
const url = this.cfg.url.replace('{z}', parts[0]).replace('{x}', parts[1]).replace('{y}', parts[2]);
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
const img = new Image();
|
|
308
|
+
// Without this attribute the canvas is tainted and getImageData throws: the tile
|
|
309
|
+
// would display, but stay unreadable. The intended sources answer with
|
|
310
|
+
// Access-Control-Allow-Origin: *.
|
|
311
|
+
img.crossOrigin = 'anonymous';
|
|
312
|
+
img.onload = () => {
|
|
313
|
+
try {
|
|
314
|
+
const ts = this.cfg.tileSize;
|
|
315
|
+
const cv = document.createElement('canvas');
|
|
316
|
+
cv.width = ts; cv.height = ts;
|
|
317
|
+
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
|
318
|
+
ctx.drawImage(img, 0, 0, ts, ts);
|
|
319
|
+
resolve(ctx.getImageData(0, 0, ts, ts).data);
|
|
320
|
+
} catch (e) { resolve(null); }
|
|
321
|
+
};
|
|
322
|
+
img.onerror = () => resolve(null);
|
|
323
|
+
img.src = url;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Loads the missing tiles, `concurrency` in flight. Resolves false if one is missing.
|
|
329
|
+
*
|
|
330
|
+
* Lost tiles are remembered as such: without that, a second pass over the same track
|
|
331
|
+
* would fire the same requests, doomed to fail again.
|
|
332
|
+
*/
|
|
333
|
+
load(keys) {
|
|
334
|
+
const todo = Array.from(keys).filter((k) => !this.tiles.has(k));
|
|
335
|
+
let next = 0, ok = true;
|
|
336
|
+
const worker = () => {
|
|
337
|
+
if (next >= todo.length) return Promise.resolve();
|
|
338
|
+
const k = todo[next++];
|
|
339
|
+
return this._fetch(k).then((data) => { this.tiles.set(k, data); if (!data) ok = false; return worker(); });
|
|
340
|
+
};
|
|
341
|
+
const lanes = [];
|
|
342
|
+
for (let i = 0; i < Math.min(this.cfg.concurrency, todo.length); i++) lanes.push(worker());
|
|
343
|
+
return Promise.all(lanes).then(() => ok);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The `dem` option (true | source name | object) → a full configuration, or null. */
|
|
348
|
+
function demConfig(opt) {
|
|
349
|
+
if (!opt) return null;
|
|
350
|
+
const raw = (opt === true || typeof opt === 'string') ? { source: opt === true ? 'terrarium' : opt } : Object.assign({}, opt);
|
|
351
|
+
const preset = DEM_PRESETS[raw.source] || (raw.url ? {} : DEM_PRESETS.terrarium);
|
|
352
|
+
const cfg = Object.assign({}, DEM_DEFAULTS, preset, raw);
|
|
353
|
+
return cfg.url ? cfg : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Zoom at which to download the model: the finest one that fits within `maxTiles`.
|
|
358
|
+
*
|
|
359
|
+
* It is the tiling that widens as the track grows, not a model that degrades: a 10 km
|
|
360
|
+
* track is read at the finest step available, a 300 km one at a coarser step rather
|
|
361
|
+
* than in three hundred requests.
|
|
362
|
+
*/
|
|
363
|
+
function demZoomFor(sampler, lonlats, cfg) {
|
|
364
|
+
if (typeof cfg.zoom === 'number') return cfg.zoom;
|
|
365
|
+
for (let z = cfg.maxZoom; z > 0; z--) if (sampler.tilesFor(lonlats, z).size <= cfg.maxTiles) return z;
|
|
366
|
+
return 1;
|
|
367
|
+
}
|
|
368
|
+
|
|
129
369
|
// =======================================================================
|
|
130
370
|
/**
|
|
131
371
|
* @typedef {Object} ElevationProfileOptions
|
|
@@ -159,12 +399,15 @@ import * as d3 from 'd3';
|
|
|
159
399
|
* @property {boolean} [responsive=true] Adapt width/placement; mobile included.
|
|
160
400
|
* @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
|
|
161
401
|
* @property {boolean} [zoom=false] A/B buttons to crop map + profile to a sub-range.
|
|
162
|
-
* @property {
|
|
163
|
-
* @property {
|
|
402
|
+
* @property {boolean} [ignoreStops=true] When computing time, ignore stopped segments (moving time).
|
|
403
|
+
* @property {number} [stopSpeed=0.5] Speed threshold (m/s) below which a segment counts as a stop.
|
|
404
|
+
* @property {Array<'distance'|'elevation'|'slope'|'time'>} [tooltipItems=['distance','elevation']] Tooltip content (`'time'` = elapsed time at the cursor, if the track has time data).
|
|
405
|
+
* @property {Array<string|{property:string,label?:string,asLink?:boolean,linkText?:string}>} [headerItems] Header content (string tokens: distance, ascent, descent, min, max, minmax, `'duration'` = total elapsed time).
|
|
164
406
|
* @property {string} [titleProperty='name'] Feature property used as the title.
|
|
165
407
|
* @property {?string} [titleLink=null] Feature property holding a URL → clickable title.
|
|
166
408
|
* @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
|
|
167
409
|
* @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
|
|
410
|
+
* @property {?(boolean|string|Object)} [dem='terrarium'] Fill missing elevations from a terrain model. `'terrarium'` (default) or `true` = AWS Terrain Tiles (keyless); `null` disables it; or `{url,encoding:'terrarium'|'mapbox',zoom,maxZoom,maxTiles,tileSize,concurrency}`.
|
|
168
411
|
*/
|
|
169
412
|
|
|
170
413
|
/**
|
|
@@ -192,12 +435,13 @@ import * as d3 from 'd3';
|
|
|
192
435
|
this._marker = null;
|
|
193
436
|
this._collapsed = !!o.collapsed;
|
|
194
437
|
this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
|
|
438
|
+
this._demZ = null; this._demFor = null; this._demSeq = 0; this._demLoading = false;
|
|
195
439
|
this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
|
|
196
440
|
this._buildDom(element);
|
|
197
441
|
element.style.display = 'none';
|
|
198
442
|
}
|
|
199
443
|
|
|
200
|
-
// ----------
|
|
444
|
+
// ---------- static helpers ----------------------------------------
|
|
201
445
|
/**
|
|
202
446
|
* Whether a feature has any Z (elevation) coordinate.
|
|
203
447
|
* @param {import('ol/Feature').default} feature
|
|
@@ -206,11 +450,21 @@ import * as d3 from 'd3';
|
|
|
206
450
|
static featureHasZ(feature) {
|
|
207
451
|
const g = feature && feature.getGeometry && feature.getGeometry();
|
|
208
452
|
if (!g) return false;
|
|
209
|
-
const
|
|
210
|
-
for (const seg of coords) for (const c of seg) if (c.length > 2 && isFinite(c[2])) return true;
|
|
453
|
+
for (const seg of geomLines(g)) for (const c of seg) if (c.length > 2 && isFinite(c[2])) return true;
|
|
211
454
|
return false;
|
|
212
455
|
}
|
|
213
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Whether a feature carries per-point time data (coordTimes / XYZM).
|
|
459
|
+
* @param {import('ol/Feature').default} feature
|
|
460
|
+
* @returns {boolean}
|
|
461
|
+
*/
|
|
462
|
+
static featureHasTime(feature) {
|
|
463
|
+
const g = feature && feature.getGeometry && feature.getGeometry();
|
|
464
|
+
if (!g) return false;
|
|
465
|
+
return !!extractTimes(feature, geomLines(g));
|
|
466
|
+
}
|
|
467
|
+
|
|
214
468
|
// ---------- DOM ---------------------------------------------------
|
|
215
469
|
_buildDom(root) {
|
|
216
470
|
const o = this.options;
|
|
@@ -297,7 +551,7 @@ import * as d3 from 'd3';
|
|
|
297
551
|
this._adjustAttribution();
|
|
298
552
|
}
|
|
299
553
|
|
|
300
|
-
// ---------- responsive
|
|
554
|
+
// ---------- responsive --------------------------------------------
|
|
301
555
|
_availWidth() {
|
|
302
556
|
const map = this.getMap();
|
|
303
557
|
const t = map && map.getTargetElement && map.getTargetElement();
|
|
@@ -313,7 +567,7 @@ import * as d3 from 'd3';
|
|
|
313
567
|
return mobile;
|
|
314
568
|
}
|
|
315
569
|
|
|
316
|
-
//
|
|
570
|
+
// Lift the OL attribution above the profile when the profile takes the bottom-right corner
|
|
317
571
|
_adjustAttribution() {
|
|
318
572
|
const map = this.getMap();
|
|
319
573
|
const target = map && map.getTargetElement && map.getTargetElement();
|
|
@@ -324,17 +578,17 @@ import * as d3 from 'd3';
|
|
|
324
578
|
const mapR = target.getBoundingClientRect();
|
|
325
579
|
const pR = this.element.getBoundingClientRect();
|
|
326
580
|
if (!pR.height) return;
|
|
327
|
-
const bottomGap = mapR.bottom - pR.bottom; //
|
|
581
|
+
const bottomGap = mapR.bottom - pR.bottom; // map bottom edge <-> profile bottom
|
|
328
582
|
const rightGap = mapR.right - pR.right;
|
|
329
|
-
const atBottom = bottomGap < pR.height; //
|
|
330
|
-
const reachesRight = rightGap < 24; //
|
|
583
|
+
const atBottom = bottomGap < pR.height; // profile sits in the bottom band
|
|
584
|
+
const reachesRight = rightGap < 24; // reaches the bottom-right corner (attributions)
|
|
331
585
|
if (atBottom && reachesRight) {
|
|
332
586
|
attr.style.right = `${Math.max(0, Math.round(rightGap))}px`;
|
|
333
|
-
attr.style.bottom = `${Math.round(pR.height + 2 * bottomGap)}px`; //
|
|
587
|
+
attr.style.bottom = `${Math.round(pR.height + 2 * bottomGap)}px`; // same gap above the profile
|
|
334
588
|
}
|
|
335
589
|
}
|
|
336
590
|
|
|
337
|
-
// ----------
|
|
591
|
+
// ---------- map ---------------------------------------------------
|
|
338
592
|
setMap(map) {
|
|
339
593
|
const prev = this.getMap();
|
|
340
594
|
super.setMap(map);
|
|
@@ -353,7 +607,7 @@ import * as d3 from 'd3';
|
|
|
353
607
|
if (typeof window !== 'undefined') window.addEventListener('resize', this._onResize);
|
|
354
608
|
|
|
355
609
|
const lineAt = (pixel) => map.forEachFeatureAtPixel(pixel, (f) => {
|
|
356
|
-
const g = f.getGeometry(); return (g
|
|
610
|
+
const g = f.getGeometry(); return isProfilable(g) ? f : undefined;
|
|
357
611
|
});
|
|
358
612
|
|
|
359
613
|
this._mapKeys = [];
|
|
@@ -363,11 +617,11 @@ import * as d3 from 'd3';
|
|
|
363
617
|
if (o.hideOnMapClick) this._mapKeys.push(map.on('click', (evt) => { if (!lineAt(evt.pixel) && this._feature) this.clear(); }));
|
|
364
618
|
if (o.followMap) this._mapKeys.push(map.on('pointermove', (evt) => {
|
|
365
619
|
if (!this._feature || this._collapsed) return;
|
|
366
|
-
const cp = this.
|
|
620
|
+
const cp = this._closestOnProfile(evt.coordinate); if (!cp) return;
|
|
367
621
|
const px = map.getPixelFromCoordinate(cp); if (!px) return;
|
|
368
622
|
if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
|
|
369
623
|
}));
|
|
370
|
-
//
|
|
624
|
+
// leave the A/B crop when the map is zoomed out
|
|
371
625
|
this._mapKeys.push(map.on('moveend', () => {
|
|
372
626
|
if (this._cropMode && this._fitRes && map.getView().getResolution() > this._fitRes * 1.25) this._exitZoom();
|
|
373
627
|
}));
|
|
@@ -376,7 +630,8 @@ import * as d3 from 'd3';
|
|
|
376
630
|
|
|
377
631
|
// ---------- API ---------------------------------------------------
|
|
378
632
|
/**
|
|
379
|
-
* Show the profile for the given feature (LineString
|
|
633
|
+
* Show the profile for the given feature (LineString, MultiLineString, or a Polygon /
|
|
634
|
+
* MultiPolygon profiled along its outer ring), ideally 3D.
|
|
380
635
|
* Passing a falsy value hides the control.
|
|
381
636
|
* @param {?import('ol/Feature').default} feature
|
|
382
637
|
* @returns {this}
|
|
@@ -384,17 +639,21 @@ import * as d3 from 'd3';
|
|
|
384
639
|
setFeature(feature) {
|
|
385
640
|
this._feature = feature || null;
|
|
386
641
|
this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null;
|
|
387
|
-
if (!feature) { this._fullSamples = this._samples = null; this._clear(); return this; }
|
|
642
|
+
if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._clear(); return this; }
|
|
388
643
|
this.element.style.display = '';
|
|
389
644
|
this._compute();
|
|
645
|
+
// Before the render, not after: it is _fillFromDem that knows whether a fill is
|
|
646
|
+
// starting, and the render needs that to draw the spinner instead of a flat profile.
|
|
647
|
+
this._fillFromDem(feature);
|
|
390
648
|
this._updateZoomButtons();
|
|
391
|
-
if (
|
|
649
|
+
if (this._collapsed) this._renderTitle(); else this._render();
|
|
392
650
|
return this;
|
|
393
651
|
}
|
|
394
652
|
/** Hide the profile and clear the current feature. @returns {this} */
|
|
395
653
|
clear() { return this.setFeature(null); }
|
|
396
654
|
/**
|
|
397
|
-
* @returns {?{distance:number,ascent:number,descent:number,min:number,max:number,maxAbsSlope:number,points:number}}
|
|
655
|
+
* @returns {?{distance:number,duration:?number,ascent:number,descent:number,min:number,max:number,maxAbsSlope:number,points:number}}
|
|
656
|
+
* `duration` is `null` when the track carries no time data.
|
|
398
657
|
*/
|
|
399
658
|
getStats() { return this._stats; }
|
|
400
659
|
/** @param {string|Object} t Theme name or colors object. @returns {this} */
|
|
@@ -415,24 +674,102 @@ import * as d3 from 'd3';
|
|
|
415
674
|
if (patch && 'collapsable' in patch) this._applyCollapsable();
|
|
416
675
|
if (patch && 'zoom' in patch) this._updateZoomButtons();
|
|
417
676
|
if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
|
|
418
|
-
if (
|
|
677
|
+
if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
|
|
678
|
+
if (this._feature) { this._compute(); this._fillFromDem(this._feature); this._updateZoomButtons(); if (this._collapsed) this._renderTitle(); else this._render(); }
|
|
419
679
|
return this;
|
|
420
680
|
}
|
|
421
681
|
|
|
422
|
-
// ----------
|
|
682
|
+
// ---------- digital elevation model -------------------------------
|
|
683
|
+
/**
|
|
684
|
+
* Fills the missing elevations from a terrain model, then redraws.
|
|
685
|
+
*
|
|
686
|
+
* **A track is filled entirely or not at all.** A profile missing a few points is not
|
|
687
|
+
* an incomplete profile: points without Z count as zero, the line dives to sea level
|
|
688
|
+
* and the ascent total becomes absurd. Better the flat profile we would have had
|
|
689
|
+
* without the DEM.
|
|
690
|
+
*
|
|
691
|
+
* Nothing is reported to the user on failure — the fill is a supplement, it has no
|
|
692
|
+
* business breaking a display that succeeded without it. The `demload` event lets the
|
|
693
|
+
* application know if it wants to.
|
|
694
|
+
*
|
|
695
|
+
* @param {import('ol/Feature').default} feature
|
|
696
|
+
* @fires demload
|
|
697
|
+
* @private
|
|
698
|
+
*/
|
|
699
|
+
_fillFromDem(feature) {
|
|
700
|
+
const cfg = demConfig(this.options.dem);
|
|
701
|
+
if (!cfg || !feature || this._demFor === feature) return;
|
|
702
|
+
if (ElevationProfile.featureHasZ(feature)) return; // the track already carries its Z
|
|
703
|
+
if (typeof Image === 'undefined' || typeof document === 'undefined') return;
|
|
704
|
+
|
|
705
|
+
const geom = feature.getGeometry && feature.getGeometry();
|
|
706
|
+
if (!geom) return;
|
|
707
|
+
const lines = geomLines(geom);
|
|
708
|
+
const dataProj = this.options.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
|
|
709
|
+
const lonlats = [];
|
|
710
|
+
for (const seg of lines) for (const c of seg) lonlats.push(toLonLat(c, dataProj));
|
|
711
|
+
if (!lonlats.length) return;
|
|
712
|
+
|
|
713
|
+
// Sequence number: a track clicked while another is loading must win, otherwise the
|
|
714
|
+
// slowest response would overwrite the profile on screen.
|
|
715
|
+
const seq = ++this._demSeq;
|
|
716
|
+
const sampler = new DemSampler(cfg);
|
|
717
|
+
const z = demZoomFor(sampler, lonlats, cfg);
|
|
718
|
+
this._demLoading = true;
|
|
719
|
+
sampler.load(sampler.tilesFor(lonlats, z)).then((ok) => {
|
|
720
|
+
// A newer fill has taken over: it owns _demLoading and will clear it itself.
|
|
721
|
+
if (seq !== this._demSeq || this._feature !== feature) return;
|
|
722
|
+
const zs = ok ? lonlats.map((ll) => sampler.sample(ll[0], ll[1], z)) : null;
|
|
723
|
+
const complete = !!zs && zs.every((v) => v != null && isFinite(v));
|
|
724
|
+
this._demLoading = false;
|
|
725
|
+
if (complete) { this._demZ = zs; this._demFor = feature; this._compute(); }
|
|
726
|
+
// Redrawn even on failure: the spinner has to give way to the flat profile.
|
|
727
|
+
this._updateZoomButtons();
|
|
728
|
+
if (!this._collapsed) this._render();
|
|
729
|
+
/**
|
|
730
|
+
* Fired once a terrain-model fill has completed (or failed).
|
|
731
|
+
* @event demload
|
|
732
|
+
* @property {boolean} ok Whether every point could be sampled.
|
|
733
|
+
* @property {number} zoom Tile zoom level used.
|
|
734
|
+
* @property {number} tiles Tiles fetched.
|
|
735
|
+
*/
|
|
736
|
+
this.dispatchEvent({ type: 'demload', ok: complete, zoom: z, tiles: sampler.tiles.size });
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ---------- computation -------------------------------------------
|
|
423
741
|
_compute() {
|
|
424
742
|
const o = this.options;
|
|
425
743
|
const geom = this._feature.getGeometry();
|
|
426
|
-
const lines =
|
|
744
|
+
const lines = geomLines(geom);
|
|
427
745
|
const dataProj = o.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
|
|
428
746
|
|
|
747
|
+
// Terrain-model elevations, if they were loaded for THIS feature.
|
|
748
|
+
const demZ = (this._demFor === this._feature) ? this._demZ : null;
|
|
749
|
+
|
|
750
|
+
const times = extractTimes(this._feature, lines);
|
|
751
|
+
this._hasTime = !!times;
|
|
752
|
+
const ignoreStops = o.ignoreStops !== false; // default: stops are excluded
|
|
753
|
+
const stopSpeed = (o.stopSpeed != null ? o.stopSpeed : 0.5); // m/s
|
|
429
754
|
const pts = [];
|
|
430
|
-
let cum = 0, prev = null;
|
|
755
|
+
let cum = 0, prev = null, i = -1, tAcc = 0, prevMs = null;
|
|
431
756
|
for (const seg of lines) for (const c of seg) {
|
|
757
|
+
i++;
|
|
432
758
|
const ll = toLonLat(c, dataProj);
|
|
433
|
-
|
|
759
|
+
let dseg = 0;
|
|
760
|
+
if (prev) { dseg = getDistance(prev, ll); cum += dseg; }
|
|
761
|
+
let t = null;
|
|
762
|
+
if (times && times[i] != null) {
|
|
763
|
+
const ms = times[i];
|
|
764
|
+
if (prevMs != null) {
|
|
765
|
+
const dt = (ms - prevMs) / 1000; // seconds over the segment
|
|
766
|
+
if (dt > 0 && (!ignoreStops || (dseg / dt) >= stopSpeed)) tAcc += dt;
|
|
767
|
+
}
|
|
768
|
+
t = tAcc; prevMs = ms;
|
|
769
|
+
}
|
|
434
770
|
prev = ll;
|
|
435
|
-
|
|
771
|
+
const z = (c.length > 2 && isFinite(c[2])) ? c[2] : (demZ ? demZ[i] : 0);
|
|
772
|
+
pts.push({ x: cum, z, coord: c, t });
|
|
436
773
|
}
|
|
437
774
|
if (o.smoothing > 0) this._smooth(pts, o.smoothing);
|
|
438
775
|
|
|
@@ -450,11 +787,15 @@ import * as d3 from 'd3';
|
|
|
450
787
|
const s = Math.abs(samples[k].slope || 0); if (s > maxAbs) maxAbs = s;
|
|
451
788
|
}
|
|
452
789
|
const distance = samples.length ? samples[samples.length - 1].x - samples[0].x : 0;
|
|
453
|
-
|
|
790
|
+
const ta = samples.length ? samples[0].t : null, tb = samples.length ? samples[samples.length - 1].t : null;
|
|
791
|
+
const duration = (ta != null && tb != null) ? (tb - ta) : null;
|
|
792
|
+
// Reported for a closed ring too, where they are equal by construction: on a loop,
|
|
793
|
+
// D+ is exactly the figure one is after.
|
|
794
|
+
return { distance, duration, ascent, descent, min: isFinite(zmin) ? zmin : 0, max: isFinite(zmax) ? zmax : 0, maxAbsSlope: maxAbs, points: samples.length };
|
|
454
795
|
}
|
|
455
796
|
_smooth(pts, meters) {
|
|
456
797
|
if (!(meters > 0) || pts.length < 3) return;
|
|
457
|
-
const half = meters / 2; //
|
|
798
|
+
const half = meters / 2; // window = +/-(meters/2) along the track
|
|
458
799
|
const z = pts.map((p) => p.z);
|
|
459
800
|
let lo = 0, hi = 0, sum = 0;
|
|
460
801
|
for (let i = 0; i < pts.length; i++) {
|
|
@@ -465,7 +806,7 @@ import * as d3 from 'd3';
|
|
|
465
806
|
}
|
|
466
807
|
}
|
|
467
808
|
_decimate(pts, maxPoints) {
|
|
468
|
-
const copy = (p) => ({ x: p.x, z: p.z, coord: p.coord });
|
|
809
|
+
const copy = (p) => ({ x: p.x, z: p.z, coord: p.coord, t: p.t });
|
|
469
810
|
if (!maxPoints || pts.length <= maxPoints) return pts.map(copy);
|
|
470
811
|
const step = pts.length / maxPoints, out = [];
|
|
471
812
|
for (let i = 0; i < maxPoints; i++) out.push(copy(pts[Math.floor(i * step)]));
|
|
@@ -479,20 +820,20 @@ import * as d3 from 'd3';
|
|
|
479
820
|
if (samples.length) samples[0].slope = samples.length > 1 ? samples[1].slope : 0;
|
|
480
821
|
}
|
|
481
822
|
|
|
482
|
-
// ----------
|
|
823
|
+
// ---------- slope: colours ----------------------------------------
|
|
483
824
|
_slopeScale() {
|
|
484
825
|
const cs = this.options.slopeClassSize || 2.5;
|
|
485
826
|
const maxClasses = this.options.maxClasses || 8;
|
|
486
827
|
const realIdx = Math.max(1, Math.floor((this._stats.maxAbsSlope || 0) / cs));
|
|
487
|
-
const maxIdx = Math.min(realIdx, maxClasses - 1); //
|
|
488
|
-
const capped = realIdx > maxIdx; //
|
|
828
|
+
const maxIdx = Math.min(realIdx, maxClasses - 1); // at most `maxClasses` classes (default 8)
|
|
829
|
+
const capped = realIdx > maxIdx; // some slopes overflow the last class
|
|
489
830
|
let colorByIndex;
|
|
490
831
|
if (this.slopeColors && this.slopeColors.length) {
|
|
491
832
|
const interp = d3.interpolateRgbBasis(this.slopeColors);
|
|
492
833
|
colorByIndex = (idx) => interp(maxIdx ? idx / maxIdx : 0);
|
|
493
834
|
} else {
|
|
494
|
-
//
|
|
495
|
-
//
|
|
835
|
+
// Ramp with non-uniform stops: the cold end (blue-green, hard to read) is squeezed,
|
|
836
|
+
// PURE yellow in the middle, then orange, red. Class 0 = blue, top class = red.
|
|
496
837
|
const ramp = d3.scaleLinear()
|
|
497
838
|
.domain([0, 0.16, 0.42, 0.68, 1])
|
|
498
839
|
.range(['#2166ac', '#27a35a', '#ffe000', '#f4791f', '#d7191c'])
|
|
@@ -504,14 +845,53 @@ import * as d3 from 'd3';
|
|
|
504
845
|
}
|
|
505
846
|
_classIndex(slope, sc) { return Math.min(sc.maxIdx, Math.floor(Math.abs(slope) / sc.classSize)); }
|
|
506
847
|
|
|
507
|
-
// ----------
|
|
508
|
-
|
|
509
|
-
|
|
848
|
+
// ---------- time: adaptive format ---------------------------------
|
|
849
|
+
// 7 sec | 26 min | 1 h 48 min | 2 d 3 h (days + hours normalised)
|
|
850
|
+
_fmtDuration(sec) {
|
|
851
|
+
if (sec == null || !isFinite(sec)) return '';
|
|
852
|
+
const u = (this.options.labels && this.options.labels.durationUnits) || { s: 'sec', m: 'min', h: 'h', d: 'j' };
|
|
853
|
+
sec = Math.max(0, Math.round(sec));
|
|
854
|
+
if (sec < 60) return sec + ' ' + u.s;
|
|
855
|
+
if (sec < 3600) return Math.round(sec / 60) + ' ' + u.m;
|
|
856
|
+
if (sec < 86400) {
|
|
857
|
+
let h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
|
|
858
|
+
if (m === 60) { h++; m = 0; }
|
|
859
|
+
return m ? `${h} ${u.h} ${m} ${u.m}` : `${h} ${u.h}`;
|
|
860
|
+
}
|
|
861
|
+
let d = Math.floor(sec / 86400), h = Math.round((sec % 86400) / 3600);
|
|
862
|
+
if (h === 24) { d++; h = 0; }
|
|
863
|
+
return h ? `${d} ${u.d} ${h} ${u.h}` : `${d} ${u.d}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ---------- header + legend ---------------------------------------
|
|
867
|
+
/**
|
|
868
|
+
* Track title, and its optional link.
|
|
869
|
+
*
|
|
870
|
+
* Its own method because it is the only part of the header that stays visible once
|
|
871
|
+
* collapsed — the CSS hides the body, the stats, the legend and the toolbar. `_render`
|
|
872
|
+
* is skipped while collapsed, so without a separate entry point the title would keep
|
|
873
|
+
* naming the previous track after a change of feature.
|
|
874
|
+
*/
|
|
875
|
+
_renderTitle() {
|
|
876
|
+
const o = this.options, f = this._feature;
|
|
877
|
+
if (!f) return;
|
|
510
878
|
const name = (f.get && f.get(o.titleProperty)) || 'Profil';
|
|
511
879
|
const linkUrl = o.titleLink && f.get && f.get(o.titleLink);
|
|
512
880
|
if (isUrl(linkUrl)) this._titleEl.innerHTML = `<a href="${esc(linkUrl)}" target="_blank" rel="noopener">${esc(name)}</a>`;
|
|
513
881
|
else this._titleEl.textContent = name;
|
|
514
882
|
this._titleEl.setAttribute('title', name);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
_renderHeader() {
|
|
886
|
+
const o = this.options, s = this._stats, f = this._feature;
|
|
887
|
+
this._renderTitle();
|
|
888
|
+
// While the elevations are still unknown, every figure would read zero: a D+ of 0 m
|
|
889
|
+
// then jumping to 1 200 is worse than no figure at all.
|
|
890
|
+
if (this._demLoading) {
|
|
891
|
+
this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
|
|
892
|
+
this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
515
895
|
|
|
516
896
|
const html = [], text = [];
|
|
517
897
|
o.headerItems.forEach((it) => {
|
|
@@ -522,6 +902,7 @@ import * as d3 from 'd3';
|
|
|
522
902
|
else if (it === 'min') { html.push(fmtElevation(s.min, o.units)); text.push(fmtElevation(s.min, o.units)); }
|
|
523
903
|
else if (it === 'max') { html.push(fmtElevation(s.max, o.units)); text.push(fmtElevation(s.max, o.units)); }
|
|
524
904
|
else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}–${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
|
|
905
|
+
else if (it === 'duration') { if (s.duration != null) { const v = this._fmtDuration(s.duration); html.push(`<span class="oep-time">${esc(o.labels.duration)} ${v}</span>`); text.push(`${o.labels.duration} ${v}`); } }
|
|
525
906
|
} else if (it && it.property) {
|
|
526
907
|
const val = f.get && f.get(it.property); if (val == null || val === '') return;
|
|
527
908
|
const lbl = it.label ? `${it.label} ` : '';
|
|
@@ -546,18 +927,40 @@ import * as d3 from 'd3';
|
|
|
546
927
|
} else this._legendEl.style.display = 'none';
|
|
547
928
|
}
|
|
548
929
|
|
|
549
|
-
|
|
930
|
+
/**
|
|
931
|
+
* Spinner shown in place of the chart while the terrain model loads.
|
|
932
|
+
*
|
|
933
|
+
* It keeps the chart's height so the panel does not jump when the profile replaces it,
|
|
934
|
+
* and it carries no colour of its own: the CSS takes `--oep-area`, which `_applyTheme`
|
|
935
|
+
* has just set - the theme colour, or the track colour under `color: 'auto'`.
|
|
936
|
+
*/
|
|
937
|
+
_renderSpinner() {
|
|
938
|
+
const H = typeof this.options.height === 'number' ? this.options.height : 180;
|
|
939
|
+
this._body.innerHTML = '';
|
|
940
|
+
const box = document.createElement('div');
|
|
941
|
+
box.className = 'oep-loading';
|
|
942
|
+
box.style.height = `${H}px`;
|
|
943
|
+
box.setAttribute('role', 'status');
|
|
944
|
+
box.setAttribute('aria-label', this.options.labels.loading);
|
|
945
|
+
const sp = document.createElement('div');
|
|
946
|
+
sp.className = 'oep-spinner';
|
|
947
|
+
box.appendChild(sp);
|
|
948
|
+
this._body.appendChild(box);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---------- rendering ---------------------------------------------
|
|
550
952
|
_render() {
|
|
551
953
|
const o = this.options, s = this._stats, data = this._samples;
|
|
552
954
|
if (!data || !data.length) return;
|
|
553
955
|
this._applyTheme();
|
|
554
956
|
const mobile = this._applyPlacement();
|
|
555
957
|
this._renderHeader();
|
|
958
|
+
if (this._demLoading) { this._renderSpinner(); return; }
|
|
556
959
|
|
|
557
960
|
const m = o.margins, u = m.unit || 'px';
|
|
558
961
|
const toPx = (v) => u === 'px' ? v : v * (parseFloat(getComputedStyle(this.element).fontSize) || 16);
|
|
559
962
|
|
|
560
|
-
//
|
|
963
|
+
// Width: 100% on mobile; 'auto'/'100%'/'full' = map width; otherwise a number capped to the map
|
|
561
964
|
const avail = this._availWidth();
|
|
562
965
|
const isAuto = (o.width === 'auto' || o.width === '100%' || o.width === 'full');
|
|
563
966
|
const desktopW = isAuto ? avail : Math.min(typeof o.width === 'number' ? o.width : (parseFloat(o.width) || avail), avail);
|
|
@@ -594,7 +997,7 @@ import * as d3 from 'd3';
|
|
|
594
997
|
const cls = this._classIndex(data[i].slope, sc);
|
|
595
998
|
let j = i; while (j + 1 < data.length && this._classIndex(data[j + 1].slope, sc) === cls) j++;
|
|
596
999
|
g.append('path').datum(data.slice(i - 1, j + 1)).attr('class', 'oep-area-slope').attr('fill', sc.colorByIndex(cls)).attr('d', areaGen);
|
|
597
|
-
if (i > 1) seps.push(data[i - 1]); //
|
|
1000
|
+
if (i > 1) seps.push(data[i - 1]); // a class change is a boundary
|
|
598
1001
|
i = j + 1;
|
|
599
1002
|
}
|
|
600
1003
|
if (o.slopeSeparators) seps.forEach((d) => {
|
|
@@ -610,7 +1013,7 @@ import * as d3 from 'd3';
|
|
|
610
1013
|
g.append('text').attr('class', 'oep-axis-label').attr('x', innerW).attr('y', innerH + mb - 4).attr('text-anchor', 'end').text(distAxisLabel(o.units));
|
|
611
1014
|
g.append('g').attr('class', 'oep-axis oep-axis-y').call(d3.axisLeft(y).ticks(yTicks).tickFormat((d) => o.units === 'imperial' ? Math.round(d * 3.28084) : d));
|
|
612
1015
|
|
|
613
|
-
//
|
|
1016
|
+
// A / B markers while a range is being picked
|
|
614
1017
|
if (o.zoom && !this._cropMode) {
|
|
615
1018
|
[['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
|
|
616
1019
|
if (val == null) return;
|
|
@@ -662,7 +1065,8 @@ import * as d3 from 'd3';
|
|
|
662
1065
|
const raw = this._fullSamples.filter((p) => p.x >= a && p.x <= b);
|
|
663
1066
|
if (raw.length < 2) return;
|
|
664
1067
|
const off = raw[0].x; // A devient 0
|
|
665
|
-
const
|
|
1068
|
+
const tOff = raw[0].t != null ? raw[0].t : 0;
|
|
1069
|
+
const cropped = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope, t: (p.t != null ? p.t - tOff : null) }));
|
|
666
1070
|
const coords = raw.map((p) => p.coord);
|
|
667
1071
|
this._samples = cropped; this._stats = this._statsOf(cropped, false); this._cropMode = true;
|
|
668
1072
|
this._updateZoomButtons(); this._render();
|
|
@@ -682,6 +1086,29 @@ import * as d3 from 'd3';
|
|
|
682
1086
|
if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
|
|
683
1087
|
}
|
|
684
1088
|
|
|
1089
|
+
/**
|
|
1090
|
+
* Point of the profiled outline closest to a map coordinate.
|
|
1091
|
+
*
|
|
1092
|
+
* A polygon's own `getClosestPoint` answers for its **surface**: with the cursor inside
|
|
1093
|
+
* the ring it returns the cursor itself, so the marker would follow the pointer across
|
|
1094
|
+
* the whole shape instead of sliding along the outline. Lines keep the geometry's own
|
|
1095
|
+
* answer, which is exact rather than limited to the decimated samples.
|
|
1096
|
+
*/
|
|
1097
|
+
_closestOnProfile(coordinate) {
|
|
1098
|
+
const g = this._feature && this._feature.getGeometry();
|
|
1099
|
+
if (!g) return null;
|
|
1100
|
+
if (!/Polygon/.test(g.getType())) return g.getClosestPoint(coordinate);
|
|
1101
|
+
const data = this._fullSamples;
|
|
1102
|
+
if (!data || !data.length) return null;
|
|
1103
|
+
let best = null, bd = Infinity;
|
|
1104
|
+
for (const p of data) {
|
|
1105
|
+
const dx = p.coord[0] - coordinate[0], dy = p.coord[1] - coordinate[1];
|
|
1106
|
+
const dd = dx * dx + dy * dy;
|
|
1107
|
+
if (dd < bd) { bd = dd; best = p.coord; }
|
|
1108
|
+
}
|
|
1109
|
+
return best;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
685
1112
|
// ---------- focus -------------------------------------------------
|
|
686
1113
|
_tooltipText(d) {
|
|
687
1114
|
const o = this.options, parts = [];
|
|
@@ -689,6 +1116,7 @@ import * as d3 from 'd3';
|
|
|
689
1116
|
if (it === 'distance') parts.push(fmtDistance(d.x, o.units));
|
|
690
1117
|
else if (it === 'elevation') parts.push(fmtElevation(d.z, o.units));
|
|
691
1118
|
else if (it === 'slope') parts.push(fmtSlope(d.slope || 0));
|
|
1119
|
+
else if (it === 'time') { if (d.t != null) parts.push(this._fmtDuration(d.t)); }
|
|
692
1120
|
});
|
|
693
1121
|
return parts.join(' · ');
|
|
694
1122
|
}
|
|
@@ -718,7 +1146,7 @@ import * as d3 from 'd3';
|
|
|
718
1146
|
this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
|
|
719
1147
|
this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
|
|
720
1148
|
this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
|
|
721
|
-
this._cropMode = false; this._updateZoomButtons();
|
|
1149
|
+
this._cropMode = false; this._demLoading = false; this._updateZoomButtons();
|
|
722
1150
|
this._clearFocus();
|
|
723
1151
|
this.element.style.display = 'none';
|
|
724
1152
|
this._adjustAttribution();
|
|
@@ -732,7 +1160,10 @@ import * as d3 from 'd3';
|
|
|
732
1160
|
*/
|
|
733
1161
|
ElevationProfile.addTheme = (name, colors) => { THEMES[name] = colors; };
|
|
734
1162
|
ElevationProfile.THEMES = THEMES;
|
|
1163
|
+
/** Known keyless terrain-tile sources, keyed by `dem.source` name. */
|
|
1164
|
+
ElevationProfile.DEM_PRESETS = DEM_PRESETS;
|
|
1165
|
+
ElevationProfile.DemSampler = DemSampler;
|
|
735
1166
|
ElevationProfile.POSITIONS = POSITIONS;
|
|
736
|
-
ElevationProfile.version = '0.
|
|
1167
|
+
ElevationProfile.version = '0.6.0';
|
|
737
1168
|
|
|
738
1169
|
export default ElevationProfile;
|