diff --git a/server/static/app.css b/server/static/app.css index 20496ee..eaaccea 100644 --- a/server/static/app.css +++ b/server/static/app.css @@ -39,10 +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 { display: none; align-items: center; gap: 8px; margin: 4px 0; font-size: 0.72rem; color: #ccc; flex-wrap: wrap; } .elevation-los-controls.visible { display: flex; } .elevation-los-controls label { display: flex; align-items: center; gap: 6px; } -#elevationLosStartOffset { width: 170px; accent-color: #ff8800; } +#elevationLosStartOffset, #elevationLosEndOffset { width: 140px; accent-color: #ff8800; } #qualityVizPanel { display: none; margin-top: 8px; } diff --git a/server/static/app.js b/server/static/app.js index 6061aa3..5f19226 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -81,8 +81,11 @@ let elevProfileLink = null; let elevProfileLinkKey = null; let elevProfileMapLine = null; const TIMELINE_LOS_START_OFFSET_KEY = 'timelineLosStartOffsetM'; +const TIMELINE_LOS_END_OFFSET_KEY = 'timelineLosEndOffsetM'; let timelineLosStartOffsetM = Number(sessionStorage.getItem(TIMELINE_LOS_START_OFFSET_KEY) || '0') || 0; +let timelineLosEndOffsetM = Number(sessionStorage.getItem(TIMELINE_LOS_END_OFFSET_KEY) || '0') || 0; let timelineLosStartDrag = false; +let timelineLosEndDrag = false; let elevationLoadState = 'idle'; let mapRulerOpen = false; let apiDebugOpen = false; @@ -1275,6 +1278,14 @@ function setTimelineLosStartOffset(v, redraw = true) { if (redraw) drawElevationChart(); } +function setTimelineLosEndOffset(v, redraw = true) { + timelineLosEndOffsetM = clampLosStartOffset(v); + sessionStorage.setItem(TIMELINE_LOS_END_OFFSET_KEY, String(timelineLosEndOffsetM)); + updateTimelineLosControls(); + updateTimelineElevationBaseStatus(); + if (redraw) drawElevationChart(); +} + function fmtSignedM(v) { if (!Number.isFinite(v)) return '—'; return `${v >= 0 ? '+' : ''}${v.toFixed(1)} m`; @@ -1290,28 +1301,33 @@ function timelineLosSummary(profile = elevProfileLink) { return null; } const elevA = groundA + timelineLosStartOffsetM; - const delta = groundB - elevA; + const elevB = groundB + timelineLosEndOffsetM; + const delta = elevB - elevA; const angleDeg = Math.atan2(delta, total) * 180 / Math.PI; return { groundA, groundB, elevA, - elevB: groundB, + elevB, total, delta, angleDeg, - label: `TX ант. ${fmtSignedM(timelineLosStartOffsetM)} · Δ ${fmtSignedM(delta)} · ${angleDeg.toFixed(2)}°` + label: `TX ант. ${fmtSignedM(timelineLosStartOffsetM)} · RX ант. ${fmtSignedM(timelineLosEndOffsetM)} · Δ ${fmtSignedM(delta)} · ${angleDeg.toFixed(2)}°` }; } function updateTimelineLosControls() { const box = document.getElementById('elevationLosControls'); - const slider = document.getElementById('elevationLosStartOffset'); - const label = document.getElementById('elevationLosStartOffsetLabel'); + const sliderTx = document.getElementById('elevationLosStartOffset'); + const labelTx = document.getElementById('elevationLosStartOffsetLabel'); + const sliderRx = document.getElementById('elevationLosEndOffset'); + const labelRx = document.getElementById('elevationLosEndOffsetLabel'); const visible = dualTracksActive && elevationPointCount(elevProfileLink) > 0; box?.classList.toggle('visible', visible); - if (slider) slider.value = String(timelineLosStartOffsetM); - if (label) label.textContent = fmtSignedM(timelineLosStartOffsetM); + if (sliderTx) sliderTx.value = String(timelineLosStartOffsetM); + if (labelTx) labelTx.textContent = fmtSignedM(timelineLosStartOffsetM); + if (sliderRx) sliderRx.value = String(timelineLosEndOffsetM); + if (labelRx) labelRx.textContent = fmtSignedM(timelineLosEndOffsetM); } function updateTimelineElevationBaseStatus() { @@ -2051,9 +2067,11 @@ function getTimelineElevationSeries(cursors) { losLine: elevA != null && elevB != null ? { elevA: summary?.elevA ?? elevA, - elevB, + elevB: summary?.elevB ?? elevB, groundA: elevA, + groundB: elevB, offsetA: timelineLosStartOffsetM, + offsetB: timelineLosEndOffsetM, delta: summary?.delta, angleDeg: summary?.angleDeg, totalM: summary?.total, @@ -2195,6 +2213,17 @@ function renderElevationCanvas(canvas, series, loadState, emptyIdleMsg) { ctx.beginPath(); ctx.arc(x0, y0, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); + ctx.arc(x1, y1, 5.5, 0, Math.PI * 2); + ctx.fillStyle = '#0a0a14'; + ctx.strokeStyle = '#ffb74d'; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = '#ffb74d'; + ctx.beginPath(); + ctx.arc(x1, y1, 2.5, 0, Math.PI * 2); + ctx.fill(); ctx.fillStyle = '#ffb74d'; ctx.font = '9px system-ui'; const losLabel = Number.isFinite(s.losLine.delta) && Number.isFinite(s.losLine.angleDeg) @@ -3920,6 +3949,52 @@ function buildTimelineModalHtml(cursor, txPos, rxPos) { return html; } +function timelineGhostIcon(color, label) { + return L.divIcon({ + className: 'timeline-ghost-marker', + html: `
${label}
`, + iconSize: [26, 26], + iconAnchor: [13, 13], + }); +} + +function createTimelineGhostMarker(role, pos, color, cursor, txBase, rxBase) { + const marker = L.marker([pos.lat, pos.lon], { + draggable: true, + icon: timelineGhostIcon(color, role), + zIndexOffset: 1000, + }).addTo(map); + marker.on('drag', () => syncDraggedTimelineLink(cursor, txBase, rxBase, false)); + marker.on('dragend', () => syncDraggedTimelineLink(cursor, txBase, rxBase, true)); + return marker; +} + +function syncDraggedTimelineLink(cursor, txBase, rxBase, reloadElevation) { + if (!ghostTx || !ghostRx) return; + const txLatLng = ghostTx.getLatLng(); + const rxLatLng = ghostRx.getLatLng(); + const txPos = { ...txBase, lat: txLatLng.lat, lon: txLatLng.lng }; + const rxPos = { ...rxBase, lat: rxLatLng.lat, lon: rxLatLng.lng }; + timelineLinkTxPos = txPos; + timelineLinkRxPos = rxPos; + if (linkLine) { + linkLine.setLatLngs([[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]]); + } + const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon); + document.getElementById('distanceNow').textContent = + `Расстояние GPS: ${dist.toFixed(0)} m`; + if (reloadElevation) { + scheduleLinkElevation(txPos, rxPos).then(() => { + drawElevationChart(); + updateElevationProbeClass(); + }); + } + if (isModalOpen() && modalMode === 'timeline') { + openMapModal(buildTimelineModalHtml(cursor, txPos, rxPos), 'timeline'); + } + redrawQualityDistHighlight(); +} + function singleTrackRange(points) { if (!points || !points.length) return null; return { min: Number(points[0].ts), max: Number(points[points.length - 1].ts), mode: 'single' }; @@ -3944,16 +4019,12 @@ function updateTimelineAt(tOrCursor, opts) { if (linkLine) map.removeLayer(linkLine); if (txPos) { - ghostTx = L.circleMarker([txPos.lat, txPos.lon], { - radius: 10, color: '#fff', fillColor: TX_COLOR, fillOpacity: 0.95, weight: 3 - }).addTo(map); + ghostTx = createTimelineGhostMarker('TX', txPos, TX_COLOR, cursor, txPos, rxPos); } if (rxPos) { const q = rxQualityFromMeta(rxPos.meta); const rxGhostColor = q != null ? (qualityColor(q) || RX_COLOR) : RX_COLOR; - ghostRx = L.circleMarker([rxPos.lat, rxPos.lon], { - radius: 10, color: '#fff', fillColor: rxGhostColor, fillOpacity: 0.95, weight: 3 - }).addTo(map); + ghostRx = createTimelineGhostMarker('RX', rxPos, rxGhostColor, cursor, txPos, rxPos); } if (txPos && rxPos) { const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon); @@ -4240,10 +4311,8 @@ async function showSingleTrack() { } const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR; drawTrackLine(loadedSingleTrack, color, 'tx', loadedSingleTrack.role === 'RX'); - if (!userMovedMap) { - const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon])); - setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 })); - } + const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon])); + setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 })); singleTrackActive = true; setupTimelineSingle(); const range = singleTrackRange(loadedSingleTrack.points); @@ -4299,11 +4368,9 @@ async function showDualTracks() { drawTrackLine(loadedTxTrack, TX_COLOR, 'tx', false); drawTrackLine(loadedRxTrack, RX_COLOR, 'rx', true); - if (!userMovedMap) { - const bounds = L.latLngBounds([]); - [...loadedTxTrack.points, ...loadedRxTrack.points].forEach(p => bounds.extend([p.lat, p.lon])); - setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 })); - } + const bounds = L.latLngBounds([]); + [...loadedTxTrack.points, ...loadedRxTrack.points].forEach(p => bounds.extend([p.lat, p.lon])); + setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 })); dualTracksActive = true; setupTimeline(); @@ -4475,22 +4542,29 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick(); (function bindTimelineElevationProbe() { const canvas = document.getElementById('elevationCanvas'); if (!canvas) return; - const losStartHit = (layout, x, y) => { + const losHandleHit = (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 + if (!layout || !line || !Number.isFinite(line.elevA) || !Number.isFinite(line.elevB)) return null; + const startX = layout.margin.l; + const startY = layout.margin.t + layout.plotH - ((line.elevA - layout.minE) / (layout.maxE - layout.minE)) * layout.plotH; - return Math.hypot(x - handleX, y - handleY) <= 14; + const endX = layout.margin.l + layout.plotW; + const endY = layout.margin.t + layout.plotH + - ((line.elevB - layout.minE) / (layout.maxE - layout.minE)) * layout.plotH; + if (Math.hypot(x - startX, y - startY) <= 14) return 'start'; + if (Math.hypot(x - endX, y - endY) <= 14) return 'end'; + return null; }; - const setLosOffsetFromCanvasY = y => { + const setLosOffsetFromCanvasY = (y, which) => { const layout = canvas._elevLayout; const line = layout?.series?.find(s => s.losLine)?.losLine; - if (!layout || !line || !Number.isFinite(line.groundA)) return; + const ground = which === 'end' ? line?.groundB : line?.groundA; + if (!layout || !line || !Number.isFinite(ground)) 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); + if (which === 'end') setTimelineLosEndOffset(elev - ground); + else setTimelineLosStartOffset(elev - ground); }; canvas.addEventListener('mousedown', e => { if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return; @@ -4499,12 +4573,14 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick(); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; - if (!losStartHit(layout, x, y)) return; - timelineLosStartDrag = true; + const hit = losHandleHit(layout, x, y); + if (!hit) return; + timelineLosStartDrag = hit === 'start'; + timelineLosEndDrag = hit === 'end'; timelineElevChartHover = true; clearTimeout(timelineElevLeaveTimer); e.preventDefault(); - setLosOffsetFromCanvasY(y); + setLosOffsetFromCanvasY(y, hit); }); canvas.addEventListener('mousemove', e => { if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return; @@ -4515,23 +4591,24 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick(); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; - if (timelineLosStartDrag) { - setLosOffsetFromCanvasY(y); + if (timelineLosStartDrag || timelineLosEndDrag) { + setLosOffsetFromCanvasY(y, timelineLosEndDrag ? 'end' : 'start'); return; } - canvas.style.cursor = losStartHit(layout, x, y) ? 'ns-resize' : 'crosshair'; + canvas.style.cursor = losHandleHit(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; + if (timelineLosStartDrag || timelineLosEndDrag) return; timelineElevChartHover = false; scheduleClearTimelineElevCursor(); }); window.addEventListener('mouseup', () => { - if (!timelineLosStartDrag) return; + if (!timelineLosStartDrag && !timelineLosEndDrag) return; timelineLosStartDrag = false; + timelineLosEndDrag = false; timelineElevChartHover = false; updateTimelineElevationBaseStatus(); scheduleClearTimelineElevCursor(); @@ -4539,11 +4616,16 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick(); })(); (function bindTimelineLosControls() { - const slider = document.getElementById('elevationLosStartOffset'); - if (!slider) return; - slider.value = String(timelineLosStartOffsetM); - slider.addEventListener('input', () => { - setTimelineLosStartOffset(slider.value); + const sliderTx = document.getElementById('elevationLosStartOffset'); + const sliderRx = document.getElementById('elevationLosEndOffset'); + if (!sliderTx || !sliderRx) return; + sliderTx.value = String(timelineLosStartOffsetM); + sliderRx.value = String(timelineLosEndOffsetM); + sliderTx.addEventListener('input', () => { + setTimelineLosStartOffset(sliderTx.value); + }); + sliderRx.addEventListener('input', () => { + setTimelineLosEndOffset(sliderRx.value); }); updateTimelineLosControls(); })(); diff --git a/server/static/index.html b/server/static/index.html index e0889da..733436c 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -276,6 +276,10 @@ +0.0 m + + +0.0 m