generated from Grigo/AndroidTemplate
added gistogramm
This commit is contained in:
@@ -379,7 +379,7 @@ def health():
|
||||
return {
|
||||
"ok": status["db_ok"],
|
||||
"ts": time.time(),
|
||||
"api_build": "2026-06-16g",
|
||||
"api_build": "2026-06-16h",
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
+207
-6
@@ -45,7 +45,22 @@
|
||||
}
|
||||
#elevationStatus { font-size: 0.7rem; color: #aaa; font-weight: 400; }
|
||||
#elevationCanvas { width: 100%; height: 130px; display: block; background: #0a0a14; border-radius: 4px; }
|
||||
#elevationCanvas.elev-probe { cursor: crosshair; }
|
||||
.elev-legend { font-size: 0.7rem; }
|
||||
#qualityVizPanel {
|
||||
display: none; margin-top: 8px; gap: 8px;
|
||||
grid-template-columns: 140px 1fr; align-items: start;
|
||||
}
|
||||
#qualityVizPanel.visible { display: grid; }
|
||||
@media (max-width: 700px) {
|
||||
#qualityVizPanel.visible { grid-template-columns: 1fr; }
|
||||
}
|
||||
.quality-viz-box {
|
||||
background: #0f3460; border: 1px solid #444; border-radius: 6px; padding: 6px;
|
||||
}
|
||||
.quality-viz-title { font-size: 0.7rem; color: #aaa; margin-bottom: 4px; }
|
||||
#qualityRoseCanvas { width: 100%; height: 140px; display: block; background: #0a0a14; border-radius: 4px; }
|
||||
#qualityDistCanvas { width: 100%; height: 140px; display: block; background: #0a0a14; border-radius: 4px; }
|
||||
#timelineStatsPanel {
|
||||
display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;
|
||||
}
|
||||
@@ -320,6 +335,16 @@
|
||||
</div>
|
||||
<canvas id="elevationCanvas" width="800" height="130"></canvas>
|
||||
</div>
|
||||
<div id="qualityVizPanel">
|
||||
<div class="quality-viz-box">
|
||||
<div class="quality-viz-title">Качество по направлению TX→RX</div>
|
||||
<canvas id="qualityRoseCanvas" width="140" height="140"></canvas>
|
||||
</div>
|
||||
<div class="quality-viz-box">
|
||||
<div class="quality-viz-title">RX Quality vs расстояние</div>
|
||||
<canvas id="qualityDistCanvas" width="400" height="140"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -332,6 +357,7 @@
|
||||
</div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/static/radio-ui.js"></script>
|
||||
<script src="/static/quality-viz.js"></script>
|
||||
<script>
|
||||
if (typeof RadioUI === 'undefined') {
|
||||
console.error('radio-ui.js not loaded — check /static/radio-ui.js');
|
||||
@@ -361,7 +387,7 @@
|
||||
{ position: 'topright', collapsed: true }
|
||||
).addTo(map);
|
||||
|
||||
const API_BUILD = '2026-06-16g';
|
||||
const API_BUILD = '2026-06-16h';
|
||||
|
||||
const markers = {};
|
||||
let selectedId = null;
|
||||
@@ -430,6 +456,14 @@
|
||||
let mapRulerPointsAuto = true;
|
||||
let mapRulerManualPoints = 100;
|
||||
let mapRulerReloadTimer = null;
|
||||
let timelineElevHoverDist = null;
|
||||
let timelineElevCursorMarker = null;
|
||||
let timelineElevBaseStatus = '';
|
||||
let timelineLinkTxPos = null;
|
||||
let timelineLinkRxPos = null;
|
||||
let timelineElevLeaveTimer = null;
|
||||
let timelineElevChartHover = false;
|
||||
let qualitySamplesCache = [];
|
||||
|
||||
const DEVICE_POLL_MS = 1000;
|
||||
const CHAT_POLL_MS = 2500;
|
||||
@@ -438,6 +472,7 @@
|
||||
|
||||
const TX_COLOR = '#e94560';
|
||||
const RX_COLOR = '#4fc3f7';
|
||||
const GHOST_TX_COLOR = '#ff9800';
|
||||
|
||||
map.on('zoomend moveend', () => {
|
||||
if (!programmaticMove) userMovedMap = true;
|
||||
@@ -1092,6 +1127,118 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimelineElevCursorMarker() {
|
||||
if (timelineElevCursorMarker) {
|
||||
map.removeLayer(timelineElevCursorMarker);
|
||||
timelineElevCursorMarker = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimelineElevCursorMarker(pt) {
|
||||
clearTimelineElevCursorMarker();
|
||||
timelineElevCursorMarker = L.circleMarker([pt.lat, pt.lon], {
|
||||
radius: 8,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: '#00ff88',
|
||||
fillOpacity: 0.95,
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
function setTimelineElevCursor(distM) {
|
||||
if (!dualTracksActive || !timelineLinkTxPos || !timelineLinkRxPos
|
||||
|| elevationPointCount(elevProfileLink) === 0) return;
|
||||
const total = elevProfileLink.total_m
|
||||
|| lineLengthM(timelineLinkTxPos, timelineLinkRxPos);
|
||||
timelineElevHoverDist = Math.max(0, Math.min(distM, total));
|
||||
const pt = latLonAtLineDist(
|
||||
timelineLinkTxPos, timelineLinkRxPos, timelineElevHoverDist);
|
||||
updateTimelineElevCursorMarker(pt);
|
||||
const elev = elevationAtDist(elevProfileLink, timelineElevHoverDist);
|
||||
if (elev != null) {
|
||||
setElevationStatus(`${timelineElevHoverDist.toFixed(0)} m · ${elev.toFixed(1)} m`);
|
||||
}
|
||||
drawElevationChart();
|
||||
}
|
||||
|
||||
function scheduleClearTimelineElevCursor() {
|
||||
clearTimeout(timelineElevLeaveTimer);
|
||||
timelineElevLeaveTimer = setTimeout(() => {
|
||||
if (timelineElevChartHover) return;
|
||||
timelineElevHoverDist = null;
|
||||
clearTimelineElevCursorMarker();
|
||||
setElevationStatus(timelineElevBaseStatus);
|
||||
drawElevationChart();
|
||||
}, 60);
|
||||
}
|
||||
|
||||
function updateElevationProbeClass() {
|
||||
const canvas = document.getElementById('elevationCanvas');
|
||||
if (!canvas) return;
|
||||
canvas.classList.toggle(
|
||||
'elev-probe',
|
||||
dualTracksActive && elevationPointCount(elevProfileLink) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildQualitySamples() {
|
||||
if (!dualTracksActive || !loadedTxTrack?.points?.length || !loadedRxTrack?.points?.length) {
|
||||
return [];
|
||||
}
|
||||
const samples = [];
|
||||
const steps = Math.min(
|
||||
200,
|
||||
Math.max(loadedTxTrack.points.length, loadedRxTrack.points.length, 2) - 1
|
||||
);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const cursor = timelineUseProgress
|
||||
? { progress: i / steps }
|
||||
: { t: overlapMin + (i / steps) * Math.max(overlapMax - overlapMin, 1e-6) };
|
||||
const txPos = positionAtCursor(loadedTxTrack.points, cursor);
|
||||
const rxPos = positionAtCursor(loadedRxTrack.points, cursor);
|
||||
if (!txPos || !rxPos) continue;
|
||||
const quality = rxQualityFromMeta(rxPos.meta);
|
||||
if (quality == null) continue;
|
||||
const distM = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
|
||||
const bearing = QualityViz.bearingDeg(
|
||||
txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
|
||||
samples.push({ distM, bearing, quality });
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
function currentTimelineLinkDist() {
|
||||
if (!timelineLinkTxPos || !timelineLinkRxPos) return null;
|
||||
return haversineM(
|
||||
timelineLinkTxPos.lat, timelineLinkTxPos.lon,
|
||||
timelineLinkRxPos.lat, timelineLinkRxPos.lon);
|
||||
}
|
||||
|
||||
function redrawQualityDistHighlight() {
|
||||
if (!dualTracksActive) return;
|
||||
const distCanvas = document.getElementById('qualityDistCanvas');
|
||||
if (!distCanvas) return;
|
||||
QualityViz.drawQualityDistChart(
|
||||
distCanvas, qualitySamplesCache, qualityColor, currentTimelineLinkDist());
|
||||
}
|
||||
|
||||
function rebuildQualityViz() {
|
||||
const panel = document.getElementById('qualityVizPanel');
|
||||
if (!dualTracksActive) {
|
||||
panel?.classList.remove('visible');
|
||||
qualitySamplesCache = [];
|
||||
return;
|
||||
}
|
||||
qualitySamplesCache = buildQualitySamples();
|
||||
const roseCanvas = document.getElementById('qualityRoseCanvas');
|
||||
const distCanvas = document.getElementById('qualityDistCanvas');
|
||||
QualityViz.drawQualityRose(roseCanvas, qualitySamplesCache, qualityColor);
|
||||
QualityViz.drawQualityDistChart(
|
||||
distCanvas, qualitySamplesCache, qualityColor, currentTimelineLinkDist());
|
||||
panel?.classList.add('visible');
|
||||
}
|
||||
|
||||
function scheduleClearMapRulerCursor() {
|
||||
clearTimeout(mapRulerLeaveTimer);
|
||||
mapRulerLeaveTimer = setTimeout(() => {
|
||||
@@ -1533,6 +1680,7 @@
|
||||
profile: elevProfileLink,
|
||||
label: 'рельеф TX↔RX',
|
||||
losLine: elevA != null && elevB != null ? { elevA, elevB } : null,
|
||||
cursor: timelineElevHoverDist,
|
||||
});
|
||||
return series;
|
||||
}
|
||||
@@ -1785,8 +1933,11 @@
|
||||
if (n > 0) {
|
||||
const src = profile.source === 'elevation' ? 'высоты'
|
||||
: profile.source === 'server' ? 'сервер' : (profile.source || 'данные');
|
||||
setElevationStatus(`срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`);
|
||||
timelineElevBaseStatus =
|
||||
`срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`;
|
||||
setElevationStatus(timelineElevBaseStatus);
|
||||
}
|
||||
updateElevationProbeClass();
|
||||
}
|
||||
|
||||
async function loadElevationProfiles() {
|
||||
@@ -1821,7 +1972,14 @@
|
||||
: ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные');
|
||||
if (dualTracksActive && elevProfileLink) {
|
||||
const n = elevationPointCount(elevProfileLink);
|
||||
setElevationStatus(`срез TX↔RX · ${srcLabel} · ${n} точек · оранжевая — прямая`);
|
||||
const dist = timelineLinkTxPos && timelineLinkRxPos
|
||||
? haversineM(
|
||||
timelineLinkTxPos.lat, timelineLinkTxPos.lon,
|
||||
timelineLinkRxPos.lat, timelineLinkRxPos.lon)
|
||||
: (elevProfileLink.total_m || 0);
|
||||
timelineElevBaseStatus =
|
||||
`срез TX↔RX · ${dist.toFixed(0)} m · ${srcLabel} · ${n} точек · оранжевая — прямая`;
|
||||
setElevationStatus(timelineElevBaseStatus);
|
||||
} else if (dualTracksActive && elevProfileTx && elevProfileRx) {
|
||||
const nTx = elevationPointCount(elevProfileTx);
|
||||
const nRx = elevationPointCount(elevProfileRx);
|
||||
@@ -1835,6 +1993,7 @@
|
||||
setElevationStatus(err ? `ошибка: ${err}` : 'нет данных');
|
||||
}
|
||||
drawElevationChart();
|
||||
updateElevationProbeClass();
|
||||
requestAnimationFrame(() => drawElevationChart(
|
||||
singleTrackActive
|
||||
? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) }
|
||||
@@ -2132,6 +2291,14 @@
|
||||
elevProfileSingle = null;
|
||||
elevProfileLink = null;
|
||||
elevProfileLinkKey = null;
|
||||
timelineElevHoverDist = null;
|
||||
timelineLinkTxPos = null;
|
||||
timelineLinkRxPos = null;
|
||||
timelineElevBaseStatus = '';
|
||||
clearTimelineElevCursorMarker();
|
||||
qualitySamplesCache = [];
|
||||
document.getElementById('qualityVizPanel')?.classList.remove('visible');
|
||||
updateElevationProbeClass();
|
||||
drawElevationChart();
|
||||
if (playTimer) {
|
||||
clearInterval(playTimer);
|
||||
@@ -2237,7 +2404,7 @@
|
||||
|
||||
if (txPos) {
|
||||
ghostTx = L.circleMarker([txPos.lat, txPos.lon], {
|
||||
radius: 10, color: TX_COLOR, fillColor: TX_COLOR, fillOpacity: 0.9, weight: 3
|
||||
radius: 10, color: '#fff', fillColor: GHOST_TX_COLOR, fillOpacity: 0.9, weight: 3
|
||||
}).addTo(map);
|
||||
}
|
||||
if (rxPos) {
|
||||
@@ -2268,11 +2435,18 @@
|
||||
deviceDisplayName(loadedTxTrack?.device_id),
|
||||
deviceDisplayName(loadedRxTrack?.device_id)
|
||||
));
|
||||
timelineLinkTxPos = txPos || null;
|
||||
timelineLinkRxPos = rxPos || null;
|
||||
|
||||
if (txPos && rxPos) {
|
||||
scheduleLinkElevation(txPos, rxPos).then(() => drawElevationChart());
|
||||
scheduleLinkElevation(txPos, rxPos).then(() => {
|
||||
drawElevationChart();
|
||||
updateElevationProbeClass();
|
||||
});
|
||||
} else {
|
||||
drawElevationChart();
|
||||
}
|
||||
redrawQualityDistHighlight();
|
||||
}
|
||||
|
||||
function updateTimelineAtSingle(cursor, openModal) {
|
||||
@@ -2316,9 +2490,14 @@
|
||||
function setTimelineVisible(visible) {
|
||||
document.getElementById('trackTimeline').classList.toggle('visible', visible);
|
||||
document.getElementById('timelineStatsPanel').classList.toggle('visible', visible);
|
||||
document.getElementById('qualityVizPanel')?.classList.toggle(
|
||||
'visible', visible && dualTracksActive);
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
if (visible) drawElevationChart();
|
||||
if (visible) {
|
||||
drawElevationChart();
|
||||
if (dualTracksActive) rebuildQualityViz();
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
@@ -2419,6 +2598,7 @@
|
||||
setTimelineVisible(true);
|
||||
updateTimelineAt(timelineCursor());
|
||||
loadElevationProfiles();
|
||||
rebuildQualityViz();
|
||||
}
|
||||
|
||||
async function refreshTimelineTelemetry() {
|
||||
@@ -2595,6 +2775,7 @@
|
||||
updateTimelineAt(timelineCursor());
|
||||
}
|
||||
|
||||
rebuildQualityViz();
|
||||
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
|
||||
document.getElementById('trackInfo').textContent =
|
||||
`TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`;
|
||||
@@ -2689,6 +2870,26 @@
|
||||
});
|
||||
})();
|
||||
|
||||
(function bindTimelineElevationProbe() {
|
||||
const canvas = document.getElementById('elevationCanvas');
|
||||
if (!canvas) return;
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return;
|
||||
const layout = canvas._elevLayout;
|
||||
if (!layout || layout.plotW <= 0) return;
|
||||
timelineElevChartHover = true;
|
||||
clearTimeout(timelineElevLeaveTimer);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const dist = ((x - layout.margin.l) / layout.plotW) * layout.maxDist;
|
||||
setTimelineElevCursor(dist);
|
||||
});
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
timelineElevChartHover = false;
|
||||
scheduleClearTimelineElevCursor();
|
||||
});
|
||||
})();
|
||||
|
||||
(function bindMapRulerChartProbe() {
|
||||
const canvas = document.getElementById('mapRulerCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/** Polar rose and quality-vs-distance charts for TX/RX track compare. */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||
const toRad = d => d * Math.PI / 180;
|
||||
const toDeg = r => r * 180 / Math.PI;
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const y = Math.sin(dLon) * Math.cos(toRad(lat2));
|
||||
const x = Math.cos(toRad(lat1)) * Math.sin(toRad(lat2))
|
||||
- Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLon);
|
||||
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
|
||||
function drawQualityRose(canvas, samples, qualityColor) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.clientWidth || 140;
|
||||
const h = canvas.clientHeight || 140;
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
ctx.fillStyle = '#0a0a14';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const sectors = 16;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const maxR = Math.min(w, h) * 0.38;
|
||||
|
||||
if (!samples?.length) {
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '11px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('нет данных качества', cx, cy);
|
||||
return;
|
||||
}
|
||||
|
||||
const bins = Array.from({ length: sectors }, () => ({ sum: 0, count: 0 }));
|
||||
const step = 360 / sectors;
|
||||
for (const s of samples) {
|
||||
const idx = Math.floor(((s.bearing % 360) + 360) % 360 / step) % sectors;
|
||||
bins[idx].sum += s.quality;
|
||||
bins[idx].count += 1;
|
||||
}
|
||||
const maxCount = Math.max(1, ...bins.map(b => b.count));
|
||||
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, maxR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
for (let i = 0; i < sectors; i++) {
|
||||
const start = (i * step - 90) * Math.PI / 180;
|
||||
const end = ((i + 1) * step - 90) * Math.PI / 180;
|
||||
const b = bins[i];
|
||||
if (!b.count) continue;
|
||||
const avgQ = b.sum / b.count;
|
||||
const r = maxR * (0.15 + 0.85 * (b.count / maxCount));
|
||||
const col = qualityColor ? qualityColor(avgQ) : '#888';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, r, start, end);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = col;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = '#222';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#aaa';
|
||||
ctx.font = '9px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('N', cx, cy - maxR - 4);
|
||||
ctx.fillText('S', cx, cy + maxR + 10);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('E', cx + maxR + 4, cy + 3);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('W', cx - maxR - 4, cy + 3);
|
||||
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '8px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('длина ∝ число точек', cx, h - 4);
|
||||
}
|
||||
|
||||
function drawQualityDistChart(canvas, samples, qualityColor, highlightDist) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.clientWidth || 280;
|
||||
const h = canvas.clientHeight || 140;
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
ctx.fillStyle = '#0a0a14';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
if (!samples?.length) {
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '11px system-ui';
|
||||
ctx.fillText('нет данных качества', 12, h / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const dists = samples.map(s => s.distM);
|
||||
const minD = Math.min(...dists);
|
||||
const maxD = Math.max(...dists);
|
||||
const span = Math.max(maxD - minD, 1);
|
||||
const binCount = Math.min(20, Math.max(5, Math.ceil(span / 10)));
|
||||
const binW = span / binCount;
|
||||
const bins = Array.from({ length: binCount }, (_, i) => ({
|
||||
min: minD + i * binW,
|
||||
max: minD + (i + 1) * binW,
|
||||
qualities: [],
|
||||
}));
|
||||
for (const s of samples) {
|
||||
let idx = Math.floor((s.distM - minD) / binW);
|
||||
if (idx >= binCount) idx = binCount - 1;
|
||||
if (idx < 0) idx = 0;
|
||||
bins[idx].qualities.push(s.quality);
|
||||
}
|
||||
|
||||
const margin = { l: 36, r: 8, t: 16, b: 22 };
|
||||
const plotW = w - margin.l - margin.r;
|
||||
const plotH = h - margin.t - margin.b;
|
||||
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.l, margin.t);
|
||||
ctx.lineTo(margin.l, margin.t + plotH);
|
||||
ctx.lineTo(margin.l + plotW, margin.t + plotH);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '9px system-ui';
|
||||
ctx.fillText('0%', 2, margin.t + plotH);
|
||||
ctx.fillText('100%', 2, margin.t + 8);
|
||||
ctx.fillText(`${Math.round(minD)}m`, margin.l, h - 2);
|
||||
ctx.fillText(`${Math.round(maxD)}m`, margin.l + plotW - 24, h - 2);
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.font = '10px system-ui';
|
||||
ctx.fillText('RX Quality vs расстояние', margin.l, margin.t - 4);
|
||||
|
||||
const barW = plotW / binCount * 0.75;
|
||||
bins.forEach((b, i) => {
|
||||
if (!b.qualities.length) return;
|
||||
const avg = b.qualities.reduce((a, v) => a + v, 0) / b.qualities.length;
|
||||
const cx = margin.l + (i + 0.5) / binCount * plotW;
|
||||
const barH = (avg / 100) * plotH;
|
||||
const x = cx - barW / 2;
|
||||
const y = margin.t + plotH - barH;
|
||||
const col = qualityColor ? qualityColor(avg) : '#888';
|
||||
const highlight = highlightDist != null
|
||||
&& highlightDist >= b.min && highlightDist < b.max;
|
||||
ctx.fillStyle = col;
|
||||
ctx.globalAlpha = highlight ? 1 : 0.75;
|
||||
ctx.fillRect(x, y, barW, barH);
|
||||
ctx.globalAlpha = 1;
|
||||
if (highlight) {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x, y, barW, barH);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
samples.forEach(s => {
|
||||
const x = margin.l + ((s.distM - minD) / span) * plotW;
|
||||
const y = margin.t + plotH - (s.quality / 100) * plotH;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
global.QualityViz = {
|
||||
bearingDeg,
|
||||
drawQualityRose,
|
||||
drawQualityDistChart,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Reference in New Issue
Block a user