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