ol-elevation-profile 0.5.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.
@@ -0,0 +1,740 @@
1
+ /*! ol-elevation-profile | MIT License | https://github.com/lc-4918/ol-elevation-profile */
2
+ import Control from 'ol/control/Control.js';
3
+ import Overlay from 'ol/Overlay.js';
4
+ import { unByKey } from 'ol/Observable.js';
5
+ import { toLonLat } from 'ol/proj.js';
6
+ import { getDistance } from 'ol/sphere.js';
7
+ import { boundingExtent } from 'ol/extent.js';
8
+ import * as d3 from 'd3';
9
+
10
+ /**
11
+ * Synchronized elevation profile control for OpenLayers, rendered with d3.
12
+ *
13
+ * Reads elevation (Z) directly from 3D line geometries (`[lon, lat, z]`),
14
+ * so no external elevation service is queried. Clicking (or hovering) a track
15
+ * shows its profile; a marker stays synchronized on both the map and the chart.
16
+ *
17
+ * Peer dependencies (provided by the host application, not bundled):
18
+ * - OpenLayers >= 6 (https://openlayers.org/)
19
+ * - d3 >= 7 (https://d3js.org/)
20
+ *
21
+ * @module ol-elevation-profile
22
+ * @license MIT
23
+ */
24
+
25
+ const THEMES = {
26
+ steelblue: { area: '#4682b4', line: '#3a6d96', axis: '#555', text: '#222', focus: '#e6550d' },
27
+ lime: { area: '#9ccc2f', line: '#7da521', axis: '#555', text: '#222', focus: '#d62728' },
28
+ purple: { area: '#9467bd', line: '#76529c', axis: '#555', text: '#222', focus: '#ff9f1c' },
29
+ slate: { area: '#7c8a99', line: '#4a5560', axis: '#5a6570', text: '#26303a', focus: '#2f81f7' },
30
+ graphite: { area: '#9a948c', line: '#5c574f', axis: '#5c574f', text: '#2b2823', focus: '#e07a3f' },
31
+ amber: { area: '#f0a23b', line: '#c4671a', axis: '#7a5a36', text: '#3a2a16', focus: '#1f6fb2' }
32
+ };
33
+
34
+ const POSITIONS = ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
35
+
36
+ const DEFAULTS = {
37
+ immersion: 'docked',
38
+ position: 'bottom',
39
+ width: 520, // nombre (px, plafonné à la largeur de la carte) ou 'auto'/'100%'/'full' = largeur de la carte
40
+ height: 180,
41
+ margins: { unit: 'px', top: 20, right: 24, bottom: 30, left: 48 },
42
+ units: 'meters',
43
+ dataProjection: null,
44
+ maxPoints: 2000,
45
+ smoothing: 0, // lissage de l'altitude : fenêtre en MÈTRES (0 = aucun)
46
+ theme: 'steelblue',
47
+ color: null, // null=thème, 'auto'=couleur de la trace, ou couleur CSS
48
+ trackLayer: null,
49
+ transparency: false, // false | true | nombre 0..1
50
+ transparencyLevel: 0.45,
51
+ grid: true,
52
+ slope: false,
53
+ slopeClassSize: 2.5,
54
+ slopeColors: null, // null = dégradé bleu->rouge (HSL) ; sinon tableau interpolé
55
+ slopeSeparators: true, // ligne verticale à chaque changement de classe
56
+ slopeLegend: true,
57
+ maxClasses: 8, // nombre maximal de classes de pente (couleurs + légende)
58
+ maxLegendClasses: 8,
59
+ xTicks: null,
60
+ yTicks: null,
61
+ show: 'click',
62
+ collapsable: true,
63
+ collapsed: false,
64
+ followMap: true,
65
+ marker: true,
66
+ hideOnMapClick: true,
67
+ responsive: true, // adapte la largeur/placement, mobile inclus
68
+ mobileBreakpoint: 640, // <= largeur écran -> mode mobile (100% largeur, top/bottom)
69
+ zoom: false, // boutons début/fin pour recadrer carte + profil sur A..B
70
+ tooltipItems: ['distance', 'elevation'],
71
+ headerItems: ['distance', 'ascent', 'descent', 'minmax'],
72
+ titleProperty: 'name',
73
+ titleLink: null,
74
+ labels: {
75
+ distance: 'Distance', elevation: 'Altitude', slope: 'Pente',
76
+ ascent: 'D+', descent: 'D-', empty: 'Cliquez un tracé',
77
+ zoomStart: 'Définir le début (A)', zoomEnd: 'Définir la fin (B)', zoomAll: 'Tout voir'
78
+ }
79
+ };
80
+
81
+ // ----- helpers ---------------------------------------------------------
82
+ function deepMerge(base, over) {
83
+ const out = {};
84
+ Object.keys(base).forEach((k) => { out[k] = base[k]; });
85
+ if (over) Object.keys(over).forEach((k) => {
86
+ if (over[k] && typeof over[k] === 'object' && !Array.isArray(over[k]) && base[k] && typeof base[k] === 'object') out[k] = deepMerge(base[k], over[k]);
87
+ else out[k] = over[k];
88
+ });
89
+ return out;
90
+ }
91
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
92
+ const isUrl = (v) => typeof v === 'string' && /^(https?:)?\/\/|^mailto:/i.test(v);
93
+
94
+ function colorToCss(c) {
95
+ if (c == null) return null;
96
+ if (typeof c === 'string') return c;
97
+ if (Array.isArray(c)) { const a = c.length > 3 ? c[3] : 1; return `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${a})`; }
98
+ return null;
99
+ }
100
+ function strokeColorOf(styleLike, feature, res) {
101
+ let st = styleLike;
102
+ if (typeof st === 'function') { try { st = st(feature, res); } catch (e) { return null; } }
103
+ if (Array.isArray(st)) st = st[0];
104
+ if (st && st.getStroke) { const s = st.getStroke(); if (s) return s.getColor(); }
105
+ return null;
106
+ }
107
+ const fmtDistance = (m, units) => {
108
+ if (units === 'imperial') { const mi = m / 1609.344; return mi < 0.2 ? `${Math.round(m * 3.28084)} ft` : `${mi.toFixed(mi < 10 ? 2 : 1)} mi`; }
109
+ return m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(m < 10000 ? 2 : 1)} km`;
110
+ };
111
+ const fmtElevation = (z, units) => units === 'imperial' ? `${Math.round(z * 3.28084)} ft` : `${Math.round(z)} m`;
112
+ const fmtSlope = (p) => `${p >= 0 ? '+' : ''}${p.toFixed(1)} %`;
113
+ const distAxisLabel = (units) => units === 'imperial' ? 'mi' : 'km';
114
+ const distAxisScale = (units) => units === 'imperial' ? 1609.344 : 1000;
115
+
116
+ function buildElement(o) {
117
+ const el = document.createElement('div');
118
+ el.className = 'ol-elevation-profile ol-unselectable ol-control'
119
+ + ` oep-pos-${o.position}`
120
+ + ` oep-theme-${typeof o.theme === 'string' ? o.theme : 'custom'}`
121
+ + (o.immersion === 'floating' ? ' oep-floating' : '');
122
+ return el;
123
+ }
124
+
125
+ const ICON_COLLAPSE = '<svg viewBox="0 0 24 24"><path d="M5 13h14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>';
126
+ 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>';
127
+ 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>';
128
+ 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>';
129
+ 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>';
130
+
131
+ // =======================================================================
132
+ /**
133
+ * @typedef {Object} ElevationProfileOptions
134
+ * @property {'docked'|'floating'} [immersion='docked'] Panel placement strategy (floating is partial).
135
+ * @property {'top'|'bottom'|'left'|'right'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [position='bottom'] Anchor on the map.
136
+ * @property {number|'auto'|'100%'|'full'} [width=520] Width in px (capped to the map width), or full map width.
137
+ * @property {number} [height=180] Height in px.
138
+ * @property {{unit:('px'|'em'|'rem'),top:number,right:number,bottom:number,left:number}} [margins] Inner chart margins.
139
+ * @property {'meters'|'imperial'} [units='meters'] Distance/elevation units.
140
+ * @property {string} [theme='steelblue'] Built-in theme name or a colors object.
141
+ * @property {?string} [color=null] `null` = theme, `'auto'` = track color (area = color, darker line), or a CSS color.
142
+ * @property {?import('ol/layer/Vector').default} [trackLayer=null] Layer used to read the track color when `color:'auto'`.
143
+ * @property {boolean|number} [transparency=false] `false`, `true` (= transparencyLevel), or alpha 0..1 (0 = fully transparent).
144
+ * @property {number} [transparencyLevel=0.45] Background alpha when `transparency===true`.
145
+ * @property {boolean} [grid=true] Horizontal grid lines.
146
+ * @property {boolean} [slope=false] Split the profile into slope-class colored portions.
147
+ * @property {number} [slopeClassSize=2.5] Slope class width, in percent.
148
+ * @property {number} [maxClasses=8] Maximum number of slope classes (colors + legend).
149
+ * @property {?string[]} [slopeColors=null] `null` = blue→red ramp; otherwise an interpolated color array.
150
+ * @property {boolean} [slopeSeparators=true] Vertical separator at each slope-class change.
151
+ * @property {boolean} [slopeLegend=true] Color legend under the title.
152
+ * @property {number} [smoothing=0] Elevation smoothing window, in METERS (0 = none).
153
+ * @property {?number} [xTicks=null] X axis ticks (null = auto from width).
154
+ * @property {?number} [yTicks=null] Y axis ticks (null = auto from height).
155
+ * @property {'click'|'mouseover'} [show='click'] How a track is selected on the map.
156
+ * @property {boolean} [hideOnMapClick=true] Click on empty map hides the profile.
157
+ * @property {boolean} [collapsable=true] Show the collapse/expand button.
158
+ * @property {boolean} [collapsed=false] Initial collapsed state.
159
+ * @property {boolean} [followMap=true] Hovering the map moves the chart indicator.
160
+ * @property {boolean} [marker=true] Show the position marker on the map.
161
+ * @property {boolean} [responsive=true] Adapt width/placement; mobile included.
162
+ * @property {number} [mobileBreakpoint=640] Below this width: mobile mode (100% width, top/bottom only).
163
+ * @property {boolean} [zoom=false] A/B buttons to crop map + profile to a sub-range.
164
+ * @property {Array<'distance'|'elevation'|'slope'>} [tooltipItems=['distance','elevation']] Tooltip content.
165
+ * @property {Array<string|{property:string,label?:string,asLink?:boolean,linkText?:string}>} [headerItems] Header content.
166
+ * @property {string} [titleProperty='name'] Feature property used as the title.
167
+ * @property {?string} [titleLink=null] Feature property holding a URL → clickable title.
168
+ * @property {number} [maxPoints=2000] Decimation for render/interaction (stats use full data).
169
+ * @property {?import('ol/proj/Projection').default|string} [dataProjection=null] Projection of the feature coordinates.
170
+ */
171
+
172
+ /**
173
+ * Elevation profile control.
174
+ * @extends {import('ol/control/Control').default}
175
+ *
176
+ * @example
177
+ * const profile = new OlElevationProfile({ theme: 'steelblue', color: 'auto', trackLayer });
178
+ * map.addControl(profile);
179
+ * profile.setFeature(feature); // feature with a 3D LineString geometry
180
+ */
181
+ class ElevationProfile extends Control {
182
+ /** @param {ElevationProfileOptions} [opts] */
183
+ constructor(opts) {
184
+ const o = deepMerge(DEFAULTS, opts || {});
185
+ if (POSITIONS.indexOf(o.position) === -1) o.position = 'bottom';
186
+ const element = buildElement(o);
187
+ super({ element, target: opts && opts.target });
188
+
189
+ this.options = o;
190
+ this.slopeColors = o.slopeColors || null;
191
+ this._feature = null;
192
+ this._fullSamples = null; this._fullStats = null;
193
+ this._samples = null; this._stats = null;
194
+ this._marker = null;
195
+ this._collapsed = !!o.collapsed;
196
+ this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
197
+ this._onResize = () => { if (this._feature && !this._collapsed) this._render(); };
198
+ this._buildDom(element);
199
+ element.style.display = 'none';
200
+ }
201
+
202
+ // ---------- util statique ----------------------------------------
203
+ /**
204
+ * Whether a feature has any Z (elevation) coordinate.
205
+ * @param {import('ol/Feature').default} feature
206
+ * @returns {boolean}
207
+ */
208
+ static featureHasZ(feature) {
209
+ const g = feature && feature.getGeometry && feature.getGeometry();
210
+ if (!g) return false;
211
+ const coords = g.getType() === 'MultiLineString' ? g.getCoordinates() : [g.getCoordinates()];
212
+ for (const seg of coords) for (const c of seg) if (c.length > 2 && isFinite(c[2])) return true;
213
+ return false;
214
+ }
215
+
216
+ // ---------- DOM ---------------------------------------------------
217
+ _buildDom(root) {
218
+ const o = this.options;
219
+ this._applyTheme();
220
+ this._applyTransparency();
221
+
222
+ const header = document.createElement('div');
223
+ header.className = 'oep-header';
224
+ this._titleEl = document.createElement('span'); this._titleEl.className = 'oep-title'; this._titleEl.textContent = o.labels.empty;
225
+ this._statsEl = document.createElement('span'); this._statsEl.className = 'oep-stats';
226
+ header.appendChild(this._titleEl);
227
+ header.appendChild(this._statsEl);
228
+
229
+ // barre d'outils zoom (A / B / Tout voir)
230
+ this._toolbar = document.createElement('span'); this._toolbar.className = 'oep-toolbar';
231
+ const mkBtn = (cls, html, title, onclick) => {
232
+ const b = document.createElement('button'); b.type = 'button'; b.className = `oep-tbtn ${cls}`;
233
+ b.innerHTML = html; b.title = title; b.setAttribute('aria-label', title);
234
+ b.addEventListener('click', onclick); this._toolbar.appendChild(b); return b;
235
+ };
236
+ this._btnA = mkBtn('oep-a', ICON_A, o.labels.zoomStart, () => this._arm('A'));
237
+ this._btnB = mkBtn('oep-b', ICON_B, o.labels.zoomEnd, () => this._arm('B'));
238
+ this._btnAll = mkBtn('oep-all', ICON_ALL, o.labels.zoomAll, () => this._exitZoom());
239
+ header.appendChild(this._toolbar);
240
+
241
+ const btn = document.createElement('button');
242
+ btn.className = 'oep-toggle'; btn.type = 'button';
243
+ btn.setAttribute('aria-label', 'Réduire ou agrandir le profil');
244
+ btn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
245
+ btn.addEventListener('click', () => this.toggleCollapsed());
246
+ header.appendChild(btn);
247
+ this._toggleBtn = btn;
248
+
249
+ this._legendEl = document.createElement('div'); this._legendEl.className = 'oep-legend'; this._legendEl.style.display = 'none';
250
+ this._body = document.createElement('div'); this._body.className = 'oep-body';
251
+
252
+ root.appendChild(header); root.appendChild(this._legendEl); root.appendChild(this._body);
253
+ this._applyCollapsable();
254
+ this._updateZoomButtons();
255
+ if (this._collapsed) root.classList.add('oep-collapsed');
256
+ }
257
+
258
+ _resolveColor() { const o = this.options; if (!o.color) return null; if (o.color === 'auto') return this._featureColor(); return o.color; }
259
+ _featureColor() {
260
+ const f = this._feature; if (!f) return null;
261
+ const res = this.getMap() ? this.getMap().getView().getResolution() : 1;
262
+ let col = strokeColorOf(f.getStyle && f.getStyle(), f, res);
263
+ if (!col && this.options.trackLayer) col = strokeColorOf(this.options.trackLayer.getStyle && this.options.trackLayer.getStyle(), f, res);
264
+ return colorToCss(col);
265
+ }
266
+ _applyTheme() {
267
+ const o = this.options;
268
+ const c = (typeof o.theme === 'object') ? o.theme : (THEMES[o.theme] || THEMES.steelblue);
269
+ this.themeColors = c;
270
+ const r = this.element, resolved = this._resolveColor();
271
+ const area = resolved || c.area;
272
+ let line = c.line;
273
+ if (resolved) { try { line = String(d3.color(resolved).darker(0.7)); } catch (e) { line = resolved; } }
274
+ r.style.setProperty('--oep-area', area);
275
+ r.style.setProperty('--oep-line', line);
276
+ r.style.setProperty('--oep-axis', c.axis);
277
+ r.style.setProperty('--oep-text', c.text);
278
+ r.style.setProperty('--oep-focus', c.focus);
279
+ }
280
+ _applyTransparency() {
281
+ const t = this.options.transparency;
282
+ let alpha;
283
+ if (t === false || t == null) alpha = 1; else if (t === true) alpha = this.options.transparencyLevel; else alpha = Math.max(0, Math.min(1, +t));
284
+ this.element.style.setProperty('--oep-bg', `rgba(255,255,255,${alpha})`);
285
+ this.element.classList.toggle('oep-transparent', alpha < 1);
286
+ }
287
+ _applyCollapsable() {
288
+ const on = this.options.collapsable;
289
+ this._toggleBtn.style.display = on ? '' : 'none';
290
+ if (!on && this._collapsed) this.toggleCollapsed(false);
291
+ }
292
+
293
+ /** @param {boolean} [force] Force collapsed (true) or expanded (false). */
294
+ toggleCollapsed(force) {
295
+ this._collapsed = (typeof force === 'boolean') ? force : !this._collapsed;
296
+ this.element.classList.toggle('oep-collapsed', this._collapsed);
297
+ this._toggleBtn.innerHTML = this._collapsed ? ICON_EXPAND : ICON_COLLAPSE;
298
+ if (!this._collapsed && this._feature) this._render();
299
+ this._adjustAttribution();
300
+ }
301
+
302
+ // ---------- responsive -------------------------------------------
303
+ _availWidth() {
304
+ const map = this.getMap();
305
+ const t = map && map.getTargetElement && map.getTargetElement();
306
+ return (t && t.clientWidth) || (typeof window !== 'undefined' ? window.innerWidth : 1024);
307
+ }
308
+ _isMobile() { return this.options.responsive && (typeof window !== 'undefined') && window.innerWidth <= (this.options.mobileBreakpoint || 640); }
309
+ _applyPlacement() {
310
+ const mobile = this._isMobile();
311
+ let pos = this.options.position;
312
+ if (mobile) pos = /top/.test(pos) ? 'top' : 'bottom';
313
+ this.element.className = this.element.className.replace(/oep-pos-\S+/, `oep-pos-${pos}`);
314
+ this.element.classList.toggle('oep-mobile', mobile);
315
+ return mobile;
316
+ }
317
+
318
+ // remonte les attributions OL au-dessus du profil quand celui-ci occupe le coin bas-droite
319
+ _adjustAttribution() {
320
+ const map = this.getMap();
321
+ const target = map && map.getTargetElement && map.getTargetElement();
322
+ const attr = target && target.querySelector && target.querySelector('.ol-attribution');
323
+ if (!attr) return;
324
+ attr.style.bottom = ''; attr.style.right = ''; // reset
325
+ if (this.element.style.display === 'none' || this._collapsed) return;
326
+ const mapR = target.getBoundingClientRect();
327
+ const pR = this.element.getBoundingClientRect();
328
+ if (!pR.height) return;
329
+ const bottomGap = mapR.bottom - pR.bottom; // bord bas de carte <-> bas du profil
330
+ const rightGap = mapR.right - pR.right;
331
+ const atBottom = bottomGap < pR.height; // profil dans la bande basse
332
+ const reachesRight = rightGap < 24; // atteint le coin bas-droite (attributions)
333
+ if (atBottom && reachesRight) {
334
+ attr.style.right = `${Math.max(0, Math.round(rightGap))}px`;
335
+ attr.style.bottom = `${Math.round(pR.height + 2 * bottomGap)}px`; // même écart au-dessus du profil
336
+ }
337
+ }
338
+
339
+ // ---------- carte -------------------------------------------------
340
+ setMap(map) {
341
+ const prev = this.getMap();
342
+ super.setMap(map);
343
+ if (this._mapKeys) { this._mapKeys.forEach(unByKey); this._mapKeys = null; }
344
+ if (prev && typeof window !== 'undefined') window.removeEventListener('resize', this._onResize);
345
+ if (this._marker && !map) this._marker.setPosition(undefined);
346
+ if (!map) return;
347
+
348
+ const o = this.options;
349
+ if (!o.dataProjection) o.dataProjection = map.getView().getProjection();
350
+ if (o.marker && !this._marker) {
351
+ const dot = document.createElement('div'); dot.className = 'oep-marker';
352
+ this._marker = new Overlay({ element: dot, positioning: 'center-center', stopEvent: false });
353
+ map.addOverlay(this._marker);
354
+ }
355
+ if (typeof window !== 'undefined') window.addEventListener('resize', this._onResize);
356
+
357
+ const lineAt = (pixel) => map.forEachFeatureAtPixel(pixel, (f) => {
358
+ const g = f.getGeometry(); return (g && /LineString/.test(g.getType())) ? f : undefined;
359
+ });
360
+
361
+ this._mapKeys = [];
362
+ this._mapKeys.push(map.on(o.show === 'mouseover' ? 'pointermove' : 'click', (evt) => {
363
+ const feature = lineAt(evt.pixel); if (feature && feature !== this._feature) this.setFeature(feature);
364
+ }));
365
+ if (o.hideOnMapClick) this._mapKeys.push(map.on('click', (evt) => { if (!lineAt(evt.pixel) && this._feature) this.clear(); }));
366
+ if (o.followMap) this._mapKeys.push(map.on('pointermove', (evt) => {
367
+ if (!this._feature || this._collapsed) return;
368
+ const cp = this._feature.getGeometry().getClosestPoint(evt.coordinate);
369
+ const px = map.getPixelFromCoordinate(cp); if (!px) return;
370
+ if (Math.hypot(px[0] - evt.pixel[0], px[1] - evt.pixel[1]) < 14) this._focusByCoord(cp); else this._clearFocus();
371
+ }));
372
+ // sortie du zoom au dézoom de la carte
373
+ this._mapKeys.push(map.on('moveend', () => {
374
+ if (this._cropMode && this._fitRes && map.getView().getResolution() > this._fitRes * 1.25) this._exitZoom();
375
+ }));
376
+ this._mapKeys.push(map.on('change:size', this._onResize));
377
+ }
378
+
379
+ // ---------- API ---------------------------------------------------
380
+ /**
381
+ * Show the profile for the given feature (LineString/MultiLineString, ideally 3D).
382
+ * Passing a falsy value hides the control.
383
+ * @param {?import('ol/Feature').default} feature
384
+ * @returns {this}
385
+ */
386
+ setFeature(feature) {
387
+ this._feature = feature || null;
388
+ this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null;
389
+ if (!feature) { this._fullSamples = this._samples = null; this._clear(); return this; }
390
+ this.element.style.display = '';
391
+ this._compute();
392
+ this._updateZoomButtons();
393
+ if (!this._collapsed) this._render();
394
+ return this;
395
+ }
396
+ /** Hide the profile and clear the current feature. @returns {this} */
397
+ clear() { return this.setFeature(null); }
398
+ /**
399
+ * @returns {?{distance:number,ascent:number,descent:number,min:number,max:number,maxAbsSlope:number,points:number}}
400
+ */
401
+ getStats() { return this._stats; }
402
+ /** @param {string|Object} t Theme name or colors object. @returns {this} */
403
+ setTheme(t) { this.options.theme = t; this._applyTheme(); if (this._feature && !this._collapsed) this._render(); return this; }
404
+ /** @param {?string} c CSS color, `'auto'`, or `null` (theme). @returns {this} */
405
+ setColor(c) { this.options.color = c || null; this._applyTheme(); if (this._feature && !this._collapsed) this._render(); return this; }
406
+
407
+ /**
408
+ * Update one or more options at runtime and re-render.
409
+ * @param {Partial<ElevationProfileOptions>} patch
410
+ * @returns {this}
411
+ */
412
+ setOptions(patch) {
413
+ this.options = deepMerge(this.options, patch || {});
414
+ if (patch && 'slopeColors' in patch) this.slopeColors = patch.slopeColors || null;
415
+ if (patch && (patch.theme || 'color' in patch)) this._applyTheme();
416
+ if (patch && ('transparency' in patch || 'transparencyLevel' in patch)) this._applyTransparency();
417
+ if (patch && 'collapsable' in patch) this._applyCollapsable();
418
+ if (patch && 'zoom' in patch) this._updateZoomButtons();
419
+ if (patch && typeof patch.width !== 'undefined' && typeof patch.width === 'number') this.options.width = patch.width;
420
+ if (this._feature) { this._compute(); this._updateZoomButtons(); if (!this._collapsed) this._render(); }
421
+ return this;
422
+ }
423
+
424
+ // ---------- calcul ------------------------------------------------
425
+ _compute() {
426
+ const o = this.options;
427
+ const geom = this._feature.getGeometry();
428
+ const lines = geom.getType() === 'MultiLineString' ? geom.getCoordinates() : [geom.getCoordinates()];
429
+ const dataProj = o.dataProjection || (this.getMap() && this.getMap().getView().getProjection()) || 'EPSG:3857';
430
+
431
+ const pts = [];
432
+ let cum = 0, prev = null;
433
+ for (const seg of lines) for (const c of seg) {
434
+ const ll = toLonLat(c, dataProj);
435
+ if (prev) cum += getDistance(prev, ll);
436
+ prev = ll;
437
+ pts.push({ x: cum, z: (c.length > 2 && isFinite(c[2])) ? c[2] : 0, coord: c });
438
+ }
439
+ if (o.smoothing > 0) this._smooth(pts, o.smoothing);
440
+
441
+ const samples = this._decimate(pts, o.maxPoints);
442
+ this._addSlope(samples);
443
+ this._fullSamples = samples;
444
+ this._fullStats = this._statsOf(samples, true);
445
+ this._samples = samples; this._stats = this._fullStats;
446
+ }
447
+ _statsOf(samples, fromTotal) {
448
+ let ascent = 0, descent = 0, zmin = Infinity, zmax = -Infinity, maxAbs = 0;
449
+ for (let k = 0; k < samples.length; k++) {
450
+ const z = samples[k].z; if (z < zmin) zmin = z; if (z > zmax) zmax = z;
451
+ if (k > 0) { const dz = z - samples[k - 1].z; if (dz > 0) ascent += dz; else descent -= dz; }
452
+ const s = Math.abs(samples[k].slope || 0); if (s > maxAbs) maxAbs = s;
453
+ }
454
+ const distance = samples.length ? samples[samples.length - 1].x - samples[0].x : 0;
455
+ return { distance, ascent, descent, min: isFinite(zmin) ? zmin : 0, max: isFinite(zmax) ? zmax : 0, maxAbsSlope: maxAbs, points: samples.length };
456
+ }
457
+ _smooth(pts, meters) {
458
+ if (!(meters > 0) || pts.length < 3) return;
459
+ const half = meters / 2; // fenêtre = ±(meters/2) le long du tracé
460
+ const z = pts.map((p) => p.z);
461
+ let lo = 0, hi = 0, sum = 0;
462
+ for (let i = 0; i < pts.length; i++) {
463
+ const xi = pts[i].x;
464
+ while (lo < pts.length && pts[lo].x < xi - half) { sum -= z[lo]; lo++; }
465
+ while (hi < pts.length && pts[hi].x <= xi + half) { sum += z[hi]; hi++; }
466
+ pts[i].z = (hi > lo) ? sum / (hi - lo) : z[i];
467
+ }
468
+ }
469
+ _decimate(pts, maxPoints) {
470
+ const copy = (p) => ({ x: p.x, z: p.z, coord: p.coord });
471
+ if (!maxPoints || pts.length <= maxPoints) return pts.map(copy);
472
+ const step = pts.length / maxPoints, out = [];
473
+ for (let i = 0; i < maxPoints; i++) out.push(copy(pts[Math.floor(i * step)]));
474
+ out.push(copy(pts[pts.length - 1])); return out;
475
+ }
476
+ _addSlope(samples) {
477
+ for (let j = 1; j < samples.length; j++) {
478
+ const ddx = samples[j].x - samples[j - 1].x;
479
+ samples[j].slope = ddx > 0 ? ((samples[j].z - samples[j - 1].z) / ddx) * 100 : 0;
480
+ }
481
+ if (samples.length) samples[0].slope = samples.length > 1 ? samples[1].slope : 0;
482
+ }
483
+
484
+ // ---------- pente : couleurs --------------------------------------
485
+ _slopeScale() {
486
+ const cs = this.options.slopeClassSize || 2.5;
487
+ const maxClasses = this.options.maxClasses || 8;
488
+ const realIdx = Math.max(1, Math.floor((this._stats.maxAbsSlope || 0) / cs));
489
+ const maxIdx = Math.min(realIdx, maxClasses - 1); // au plus `maxClasses` classes (défaut 8)
490
+ const capped = realIdx > maxIdx; // des pentes dépassent la dernière classe
491
+ let colorByIndex;
492
+ if (this.slopeColors && this.slopeColors.length) {
493
+ const interp = d3.interpolateRgbBasis(this.slopeColors);
494
+ colorByIndex = (idx) => interp(maxIdx ? idx / maxIdx : 0);
495
+ } else {
496
+ // rampe à arrêts non uniformes : partie froide (bleu-vert, peu lisible) compressée,
497
+ // jaune PUR au milieu, puis orange, rouge. classe 0 = bleu, classe max = rouge.
498
+ const ramp = d3.scaleLinear()
499
+ .domain([0, 0.16, 0.42, 0.68, 1])
500
+ .range(['#2166ac', '#27a35a', '#ffe000', '#f4791f', '#d7191c'])
501
+ .interpolate(d3.interpolateRgb)
502
+ .clamp(true);
503
+ colorByIndex = (idx) => String(ramp(maxIdx ? idx / maxIdx : 0));
504
+ }
505
+ return { classSize: cs, maxIdx, capped, colorByIndex };
506
+ }
507
+ _classIndex(slope, sc) { return Math.min(sc.maxIdx, Math.floor(Math.abs(slope) / sc.classSize)); }
508
+
509
+ // ---------- entête + légende --------------------------------------
510
+ _renderHeader() {
511
+ const o = this.options, s = this._stats, f = this._feature;
512
+ const name = (f.get && f.get(o.titleProperty)) || 'Profil';
513
+ const linkUrl = o.titleLink && f.get && f.get(o.titleLink);
514
+ if (isUrl(linkUrl)) this._titleEl.innerHTML = `<a href="${esc(linkUrl)}" target="_blank" rel="noopener">${esc(name)}</a>`;
515
+ else this._titleEl.textContent = name;
516
+ this._titleEl.setAttribute('title', name);
517
+
518
+ const html = [], text = [];
519
+ o.headerItems.forEach((it) => {
520
+ if (typeof it === 'string') {
521
+ if (it === 'distance') { html.push(`<b>${fmtDistance(s.distance, o.units)}</b>`); text.push(fmtDistance(s.distance, o.units)); }
522
+ else if (it === 'ascent') { html.push(`<span class="oep-up">${o.labels.ascent} ${fmtElevation(s.ascent, o.units)}</span>`); text.push(`${o.labels.ascent} ${fmtElevation(s.ascent, o.units)}`); }
523
+ 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)}`); }
524
+ else if (it === 'min') { html.push(fmtElevation(s.min, o.units)); text.push(fmtElevation(s.min, o.units)); }
525
+ else if (it === 'max') { html.push(fmtElevation(s.max, o.units)); text.push(fmtElevation(s.max, o.units)); }
526
+ else if (it === 'minmax') { const v = `${fmtElevation(s.min, o.units)}–${fmtElevation(s.max, o.units)}`; html.push(v); text.push(v); }
527
+ } else if (it && it.property) {
528
+ const val = f.get && f.get(it.property); if (val == null || val === '') return;
529
+ const lbl = it.label ? `${it.label} ` : '';
530
+ if (it.asLink && isUrl(val)) { html.push(`${lbl}<a href="${esc(val)}" target="_blank" rel="noopener">${esc(it.linkText || it.label || val)}</a>`); text.push(`${it.label || ''} ${val}`); }
531
+ else { html.push(esc(lbl + val)); text.push(lbl + val); }
532
+ }
533
+ });
534
+ this._statsEl.innerHTML = html.join(' · ');
535
+ this._statsEl.setAttribute('title', text.join(' · '));
536
+
537
+ this._legendEl.innerHTML = '';
538
+ if (o.slope && o.slopeLegend) {
539
+ const sc = this._slopeScale();
540
+ for (let idx = 0; idx <= sc.maxIdx; idx++) {
541
+ const color = sc.colorByIndex(idx);
542
+ const label = (sc.capped && idx === sc.maxIdx) ? `≥ ${idx * sc.classSize} %` : `${idx * sc.classSize}–${(idx + 1) * sc.classSize} %`;
543
+ const item = document.createElement('span'); item.className = 'oep-leg-it';
544
+ item.innerHTML = `<i class="oep-sw" style="background:${color}"></i>${label}`;
545
+ this._legendEl.appendChild(item);
546
+ }
547
+ this._legendEl.style.display = '';
548
+ } else this._legendEl.style.display = 'none';
549
+ }
550
+
551
+ // ---------- rendu -------------------------------------------------
552
+ _render() {
553
+ const o = this.options, s = this._stats, data = this._samples;
554
+ if (!data || !data.length) return;
555
+ this._applyTheme();
556
+ const mobile = this._applyPlacement();
557
+ this._renderHeader();
558
+
559
+ const m = o.margins, u = m.unit || 'px';
560
+ const toPx = (v) => u === 'px' ? v : v * (parseFloat(getComputedStyle(this.element).fontSize) || 16);
561
+
562
+ // largeur : 100% en mobile ; 'auto'/'100%'/'full' = largeur de la carte ; sinon nombre plafonné à la carte
563
+ const avail = this._availWidth();
564
+ const isAuto = (o.width === 'auto' || o.width === '100%' || o.width === 'full');
565
+ const desktopW = isAuto ? avail : Math.min(typeof o.width === 'number' ? o.width : (parseFloat(o.width) || avail), avail);
566
+ if (mobile) this.element.style.width = '';
567
+ else this.element.style.width = `${desktopW}px`;
568
+ const cw = this.element.clientWidth || (mobile ? avail : desktopW);
569
+ const W = Math.max(220, cw - 16);
570
+ const H = typeof o.height === 'number' ? o.height : 180;
571
+
572
+ const mt = toPx(m.top), mr = toPx(m.right), mb = toPx(m.bottom), ml = toPx(m.left);
573
+ const innerW = Math.max(10, W - ml - mr), innerH = Math.max(10, H - mt - mb);
574
+ const xTicks = o.xTicks != null ? o.xTicks : Math.max(2, Math.round(innerW / 80));
575
+ const yTicks = o.yTicks != null ? o.yTicks : Math.max(2, Math.round(innerH / 40));
576
+
577
+ this._body.innerHTML = '';
578
+ const svg = d3.select(this._body).append('svg').attr('class', 'oep-svg').attr('width', W).attr('height', H).attr('viewBox', `0 0 ${W} ${H}`);
579
+ const g = svg.append('g').attr('transform', `translate(${ml},${mt})`);
580
+
581
+ const x = d3.scaleLinear().domain([0, s.distance]).range([0, innerW]);
582
+ const zpad = (s.max - s.min) * 0.1 || 10;
583
+ const y = d3.scaleLinear().domain([s.min - zpad, s.max + zpad]).range([innerH, 0]).nice();
584
+ this._x = x; this._y = y; this._dims = { innerW, innerH };
585
+
586
+ if (o.grid) g.append('g').attr('class', 'oep-grid').call(d3.axisLeft(y).ticks(yTicks).tickSize(-innerW).tickFormat(''));
587
+
588
+ const dscale = distAxisScale(o.units);
589
+ const areaGen = d3.area().x((d) => x(d.x)).y0(innerH).y1((d) => y(d.z));
590
+
591
+ if (o.slope) {
592
+ const sc = this._slopeScale();
593
+ const seps = [];
594
+ let i = 1;
595
+ while (i < data.length) {
596
+ const cls = this._classIndex(data[i].slope, sc);
597
+ let j = i; while (j + 1 < data.length && this._classIndex(data[j + 1].slope, sc) === cls) j++;
598
+ g.append('path').datum(data.slice(i - 1, j + 1)).attr('class', 'oep-area-slope').attr('fill', sc.colorByIndex(cls)).attr('d', areaGen);
599
+ if (i > 1) seps.push(data[i - 1]); // changement de classe = frontière
600
+ i = j + 1;
601
+ }
602
+ if (o.slopeSeparators) seps.forEach((d) => {
603
+ g.append('line').attr('class', 'oep-slope-sep').attr('x1', x(d.x)).attr('x2', x(d.x)).attr('y1', y(d.z)).attr('y2', innerH);
604
+ });
605
+ } else {
606
+ g.append('path').datum(data).attr('class', 'oep-area').attr('d', areaGen);
607
+ }
608
+ g.append('path').datum(data).attr('class', 'oep-line').attr('d', d3.line().x((d) => x(d.x)).y((d) => y(d.z)));
609
+
610
+ g.append('g').attr('class', 'oep-axis oep-axis-x').attr('transform', `translate(0,${innerH})`)
611
+ .call(d3.axisBottom(x).ticks(xTicks).tickFormat((d) => (d / dscale).toFixed(d / dscale < 10 ? 1 : 0)));
612
+ g.append('text').attr('class', 'oep-axis-label').attr('x', innerW).attr('y', innerH + mb - 4).attr('text-anchor', 'end').text(distAxisLabel(o.units));
613
+ 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));
614
+
615
+ // marqueurs A / B en cours de sélection
616
+ if (o.zoom && !this._cropMode) {
617
+ [['A', this._zoomA], ['B', this._zoomB]].forEach(([nm, val]) => {
618
+ if (val == null) return;
619
+ const px = x(val);
620
+ g.append('line').attr('class', 'oep-ab-line').attr('x1', px).attr('x2', px).attr('y1', 0).attr('y2', innerH);
621
+ g.append('text').attr('class', 'oep-ab-label').attr('x', px).attr('y', -6).attr('text-anchor', 'middle').text(nm);
622
+ });
623
+ }
624
+
625
+ const focus = g.append('g').attr('class', 'oep-focus').style('display', 'none');
626
+ focus.append('line').attr('class', 'oep-focus-line').attr('y1', 0).attr('y2', innerH);
627
+ focus.append('circle').attr('class', 'oep-focus-dot').attr('r', 4);
628
+ 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');
629
+ this._focus = focus;
630
+
631
+ const bisect = d3.bisector((d) => d.x).left;
632
+ const pick = (event) => { const mx = d3.pointer(event, g.node())[0]; return Math.max(0, Math.min(s.distance, x.invert(mx))); };
633
+ const overlay = svg.append('rect').attr('class', 'oep-overlay').attr('x', ml).attr('y', mt).attr('width', innerW).attr('height', innerH);
634
+ overlay.on('mousemove', (event) => {
635
+ const x0 = pick(event); const idx = bisect(data, x0, 1);
636
+ const d0 = data[idx - 1], d1 = data[idx] || d0; const d = (x0 - d0.x) > (d1.x - x0) ? d1 : d0;
637
+ this._setFocus(d); if (this._marker) this._marker.setPosition(d.coord);
638
+ }).on('mouseout', () => this._clearFocus());
639
+ overlay.on('click', (event) => {
640
+ if (!this._armed || this._cropMode) return;
641
+ const x0 = pick(event);
642
+ if (this._armed === 'A') this._zoomA = x0; else this._zoomB = x0;
643
+ this._armed = null; this._updateZoomButtons();
644
+ if (this._zoomA != null && this._zoomB != null) this._applyZoom(); else this._render();
645
+ });
646
+ if (this._armed) overlay.style('cursor', 'col-resize');
647
+ this._adjustAttribution();
648
+ }
649
+
650
+ // ---------- zoom A/B ----------------------------------------------
651
+ _arm(which) { this._armed = (this._armed === which) ? null : which; this._updateZoomButtons(); if (this._focus) this._render(); }
652
+ _updateZoomButtons() {
653
+ const show = !!this.options.zoom && !!this._feature;
654
+ this._toolbar.style.display = show ? '' : 'none';
655
+ const crop = this._cropMode;
656
+ this._btnA.style.display = show && !crop ? '' : 'none';
657
+ this._btnB.style.display = show && !crop ? '' : 'none';
658
+ this._btnAll.style.display = show && crop ? '' : 'none';
659
+ this._btnA.classList.toggle('armed', this._armed === 'A');
660
+ this._btnB.classList.toggle('armed', this._armed === 'B');
661
+ }
662
+ _applyZoom() {
663
+ const a = Math.min(this._zoomA, this._zoomB), b = Math.max(this._zoomA, this._zoomB);
664
+ const raw = this._fullSamples.filter((p) => p.x >= a && p.x <= b);
665
+ if (raw.length < 2) return;
666
+ const off = raw[0].x; // A devient 0
667
+ const cropped = raw.map((p) => ({ x: p.x - off, z: p.z, coord: p.coord, slope: p.slope }));
668
+ const coords = raw.map((p) => p.coord);
669
+ this._samples = cropped; this._stats = this._statsOf(cropped, false); this._cropMode = true;
670
+ this._updateZoomButtons(); this._render();
671
+ const map = this.getMap();
672
+ if (map) {
673
+ const ext = boundingExtent(coords);
674
+ const view = map.getView();
675
+ try { this._fitRes = view.getResolutionForExtent(ext, map.getSize()); } catch (e) { this._fitRes = null; }
676
+ view.fit(ext, { padding: [40, 40, 40, 40], duration: 400 });
677
+ }
678
+ }
679
+ _exitZoom() {
680
+ this._cropMode = false; this._zoomA = null; this._zoomB = null; this._armed = null; this._fitRes = null;
681
+ this._samples = this._fullSamples; this._stats = this._fullStats;
682
+ this._updateZoomButtons(); if (!this._collapsed) this._render();
683
+ const map = this.getMap();
684
+ if (map && this._feature) map.getView().fit(this._feature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 400 });
685
+ }
686
+
687
+ // ---------- focus -------------------------------------------------
688
+ _tooltipText(d) {
689
+ const o = this.options, parts = [];
690
+ o.tooltipItems.forEach((it) => {
691
+ if (it === 'distance') parts.push(fmtDistance(d.x, o.units));
692
+ else if (it === 'elevation') parts.push(fmtElevation(d.z, o.units));
693
+ else if (it === 'slope') parts.push(fmtSlope(d.slope || 0));
694
+ });
695
+ return parts.join(' · ');
696
+ }
697
+ _setFocus(d) {
698
+ if (!this._focus) return;
699
+ const x = this._x, y = this._y;
700
+ this._focus.style('display', null);
701
+ this._focus.select('.oep-focus-line').attr('x1', x(d.x)).attr('x2', x(d.x));
702
+ this._focus.select('.oep-focus-dot').attr('cx', x(d.x)).attr('cy', y(d.z));
703
+ let yLbl = y(d.z) - 16; if (yLbl < 12) yLbl = y(d.z) + 22;
704
+ const t = this._focus.select('.oep-focus-txt').attr('x', x(d.x)).attr('y', yLbl).text(this._tooltipText(d));
705
+ const bb = t.node().getBBox();
706
+ this._focus.select('.oep-focus-bg').attr('x', bb.x - 4).attr('y', bb.y - 2).attr('width', bb.width + 8).attr('height', bb.height + 4);
707
+ if (x(d.x) + bb.width / 2 > this._dims.innerW) t.attr('text-anchor', 'end');
708
+ else if (x(d.x) - bb.width / 2 < 0) t.attr('text-anchor', 'start');
709
+ else t.attr('text-anchor', 'middle');
710
+ }
711
+ _focusByCoord(coord) {
712
+ const data = this._samples; if (!data) return;
713
+ let best = null, bd = Infinity;
714
+ for (const p of data) { const dx = p.coord[0] - coord[0], dy = p.coord[1] - coord[1]; const dd = dx * dx + dy * dy; if (dd < bd) { bd = dd; best = p; } }
715
+ if (best) { this._setFocus(best); if (this._marker) this._marker.setPosition(best.coord); }
716
+ }
717
+ _clearFocus() { if (this._focus) this._focus.style('display', 'none'); if (this._marker) this._marker.setPosition(undefined); }
718
+ _clear() {
719
+ this._body.innerHTML = '';
720
+ this._legendEl.innerHTML = ''; this._legendEl.style.display = 'none';
721
+ this._titleEl.textContent = this.options.labels.empty; this._titleEl.removeAttribute('title');
722
+ this._statsEl.innerHTML = ''; this._statsEl.removeAttribute('title');
723
+ this._cropMode = false; this._updateZoomButtons();
724
+ this._clearFocus();
725
+ this.element.style.display = 'none';
726
+ this._adjustAttribution();
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Register a custom theme.
732
+ * @param {string} name
733
+ * @param {{area:string,line:string,axis:string,text:string,focus:string}} colors
734
+ */
735
+ ElevationProfile.addTheme = (name, colors) => { THEMES[name] = colors; };
736
+ ElevationProfile.THEMES = THEMES;
737
+ ElevationProfile.POSITIONS = POSITIONS;
738
+ ElevationProfile.version = '0.5.0';
739
+
740
+ export { ElevationProfile as default };