diff --git a/server/static/index.html b/server/static/index.html
index 1d8b951..66915a8 100644
--- a/server/static/index.html
+++ b/server/static/index.html
@@ -150,7 +150,7 @@
background: none; border: none; color: #eee; font-size: 1.1rem;
cursor: pointer; padding: 0 4px; line-height: 1;
}
- #mapRulerCanvas { width: 100%; height: 120px; display: block; background: #0a0a14; border-radius: 4px; }
+ #mapRulerCanvas { width: 100%; height: 120px; display: block; background: #0a0a14; border-radius: 4px; cursor: crosshair; }
#mapRulerTools { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
#mapRulerTools button {
padding: 3px 8px; font-size: 0.7rem; border: 1px solid #444; border-radius: 4px;
@@ -359,8 +359,15 @@
let mapRulerPtB = null;
let mapRulerLoadState = 'idle';
let mapRulerLineLayer = null;
+ let mapRulerHitLayer = null;
let mapRulerMarkerA = null;
let mapRulerMarkerB = null;
+ let mapRulerCursorDist = null;
+ let mapRulerCursorMarker = null;
+ let mapRulerBaseStatus = '';
+ let mapRulerChartHover = false;
+ let mapRulerLineHover = false;
+ let mapRulerLeaveTimer = null;
const DEVICE_POLL_MS = 1000;
const CHAT_POLL_MS = 2500;
@@ -521,6 +528,57 @@
return { tx, rx };
}
+ function lineLengthM(a, b) {
+ return haversineM(a.lat, a.lon, b.lat, b.lon);
+ }
+
+ function latLonAtLineDist(a, b, distM) {
+ const total = lineLengthM(a, b);
+ if (total < 1e-3) return { lat: a.lat, lon: a.lon };
+ const f = Math.min(1, Math.max(0, distM / total));
+ return {
+ lat: a.lat + (b.lat - a.lat) * f,
+ lon: a.lon + (b.lon - a.lon) * f
+ };
+ }
+
+ function projectPointToLineDist(a, b, lat, lon) {
+ const total = lineLengthM(a, b);
+ if (total < 1e-3) return 0;
+ const ax = a.lon;
+ const ay = a.lat;
+ const bx = b.lon;
+ const by = b.lat;
+ const dx = bx - ax;
+ const dy = by - ay;
+ const len2 = dx * dx + dy * dy;
+ if (len2 < 1e-15) return 0;
+ let t = ((lon - ax) * dx + (lat - ay) * dy) / len2;
+ t = Math.max(0, Math.min(1, t));
+ const projLat = ay + t * dy;
+ const projLon = ax + t * dx;
+ return haversineM(a.lat, a.lon, projLat, projLon);
+ }
+
+ function elevationAtDist(profile, distM) {
+ if (!profile?.points?.length || distM == null) return null;
+ const pts = profile.points.filter(p => p.elevation_m != null);
+ if (!pts.length) return null;
+ if (distM <= pts[0].dist_m) return pts[0].elevation_m;
+ const last = pts[pts.length - 1];
+ if (distM >= last.dist_m) return last.elevation_m;
+ for (let i = 1; i < pts.length; i++) {
+ const p0 = pts[i - 1];
+ const p1 = pts[i];
+ if (distM <= p1.dist_m) {
+ const span = p1.dist_m - p0.dist_m;
+ const t = span <= 0 ? 0 : (distM - p0.dist_m) / span;
+ return p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t;
+ }
+ }
+ return null;
+ }
+
function buildDirectLinePoints(tx, rx, stepM = 10) {
const total = haversineM(tx.lat, tx.lon, rx.lat, rx.lon);
if (total < 1) {
@@ -556,23 +614,85 @@
if (el) el.textContent = text || '';
}
- function updateMapRulerLineLayer(a, b) {
- if (mapRulerLineLayer) {
- map.removeLayer(mapRulerLineLayer);
- mapRulerLineLayer = null;
- }
- if (!mapRulerOpen || !a || !b) return;
- mapRulerLineLayer = L.polyline(
- [[a.lat, a.lon], [b.lat, b.lon]],
- { color: '#00ff88', weight: 3, dashArray: '8,6', opacity: 0.85 }
- ).addTo(map);
- }
-
function clearMapRulerLineLayer() {
if (mapRulerLineLayer) {
map.removeLayer(mapRulerLineLayer);
mapRulerLineLayer = null;
}
+ if (mapRulerHitLayer) {
+ map.removeLayer(mapRulerHitLayer);
+ mapRulerHitLayer = null;
+ }
+ }
+
+ function clearMapRulerCursorMarker() {
+ if (mapRulerCursorMarker) {
+ map.removeLayer(mapRulerCursorMarker);
+ mapRulerCursorMarker = null;
+ }
+ }
+
+ function scheduleClearMapRulerCursor() {
+ clearTimeout(mapRulerLeaveTimer);
+ mapRulerLeaveTimer = setTimeout(() => {
+ if (mapRulerChartHover || mapRulerLineHover) return;
+ mapRulerCursorDist = null;
+ clearMapRulerCursorMarker();
+ setMapRulerStatus(mapRulerBaseStatus);
+ drawMapRulerChart();
+ }, 60);
+ }
+
+ function updateMapRulerCursorMarker(pt) {
+ clearMapRulerCursorMarker();
+ mapRulerCursorMarker = L.circleMarker([pt.lat, pt.lon], {
+ radius: 8,
+ color: '#fff',
+ weight: 2,
+ fillColor: '#00ff88',
+ fillOpacity: 0.95,
+ interactive: false
+ }).addTo(map);
+ }
+
+ function setMapRulerCursor(distM) {
+ if (!mapRulerPtA || !mapRulerPtB || elevationPointCount(elevProfileMapLine) === 0) return;
+ const total = elevProfileMapLine.total_m || lineLengthM(mapRulerPtA, mapRulerPtB);
+ mapRulerCursorDist = Math.max(0, Math.min(distM, total));
+ const pt = latLonAtLineDist(mapRulerPtA, mapRulerPtB, mapRulerCursorDist);
+ updateMapRulerCursorMarker(pt);
+ const elev = elevationAtDist(elevProfileMapLine, mapRulerCursorDist);
+ if (elev != null) {
+ setMapRulerStatus(`${mapRulerCursorDist.toFixed(0)} m · ${elev.toFixed(1)} m`);
+ }
+ drawMapRulerChart();
+ }
+
+ function clearMapRulerCursor() {
+ mapRulerCursorDist = null;
+ clearMapRulerCursorMarker();
+ }
+
+ function updateMapRulerLineLayer(a, b) {
+ clearMapRulerLineLayer();
+ if (!mapRulerOpen || !a || !b) return;
+ mapRulerLineLayer = L.polyline(
+ [[a.lat, a.lon], [b.lat, b.lon]],
+ { color: '#00ff88', weight: 3, dashArray: '8,6', opacity: 0.85, interactive: false }
+ ).addTo(map);
+ mapRulerHitLayer = L.polyline(
+ [[a.lat, a.lon], [b.lat, b.lon]],
+ { color: '#000', weight: 22, opacity: 0, interactive: true }
+ ).addTo(map);
+ mapRulerHitLayer.on('mousemove', e => {
+ mapRulerLineHover = true;
+ clearTimeout(mapRulerLeaveTimer);
+ setMapRulerCursor(projectPointToLineDist(a, b, e.latlng.lat, e.latlng.lng));
+ });
+ mapRulerHitLayer.on('mouseout', () => {
+ mapRulerLineHover = false;
+ scheduleClearMapRulerCursor();
+ });
}
function clearMapRulerPickPoints() {
@@ -587,6 +707,8 @@
mapRulerMarkerB = null;
}
clearMapRulerLineLayer();
+ clearMapRulerCursor();
+ mapRulerBaseStatus = '';
elevProfileMapLine = null;
}
@@ -595,13 +717,17 @@
document.getElementById('btnRulerPick').classList.toggle('active', mode === 'pick');
document.getElementById('btnRulerAutoTxRx').classList.toggle('active', mode === 'auto');
if (mode === 'pick') {
- setMapRulerHint(mapRulerPtB
- ? 'A и B заданы — клик сбрасывает и задаёт новую A'
- : mapRulerPtA
- ? 'Клик на карте — точка B'
- : 'Клик на карте — точка A, затем точка B');
+ setMapRulerHint(mapRulerPtB && elevationPointCount(elevProfileMapLine) > 0
+ ? 'Наведите на линию или график — высота и позиция'
+ : mapRulerPtB
+ ? 'A и B заданы — клик сбрасывает и задаёт новую A'
+ : mapRulerPtA
+ ? 'Клик на карте — точка B'
+ : 'Клик на карте — точка A, затем точка B');
} else {
- setMapRulerHint('Линия между устройствами TX и RX (обновляется при poll)');
+ setMapRulerHint(elevationPointCount(elevProfileMapLine) > 0
+ ? 'Наведите на линию или график — высота и позиция'
+ : 'Линия между устройствами TX и RX (обновляется при poll)');
}
}
@@ -632,6 +758,7 @@
async function loadMapRulerProfileFromPoints(a, b) {
if (!a || !b) return;
mapRulerLoadState = 'loading';
+ clearMapRulerCursor();
setMapRulerStatus('загрузка…');
updateMapRulerLineLayer(a, b);
drawMapRulerChart();
@@ -644,8 +771,11 @@
const src = elevProfileMapLine.source === 'elevation' ? 'высоты'
: elevProfileMapLine.source === 'server' ? 'сервер'
: elevProfileMapLine.source || 'данные';
- setMapRulerStatus(`${dist.toFixed(0)} m · ${src} · ${n} точек`);
+ mapRulerBaseStatus = `${dist.toFixed(0)} m · ${src} · ${n} точек`;
+ setMapRulerStatus(mapRulerBaseStatus);
+ setMapRulerHint('Наведите на линию или график — высота и позиция');
} else {
+ mapRulerBaseStatus = '';
setMapRulerStatus(`${dist.toFixed(0)} m · ${elevProfileMapLine?.api_error || 'нет данных'}`);
}
drawMapRulerChart();
@@ -991,6 +1121,7 @@
ctx.stroke();
if (s.cursor != null && maxDist > 0) {
const cx = margin.l + (s.cursor / maxDist) * plotW;
+ const elev = elevationAtDist(s.profile, s.cursor);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
@@ -999,8 +1130,29 @@
ctx.lineTo(cx, margin.t + plotH);
ctx.stroke();
ctx.setLineDash([]);
+ if (elev != null && isFinite(elev)) {
+ const cy = margin.t + plotH - ((elev - minE) / (maxE - minE)) * plotH;
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(cx, cy, 4.5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = s.color;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ const label = `${Math.round(s.cursor)} m · ${elev.toFixed(1)} m`;
+ ctx.font = '10px system-ui';
+ const tw = ctx.measureText(label).width;
+ let lx = cx - tw / 2;
+ lx = Math.max(margin.l, Math.min(lx, margin.l + plotW - tw));
+ ctx.fillStyle = 'rgba(10,10,20,0.92)';
+ ctx.fillRect(lx - 3, margin.t - 1, tw + 6, 14);
+ ctx.fillStyle = '#00ff88';
+ ctx.fillText(label, lx, margin.t + 10);
+ }
}
});
+
+ canvas._elevLayout = { margin, plotW, plotH, maxDist, minE, maxE };
}
function drawElevationChart(cursors) {
@@ -1014,7 +1166,11 @@
function drawMapRulerChart() {
const series = elevationPointCount(elevProfileMapLine) > 0
- ? [{ color: '#00ff88', profile: elevProfileMapLine, cursor: null }]
+ ? [{
+ color: '#00ff88',
+ profile: elevProfileMapLine,
+ cursor: mapRulerCursorDist
+ }]
: [];
const idleMsg = elevProfileMapLine?.api_error
? elevProfileMapLine.api_error
@@ -1072,6 +1228,8 @@
function resetMapRulerPick() {
clearMapRulerPickPoints();
mapRulerLoadState = 'idle';
+ mapRulerChartHover = false;
+ mapRulerLineHover = false;
setMapRulerStatus('');
setMapRulerMode('pick');
drawMapRulerChart();
@@ -1766,6 +1924,26 @@
};
document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick();
+ (function bindMapRulerChartProbe() {
+ const canvas = document.getElementById('mapRulerCanvas');
+ if (!canvas) return;
+ canvas.addEventListener('mousemove', e => {
+ if (!mapRulerOpen || elevationPointCount(elevProfileMapLine) === 0) return;
+ const layout = canvas._elevLayout;
+ if (!layout || layout.plotW <= 0) return;
+ mapRulerChartHover = true;
+ clearTimeout(mapRulerLeaveTimer);
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const dist = ((x - layout.margin.l) / layout.plotW) * layout.maxDist;
+ setMapRulerCursor(dist);
+ });
+ canvas.addEventListener('mouseleave', () => {
+ mapRulerChartHover = false;
+ scheduleClearMapRulerCursor();
+ });
+ })();
+
async function refreshPairedStatus() {
try {
const res = await fetch('/api/paired-tracks/active', { cache: 'no-store' });