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