elevation.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. const Elevation = (() => {
  2. const CHART_MAX_PTS = 500;
  3. const PAD = { top: 20, right: 16, bottom: 28, left: 46 };
  4. let canvas = null;
  5. let tooltip = null;
  6. let points = null; // full-res flat [{lat,lon,ele,time,dist}]
  7. let chartPts = null; // downsampled
  8. let bounds = null; // computed after draw: {cw,ch,minE,eRange,totDist}
  9. let trackMeta = null; // {name, trackDate, ...} from the track's meta
  10. let elevStats = null; // {gain, loss} computed from full-res points
  11. // ===== Public API =====
  12. function init() {
  13. canvas = document.getElementById('elevation-canvas');
  14. tooltip = document.getElementById('elevation-tooltip');
  15. if (!canvas) return;
  16. canvas.addEventListener('mousemove', onChartMove);
  17. canvas.addEventListener('mouseleave', onChartLeave);
  18. window.addEventListener('resize', () => { if (points) raf(draw); });
  19. }
  20. function setTrack(pts, meta) {
  21. points = pts;
  22. trackMeta = meta || null;
  23. chartPts = downsample(pts, CHART_MAX_PTS);
  24. bounds = null;
  25. elevStats = computeElevStats(pts);
  26. raf(draw);
  27. }
  28. function clear() {
  29. points = chartPts = bounds = trackMeta = elevStats = null;
  30. hideTooltip();
  31. if (canvas) {
  32. canvas.width = canvas.width; // reset context
  33. }
  34. }
  35. // Called by MapView when hovering a point that belongs to the current track.
  36. // Cursor is on the map, so only draw the indicator — the map tooltip is
  37. // already visible and is closer to the cursor than the chart tooltip would be.
  38. function onMapHover(point) {
  39. if (!bounds || !canvas) return;
  40. drawIndicator(point);
  41. hideTooltip();
  42. }
  43. // Called by MapView when hover leaves the current track
  44. function onMapLeave() {
  45. hideTooltip();
  46. if (bounds) draw();
  47. }
  48. // Shared tooltip formatter used by MapView for the Leaflet map tooltip
  49. function formatTooltip(p, meta) {
  50. const dist = p.dist >= 1000
  51. ? (p.dist / 1000).toFixed(2) + ' km'
  52. : Math.round(p.dist) + ' m';
  53. let html = '';
  54. if (meta?.name) {
  55. html += `<div style="font-weight:600;margin-bottom:2px">${escHtml(meta.name)}</div>`;
  56. }
  57. if (meta?.trackDate) {
  58. const d = new Date(meta.trackDate);
  59. if (!isNaN(d)) html += `<div style="color:rgba(255,255,255,0.75);margin-bottom:4px">${d.toLocaleDateString()}</div>`;
  60. }
  61. html += `<div><b>Dist:</b> ${dist}</div>`;
  62. if (p.ele != null) html += `<div><b>Ele:</b> ${Math.round(p.ele)} m</div>`;
  63. if (p.time) {
  64. const t = new Date(p.time);
  65. if (!isNaN(t)) {
  66. html += `<div><b>Time:</b> ${t.toLocaleTimeString(undefined,
  67. { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</div>`;
  68. }
  69. }
  70. html += `<div style="color:rgba(255,255,255,0.6)">${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}</div>`;
  71. return html;
  72. }
  73. // ===== Internal =====
  74. function raf(fn) {
  75. requestAnimationFrame(() => requestAnimationFrame(fn));
  76. }
  77. function downsample(pts, max) {
  78. if (pts.length <= max) return pts;
  79. const out = [];
  80. const step = (pts.length - 1) / (max - 1);
  81. for (let i = 0; i < max; i++) out.push(pts[Math.round(i * step)]);
  82. return out;
  83. }
  84. function computeElevStats(pts) {
  85. // Use hysteresis threshold to filter GPS elevation noise.
  86. // Only count a direction change as real gain/loss once it exceeds THRESHOLD metres.
  87. const THRESHOLD = 5;
  88. let gain = 0, loss = 0, anchor = null;
  89. for (const p of pts) {
  90. if (p.ele == null) continue;
  91. if (anchor == null) { anchor = p.ele; continue; }
  92. const d = p.ele - anchor;
  93. if (d >= THRESHOLD) { gain += d; anchor = p.ele; }
  94. else if (d <= -THRESHOLD) { loss -= d; anchor = p.ele; }
  95. }
  96. return { gain: Math.round(gain), loss: Math.round(loss) };
  97. }
  98. // ===== Chart drawing =====
  99. function draw() {
  100. if (!canvas || !chartPts || chartPts.length === 0) return;
  101. const rect = canvas.getBoundingClientRect();
  102. if (!rect.width || !rect.height) return;
  103. const dpr = window.devicePixelRatio || 1;
  104. canvas.width = rect.width * dpr;
  105. canvas.height = rect.height * dpr;
  106. const ctx = canvas.getContext('2d');
  107. ctx.scale(dpr, dpr); // from here on, all coords are CSS pixels
  108. ctx.clearRect(0, 0, rect.width, rect.height);
  109. const hasEle = chartPts.some(p => p.ele != null);
  110. if (!hasEle) {
  111. ctx.fillStyle = '#95a5a6';
  112. ctx.font = '11px sans-serif';
  113. ctx.textAlign = 'center';
  114. ctx.fillText('No elevation data', rect.width / 2, rect.height / 2);
  115. bounds = null;
  116. return;
  117. }
  118. const cw = rect.width - PAD.left - PAD.right;
  119. const ch = rect.height - PAD.top - PAD.bottom;
  120. const eles = chartPts.map(p => p.ele).filter(e => e != null);
  121. const minE = Math.min(...eles);
  122. const maxE = Math.max(...eles);
  123. const eRange = maxE - minE || 1;
  124. const totDist = chartPts[chartPts.length - 1].dist;
  125. drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist);
  126. drawProfile(ctx, cw, ch, minE, eRange, totDist);
  127. if (elevStats) drawStats(ctx, cw);
  128. bounds = { cw, ch, minE, maxE, eRange, totDist };
  129. }
  130. function drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist) {
  131. ctx.strokeStyle = '#e8e8e8';
  132. ctx.lineWidth = 1;
  133. ctx.fillStyle = '#999';
  134. ctx.font = '10px sans-serif';
  135. // Horizontal grid + elevation labels
  136. for (let i = 0; i <= 4; i++) {
  137. const y = PAD.top + ch * i / 4;
  138. ctx.beginPath();
  139. ctx.moveTo(PAD.left, y);
  140. ctx.lineTo(PAD.left + cw, y);
  141. ctx.stroke();
  142. ctx.textAlign = 'right';
  143. ctx.fillText(Math.round(maxE - eRange * i / 4) + 'm', PAD.left - 4, y + 3);
  144. }
  145. // Distance labels
  146. ctx.textAlign = 'center';
  147. for (let i = 0; i <= 5; i++) {
  148. const x = PAD.left + cw * i / 5;
  149. const d = totDist * i / 5;
  150. const label = d >= 1000 ? (d / 1000).toFixed(1) + 'k' : Math.round(d) + 'm';
  151. ctx.fillText(label, x, rect.height - 5);
  152. }
  153. }
  154. function drawProfile(ctx, cw, ch, minE, eRange, totDist) {
  155. const toX = p => PAD.left + (p.dist / totDist) * cw;
  156. const toY = p => PAD.top + ch - ((p.ele - minE) / eRange) * ch;
  157. // Gradient fill
  158. const grad = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + ch);
  159. grad.addColorStop(0, 'rgba(52,152,219,0.55)');
  160. grad.addColorStop(1, 'rgba(52,152,219,0.07)');
  161. ctx.beginPath();
  162. let first = true;
  163. for (const p of chartPts) {
  164. if (p.ele == null) continue;
  165. if (first) { ctx.moveTo(toX(p), toY(p)); first = false; }
  166. else ctx.lineTo(toX(p), toY(p));
  167. }
  168. ctx.lineTo(PAD.left + cw, PAD.top + ch);
  169. ctx.lineTo(PAD.left, PAD.top + ch);
  170. ctx.closePath();
  171. ctx.fillStyle = grad;
  172. ctx.fill();
  173. // Profile line
  174. ctx.beginPath();
  175. ctx.strokeStyle = '#3498db';
  176. ctx.lineWidth = 1.5;
  177. first = true;
  178. for (const p of chartPts) {
  179. if (p.ele == null) continue;
  180. if (first) { ctx.moveTo(toX(p), toY(p)); first = false; }
  181. else ctx.lineTo(toX(p), toY(p));
  182. }
  183. ctx.stroke();
  184. }
  185. function drawStats(ctx, cw) {
  186. const text = `▲ ${elevStats.gain} m ▼ ${elevStats.loss} m`;
  187. ctx.font = '10px sans-serif';
  188. ctx.textAlign = 'right';
  189. ctx.fillStyle = 'rgba(120,120,120,0.9)';
  190. ctx.fillText(text, PAD.left + cw - 2, PAD.top - 4);
  191. }
  192. // Draw a vertical cursor + dot at the given point (CSS pixel coords)
  193. function drawIndicator(point) {
  194. if (!bounds || !canvas) return;
  195. draw(); // resets canvas and re-applies ctx.scale(dpr,dpr) — use CSS px below
  196. const { cw, ch, minE, eRange, totDist } = bounds;
  197. const ctx = canvas.getContext('2d');
  198. const x = PAD.left + (point.dist / totDist) * cw;
  199. const y = point.ele != null
  200. ? PAD.top + ch - ((point.ele - minE) / eRange) * ch
  201. : PAD.top + ch / 2;
  202. ctx.save();
  203. // Vertical dashed line
  204. ctx.strokeStyle = 'rgba(231,76,60,0.55)';
  205. ctx.lineWidth = 1;
  206. ctx.setLineDash([4, 4]);
  207. ctx.beginPath();
  208. ctx.moveTo(x, PAD.top);
  209. ctx.lineTo(x, PAD.top + ch);
  210. ctx.stroke();
  211. ctx.setLineDash([]);
  212. // Dot
  213. ctx.fillStyle = '#e74c3c';
  214. ctx.strokeStyle = 'white';
  215. ctx.lineWidth = 1.5;
  216. ctx.beginPath();
  217. ctx.arc(x, y, 4, 0, Math.PI * 2);
  218. ctx.fill();
  219. ctx.stroke();
  220. ctx.restore();
  221. }
  222. // ===== Hover =====
  223. function onChartMove(e) {
  224. if (!bounds || !chartPts) return;
  225. const rect = canvas.getBoundingClientRect();
  226. const x = e.clientX - rect.left;
  227. if (x < PAD.left || x > PAD.left + bounds.cw) {
  228. onChartLeave();
  229. return;
  230. }
  231. const dist = ((x - PAD.left) / bounds.cw) * bounds.totDist;
  232. // Find nearest downsampled point for chart indicator
  233. const chartPt = findNearestByDist(chartPts, dist);
  234. // Find nearest full-res point for map marker
  235. const fullPt = findNearestByDist(points, dist);
  236. if (!chartPt) return;
  237. drawIndicator(chartPt);
  238. positionTooltip(chartPt, x, e.clientY - rect.top);
  239. // Cursor is on the chart, so move the map marker without a Leaflet tooltip —
  240. // the chart tooltip is already visible and is closer to the cursor.
  241. if (fullPt && typeof MapView !== 'undefined') {
  242. MapView.showHoverMarker(fullPt.lat, fullPt.lon, fullPt, trackMeta, true);
  243. }
  244. }
  245. function onChartLeave() {
  246. hideTooltip();
  247. if (typeof MapView !== 'undefined') MapView.hideHoverMarker();
  248. if (bounds) draw();
  249. }
  250. function findNearestByDist(pts, targetDist) {
  251. if (!pts || pts.length === 0) return null;
  252. let nearest = null, nearestD = Infinity;
  253. for (const p of pts) {
  254. const d = Math.abs(p.dist - targetDist);
  255. if (d < nearestD) { nearestD = d; nearest = p; }
  256. }
  257. return nearest;
  258. }
  259. function positionTooltip(point, cx, cy) {
  260. if (!tooltip || !canvas) return;
  261. tooltip.innerHTML = formatTooltip(point, trackMeta);
  262. tooltip.classList.add('visible');
  263. const cRect = canvas.getBoundingClientRect();
  264. const tw = tooltip.offsetWidth;
  265. const th = tooltip.offsetHeight;
  266. const gap = 8;
  267. // Decide above/below using viewport coordinates so the tooltip can
  268. // float above the chart container when needed (container has no
  269. // overflow:hidden, so negative top values are fine).
  270. const cursorViewportY = cRect.top + cy;
  271. const fitsAbove = cursorViewportY - th - gap >= 0;
  272. // top is relative to the container (may be negative = above the panel)
  273. const top = fitsAbove ? cy - th - gap : cy + gap;
  274. // Prefer right of the indicator line; clamp within canvas width
  275. const left = Math.min(cx + gap, cRect.width - tw - 4);
  276. tooltip.style.left = Math.max(0, left) + 'px';
  277. tooltip.style.top = top + 'px';
  278. }
  279. function hideTooltip() {
  280. if (tooltip) tooltip.classList.remove('visible');
  281. }
  282. return { init, setTrack, clear, formatTooltip, onMapHover, onMapLeave };
  283. })();