elevation.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  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. // ===== Public API =====
  10. function init() {
  11. canvas = document.getElementById('elevation-canvas');
  12. tooltip = document.getElementById('elevation-tooltip');
  13. if (!canvas) return;
  14. canvas.addEventListener('mousemove', onChartMove);
  15. canvas.addEventListener('mouseleave', onChartLeave);
  16. window.addEventListener('resize', () => { if (points) raf(draw); });
  17. }
  18. function setTrack(pts) {
  19. points = pts;
  20. chartPts = downsample(pts, CHART_MAX_PTS);
  21. bounds = null;
  22. raf(draw);
  23. }
  24. function clear() {
  25. points = chartPts = bounds = null;
  26. hideTooltip();
  27. if (canvas) {
  28. canvas.width = canvas.width; // reset context
  29. }
  30. }
  31. // Called by MapView when hovering a point that belongs to the current track
  32. function onMapHover(point) {
  33. if (!bounds || !canvas) return;
  34. drawIndicator(point);
  35. const x = PAD.left + (point.dist / bounds.totDist) * bounds.cw;
  36. positionTooltip(point, x, PAD.top + bounds.ch / 2);
  37. }
  38. // Called by MapView when hover leaves the current track
  39. function onMapLeave() {
  40. hideTooltip();
  41. if (bounds) draw();
  42. }
  43. // Shared tooltip formatter used by MapView for the Leaflet map tooltip
  44. function formatTooltip(p) {
  45. const dist = p.dist >= 1000
  46. ? (p.dist / 1000).toFixed(2) + ' km'
  47. : Math.round(p.dist) + ' m';
  48. let html = `<div><b>Dist:</b> ${dist}</div>`;
  49. if (p.ele != null) html += `<div><b>Ele:</b> ${Math.round(p.ele)} m</div>`;
  50. if (p.time) {
  51. const t = new Date(p.time);
  52. if (!isNaN(t)) {
  53. html += `<div><b>Time:</b> ${t.toLocaleTimeString(undefined,
  54. { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</div>`;
  55. }
  56. }
  57. html += `<div style="color:rgba(255,255,255,0.6)">${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}</div>`;
  58. return html;
  59. }
  60. // ===== Internal =====
  61. function raf(fn) {
  62. requestAnimationFrame(() => requestAnimationFrame(fn));
  63. }
  64. function downsample(pts, max) {
  65. if (pts.length <= max) return pts;
  66. const out = [];
  67. const step = (pts.length - 1) / (max - 1);
  68. for (let i = 0; i < max; i++) out.push(pts[Math.round(i * step)]);
  69. return out;
  70. }
  71. // ===== Chart drawing =====
  72. function draw() {
  73. if (!canvas || !chartPts || chartPts.length === 0) return;
  74. const rect = canvas.getBoundingClientRect();
  75. if (!rect.width || !rect.height) return;
  76. const dpr = window.devicePixelRatio || 1;
  77. canvas.width = rect.width * dpr;
  78. canvas.height = rect.height * dpr;
  79. const ctx = canvas.getContext('2d');
  80. ctx.scale(dpr, dpr); // from here on, all coords are CSS pixels
  81. ctx.clearRect(0, 0, rect.width, rect.height);
  82. const hasEle = chartPts.some(p => p.ele != null);
  83. if (!hasEle) {
  84. ctx.fillStyle = '#95a5a6';
  85. ctx.font = '11px sans-serif';
  86. ctx.textAlign = 'center';
  87. ctx.fillText('No elevation data', rect.width / 2, rect.height / 2);
  88. bounds = null;
  89. return;
  90. }
  91. const cw = rect.width - PAD.left - PAD.right;
  92. const ch = rect.height - PAD.top - PAD.bottom;
  93. const eles = chartPts.map(p => p.ele).filter(e => e != null);
  94. const minE = Math.min(...eles);
  95. const maxE = Math.max(...eles);
  96. const eRange = maxE - minE || 1;
  97. const totDist = chartPts[chartPts.length - 1].dist;
  98. drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist);
  99. drawProfile(ctx, cw, ch, minE, eRange, totDist);
  100. bounds = { cw, ch, minE, maxE, eRange, totDist };
  101. }
  102. function drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist) {
  103. ctx.strokeStyle = '#e8e8e8';
  104. ctx.lineWidth = 1;
  105. ctx.fillStyle = '#999';
  106. ctx.font = '10px sans-serif';
  107. // Horizontal grid + elevation labels
  108. for (let i = 0; i <= 4; i++) {
  109. const y = PAD.top + ch * i / 4;
  110. ctx.beginPath();
  111. ctx.moveTo(PAD.left, y);
  112. ctx.lineTo(PAD.left + cw, y);
  113. ctx.stroke();
  114. ctx.textAlign = 'right';
  115. ctx.fillText(Math.round(maxE - eRange * i / 4) + 'm', PAD.left - 4, y + 3);
  116. }
  117. // Distance labels
  118. ctx.textAlign = 'center';
  119. for (let i = 0; i <= 5; i++) {
  120. const x = PAD.left + cw * i / 5;
  121. const d = totDist * i / 5;
  122. const label = d >= 1000 ? (d / 1000).toFixed(1) + 'k' : Math.round(d) + 'm';
  123. ctx.fillText(label, x, rect.height - 5);
  124. }
  125. }
  126. function drawProfile(ctx, cw, ch, minE, eRange, totDist) {
  127. const toX = p => PAD.left + (p.dist / totDist) * cw;
  128. const toY = p => PAD.top + ch - ((p.ele - minE) / eRange) * ch;
  129. // Gradient fill
  130. const grad = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + ch);
  131. grad.addColorStop(0, 'rgba(52,152,219,0.55)');
  132. grad.addColorStop(1, 'rgba(52,152,219,0.07)');
  133. ctx.beginPath();
  134. let first = true;
  135. for (const p of chartPts) {
  136. if (p.ele == null) continue;
  137. if (first) { ctx.moveTo(toX(p), toY(p)); first = false; }
  138. else ctx.lineTo(toX(p), toY(p));
  139. }
  140. ctx.lineTo(PAD.left + cw, PAD.top + ch);
  141. ctx.lineTo(PAD.left, PAD.top + ch);
  142. ctx.closePath();
  143. ctx.fillStyle = grad;
  144. ctx.fill();
  145. // Profile line
  146. ctx.beginPath();
  147. ctx.strokeStyle = '#3498db';
  148. ctx.lineWidth = 1.5;
  149. first = true;
  150. for (const p of chartPts) {
  151. if (p.ele == null) continue;
  152. if (first) { ctx.moveTo(toX(p), toY(p)); first = false; }
  153. else ctx.lineTo(toX(p), toY(p));
  154. }
  155. ctx.stroke();
  156. }
  157. // Draw a vertical cursor + dot at the given point (CSS pixel coords)
  158. function drawIndicator(point) {
  159. if (!bounds || !canvas) return;
  160. draw(); // resets canvas and re-applies ctx.scale(dpr,dpr) — use CSS px below
  161. const { cw, ch, minE, eRange, totDist } = bounds;
  162. const ctx = canvas.getContext('2d');
  163. const x = PAD.left + (point.dist / totDist) * cw;
  164. const y = point.ele != null
  165. ? PAD.top + ch - ((point.ele - minE) / eRange) * ch
  166. : PAD.top + ch / 2;
  167. ctx.save();
  168. // Vertical dashed line
  169. ctx.strokeStyle = 'rgba(231,76,60,0.55)';
  170. ctx.lineWidth = 1;
  171. ctx.setLineDash([4, 4]);
  172. ctx.beginPath();
  173. ctx.moveTo(x, PAD.top);
  174. ctx.lineTo(x, PAD.top + ch);
  175. ctx.stroke();
  176. ctx.setLineDash([]);
  177. // Dot
  178. ctx.fillStyle = '#e74c3c';
  179. ctx.strokeStyle = 'white';
  180. ctx.lineWidth = 1.5;
  181. ctx.beginPath();
  182. ctx.arc(x, y, 4, 0, Math.PI * 2);
  183. ctx.fill();
  184. ctx.stroke();
  185. ctx.restore();
  186. }
  187. // ===== Hover =====
  188. function onChartMove(e) {
  189. if (!bounds || !chartPts) return;
  190. const rect = canvas.getBoundingClientRect();
  191. const x = e.clientX - rect.left;
  192. if (x < PAD.left || x > PAD.left + bounds.cw) {
  193. onChartLeave();
  194. return;
  195. }
  196. const dist = ((x - PAD.left) / bounds.cw) * bounds.totDist;
  197. // Find nearest downsampled point for chart indicator
  198. const chartPt = findNearestByDist(chartPts, dist);
  199. // Find nearest full-res point for map marker
  200. const fullPt = findNearestByDist(points, dist);
  201. if (!chartPt) return;
  202. drawIndicator(chartPt);
  203. positionTooltip(chartPt, x, e.clientY - rect.top);
  204. if (fullPt && typeof MapView !== 'undefined') {
  205. MapView.showHoverMarker(fullPt.lat, fullPt.lon, fullPt);
  206. }
  207. }
  208. function onChartLeave() {
  209. hideTooltip();
  210. if (typeof MapView !== 'undefined') MapView.hideHoverMarker();
  211. if (bounds) draw();
  212. }
  213. function findNearestByDist(pts, targetDist) {
  214. if (!pts || pts.length === 0) return null;
  215. let nearest = null, nearestD = Infinity;
  216. for (const p of pts) {
  217. const d = Math.abs(p.dist - targetDist);
  218. if (d < nearestD) { nearestD = d; nearest = p; }
  219. }
  220. return nearest;
  221. }
  222. function positionTooltip(point, cx, cy) {
  223. if (!tooltip || !canvas) return;
  224. tooltip.innerHTML = formatTooltip(point);
  225. tooltip.classList.add('visible');
  226. const cRect = canvas.getBoundingClientRect();
  227. tooltip.style.left = Math.min(cx + 10, cRect.width - tooltip.offsetWidth - 4) + 'px';
  228. tooltip.style.top = Math.max(4, cy - tooltip.offsetHeight / 2) + 'px';
  229. }
  230. function hideTooltip() {
  231. if (tooltip) tooltip.classList.remove('visible');
  232. }
  233. return { init, setTrack, clear, formatTooltip, onMapHover, onMapLeave };
  234. })();