| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- 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 += `<div style="font-weight:600;margin-bottom:2px">${escHtml(meta.name)}</div>`;
- }
- if (meta?.trackDate) {
- const d = new Date(meta.trackDate);
- if (!isNaN(d)) html += `<div style="color:rgba(255,255,255,0.75);margin-bottom:4px">${d.toLocaleDateString()}</div>`;
- }
- html += `<div><b>Dist:</b> ${dist}</div>`;
- if (p.ele != null) html += `<div><b>Ele:</b> ${Math.round(p.ele)} m</div>`;
- if (p.time) {
- const t = new Date(p.time);
- if (!isNaN(t)) {
- html += `<div><b>Time:</b> ${t.toLocaleTimeString(undefined,
- { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</div>`;
- }
- }
- html += `<div style="color:rgba(255,255,255,0.6)">${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}</div>`;
- 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();
- const tw = tooltip.offsetWidth;
- const th = tooltip.offsetHeight;
- const gap = 8;
- // Prefer above cursor; fall back to below if not enough room
- const topAbove = cy - th - gap;
- const top = topAbove >= 0 ? topAbove : cy + gap;
- // Prefer right of indicator; clamp to canvas bounds
- const leftRight = cx + gap;
- const left = Math.min(leftRight, cRect.width - tw - 4);
- tooltip.style.left = Math.max(0, left) + 'px';
- tooltip.style.top = top + 'px';
- }
- function hideTooltip() {
- if (tooltip) tooltip.classList.remove('visible');
- }
- return { init, setTrack, clear, formatTooltip, onMapHover, onMapLeave };
- })();
|