diff --git a/server/static/app.css b/server/static/app.css index d1b47c3..20496ee 100644 --- a/server/static/app.css +++ b/server/static/app.css @@ -39,6 +39,10 @@ main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr #elevationCanvas { width: 100%; height: 130px; display: block; background: #0a0a14; border-radius: 4px; } #elevationCanvas.elev-probe { cursor: crosshair; } .elev-legend { font-size: 0.7rem; } +.elevation-los-controls { display: none; align-items: center; gap: 8px; margin: 4px 0; font-size: 0.72rem; color: #ccc; } +.elevation-los-controls.visible { display: flex; } +.elevation-los-controls label { display: flex; align-items: center; gap: 6px; } +#elevationLosStartOffset { width: 170px; accent-color: #ff8800; } #qualityVizPanel { display: none; margin-top: 8px; } diff --git a/server/static/app.js b/server/static/app.js index 377b7cd..6061aa3 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -80,6 +80,9 @@ let elevProfileSingle = null; let elevProfileLink = null; let elevProfileLinkKey = null; let elevProfileMapLine = null; +const TIMELINE_LOS_START_OFFSET_KEY = 'timelineLosStartOffsetM'; +let timelineLosStartOffsetM = Number(sessionStorage.getItem(TIMELINE_LOS_START_OFFSET_KEY) || '0') || 0; +let timelineLosStartDrag = false; let elevationLoadState = 'idle'; let mapRulerOpen = false; let apiDebugOpen = false; @@ -1260,6 +1263,67 @@ function elevationAtDist(profile, distM) { return null; } +function clampLosStartOffset(v) { + return Math.max(-20, Math.min(100, Number(v) || 0)); +} + +function setTimelineLosStartOffset(v, redraw = true) { + timelineLosStartOffsetM = clampLosStartOffset(v); + sessionStorage.setItem(TIMELINE_LOS_START_OFFSET_KEY, String(timelineLosStartOffsetM)); + updateTimelineLosControls(); + updateTimelineElevationBaseStatus(); + if (redraw) drawElevationChart(); +} + +function fmtSignedM(v) { + if (!Number.isFinite(v)) return '—'; + return `${v >= 0 ? '+' : ''}${v.toFixed(1)} m`; +} + +function timelineLosSummary(profile = elevProfileLink) { + if (elevationPointCount(profile) === 0) return null; + const pts = profile.points.filter(p => p.elevation_m != null); + const groundA = pts[0]?.elevation_m; + const groundB = pts[pts.length - 1]?.elevation_m; + const total = profile.total_m || pts[pts.length - 1]?.dist_m || 0; + if (!Number.isFinite(groundA) || !Number.isFinite(groundB) || total <= 0) { + return null; + } + const elevA = groundA + timelineLosStartOffsetM; + const delta = groundB - elevA; + const angleDeg = Math.atan2(delta, total) * 180 / Math.PI; + return { + groundA, + groundB, + elevA, + elevB: groundB, + total, + delta, + angleDeg, + label: `TX ант. ${fmtSignedM(timelineLosStartOffsetM)} · Δ ${fmtSignedM(delta)} · ${angleDeg.toFixed(2)}°` + }; +} + +function updateTimelineLosControls() { + const box = document.getElementById('elevationLosControls'); + const slider = document.getElementById('elevationLosStartOffset'); + const label = document.getElementById('elevationLosStartOffsetLabel'); + const visible = dualTracksActive && elevationPointCount(elevProfileLink) > 0; + box?.classList.toggle('visible', visible); + if (slider) slider.value = String(timelineLosStartOffsetM); + if (label) label.textContent = fmtSignedM(timelineLosStartOffsetM); +} + +function updateTimelineElevationBaseStatus() { + if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0 || !timelineElevBaseStatus) { + updateTimelineLosControls(); + return; + } + const summary = timelineLosSummary(elevProfileLink); + setElevationStatus(summary ? `${timelineElevBaseStatus} · ${summary.label}` : timelineElevBaseStatus); + updateTimelineLosControls(); +} + function buildDirectLinePoints(tx, rx, stepM = 10) { const total = haversineM(tx.lat, tx.lon, rx.lat, rx.lon); if (total < 1) { @@ -1385,7 +1449,11 @@ function setTimelineElevCursor(distM) { updateTimelineElevCursorMarker(pt); const elev = elevationAtDist(elevProfileLink, timelineElevHoverDist); if (elev != null) { - setElevationStatus(`${timelineElevHoverDist.toFixed(0)} m · ${elev.toFixed(1)} m`); + const summary = timelineLosSummary(elevProfileLink); + setElevationStatus( + `${timelineElevHoverDist.toFixed(0)} m · ${elev.toFixed(1)} m` + + (summary ? ` · ${summary.label}` : '') + ); } drawElevationChart(); } @@ -1396,7 +1464,7 @@ function scheduleClearTimelineElevCursor() { if (timelineElevChartHover) return; timelineElevHoverDist = null; clearTimelineElevCursorMarker(); - setElevationStatus(timelineElevBaseStatus); + updateTimelineElevationBaseStatus(); drawElevationChart(); }, 60); } @@ -1408,6 +1476,7 @@ function updateElevationProbeClass() { 'elev-probe', dualTracksActive && elevationPointCount(elevProfileLink) > 0 ); + updateTimelineLosControls(); } function buildQualitySamples() { @@ -1974,11 +2043,22 @@ function getTimelineElevationSeries(cursors) { const pts = elevProfileLink.points.filter(p => p.elevation_m != null); const elevA = pts[0]?.elevation_m; const elevB = pts[pts.length - 1]?.elevation_m; + const summary = timelineLosSummary(elevProfileLink); series.push({ color: '#00ff88', profile: elevProfileLink, label: 'рельеф TX↔RX', - losLine: elevA != null && elevB != null ? { elevA, elevB } : null, + losLine: elevA != null && elevB != null + ? { + elevA: summary?.elevA ?? elevA, + elevB, + groundA: elevA, + offsetA: timelineLosStartOffsetM, + delta: summary?.delta, + angleDeg: summary?.angleDeg, + totalM: summary?.total, + } + : null, cursor: timelineElevHoverDist, }); return series; @@ -2104,9 +2184,24 @@ function renderElevationCanvas(canvas, series, loadState, emptyIdleMsg) { ctx.lineTo(x1, y1); ctx.stroke(); ctx.setLineDash([]); + ctx.fillStyle = '#0a0a14'; + ctx.strokeStyle = '#ffb74d'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x0, y0, 5.5, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = '#ffb74d'; + ctx.beginPath(); + ctx.arc(x0, y0, 2.5, 0, Math.PI * 2); + ctx.fill(); ctx.fillStyle = '#ffb74d'; ctx.font = '9px system-ui'; - ctx.fillText('прямая', x1 - 42, y1 - 4); + const losLabel = Number.isFinite(s.losLine.delta) && Number.isFinite(s.losLine.angleDeg) + ? `прямая · Δ ${fmtSignedM(s.losLine.delta)} · ${s.losLine.angleDeg.toFixed(2)}°` + : 'прямая'; + const tw = ctx.measureText(losLabel).width; + ctx.fillText(losLabel, Math.max(margin.l + 8, x1 - tw - 2), y1 - 4); } if (s.pointsOverlay?.length) { ctx.fillStyle = s.pointsOverlayColor || '#ff1744'; @@ -2178,7 +2273,7 @@ function renderElevationCanvas(canvas, series, loadState, emptyIdleMsg) { } }); - canvas._elevLayout = { margin, plotW, plotH, maxDist, minE, maxE }; + canvas._elevLayout = { margin, plotW, plotH, maxDist, minE, maxE, series }; } function drawElevationChart(cursors) { @@ -3381,7 +3476,7 @@ async function scheduleLinkElevation(txPos, rxPos) { : profile.source === 'server' ? 'сервер' : (profile.source || 'данные'); timelineElevBaseStatus = `срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`; - setElevationStatus(timelineElevBaseStatus); + updateTimelineElevationBaseStatus(); } updateElevationProbeClass(); } @@ -3425,7 +3520,7 @@ async function loadElevationProfiles() { : (elevProfileLink.total_m || 0); timelineElevBaseStatus = `срез TX↔RX · ${dist.toFixed(0)} m · ${srcLabel} · ${n} точек · оранжевая — прямая`; - setElevationStatus(timelineElevBaseStatus); + updateTimelineElevationBaseStatus(); } else if (dualTracksActive && elevProfileTx && elevProfileRx) { const nTx = elevationPointCount(elevProfileTx); const nRx = elevationPointCount(elevProfileRx); @@ -4380,6 +4475,37 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick(); (function bindTimelineElevationProbe() { const canvas = document.getElementById('elevationCanvas'); if (!canvas) return; + const losStartHit = (layout, x, y) => { + const line = layout?.series?.find(s => s.losLine)?.losLine; + if (!layout || !line || !Number.isFinite(line.elevA)) return false; + const handleX = layout.margin.l; + const handleY = layout.margin.t + layout.plotH + - ((line.elevA - layout.minE) / (layout.maxE - layout.minE)) * layout.plotH; + return Math.hypot(x - handleX, y - handleY) <= 14; + }; + const setLosOffsetFromCanvasY = y => { + const layout = canvas._elevLayout; + const line = layout?.series?.find(s => s.losLine)?.losLine; + if (!layout || !line || !Number.isFinite(line.groundA)) return; + const yy = Math.max(layout.margin.t, Math.min(layout.margin.t + layout.plotH, y)); + const elev = layout.minE + + ((layout.margin.t + layout.plotH - yy) / layout.plotH) * (layout.maxE - layout.minE); + setTimelineLosStartOffset(elev - line.groundA); + }; + canvas.addEventListener('mousedown', e => { + if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return; + const layout = canvas._elevLayout; + if (!layout) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + if (!losStartHit(layout, x, y)) return; + timelineLosStartDrag = true; + timelineElevChartHover = true; + clearTimeout(timelineElevLeaveTimer); + e.preventDefault(); + setLosOffsetFromCanvasY(y); + }); canvas.addEventListener('mousemove', e => { if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return; const layout = canvas._elevLayout; @@ -4388,13 +4514,38 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick(); clearTimeout(timelineElevLeaveTimer); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + if (timelineLosStartDrag) { + setLosOffsetFromCanvasY(y); + return; + } + canvas.style.cursor = losStartHit(layout, x, y) ? 'ns-resize' : 'crosshair'; const dist = ((x - layout.margin.l) / layout.plotW) * layout.maxDist; setTimelineElevCursor(dist); }); canvas.addEventListener('mouseleave', () => { + canvas.style.cursor = ''; + if (timelineLosStartDrag) return; timelineElevChartHover = false; scheduleClearTimelineElevCursor(); }); + window.addEventListener('mouseup', () => { + if (!timelineLosStartDrag) return; + timelineLosStartDrag = false; + timelineElevChartHover = false; + updateTimelineElevationBaseStatus(); + scheduleClearTimelineElevCursor(); + }); +})(); + +(function bindTimelineLosControls() { + const slider = document.getElementById('elevationLosStartOffset'); + if (!slider) return; + slider.value = String(timelineLosStartOffsetM); + slider.addEventListener('input', () => { + setTimelineLosStartOffset(slider.value); + }); + updateTimelineLosControls(); })(); (function bindMapRulerChartProbe() { diff --git a/server/static/index.html b/server/static/index.html index d16f6fa..e0889da 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -271,6 +271,12 @@ Линейка высот TX RX +
+ + +0.0 m +