ol-elevation-profile 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/dist/ol-elevation-profile.css +1 -1
- package/dist/ol-elevation-profile.esm.js +377 -77
- package/dist/ol-elevation-profile.esm.min.js +1 -1
- package/dist/ol-elevation-profile.js +377 -77
- package/dist/ol-elevation-profile.min.js +1 -1
- package/package.json +1 -1
- package/src/ol-elevation-profile.css +1 -1
- package/src/ol-elevation-profile.js +377 -77
|
@@ -52,6 +52,53 @@
|
|
|
52
52
|
|
|
53
53
|
const POSITIONS = ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Jeux de libellés livrés avec la librairie. `en` est le défaut et la retombée : une
|
|
57
|
+
* langue inconnue rend l'anglais plutôt que des clés vides ou un mélange.
|
|
58
|
+
*
|
|
59
|
+
* Un jeu est complet, jamais partiel. Une traduction à trous laisserait deux langues
|
|
60
|
+
* cohabiter dans le même panneau, ce qui se voit ; `labels` reste là pour corriger une
|
|
61
|
+
* clé isolée sans avoir à redéclarer les autres.
|
|
62
|
+
*
|
|
63
|
+
* `ascent` et `descent` valent `D+` / `D-` partout : la notation est celle des cartes et
|
|
64
|
+
* des topos, pas un mot à traduire.
|
|
65
|
+
*/
|
|
66
|
+
const LOCALES = {
|
|
67
|
+
en: {
|
|
68
|
+
distance: 'Distance', elevation: 'Elevation', slope: 'Slope',
|
|
69
|
+
ascent: 'D+', descent: 'D-', empty: 'Click a track',
|
|
70
|
+
time: 'Time', duration: 'Duration',
|
|
71
|
+
durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'd' },
|
|
72
|
+
zoomStart: 'Set start (A)', zoomEnd: 'Set end (B)', zoomAll: 'Show all',
|
|
73
|
+
zoomBack: 'Back one level',
|
|
74
|
+
exportPng: 'Export as PNG',
|
|
75
|
+
collapse: 'Collapse the profile', expand: 'Expand the profile',
|
|
76
|
+
loading: 'Loading the elevation profile'
|
|
77
|
+
},
|
|
78
|
+
fr: {
|
|
79
|
+
distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
|
|
80
|
+
ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
|
|
81
|
+
time: 'Temps', duration: 'Durée',
|
|
82
|
+
durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
|
|
83
|
+
zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
|
|
84
|
+
zoomBack: 'Revenir au niveau précédent',
|
|
85
|
+
exportPng: 'Exporter en PNG',
|
|
86
|
+
collapse: 'Réduire le profil', expand: 'Agrandir le profil',
|
|
87
|
+
loading: 'Chargement du profil altimétrique'
|
|
88
|
+
},
|
|
89
|
+
es: {
|
|
90
|
+
distance: 'Distancia', elevation: 'Altitud', slope: 'Pendiente',
|
|
91
|
+
ascent: 'D+', descent: 'D-', empty: 'Haga clic en una traza',
|
|
92
|
+
time: 'Tiempo', duration: 'Duración',
|
|
93
|
+
durationUnits: { s: 'seg', m: 'min', h: 'h', d: 'd' },
|
|
94
|
+
zoomStart: 'Definir el inicio (A)', zoomEnd: 'Definir el final (B)', zoomAll: 'Ver todo',
|
|
95
|
+
zoomBack: 'Volver al nivel anterior',
|
|
96
|
+
exportPng: 'Exportar como PNG',
|
|
97
|
+
collapse: 'Contraer el perfil', expand: 'Desplegar el perfil',
|
|
98
|
+
loading: 'Cargando el perfil de elevación'
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
55
102
|
const DEFAULTS = {
|
|
56
103
|
immersion: 'docked',
|
|
57
104
|
position: 'bottom',
|
|
@@ -77,6 +124,8 @@
|
|
|
77
124
|
maxClasses: 8, // maximum number of slope classes (colours + legend)
|
|
78
125
|
xTicks: null,
|
|
79
126
|
yTicks: null,
|
|
127
|
+
verticalScale: 'auto', // 'auto' : le profil remplit la hauteur ;
|
|
128
|
+
// un nombre : mètres par centimètre physique
|
|
80
129
|
show: 'click',
|
|
81
130
|
collapsable: true,
|
|
82
131
|
collapsed: false,
|
|
@@ -86,6 +135,7 @@
|
|
|
86
135
|
responsive: true, // adapts width/placement, mobile included
|
|
87
136
|
mobileBreakpoint: 640, // <= screen width -> mobile mode (100% width, top/bottom)
|
|
88
137
|
zoom: false, // start/end buttons cropping map + profile to A..B
|
|
138
|
+
zoomLevels: 3, // niveaux de recadrage imbriqués (1 = l'ancien niveau unique)
|
|
89
139
|
exportPng: false, // toolbar button exporting the panel as a PNG
|
|
90
140
|
ignoreStops: true, // duration = moving time (stops excluded)
|
|
91
141
|
stopSpeed: 0.5, // stop threshold in m/s (~1.8 km/h)
|
|
@@ -93,17 +143,15 @@
|
|
|
93
143
|
headerItems: ['distance', 'ascent', 'descent', 'minmax'],
|
|
94
144
|
titleProperty: 'name',
|
|
95
145
|
titleLink: null,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
|
|
99
|
-
time: 'Temps', duration: 'Durée',
|
|
100
|
-
durationUnits: { s: 'sec', m: 'min', h: 'h', d: 'j' },
|
|
101
|
-
zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir',
|
|
102
|
-
exportPng: 'Exporter en PNG',
|
|
103
|
-
loading: 'Chargement du profil altimétrique'
|
|
104
|
-
}
|
|
146
|
+
lang: 'en', // 'en' | 'fr' | 'es' : jeu de libellés livré
|
|
147
|
+
labels: {} // surcharges clé à clé, appliquées par-dessus la langue
|
|
105
148
|
};
|
|
106
149
|
|
|
150
|
+
/** Libellés de la langue [lang], corrigés par les surcharges [over]. */
|
|
151
|
+
function resolveLabels(lang, over) {
|
|
152
|
+
return deepMerge(LOCALES[lang] || LOCALES.en, over || null);
|
|
153
|
+
}
|
|
154
|
+
|
|
107
155
|
// ----- helpers ---------------------------------------------------------
|
|
108
156
|
function deepMerge(base, over) {
|
|
109
157
|
const out = {};
|
|
@@ -206,15 +254,20 @@
|
|
|
206
254
|
const ICON_A = '<svg viewBox="0 0 24 24"><path d="M7 5v14M11 12h8m0 0-3-3m3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
207
255
|
const ICON_B = '<svg viewBox="0 0 24 24"><path d="M17 5v14M13 12H5m0 0 3-3m-3 3 3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
208
256
|
const ICON_PNG = '<svg viewBox="0 0 24 24"><path d="M12 4v10m0 0 4-4m-4 4-4-4M5 18h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
257
|
+
const ICON_BACK = '<svg viewBox="0 0 24 24"><path d="M10 6 4 12l6 6M4 12h10a6 6 0 0 1 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
209
258
|
const ICON_ALL = '<svg viewBox="0 0 24 24"><path d="M4 12h16M4 12l4-4M4 12l4 4M20 12l-4-4M20 12l-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
210
259
|
|
|
260
|
+
// Recherche du segment contenant une abscisse. Construit une fois : le survol la sollicite
|
|
261
|
+
// à chaque mouvement de souris.
|
|
262
|
+
const bisectX = d3__namespace.bisector((d) => d.x).left;
|
|
263
|
+
|
|
211
264
|
// ----- digital elevation model (DEM) -----------------------------------
|
|
212
265
|
/**
|
|
213
266
|
* Known terrain-tile sources.
|
|
214
267
|
*
|
|
215
268
|
* A PNG tile carries elevation in its R/G/B channels; it is read on a canvas rather
|
|
216
269
|
* than queried point by point from an API. That is what makes a 10 000-point track a
|
|
217
|
-
* handful of requests, with no key, no quota and no rate limit
|
|
270
|
+
* handful of requests, with no key, no quota and no rate limit, where the free
|
|
218
271
|
* elevation APIs cap out at 100 or 200 points per call.
|
|
219
272
|
*
|
|
220
273
|
* Attribution is not automatic: the library does not own the map. It is up to the
|
|
@@ -311,7 +364,7 @@
|
|
|
311
364
|
const DEM_DEFAULTS = { source: 'terrarium', zoom: 'auto', maxZoom: 14, maxTiles: 32, concurrency: 6, tileSize: 256 };
|
|
312
365
|
|
|
313
366
|
/**
|
|
314
|
-
* RGB
|
|
367
|
+
* RGB to metres decoders, one per encoding convention.
|
|
315
368
|
*
|
|
316
369
|
* `encoding` also takes a function `(r, g, b, a) => metres`, for a tile set that packs
|
|
317
370
|
* elevation its own way. Return null where the pixel carries no measurement: the fill is
|
|
@@ -620,7 +673,7 @@
|
|
|
620
673
|
*
|
|
621
674
|
* Bilinear rather than "the pixel containing the point": on a 30 or 90 m model, taking
|
|
622
675
|
* the pixel value makes the profile advance in stairs, and every stair counts as a
|
|
623
|
-
* climb then a descent in the ascent total. The interpolation invents no relief
|
|
676
|
+
* climb then a descent in the ascent total. The interpolation invents no relief: it
|
|
624
677
|
* renders the same surface, without the steps of the sampling grid.
|
|
625
678
|
*/
|
|
626
679
|
sample(lon, lat, z) {
|
|
@@ -688,8 +741,8 @@
|
|
|
688
741
|
}
|
|
689
742
|
|
|
690
743
|
/**
|
|
691
|
-
* The `dem` option (true | source name | sampling function | object)
|
|
692
|
-
* configuration, or null.
|
|
744
|
+
* The `dem` option (true | source name | sampling function | object) into
|
|
745
|
+
* a full configuration, or null.
|
|
693
746
|
*
|
|
694
747
|
* A `sample` function short-circuits the tile machinery entirely: the application
|
|
695
748
|
* answers with the elevations itself, from wherever it can reach them - a GeoServer
|
|
@@ -758,12 +811,19 @@
|
|
|
758
811
|
* @property {boolean} [slope=false] Split the profile into slope-class colored portions.
|
|
759
812
|
* @property {number} [slopeClassSize=2.5] Slope class width, in percent.
|
|
760
813
|
* @property {number} [maxClasses=8] Maximum number of slope classes (colors + legend).
|
|
761
|
-
* @property {?string[]} [slopeColors=null] `null` = blue
|
|
814
|
+
* @property {?string[]} [slopeColors=null] `null` = blue-to-red ramp; otherwise an interpolated color array.
|
|
762
815
|
* @property {boolean} [slopeSeparators=true] Vertical separator at each slope-class change.
|
|
763
816
|
* @property {boolean} [slopeLegend=true] Color legend under the title.
|
|
764
817
|
* @property {number} [smoothing=0] Elevation smoothing window, in METERS (0 = none).
|
|
765
818
|
* @property {?number} [xTicks=null] X axis ticks (null = auto from width).
|
|
766
819
|
* @property {?number} [yTicks=null] Y axis ticks (null = auto from height).
|
|
820
|
+
* @property {('auto'|number)} [verticalScale='auto'] `'auto'`: the profile fills the
|
|
821
|
+
* height, which is legible but changes scale from one track to the next, so a 2 % ramp
|
|
822
|
+
* looks like a wall and two profiles cannot be compared. A number fixes the metres
|
|
823
|
+
* covered per physical centimetre. It is a **floor**, not a cage: a track whose range
|
|
824
|
+
* exceeds what the height can show would spill out of the frame, which is worse than
|
|
825
|
+
* losing comparability: the scale then widens silently, nothing being drawn on the
|
|
826
|
+
* chart to say so.
|
|
767
827
|
* @property {'click'|'mouseover'} [show='click'] How a track is selected on the map.
|
|
768
828
|
* @property {boolean} [hideOnMapClick=true] Click on empty map hides the profile.
|
|
769
829
|
* @property {boolean} [collapsable=true] Show the collapse/expand button.
|
|
@@ -773,20 +833,27 @@
|
|
|
773
833
|
* @property {boolean} [responsive=true] Adapt width/placement; mobile included.
|
|
774
834
|
* @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
|
|
775
835
|
* @property {boolean} [zoom=false] A/B buttons to crop map + profile to a sub-range.
|
|
836
|
+
* @property {number} [zoomLevels=3] Nested crops allowed: a crop can itself be cropped,
|
|
837
|
+
* down to this depth. `1` restores the former single level. A "back" button appears
|
|
838
|
+
* from the second level; "show all" empties the stack whatever the depth.
|
|
776
839
|
* @property {boolean} [exportPng=false] Toolbar button exporting the whole panel as a PNG.
|
|
777
840
|
* @property {boolean} [ignoreStops=true] When computing time, ignore stopped segments (moving time).
|
|
778
841
|
* @property {number} [stopSpeed=0.5] Speed threshold (m/s) below which a segment counts as a stop.
|
|
779
842
|
* @property {Array<'distance'|'elevation'|'slope'|'time'>} [tooltipItems=['distance','elevation']] Tooltip content (`'time'` = elapsed time at the cursor, if the track has time data).
|
|
780
843
|
* @property {Array<string|{property:string,label?:string,asLink?:boolean,linkText?:string}>} [headerItems] Header content (string tokens: distance, ascent, descent, min, max, minmax, `'duration'` = total elapsed time).
|
|
844
|
+
* @property {('en'|'fr'|'es')} [lang='en'] Language of the shipped labels. An unknown
|
|
845
|
+
* code falls back to English rather than leaving keys empty.
|
|
846
|
+
* @property {Object} [labels={}] Per-key overrides applied on top of `lang`. They survive
|
|
847
|
+
* a later language change, so a corrected key stays corrected.
|
|
781
848
|
* @property {string} [titleProperty='name'] Feature property used as the title.
|
|
782
|
-
* @property {?string} [titleLink=null] Feature property holding a URL
|
|
849
|
+
* @property {?string} [titleLink=null] Feature property holding a URL, making the title a link.
|
|
783
850
|
* @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
|
|
784
851
|
* @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
|
|
785
852
|
* @property {?(boolean|string|Function|Object)} [dem='terrarium'] Fill missing elevations
|
|
786
853
|
* from a terrain model. Sources: `'terrarium'` (default) or `true` = AWS Terrain Tiles;
|
|
787
854
|
* `'ign'` = IGN Géoplateforme RGE ALTI (France, keyless, `apiKey` optional);
|
|
788
855
|
* `{url}` = XYZ template; `{wms:{url,layers,params}}` = WMS tiles;
|
|
789
|
-
* `{olSource}` = any `ol/source/TileImage` (XYZ, TileWMS,
|
|
856
|
+
* `{olSource}` = any `ol/source/TileImage` (XYZ, TileWMS, and so on);
|
|
790
857
|
* `{featureInfo:{url,layers,property}}` = WMS GetFeatureInfo, one request per point,
|
|
791
858
|
* for a greyscale coverage that cannot be decoded from its pixels (slow);
|
|
792
859
|
* a function or `{sample}` `(lonlats, ctx) => number[]|Promise<number[]>` to source them
|
|
@@ -808,23 +875,37 @@
|
|
|
808
875
|
constructor(opts) {
|
|
809
876
|
const o = deepMerge(DEFAULTS, opts || {});
|
|
810
877
|
if (POSITIONS.indexOf(o.position) === -1) o.position = 'bottom';
|
|
878
|
+
// Les surcharges sont gardées à part de leur résultat : changer de langue plus tard
|
|
879
|
+
// doit reprendre le jeu neuf de la nouvelle langue sans perdre ce que l'appelant a
|
|
880
|
+
// corrigé, ce que le seul jeu résolu ne permettrait plus de démêler.
|
|
881
|
+
const userLabels = o.labels;
|
|
882
|
+
o.labels = resolveLabels(o.lang, userLabels);
|
|
811
883
|
const element = buildElement(o);
|
|
812
884
|
super({ element, target: opts && opts.target });
|
|
813
885
|
|
|
814
886
|
this.options = o;
|
|
887
|
+
this._userLabels = userLabels;
|
|
815
888
|
this.slopeColors = o.slopeColors || null;
|
|
816
889
|
this._feature = null;
|
|
817
890
|
this._fullSamples = null; this._fullStats = null;
|
|
818
891
|
this._samples = null; this._stats = null;
|
|
819
892
|
this._marker = null;
|
|
820
893
|
this._collapsed = !!o.collapsed;
|
|
821
|
-
this.
|
|
894
|
+
this._crops = []; this._off = 0;
|
|
895
|
+
this._zoomA = null; this._zoomB = null; this._armed = null;
|
|
822
896
|
this._demZ = null; this._demFor = null; this._demSeq = 0; this._demLoading = false;
|
|
823
897
|
this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
|
|
824
898
|
this._buildDom(element);
|
|
825
899
|
element.style.display = 'none';
|
|
826
900
|
}
|
|
827
901
|
|
|
902
|
+
/** Recadré dès qu'un niveau est empilé. Lu partout où le booléen l'était. */
|
|
903
|
+
get _cropMode() { return this._crops.length > 0; }
|
|
904
|
+
/** Profondeur courante ; 0 quand on voit la trace entière. */
|
|
905
|
+
get _cropDepth() { return this._crops.length; }
|
|
906
|
+
/** Niveaux imbriqués autorisés, au moins un. */
|
|
907
|
+
get _cropMax() { const n = this.options.zoomLevels; return n == null ? 3 : Math.max(1, n | 0); }
|
|
908
|
+
|
|
828
909
|
// ---------- static helpers ----------------------------------------
|
|
829
910
|
/**
|
|
830
911
|
* Whether a feature has any Z (elevation) coordinate.
|
|
@@ -871,6 +952,7 @@
|
|
|
871
952
|
};
|
|
872
953
|
this._btnA = mkBtn('oep-a', ICON_A, o.labels.zoomStart, () => this._arm('A'));
|
|
873
954
|
this._btnB = mkBtn('oep-b', ICON_B, o.labels.zoomEnd, () => this._arm('B'));
|
|
955
|
+
this._btnBack = mkBtn('oep-back', ICON_BACK, o.labels.zoomBack, () => this._popCrop());
|
|
874
956
|
this._btnAll = mkBtn('oep-all', ICON_ALL, o.labels.zoomAll, () => this._exitZoom());
|
|
875
957
|
// Last of the toolbar, so it sits to the right of the zoom buttons.
|
|
876
958
|
this._btnPng = mkBtn('oep-png', ICON_PNG, o.labels.exportPng, () => this.exportPNG());
|
|
@@ -878,11 +960,11 @@
|
|
|
878
960
|
|
|
879
961
|
const btn = document.createElement('button');
|
|
880
962
|
btn.className = 'oep-toggle'; btn.type = 'button';
|
|
881
|
-
btn.setAttribute('aria-label', 'Réduire ou agrandir le profil');
|
|
882
963
|
btn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
|
|
883
964
|
btn.addEventListener('click', () => this.toggleCollapsed());
|
|
884
965
|
header.appendChild(btn);
|
|
885
966
|
this._toggleBtn = btn;
|
|
967
|
+
this._applyToggleLabel();
|
|
886
968
|
|
|
887
969
|
this._legendEl = document.createElement('div'); this._legendEl.className = 'oep-legend'; this._legendEl.style.display = 'none';
|
|
888
970
|
this._body = document.createElement('div'); this._body.className = 'oep-body';
|
|
@@ -893,6 +975,34 @@
|
|
|
893
975
|
if (this._collapsed) root.classList.add('oep-collapsed');
|
|
894
976
|
}
|
|
895
977
|
|
|
978
|
+
/**
|
|
979
|
+
* Reporte les libellés courants sur ce que le rendu ne réécrit pas.
|
|
980
|
+
*
|
|
981
|
+
* Les boutons et le bouton de repli sont construits une fois pour toutes ; sans cela,
|
|
982
|
+
* changer de langue ne toucherait que le graphe et laisserait les infobulles dans
|
|
983
|
+
* l'ancienne. Le titre n'est repris que faute de tracé : sinon il porte le nom du tracé,
|
|
984
|
+
* qui n'est pas un libellé de la librairie.
|
|
985
|
+
*/
|
|
986
|
+
_applyLabels() {
|
|
987
|
+
const l = this.options.labels;
|
|
988
|
+
const set = (b, t) => { if (b && t) { b.title = t; b.setAttribute('aria-label', t); } };
|
|
989
|
+
set(this._btnA, l.zoomStart); set(this._btnB, l.zoomEnd);
|
|
990
|
+
set(this._btnBack, l.zoomBack); set(this._btnAll, l.zoomAll); set(this._btnPng, l.exportPng);
|
|
991
|
+
this._applyToggleLabel();
|
|
992
|
+
if (this._titleEl && !this._feature) this._titleEl.textContent = l.empty;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Le bouton de repli dit ce qu'il va faire, non ce qu'il montre.
|
|
996
|
+
*
|
|
997
|
+
* Un seul libellé pour les deux états ("réduire ou agrandir") laisse deviner lequel
|
|
998
|
+
* des deux le clic déclenche ; l'icône, elle, a déjà basculé. Le titre suit donc l'état,
|
|
999
|
+
* et l'infobulle visible dit la même chose que le nom accessible.
|
|
1000
|
+
*/
|
|
1001
|
+
_applyToggleLabel() {
|
|
1002
|
+
const l = this.options.labels, t = this._collapsed ? l.expand : l.collapse;
|
|
1003
|
+
if (!this._toggleBtn || !t) return;
|
|
1004
|
+
this._toggleBtn.title = t; this._toggleBtn.setAttribute('aria-label', t);
|
|
1005
|
+
}
|
|
896
1006
|
_resolveColor() { const o = this.options; if (!o.color) return null; if (o.color === 'auto') return this._featureColor(); return o.color; }
|
|
897
1007
|
_featureColor() {
|
|
898
1008
|
const f = this._feature; if (!f) return null;
|
|
@@ -933,6 +1043,7 @@
|
|
|
933
1043
|
this._collapsed = (typeof force === 'boolean') ? force : !this._collapsed;
|
|
934
1044
|
this.element.classList.toggle('oep-collapsed', this._collapsed);
|
|
935
1045
|
this._toggleBtn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
|
|
1046
|
+
this._applyToggleLabel();
|
|
936
1047
|
if (!this._collapsed && this._feature) this._render();
|
|
937
1048
|
this._adjustAttribution();
|
|
938
1049
|
}
|
|
@@ -997,19 +1108,32 @@
|
|
|
997
1108
|
});
|
|
998
1109
|
|
|
999
1110
|
this._mapKeys = [];
|
|
1000
|
-
|
|
1001
|
-
|
|
1111
|
+
// `show`, `hideOnMapClick` et `followMap` sont relus à l'événement, non à
|
|
1112
|
+
// l'abonnement. Les abonnements sont posés une fois pour toutes, ici ; les figer
|
|
1113
|
+
// rendait ces trois options muettes dès que `setOptions` les changeait ensuite,
|
|
1114
|
+
// basculer en survol ne faisait rien, sans que rien ne le dise. Les deux types
|
|
1115
|
+
// d'événement sont donc écoutés en permanence, et c'est l'option qui tranche.
|
|
1116
|
+
this._mapKeys.push(map.on('click', (evt) => {
|
|
1117
|
+
const feature = lineAt(evt.pixel);
|
|
1118
|
+
if (this.options.show !== 'mouseover' && feature && feature !== this._feature) this.setFeature(feature);
|
|
1119
|
+
if (this.options.hideOnMapClick && !feature && this._feature) this.clear();
|
|
1002
1120
|
}));
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1121
|
+
this._mapKeys.push(map.on('pointermove', (evt) => {
|
|
1122
|
+
if (this.options.show === 'mouseover') {
|
|
1123
|
+
const feature = lineAt(evt.pixel);
|
|
1124
|
+
if (feature && feature !== this._feature) this.setFeature(feature);
|
|
1125
|
+
}
|
|
1126
|
+
// Le survol coûte un `lineAt` par déplacement : rien n'est cherché tant qu'aucune
|
|
1127
|
+
// des deux options ne le demande.
|
|
1128
|
+
if (!this.options.followMap || !this._feature || this._collapsed) return;
|
|
1006
1129
|
const cp = this._closestOnProfile(evt.coordinate); if (!cp) return;
|
|
1007
1130
|
const px = map.getPixelFromCoordinate(cp); if (!px) return;
|
|
1008
1131
|
if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
|
|
1009
1132
|
}));
|
|
1010
1133
|
// leave the A/B crop when the map is zoomed out
|
|
1011
1134
|
this._mapKeys.push(map.on('moveend', () => {
|
|
1012
|
-
|
|
1135
|
+
const cur = this._crops[this._crops.length - 1];
|
|
1136
|
+
if (cur && cur.fitRes && map.getView().getResolution() > cur.fitRes * 1.25) this._popCrop();
|
|
1013
1137
|
}));
|
|
1014
1138
|
this._mapKeys.push(map.on('change:size', this._onResize));
|
|
1015
1139
|
}
|
|
@@ -1024,7 +1148,7 @@
|
|
|
1024
1148
|
*/
|
|
1025
1149
|
setFeature(feature) {
|
|
1026
1150
|
this._feature = feature || null;
|
|
1027
|
-
this.
|
|
1151
|
+
this._crops = []; this._off = 0; this._zoomA = null; this._zoomB = null; this._armed = null;
|
|
1028
1152
|
if (!feature) { this._fullSamples = this._samples = null; this._demZ = this._demFor = null; this._clear(); return this; }
|
|
1029
1153
|
this.element.style.display = '';
|
|
1030
1154
|
this._compute();
|
|
@@ -1058,6 +1182,13 @@
|
|
|
1058
1182
|
if (patch && (patch.theme || 'color' in patch)) this._applyTheme();
|
|
1059
1183
|
if (patch && ('transparency' in patch || 'transparencyLevel' in patch)) this._applyTransparency();
|
|
1060
1184
|
if (patch && 'collapsable' in patch) this._applyCollapsable();
|
|
1185
|
+
if (patch && 'labels' in patch) this._userLabels = deepMerge(this._userLabels || {}, patch.labels);
|
|
1186
|
+
// Recalculé depuis la langue, jamais empilé sur le jeu précédent : passer de 'fr' à
|
|
1187
|
+
// 'es' ne doit rien laisser en français derrière lui, hors surcharges de l'appelant.
|
|
1188
|
+
if (patch && ('lang' in patch || 'labels' in patch)) {
|
|
1189
|
+
this.options.labels = resolveLabels(this.options.lang, this._userLabels);
|
|
1190
|
+
this._applyLabels();
|
|
1191
|
+
}
|
|
1061
1192
|
if (patch && ('zoom' in patch || 'exportPng' in patch)) this._updateZoomButtons();
|
|
1062
1193
|
if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
|
|
1063
1194
|
if (patch && 'dem' in patch) { this._demZ = null; this._demFor = null; }
|
|
@@ -1074,7 +1205,7 @@
|
|
|
1074
1205
|
* and the ascent total becomes absurd. Better the flat profile we would have had
|
|
1075
1206
|
* without the DEM.
|
|
1076
1207
|
*
|
|
1077
|
-
* Nothing is reported to the user on failure
|
|
1208
|
+
* Nothing is reported to the user on failure: the fill is a supplement, it has no
|
|
1078
1209
|
* business breaking a display that succeeded without it. The `demload` event lets the
|
|
1079
1210
|
* application know if it wants to.
|
|
1080
1211
|
*
|
|
@@ -1204,7 +1335,60 @@
|
|
|
1204
1335
|
this._addSlope(samples);
|
|
1205
1336
|
this._fullSamples = samples;
|
|
1206
1337
|
this._fullStats = this._statsOf(samples, true);
|
|
1207
|
-
|
|
1338
|
+
// Un recalcul (option changée, altitudes arrivées du MNT) ne doit pas défaire le
|
|
1339
|
+
// recadrage : la pile est rejouée sur les nouveaux échantillons. Pile vide, on
|
|
1340
|
+
// retombe sur la trace entière, ce que faisait l'affectation directe d'avant.
|
|
1341
|
+
this._applyCrops();
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Pixels CSS d'un centimètre physique, mesurés plutôt que déduits.
|
|
1345
|
+
*
|
|
1346
|
+
* Le rapport nominal est de 96 px par pouce, soit 37,8 px par centimètre, mais le zoom
|
|
1347
|
+
* du navigateur le déplace : mesurer un élément d'un centimètre suit ce zoom là où une
|
|
1348
|
+
* constante mentirait. Non mémorisé - la mesure force un calcul de disposition, mais un
|
|
1349
|
+
* rendu est rare et un cache se périmerait au premier Ctrl+molette.
|
|
1350
|
+
*/
|
|
1351
|
+
_pxPerCm() {
|
|
1352
|
+
try {
|
|
1353
|
+
const temoin = document.createElement('div');
|
|
1354
|
+
temoin.style.cssText = 'position:absolute;left:-9999px;top:0;width:1cm;height:1cm';
|
|
1355
|
+
(this.element || document.body).appendChild(temoin);
|
|
1356
|
+
const px = temoin.getBoundingClientRect().height;
|
|
1357
|
+
temoin.remove();
|
|
1358
|
+
if (px > 0) return px;
|
|
1359
|
+
} catch (e) { /* document indisponible : on retombe sur la valeur nominale */ }
|
|
1360
|
+
return 96 / 2.54;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Échelle verticale du graphe.
|
|
1365
|
+
*
|
|
1366
|
+
* En `'auto'`, le profil remplit la hauteur : c'est lisible, mais l'échelle change d'une
|
|
1367
|
+
* trace à l'autre, si bien qu'une pente de 2 % y prend l'allure d'un mur et que deux
|
|
1368
|
+
* profils ne se comparent pas. Un nombre fixe au contraire les mètres couverts par
|
|
1369
|
+
* centimètre physique.
|
|
1370
|
+
*
|
|
1371
|
+
* La valeur demandée est un **plancher**, non un carcan : une trace dont l'amplitude
|
|
1372
|
+
* dépasse ce que la hauteur peut montrer déborderait du cadre, ce qui est pire que de
|
|
1373
|
+
* perdre la comparabilité. Le graphe n'en dit rien : l'échelle n'est pas une donnée de
|
|
1374
|
+
* la trace et n'a pas à encombrer le dessin ; l'échelle réellement appliquée est
|
|
1375
|
+
* seulement publiée dans `_vScale`, à qui veut la connaître.
|
|
1376
|
+
*
|
|
1377
|
+
* `nice()` n'est pas appliqué en échelle absolue : il arrondit le domaine vers
|
|
1378
|
+
* l'extérieur, donc il fausserait le rapport qu'on vient de fixer.
|
|
1379
|
+
*/
|
|
1380
|
+
_yScale(s, zpad, innerH) {
|
|
1381
|
+
const o = this.options, bas = s.min - zpad, haut = s.max + zpad;
|
|
1382
|
+
const vs = typeof o.verticalScale === 'number' ? o.verticalScale : 0;
|
|
1383
|
+
if (!(vs > 0)) {
|
|
1384
|
+
this._vScale = null;
|
|
1385
|
+
return d3__namespace.scaleLinear().domain([bas, haut]).range([innerH, 0]).nice();
|
|
1386
|
+
}
|
|
1387
|
+
const cm = innerH / this._pxPerCm(); // hauteur du graphe, en centimètres
|
|
1388
|
+
const etendue = Math.max(vs * cm, haut - bas); // jamais moins qu'il n'en faut
|
|
1389
|
+
const milieu = (bas + haut) / 2;
|
|
1390
|
+
this._vScale = etendue / cm; // m/cm effectivement appliqués
|
|
1391
|
+
return d3__namespace.scaleLinear().domain([milieu - etendue / 2, milieu + etendue / 2]).range([innerH, 0]);
|
|
1208
1392
|
}
|
|
1209
1393
|
_statsOf(samples, fromTotal) {
|
|
1210
1394
|
let ascent = 0, descent = 0, zmin = Infinity, zmax = -Infinity, maxAbs = 0;
|
|
@@ -1276,7 +1460,7 @@
|
|
|
1276
1460
|
// 7 sec | 26 min | 1 h 48 min | 2 d 3 h (days + hours normalised)
|
|
1277
1461
|
_fmtDuration(sec) {
|
|
1278
1462
|
if (sec == null || !isFinite(sec)) return '';
|
|
1279
|
-
const u = (this.options.labels && this.options.labels.durationUnits) ||
|
|
1463
|
+
const u = (this.options.labels && this.options.labels.durationUnits) || LOCALES.en.durationUnits;
|
|
1280
1464
|
sec = Math.max(0, Math.round(sec));
|
|
1281
1465
|
if (sec < 60) return sec + ' ' + u.s;
|
|
1282
1466
|
if (sec < 3600) return Math.round(sec / 60) + ' ' + u.m;
|
|
@@ -1295,7 +1479,7 @@
|
|
|
1295
1479
|
* Track title, and its optional link.
|
|
1296
1480
|
*
|
|
1297
1481
|
* Its own method because it is the only part of the header that stays visible once
|
|
1298
|
-
* collapsed
|
|
1482
|
+
* collapsed: the CSS hides the body, the stats, the legend and the toolbar. `_render`
|
|
1299
1483
|
* is skipped while collapsed, so without a separate entry point the title would keep
|
|
1300
1484
|
* naming the previous track after a change of feature.
|
|
1301
1485
|
*/
|
|
@@ -1328,7 +1512,7 @@
|
|
|
1328
1512
|
else if (it === 'descent') { html.push(`<span class="oep-down">${o.labels.descent} ${fmtElevation(s.descent, o.units)}</span>`); text.push(`${o.labels.descent} ${fmtElevation(s.descent, o.units)}`); }
|
|
1329
1513
|
else if (it === 'min') { html.push(fmtElevation(s.min, o.units)); text.push(fmtElevation(s.min, o.units)); }
|
|
1330
1514
|
else if (it === 'max') { html.push(fmtElevation(s.max, o.units)); text.push(fmtElevation(s.max, o.units)); }
|
|
1331
|
-
else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}
|
|
1515
|
+
else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}-${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
|
|
1332
1516
|
else if (it === 'duration') { if (s.duration != null) { const v = this._fmtDuration(s.duration); html.push(`<span class="oep-time">${esc(o.labels.duration)} ${v}</span>`); text.push(`${o.labels.duration} ${v}`); } }
|
|
1333
1517
|
} else if (it && it.property) {
|
|
1334
1518
|
const val = f.get && f.get(it.property); if (val == null || val === '') return;
|
|
@@ -1345,7 +1529,7 @@
|
|
|
1345
1529
|
const sc = this._slopeScale();
|
|
1346
1530
|
for (let idx = 0; idx <= sc.maxIdx; idx++) {
|
|
1347
1531
|
const color = sc.colorByIndex(idx);
|
|
1348
|
-
const label = (sc.capped && idx === sc.maxIdx) ? `≥ ${idx * sc.classSize} %` : `${idx * sc.classSize}
|
|
1532
|
+
const label = (sc.capped && idx === sc.maxIdx) ? `≥ ${idx * sc.classSize} %` : `${idx * sc.classSize}-${(idx + 1) * sc.classSize} %`;
|
|
1349
1533
|
const item = document.createElement('span'); item.className = 'oep-leg-it';
|
|
1350
1534
|
item.innerHTML = `<i class="oep-sw" style="background:${color}"></i>${label}`;
|
|
1351
1535
|
this._legendEl.appendChild(item);
|
|
@@ -1408,7 +1592,7 @@
|
|
|
1408
1592
|
|
|
1409
1593
|
const x = d3__namespace.scaleLinear().domain([0, s.distance]).range([0, innerW]);
|
|
1410
1594
|
const zpad = (s.max - s.min) * 0.1 || 10;
|
|
1411
|
-
const y =
|
|
1595
|
+
const y = this._yScale(s, zpad, innerH);
|
|
1412
1596
|
this._x = x; this._y = y; this._dims = { innerW, innerH };
|
|
1413
1597
|
|
|
1414
1598
|
if (o.grid) g.append('g').attr('class', 'oep-grid').call(d3__namespace.axisLeft(y).ticks(yTicks).tickSize(-innerW).tickFormat(''));
|
|
@@ -1441,7 +1625,7 @@
|
|
|
1441
1625
|
g.append('g').attr('class', 'oep-axis oep-axis-y').call(d3__namespace.axisLeft(y).ticks(yTicks).tickFormat((d) => o.units === 'imperial' ? Math.round(d * 3.28084) : d));
|
|
1442
1626
|
|
|
1443
1627
|
// A / B markers while a range is being picked
|
|
1444
|
-
if (o.zoom &&
|
|
1628
|
+
if (o.zoom && this._cropDepth < this._cropMax) {
|
|
1445
1629
|
[['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
|
|
1446
1630
|
if (val == null) return;
|
|
1447
1631
|
const px = x(val);
|
|
@@ -1456,21 +1640,13 @@
|
|
|
1456
1640
|
const lbl = focus.append('g').attr('class', 'oep-focus-label'); lbl.append('rect').attr('class', 'oep-focus-bg'); lbl.append('text').attr('class', 'oep-focus-txt');
|
|
1457
1641
|
this._focus = focus;
|
|
1458
1642
|
|
|
1459
|
-
const bisect = d3__namespace.bisector((d) => d.x).left;
|
|
1460
1643
|
const pick = (event) => { const mx = d3__namespace.pointer(event, g.node())[0]; return Math.max(0, Math.min(s.distance, x.invert(mx))); };
|
|
1461
1644
|
const overlay = svg.append('rect').attr('class', 'oep-overlay').attr('x', ml).attr('y', mt).attr('width', innerW).attr('height', innerH);
|
|
1462
1645
|
overlay.on('mousemove', (event) => {
|
|
1463
|
-
const
|
|
1464
|
-
const d0 = data[idx - 1], d1 = data[idx] || d0; const d = (x0 - d0.x) > (d1.x - x0) ? d1 : d0;
|
|
1646
|
+
const d = this._sampleAt(pick(event)); if (!d) return;
|
|
1465
1647
|
this._setFocus(d); if (this._marker) this._marker.setPosition(d.coord);
|
|
1466
1648
|
}).on('mouseout', () => this._clearFocus());
|
|
1467
|
-
overlay.on('click', (event) =>
|
|
1468
|
-
if (!this._armed || this._cropMode) return;
|
|
1469
|
-
const x0 = pick(event);
|
|
1470
|
-
if (this._armed === 'A') this._zoomA = x0; else this._zoomB = x0;
|
|
1471
|
-
this._armed = null; this._updateZoomButtons();
|
|
1472
|
-
if (this._zoomA != null && this._zoomB != null) this._applyZoom(); else this._render();
|
|
1473
|
-
});
|
|
1649
|
+
overlay.on('click', (event) => this._placeBound(pick(event)));
|
|
1474
1650
|
if (this._armed) overlay.style('cursor', 'col-resize');
|
|
1475
1651
|
this._adjustAttribution();
|
|
1476
1652
|
}
|
|
@@ -1609,6 +1785,22 @@
|
|
|
1609
1785
|
|
|
1610
1786
|
// ---------- zoom A/B ----------------------------------------------
|
|
1611
1787
|
_arm(which) { this._armed = (this._armed === which) ? null : which; this._updateZoomButtons(); if (this._focus) this._render(); }
|
|
1788
|
+
/**
|
|
1789
|
+
* Pose la borne armée à l'abscisse [x0], puis arme l'autre tant qu'elle manque.
|
|
1790
|
+
*
|
|
1791
|
+
* Poser A n'a d'intérêt que pour poser B ensuite : enchaîner d'un clic sur l'autre
|
|
1792
|
+
* évite l'aller-retour par la barre d'outils entre les deux. Les deux bornes réunies,
|
|
1793
|
+
* le recadrage part et rien ne reste armé.
|
|
1794
|
+
*/
|
|
1795
|
+
_placeBound(x0) {
|
|
1796
|
+
if (!this._armed || this._cropDepth >= this._cropMax) return;
|
|
1797
|
+
const autre = this._armed === 'A' ? 'B' : 'A';
|
|
1798
|
+
if (this._armed === 'A') this._zoomA = x0; else this._zoomB = x0;
|
|
1799
|
+
const complet = this._zoomA != null && this._zoomB != null;
|
|
1800
|
+
this._armed = complet ? null : autre;
|
|
1801
|
+
this._updateZoomButtons();
|
|
1802
|
+
if (complet) this._pushCrop(this._zoomA, this._zoomB); else this._render();
|
|
1803
|
+
}
|
|
1612
1804
|
_updateZoomButtons() {
|
|
1613
1805
|
const o = this.options, has = !!this._feature;
|
|
1614
1806
|
const show = !!o.zoom && has;
|
|
@@ -1616,37 +1808,96 @@
|
|
|
1616
1808
|
// The toolbar carries both: it stays visible as long as either has something to show.
|
|
1617
1809
|
this._toolbar.style.display = (show || png) ? '' : 'none';
|
|
1618
1810
|
this._btnPng.style.display = png ? '' : 'none';
|
|
1619
|
-
const
|
|
1620
|
-
this._btnA.style.display = show &&
|
|
1621
|
-
this._btnB.style.display = show &&
|
|
1622
|
-
|
|
1811
|
+
const deep = this._cropDepth, peutDescendre = deep < this._cropMax;
|
|
1812
|
+
this._btnA.style.display = show && peutDescendre ? '' : 'none';
|
|
1813
|
+
this._btnB.style.display = show && peutDescendre ? '' : 'none';
|
|
1814
|
+
// Au premier niveau, revenir et tout voir sont le même geste : seul "tout voir"
|
|
1815
|
+
// paraît, ce qui laisse le niveau unique (zoomLevels: 1) exactement tel qu'il était.
|
|
1816
|
+
this._btnBack.style.display = show && deep > 1 ? '' : 'none';
|
|
1817
|
+
this._btnAll.style.display = show && deep > 0 ? '' : 'none';
|
|
1623
1818
|
this._btnA.classList.toggle('armed', this._armed === 'A');
|
|
1624
1819
|
this._btnB.classList.toggle('armed', this._armed === 'B');
|
|
1625
1820
|
}
|
|
1626
|
-
|
|
1627
|
-
|
|
1821
|
+
/**
|
|
1822
|
+
* Recadre sur le sommet de la pile : échantillons, statistiques, origine des abscisses.
|
|
1823
|
+
*
|
|
1824
|
+
* Les bornes empilées sont gardées en abscisse de la trace **entière**, jamais dans le
|
|
1825
|
+
* repère du niveau courant : sinon chaque cran se lirait dans celui du précédent et
|
|
1826
|
+
* l'erreur de rebasage se composerait de niveau en niveau. Rend les coordonnées
|
|
1827
|
+
* cartographiques du niveau, dont le cadrage a besoin.
|
|
1828
|
+
*/
|
|
1829
|
+
_applyCrops() {
|
|
1830
|
+
if (!this._crops.length) {
|
|
1831
|
+
this._off = 0; this._samples = this._fullSamples; this._stats = this._fullStats;
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
const { a, b } = this._crops[this._crops.length - 1];
|
|
1628
1835
|
const raw = this._fullSamples.filter((p) => p.x >= a && p.x <= b);
|
|
1629
|
-
if (raw.length < 2) return;
|
|
1630
|
-
const off = raw[0].x
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1836
|
+
if (raw.length < 2) { this._crops.pop(); return this._applyCrops(); }
|
|
1837
|
+
const off = raw[0].x, tOff = raw[0].t != null ? raw[0].t : 0;
|
|
1838
|
+
this._off = off;
|
|
1839
|
+
this._samples = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope, t: (p.t != null ? p.t - tOff : null) }));
|
|
1840
|
+
this._stats = this._statsOf(this._samples, false);
|
|
1841
|
+
return raw.map((p) => p.coord);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Cadre la carte sur un niveau, ou sur la trace entière si [coords] est nul. */
|
|
1845
|
+
_fitMap(coords) {
|
|
1846
|
+
const map = this.getMap(); if (!map) return null;
|
|
1847
|
+
let ext = null;
|
|
1848
|
+
if (coords && coords.length) ext = extent_js.boundingExtent(coords);
|
|
1849
|
+
else if (this._feature) ext = this._feature.getGeometry().getExtent();
|
|
1850
|
+
if (!ext) return null;
|
|
1851
|
+
const view = map.getView();
|
|
1852
|
+
let res = null;
|
|
1853
|
+
try { res = view.getResolutionForExtent(ext, map.getSize()); } catch (e) { res = null; }
|
|
1854
|
+
view.fit(ext, { padding: [40, 40, 40, 40], duration: 400 });
|
|
1855
|
+
return res;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/** Empile un niveau. [a0] et [b0] sont lus dans le repère du niveau courant. */
|
|
1859
|
+
_pushCrop(a0, b0) {
|
|
1860
|
+
this._zoomA = null; this._zoomB = null; this._armed = null;
|
|
1861
|
+
if (this._cropDepth >= this._cropMax) { this._updateZoomButtons(); this._render(); return; }
|
|
1862
|
+
const a = this._off + Math.min(a0, b0), b = this._off + Math.max(a0, b0);
|
|
1863
|
+
let n = 0;
|
|
1864
|
+
for (let k = 0; k < this._fullSamples.length; k++) {
|
|
1865
|
+
const px = this._fullSamples[k].x; if (px >= a && px <= b) n++;
|
|
1642
1866
|
}
|
|
1867
|
+
if (n < 2) { this._updateZoomButtons(); this._render(); return; }
|
|
1868
|
+
this._crops.push({ a, b, fitRes: null });
|
|
1869
|
+
const coords = this._applyCrops();
|
|
1870
|
+
this._updateZoomButtons(); this._render();
|
|
1871
|
+
const cur = this._crops[this._crops.length - 1];
|
|
1872
|
+
if (cur) cur.fitRes = this._fitMap(coords);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* Recadre sur A..B déjà posés. Conservé : c'était le point d'entrée du niveau unique,
|
|
1877
|
+
* et le supprimer casserait ce qui l'appelle sans rien apporter.
|
|
1878
|
+
*/
|
|
1879
|
+
_applyZoom() {
|
|
1880
|
+
if (this._zoomA == null || this._zoomB == null) return;
|
|
1881
|
+
this._pushCrop(this._zoomA, this._zoomB);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/** Remonte d'un niveau. */
|
|
1885
|
+
_popCrop() {
|
|
1886
|
+
if (!this._crops.length) return;
|
|
1887
|
+
this._crops.pop();
|
|
1888
|
+
this._zoomA = null; this._zoomB = null; this._armed = null;
|
|
1889
|
+
const coords = this._applyCrops();
|
|
1890
|
+
this._updateZoomButtons(); if (!this._collapsed) this._render();
|
|
1891
|
+
this._fitMap(coords);
|
|
1643
1892
|
}
|
|
1893
|
+
|
|
1894
|
+
/** Vide la pile d'un coup, quel que soit le nombre de niveaux. */
|
|
1644
1895
|
_exitZoom() {
|
|
1645
|
-
this.
|
|
1646
|
-
this.
|
|
1896
|
+
this._crops.length = 0;
|
|
1897
|
+
this._zoomA = null; this._zoomB = null; this._armed = null;
|
|
1898
|
+
this._applyCrops();
|
|
1647
1899
|
this._updateZoomButtons(); if (!this._collapsed) this._render();
|
|
1648
|
-
|
|
1649
|
-
if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
|
|
1900
|
+
this._fitMap(null);
|
|
1650
1901
|
}
|
|
1651
1902
|
|
|
1652
1903
|
/**
|
|
@@ -1661,13 +1912,31 @@
|
|
|
1661
1912
|
const g = this._feature && this._feature.getGeometry();
|
|
1662
1913
|
if (!g) return null;
|
|
1663
1914
|
if (!/Polygon/.test(g.getType())) return g.getClosestPoint(coordinate);
|
|
1664
|
-
const
|
|
1915
|
+
const hit = this._projectOn(this._fullSamples, coordinate);
|
|
1916
|
+
return hit ? hit.coord : null;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Projeté d'une coordonnée sur la ligne brisée [data] : le point, et son abscisse
|
|
1921
|
+
* curviligne dans le repère de [data].
|
|
1922
|
+
*
|
|
1923
|
+
* Retenir l'échantillon le plus proche ferait sauter le marqueur de sommet en sommet,
|
|
1924
|
+
* l'écart entre deux points de trace étant souvent bien plus grand que le pas du
|
|
1925
|
+
* pointeur. La projection sur les segments donne le point réellement visé.
|
|
1926
|
+
*/
|
|
1927
|
+
_projectOn(data, coord) {
|
|
1665
1928
|
if (!data || !data.length) return null;
|
|
1929
|
+
if (data.length === 1) return { x: data[0].x, coord: data[0].coord.slice(0, 2) };
|
|
1666
1930
|
let best = null, bd = Infinity;
|
|
1667
|
-
for (
|
|
1668
|
-
const
|
|
1669
|
-
const
|
|
1670
|
-
|
|
1931
|
+
for (let i = 1; i < data.length; i++) {
|
|
1932
|
+
const a = data[i - 1].coord, b = data[i].coord;
|
|
1933
|
+
const abx = b[0] - a[0], aby = b[1] - a[1], len2 = abx * abx + aby * aby;
|
|
1934
|
+
// Segment dégénéré (deux points confondus) : le projeté est le point lui-même.
|
|
1935
|
+
let r = len2 > 0 ? ((coord[0] - a[0]) * abx + (coord[1] - a[1]) * aby) / len2 : 0;
|
|
1936
|
+
r = r < 0 ? 0 : (r > 1 ? 1 : r);
|
|
1937
|
+
const px = a[0] + abx * r, py = a[1] + aby * r;
|
|
1938
|
+
const dx = px - coord[0], dy = py - coord[1], dd = dx * dx + dy * dy;
|
|
1939
|
+
if (dd < bd) { bd = dd; best = { x: data[i - 1].x + (data[i].x - data[i - 1].x) * r, coord: [px, py] }; }
|
|
1671
1940
|
}
|
|
1672
1941
|
return best;
|
|
1673
1942
|
}
|
|
@@ -1697,11 +1966,42 @@
|
|
|
1697
1966
|
else if (x(d.x) - bb.width / 2 < 0) t.attr('text-anchor', 'start');
|
|
1698
1967
|
else t.attr('text-anchor', 'middle');
|
|
1699
1968
|
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Point du profil à une abscisse quelconque, interpolé entre les deux échantillons qui
|
|
1971
|
+
* l'encadrent.
|
|
1972
|
+
*
|
|
1973
|
+
* Se caler sur l'échantillon le plus proche ferait avancer le curseur de sommet en
|
|
1974
|
+
* sommet : sur un tracé peu dense, ou décimé par `maxPoints`, le saut est franc et le
|
|
1975
|
+
* survol perd sa continuité. Les échantillons sont les points *mesurés*, non les seules
|
|
1976
|
+
* positions que le curseur ait le droit d'occuper.
|
|
1977
|
+
*
|
|
1978
|
+
* La pente n'est pas interpolée : `_addSlope` la pose sur le segment qui précède chaque
|
|
1979
|
+
* échantillon, elle est donc constante sur ce segment et vaut celle de son extrémité.
|
|
1980
|
+
* Le temps l'est, mais seulement si les deux bornes en portent un.
|
|
1981
|
+
*/
|
|
1982
|
+
_sampleAt(x0) {
|
|
1983
|
+
const data = this._samples;
|
|
1984
|
+
if (!data || !data.length) return null;
|
|
1985
|
+
if (data.length === 1) return data[0];
|
|
1986
|
+
const xa = data[0].x, xb = data[data.length - 1].x;
|
|
1987
|
+
const xc = Math.max(xa, Math.min(xb, x0));
|
|
1988
|
+
const i = Math.min(Math.max(bisectX(data, xc, 1), 1), data.length - 1);
|
|
1989
|
+
const d0 = data[i - 1], d1 = data[i];
|
|
1990
|
+
const span = d1.x - d0.x, r = span > 0 ? (xc - d0.x) / span : 0;
|
|
1991
|
+
const lerp = (a, b) => a + (b - a) * r;
|
|
1992
|
+
return {
|
|
1993
|
+
x: xc,
|
|
1994
|
+
z: lerp(d0.z, d1.z),
|
|
1995
|
+
coord: [lerp(d0.coord[0], d1.coord[0]), lerp(d0.coord[1], d1.coord[1])],
|
|
1996
|
+
slope: d1.slope,
|
|
1997
|
+
t: (d0.t != null && d1.t != null) ? lerp(d0.t, d1.t) : null
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
/** Suit une coordonnée de la carte, projetée sur le tracé courant. */
|
|
1700
2001
|
_focusByCoord(coord) {
|
|
1701
|
-
const
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
if (best) { this._setFocus(best); if (this._marker) this._marker.setPosition(best.coord); }
|
|
2002
|
+
const hit = this._projectOn(this._samples, coord); if (!hit) return;
|
|
2003
|
+
const d = this._sampleAt(hit.x); if (!d) return;
|
|
2004
|
+
this._setFocus(d); if (this._marker) this._marker.setPosition(d.coord);
|
|
1705
2005
|
}
|
|
1706
2006
|
_clearFocus() { if (this._focus) this._focus.style('display', 'none'); if (this._marker) this._marker.setPosition(undefined); }
|
|
1707
2007
|
_clear() {
|
|
@@ -1709,7 +2009,7 @@
|
|
|
1709
2009
|
this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
|
|
1710
2010
|
this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
|
|
1711
2011
|
this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
|
|
1712
|
-
this.
|
|
2012
|
+
this._crops = []; this._off = 0; this._demLoading = false; this._updateZoomButtons();
|
|
1713
2013
|
this._clearFocus();
|
|
1714
2014
|
this.element.style.display = 'none';
|
|
1715
2015
|
this._adjustAttribution();
|