added linear lines

This commit is contained in:
2026-06-30 10:25:27 +03:00
parent 880ad3facf
commit 9726f01e2f
3 changed files with 168 additions and 7 deletions
+4
View File
@@ -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 { width: 100%; height: 130px; display: block; background: #0a0a14; border-radius: 4px; }
#elevationCanvas.elev-probe { cursor: crosshair; } #elevationCanvas.elev-probe { cursor: crosshair; }
.elev-legend { font-size: 0.7rem; } .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 { #qualityVizPanel {
display: none; margin-top: 8px; display: none; margin-top: 8px;
} }
+158 -7
View File
@@ -80,6 +80,9 @@ let elevProfileSingle = null;
let elevProfileLink = null; let elevProfileLink = null;
let elevProfileLinkKey = null; let elevProfileLinkKey = null;
let elevProfileMapLine = 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 elevationLoadState = 'idle';
let mapRulerOpen = false; let mapRulerOpen = false;
let apiDebugOpen = false; let apiDebugOpen = false;
@@ -1260,6 +1263,67 @@ function elevationAtDist(profile, distM) {
return null; 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) { function buildDirectLinePoints(tx, rx, stepM = 10) {
const total = haversineM(tx.lat, tx.lon, rx.lat, rx.lon); const total = haversineM(tx.lat, tx.lon, rx.lat, rx.lon);
if (total < 1) { if (total < 1) {
@@ -1385,7 +1449,11 @@ function setTimelineElevCursor(distM) {
updateTimelineElevCursorMarker(pt); updateTimelineElevCursorMarker(pt);
const elev = elevationAtDist(elevProfileLink, timelineElevHoverDist); const elev = elevationAtDist(elevProfileLink, timelineElevHoverDist);
if (elev != null) { 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(); drawElevationChart();
} }
@@ -1396,7 +1464,7 @@ function scheduleClearTimelineElevCursor() {
if (timelineElevChartHover) return; if (timelineElevChartHover) return;
timelineElevHoverDist = null; timelineElevHoverDist = null;
clearTimelineElevCursorMarker(); clearTimelineElevCursorMarker();
setElevationStatus(timelineElevBaseStatus); updateTimelineElevationBaseStatus();
drawElevationChart(); drawElevationChart();
}, 60); }, 60);
} }
@@ -1408,6 +1476,7 @@ function updateElevationProbeClass() {
'elev-probe', 'elev-probe',
dualTracksActive && elevationPointCount(elevProfileLink) > 0 dualTracksActive && elevationPointCount(elevProfileLink) > 0
); );
updateTimelineLosControls();
} }
function buildQualitySamples() { function buildQualitySamples() {
@@ -1974,11 +2043,22 @@ function getTimelineElevationSeries(cursors) {
const pts = elevProfileLink.points.filter(p => p.elevation_m != null); const pts = elevProfileLink.points.filter(p => p.elevation_m != null);
const elevA = pts[0]?.elevation_m; const elevA = pts[0]?.elevation_m;
const elevB = pts[pts.length - 1]?.elevation_m; const elevB = pts[pts.length - 1]?.elevation_m;
const summary = timelineLosSummary(elevProfileLink);
series.push({ series.push({
color: '#00ff88', color: '#00ff88',
profile: elevProfileLink, profile: elevProfileLink,
label: 'рельеф TX↔RX', 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, cursor: timelineElevHoverDist,
}); });
return series; return series;
@@ -2104,9 +2184,24 @@ function renderElevationCanvas(canvas, series, loadState, emptyIdleMsg) {
ctx.lineTo(x1, y1); ctx.lineTo(x1, y1);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); 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.fillStyle = '#ffb74d';
ctx.font = '9px system-ui'; 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) { if (s.pointsOverlay?.length) {
ctx.fillStyle = s.pointsOverlayColor || '#ff1744'; 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) { function drawElevationChart(cursors) {
@@ -3381,7 +3476,7 @@ async function scheduleLinkElevation(txPos, rxPos) {
: profile.source === 'server' ? 'сервер' : (profile.source || 'данные'); : profile.source === 'server' ? 'сервер' : (profile.source || 'данные');
timelineElevBaseStatus = timelineElevBaseStatus =
`срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`; `срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`;
setElevationStatus(timelineElevBaseStatus); updateTimelineElevationBaseStatus();
} }
updateElevationProbeClass(); updateElevationProbeClass();
} }
@@ -3425,7 +3520,7 @@ async function loadElevationProfiles() {
: (elevProfileLink.total_m || 0); : (elevProfileLink.total_m || 0);
timelineElevBaseStatus = timelineElevBaseStatus =
`срез TX↔RX · ${dist.toFixed(0)} m · ${srcLabel} · ${n} точек · оранжевая — прямая`; `срез TX↔RX · ${dist.toFixed(0)} m · ${srcLabel} · ${n} точек · оранжевая — прямая`;
setElevationStatus(timelineElevBaseStatus); updateTimelineElevationBaseStatus();
} else if (dualTracksActive && elevProfileTx && elevProfileRx) { } else if (dualTracksActive && elevProfileTx && elevProfileRx) {
const nTx = elevationPointCount(elevProfileTx); const nTx = elevationPointCount(elevProfileTx);
const nRx = elevationPointCount(elevProfileRx); const nRx = elevationPointCount(elevProfileRx);
@@ -4380,6 +4475,37 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick();
(function bindTimelineElevationProbe() { (function bindTimelineElevationProbe() {
const canvas = document.getElementById('elevationCanvas'); const canvas = document.getElementById('elevationCanvas');
if (!canvas) return; 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 => { canvas.addEventListener('mousemove', e => {
if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return; if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return;
const layout = canvas._elevLayout; const layout = canvas._elevLayout;
@@ -4388,13 +4514,38 @@ document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick();
clearTimeout(timelineElevLeaveTimer); clearTimeout(timelineElevLeaveTimer);
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left; 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; const dist = ((x - layout.margin.l) / layout.plotW) * layout.maxDist;
setTimelineElevCursor(dist); setTimelineElevCursor(dist);
}); });
canvas.addEventListener('mouseleave', () => { canvas.addEventListener('mouseleave', () => {
canvas.style.cursor = '';
if (timelineLosStartDrag) return;
timelineElevChartHover = false; timelineElevChartHover = false;
scheduleClearTimelineElevCursor(); 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() { (function bindMapRulerChartProbe() {
+6
View File
@@ -271,6 +271,12 @@
<span>Линейка высот <span id="elevationStatus" class="muted"></span></span> <span>Линейка высот <span id="elevationStatus" class="muted"></span></span>
<span class="elev-legend"><span class="legend-tx">TX</span> <span class="legend-rx">RX</span></span> <span class="elev-legend"><span class="legend-tx">TX</span> <span class="legend-rx">RX</span></span>
</div> </div>
<div id="elevationLosControls" class="elevation-los-controls">
<label>TX ант.:
<input type="range" id="elevationLosStartOffset" min="-20" max="100" step="0.5" value="0" />
</label>
<span id="elevationLosStartOffsetLabel" class="muted">+0.0 m</span>
</div>
<canvas id="elevationCanvas" width="800" height="130"></canvas> <canvas id="elevationCanvas" width="800" height="130"></canvas>
</div> </div>
<div id="qualityVizPanel"> <div id="qualityVizPanel">