const Elevation = (() => { const CHART_MAX_PTS = 500; const PAD = { top: 20, right: 16, bottom: 28, left: 46 }; let canvas = null; let tooltip = null; let points = null; // full-res flat [{lat,lon,ele,time,dist}] let chartPts = null; // downsampled let bounds = null; // computed after draw: {cw,ch,minE,eRange,totDist} let trackMeta = null; // {name, trackDate, ...} from the track's meta // ===== Public API ===== function init() { canvas = document.getElementById('elevation-canvas'); tooltip = document.getElementById('elevation-tooltip'); if (!canvas) return; canvas.addEventListener('mousemove', onChartMove); canvas.addEventListener('mouseleave', onChartLeave); window.addEventListener('resize', () => { if (points) raf(draw); }); } function setTrack(pts, meta) { points = pts; trackMeta = meta || null; chartPts = downsample(pts, CHART_MAX_PTS); bounds = null; raf(draw); } function clear() { points = chartPts = bounds = trackMeta = null; hideTooltip(); if (canvas) { canvas.width = canvas.width; // reset context } } // Called by MapView when hovering a point that belongs to the current track function onMapHover(point) { if (!bounds || !canvas) return; drawIndicator(point); const x = PAD.left + (point.dist / bounds.totDist) * bounds.cw; positionTooltip(point, x, PAD.top + bounds.ch / 2); } // Called by MapView when hover leaves the current track function onMapLeave() { hideTooltip(); if (bounds) draw(); } // Shared tooltip formatter used by MapView for the Leaflet map tooltip function formatTooltip(p, meta) { const dist = p.dist >= 1000 ? (p.dist / 1000).toFixed(2) + ' km' : Math.round(p.dist) + ' m'; let html = ''; if (meta?.name) { html += `
${escHtml(meta.name)}
`; } if (meta?.trackDate) { const d = new Date(meta.trackDate); if (!isNaN(d)) html += `
${d.toLocaleDateString()}
`; } html += `
Dist: ${dist}
`; if (p.ele != null) html += `
Ele: ${Math.round(p.ele)} m
`; if (p.time) { const t = new Date(p.time); if (!isNaN(t)) { html += `
Time: ${t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
`; } } html += `
${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}
`; return html; } // ===== Internal ===== function raf(fn) { requestAnimationFrame(() => requestAnimationFrame(fn)); } function downsample(pts, max) { if (pts.length <= max) return pts; const out = []; const step = (pts.length - 1) / (max - 1); for (let i = 0; i < max; i++) out.push(pts[Math.round(i * step)]); return out; } // ===== Chart drawing ===== function draw() { if (!canvas || !chartPts || chartPts.length === 0) return; const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); // from here on, all coords are CSS pixels ctx.clearRect(0, 0, rect.width, rect.height); const hasEle = chartPts.some(p => p.ele != null); if (!hasEle) { ctx.fillStyle = '#95a5a6'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('No elevation data', rect.width / 2, rect.height / 2); bounds = null; return; } const cw = rect.width - PAD.left - PAD.right; const ch = rect.height - PAD.top - PAD.bottom; const eles = chartPts.map(p => p.ele).filter(e => e != null); const minE = Math.min(...eles); const maxE = Math.max(...eles); const eRange = maxE - minE || 1; const totDist = chartPts[chartPts.length - 1].dist; drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist); drawProfile(ctx, cw, ch, minE, eRange, totDist); bounds = { cw, ch, minE, maxE, eRange, totDist }; } function drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist) { ctx.strokeStyle = '#e8e8e8'; ctx.lineWidth = 1; ctx.fillStyle = '#999'; ctx.font = '10px sans-serif'; // Horizontal grid + elevation labels for (let i = 0; i <= 4; i++) { const y = PAD.top + ch * i / 4; ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + cw, y); ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(Math.round(maxE - eRange * i / 4) + 'm', PAD.left - 4, y + 3); } // Distance labels ctx.textAlign = 'center'; for (let i = 0; i <= 5; i++) { const x = PAD.left + cw * i / 5; const d = totDist * i / 5; const label = d >= 1000 ? (d / 1000).toFixed(1) + 'k' : Math.round(d) + 'm'; ctx.fillText(label, x, rect.height - 5); } } function drawProfile(ctx, cw, ch, minE, eRange, totDist) { const toX = p => PAD.left + (p.dist / totDist) * cw; const toY = p => PAD.top + ch - ((p.ele - minE) / eRange) * ch; // Gradient fill const grad = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + ch); grad.addColorStop(0, 'rgba(52,152,219,0.55)'); grad.addColorStop(1, 'rgba(52,152,219,0.07)'); ctx.beginPath(); let first = true; for (const p of chartPts) { if (p.ele == null) continue; if (first) { ctx.moveTo(toX(p), toY(p)); first = false; } else ctx.lineTo(toX(p), toY(p)); } ctx.lineTo(PAD.left + cw, PAD.top + ch); ctx.lineTo(PAD.left, PAD.top + ch); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); // Profile line ctx.beginPath(); ctx.strokeStyle = '#3498db'; ctx.lineWidth = 1.5; first = true; for (const p of chartPts) { if (p.ele == null) continue; if (first) { ctx.moveTo(toX(p), toY(p)); first = false; } else ctx.lineTo(toX(p), toY(p)); } ctx.stroke(); } // Draw a vertical cursor + dot at the given point (CSS pixel coords) function drawIndicator(point) { if (!bounds || !canvas) return; draw(); // resets canvas and re-applies ctx.scale(dpr,dpr) — use CSS px below const { cw, ch, minE, eRange, totDist } = bounds; const ctx = canvas.getContext('2d'); const x = PAD.left + (point.dist / totDist) * cw; const y = point.ele != null ? PAD.top + ch - ((point.ele - minE) / eRange) * ch : PAD.top + ch / 2; ctx.save(); // Vertical dashed line ctx.strokeStyle = 'rgba(231,76,60,0.55)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, PAD.top + ch); ctx.stroke(); ctx.setLineDash([]); // Dot ctx.fillStyle = '#e74c3c'; ctx.strokeStyle = 'white'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore(); } // ===== Hover ===== function onChartMove(e) { if (!bounds || !chartPts) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; if (x < PAD.left || x > PAD.left + bounds.cw) { onChartLeave(); return; } const dist = ((x - PAD.left) / bounds.cw) * bounds.totDist; // Find nearest downsampled point for chart indicator const chartPt = findNearestByDist(chartPts, dist); // Find nearest full-res point for map marker const fullPt = findNearestByDist(points, dist); if (!chartPt) return; drawIndicator(chartPt); positionTooltip(chartPt, x, e.clientY - rect.top); if (fullPt && typeof MapView !== 'undefined') { MapView.showHoverMarker(fullPt.lat, fullPt.lon, fullPt, trackMeta); } } function onChartLeave() { hideTooltip(); if (typeof MapView !== 'undefined') MapView.hideHoverMarker(); if (bounds) draw(); } function findNearestByDist(pts, targetDist) { if (!pts || pts.length === 0) return null; let nearest = null, nearestD = Infinity; for (const p of pts) { const d = Math.abs(p.dist - targetDist); if (d < nearestD) { nearestD = d; nearest = p; } } return nearest; } function positionTooltip(point, cx, cy) { if (!tooltip || !canvas) return; tooltip.innerHTML = formatTooltip(point, trackMeta); tooltip.classList.add('visible'); const cRect = canvas.getBoundingClientRect(); tooltip.style.left = Math.min(cx + 10, cRect.width - tooltip.offsetWidth - 4) + 'px'; tooltip.style.top = Math.max(4, cy - tooltip.offsetHeight / 2) + 'px'; } function hideTooltip() { if (tooltip) tooltip.classList.remove('visible'); } return { init, setTrack, clear, formatTooltip, onMapHover, onMapLeave }; })();