ol-elevation-profile 1.0.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 +19 -1
- package/dist/ol-elevation-profile.esm.js +576 -31
- package/dist/ol-elevation-profile.esm.min.js +2 -2
- package/dist/ol-elevation-profile.js +578 -34
- package/dist/ol-elevation-profile.min.js +2 -2
- package/package.json +4 -3
- package/src/ol-elevation-profile.js +576 -31
|
@@ -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);
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
responsive: true, // adapts width/placement, mobile included
|
|
87
87
|
mobileBreakpoint: 640, // <= screen width -> mobile mode (100% width, top/bottom)
|
|
88
88
|
zoom: false, // start/end buttons cropping map + profile to A..B
|
|
89
|
+
exportPng: false, // toolbar button exporting the panel as a PNG
|
|
89
90
|
ignoreStops: true, // duration = moving time (stops excluded)
|
|
90
91
|
stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
|
|
91
92
|
tooltipItems: ['distance', 'elevation'],
|
|
@@ -98,6 +99,7 @@
|
|
|
98
99
|
time: 'Temps', duration: 'Durée',
|
|
99
100
|
durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
|
|
100
101
|
zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
|
|
102
|
+
exportPng: 'Exporter en PNG',
|
|
101
103
|
loading: 'Chargement du profil altimétrique'
|
|
102
104
|
}
|
|
103
105
|
};
|
|
@@ -203,6 +205,7 @@
|
|
|
203
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>';
|
|
204
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>';
|
|
205
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>';
|
|
206
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>';
|
|
207
210
|
|
|
208
211
|
// ----- digital elevation model (DEM) -----------------------------------
|
|
@@ -225,17 +228,335 @@
|
|
|
225
228
|
encoding: 'terrarium',
|
|
226
229
|
maxZoom: 14,
|
|
227
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)'
|
|
228
254
|
}
|
|
229
255
|
};
|
|
230
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
|
+
|
|
231
311
|
const DEM_DEFAULTS = { source: 'terrarium', zoom: 'auto', maxZoom: 14, maxTiles: 32, concurrency: 6, tileSize: 256 };
|
|
232
312
|
|
|
233
|
-
/**
|
|
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
|
+
*/
|
|
234
325
|
const DEM_DECODERS = {
|
|
235
326
|
terrarium: (r, g, b) => (r * 256 + g + b / 256) - 32768,
|
|
236
327
|
mapbox: (r, g, b) => -1e4 + (r * 65536 + g * 256 + b) * 0.1
|
|
237
328
|
};
|
|
238
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
|
+
|
|
239
560
|
/** Web Mercator pixel coordinates at zoom `z`, origin at the top-left corner of the world. */
|
|
240
561
|
function worldPixel(lon, lat, z, tileSize) {
|
|
241
562
|
const n = tileSize * Math.pow(2, z);
|
|
@@ -255,7 +576,9 @@
|
|
|
255
576
|
/** @param {Object} cfg `url`, `encoding`, `tileSize`, `concurrency`. */
|
|
256
577
|
constructor(cfg) {
|
|
257
578
|
this.cfg = cfg;
|
|
258
|
-
this.decode =
|
|
579
|
+
this.decode = (typeof cfg.encoding === 'function') ? cfg.encoding
|
|
580
|
+
: (DEM_DECODERS[cfg.encoding] || DEM_DECODERS.terrarium);
|
|
581
|
+
this.tileUrl = tileUrlFn(cfg);
|
|
259
582
|
this.tiles = new Map(); // "z/x/y" -> RGBA pixels, or null if the tile was lost
|
|
260
583
|
}
|
|
261
584
|
|
|
@@ -287,7 +610,8 @@
|
|
|
287
610
|
const data = this.tiles.get(r.key);
|
|
288
611
|
if (!data) return null;
|
|
289
612
|
const o = ((r.jj % ts) * ts + (r.ii % ts)) * 4;
|
|
290
|
-
|
|
613
|
+
const v = this.decode(data[o], data[o + 1], data[o + 2], data[o + 3]);
|
|
614
|
+
return (v == null || !isFinite(v)) ? null : v;
|
|
291
615
|
}
|
|
292
616
|
|
|
293
617
|
/**
|
|
@@ -321,7 +645,7 @@
|
|
|
321
645
|
/** Pixels of one tile, or null if it could not be read. */
|
|
322
646
|
_fetch(key) {
|
|
323
647
|
const parts = key.split('/');
|
|
324
|
-
const url = this.
|
|
648
|
+
const url = this.tileUrl(+parts[0], +parts[1], +parts[2]);
|
|
325
649
|
return new Promise((resolve) => {
|
|
326
650
|
const img = new Image();
|
|
327
651
|
// Without this attribute the canvas is tainted and getImageData throws: the tile
|
|
@@ -363,13 +687,44 @@
|
|
|
363
687
|
}
|
|
364
688
|
}
|
|
365
689
|
|
|
366
|
-
/**
|
|
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
|
+
*/
|
|
367
704
|
function demConfig(opt) {
|
|
368
705
|
if (!opt) return null;
|
|
706
|
+
if (typeof opt === 'function') return Object.assign({}, DEM_DEFAULTS, { sample: opt });
|
|
369
707
|
const raw = (opt === true || typeof opt === 'string') ? { source: opt === true ? 'terrarium' : opt } : Object.assign({}, opt);
|
|
370
|
-
|
|
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);
|
|
371
725
|
const cfg = Object.assign({}, DEM_DEFAULTS, preset, raw);
|
|
372
|
-
|
|
726
|
+
if (cfg.api === 'ign') { cfg.sample = ignSampler(cfg); return cfg; } // a point API
|
|
727
|
+
return (cfg.url || cfg.wms || cfg.olSource) ? cfg : null;
|
|
373
728
|
}
|
|
374
729
|
|
|
375
730
|
/**
|
|
@@ -418,6 +773,7 @@
|
|
|
418
773
|
* @property {boolean} [responsive=true] Adapt width/placement; mobile included.
|
|
419
774
|
* @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
|
|
420
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.
|
|
421
777
|
* @property {boolean} [ignoreStops=true] When computing time, ignore stopped segments (moving time).
|
|
422
778
|
* @property {number} [stopSpeed=0.5] Speed threshold (m/s) below which a segment counts as a stop.
|
|
423
779
|
* @property {Array<'distance'|'elevation'|'slope'|'time'>} [tooltipItems=['distance','elevation']] Tooltip content (`'time'` = elapsed time at the cursor, if the track has time data).
|
|
@@ -426,7 +782,16 @@
|
|
|
426
782
|
* @property {?string} [titleLink=null] Feature property holding a URL → clickable title.
|
|
427
783
|
* @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
|
|
428
784
|
* @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
|
|
429
|
-
* @property {?(boolean|string|Object)} [dem='terrarium'] Fill missing elevations
|
|
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`.
|
|
430
795
|
*/
|
|
431
796
|
|
|
432
797
|
/**
|
|
@@ -507,6 +872,8 @@
|
|
|
507
872
|
this._btnA = mkBtn('oep-a', ICON_A, o.labels.zoomStart, () => this._arm('A'));
|
|
508
873
|
this._btnB = mkBtn('oep-b', ICON_B, o.labels.zoomEnd, () => this._arm('B'));
|
|
509
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());
|
|
510
877
|
header.appendChild(this._toolbar);
|
|
511
878
|
|
|
512
879
|
const btn = document.createElement('button');
|
|
@@ -691,7 +1058,7 @@
|
|
|
691
1058
|
if (patch && (patch.theme || 'color' in patch)) this._applyTheme();
|
|
692
1059
|
if (patch && ('transparency' in patch || 'transparencyLevel' in patch)) this._applyTransparency();
|
|
693
1060
|
if (patch && 'collapsable' in patch) this._applyCollapsable();
|
|
694
|
-
if (patch && 'zoom' in patch) this._updateZoomButtons();
|
|
1061
|
+
if (patch && ('zoom' in patch || 'exportPng' in patch)) this._updateZoomButtons();
|
|
695
1062
|
if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
|
|
696
1063
|
if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
|
|
697
1064
|
if (this._feature) { this._compute(); this._fillFromDem(this._feature); this._updateZoomButtons(); if (this._collapsed) this._renderTitle(); else this._render(); }
|
|
@@ -719,7 +1086,9 @@
|
|
|
719
1086
|
const cfg = demConfig(this.options.dem);
|
|
720
1087
|
if (!cfg || !feature || this._demFor === feature) return;
|
|
721
1088
|
if (ElevationProfile.featureHasZ(feature)) return; // the track already carries its Z
|
|
722
|
-
|
|
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;
|
|
723
1092
|
|
|
724
1093
|
const geom = feature.getGeometry && feature.getGeometry();
|
|
725
1094
|
if (!geom) return;
|
|
@@ -732,28 +1101,67 @@
|
|
|
732
1101
|
// Sequence number: a track clicked while another is loading must win, otherwise the
|
|
733
1102
|
// slowest response would overwrite the profile on screen.
|
|
734
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
|
+
|
|
735
1129
|
const sampler = new DemSampler(cfg);
|
|
736
1130
|
const z = demZoomFor(sampler, lonlats, cfg);
|
|
737
|
-
this._demLoading = true;
|
|
738
1131
|
sampler.load(sampler.tilesFor(lonlats, z)).then((ok) => {
|
|
739
|
-
// A newer fill has taken over: it owns _demLoading and will clear it itself.
|
|
740
|
-
if (seq !== this._demSeq || this._feature !== feature) return;
|
|
741
1132
|
const zs = ok ? lonlats.map((ll) => sampler.sample(ll[0], ll[1], z)) : null;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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 });
|
|
757
1165
|
}
|
|
758
1166
|
|
|
759
1167
|
// ---------- computation -------------------------------------------
|
|
@@ -1067,11 +1475,147 @@
|
|
|
1067
1475
|
this._adjustAttribution();
|
|
1068
1476
|
}
|
|
1069
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
|
+
|
|
1070
1610
|
// ---------- zoom A/B ----------------------------------------------
|
|
1071
1611
|
_arm(which) { this._armed = (this._armed === which) ? null : which; this._updateZoomButtons(); if (this._focus) this._render(); }
|
|
1072
1612
|
_updateZoomButtons() {
|
|
1073
|
-
const
|
|
1074
|
-
|
|
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';
|
|
1075
1619
|
const crop = this._cropMode;
|
|
1076
1620
|
this._btnA.style.display = show && !crop ? '' : 'none';
|
|
1077
1621
|
this._btnB.style.display = show && !crop ? '' : 'none';
|