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.
// Cursor is on the map, so only draw the indicator — the map tooltip is
// already visible and is closer to the cursor than the chart tooltip would be.
function onMapHover(point) {
if (!bounds || !canvas) return;
drawIndicator(point);
hideTooltip();
}
// 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);
// Cursor is on the chart, so move the map marker without a Leaflet tooltip —
// the chart tooltip is already visible and is closer to the cursor.
if (fullPt && typeof MapView !== 'undefined') {
MapView.showHoverMarker(fullPt.lat, fullPt.lon, fullPt, trackMeta, true);
}
}
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 };
})();