added linear slider

This commit is contained in:
2026-06-15 07:50:41 +03:00
parent d28391c71f
commit ab2a3bb035
7 changed files with 239 additions and 16 deletions
Binary file not shown.
+58 -3
View File
@@ -18,6 +18,7 @@ from .config import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_BATCH_SIZE = 100 _BATCH_SIZE = 100
_MAX_PROFILE_POINTS = 500
_CACHE: dict[tuple[float, float], Optional[float]] = {} _CACHE: dict[tuple[float, float], Optional[float]] = {}
_probe_checked_at = 0.0 _probe_checked_at = 0.0
_probe_ok = False _probe_ok = False
@@ -251,12 +252,66 @@ def resample_track_path(
return samples return samples
def resample_track_path_count(
points: list[dict[str, Any]], count: int
) -> list[dict[str, float]]:
"""Sample exactly `count` points evenly spaced along polyline."""
if not points or count < 2:
return []
cleaned: list[tuple[float, float]] = []
for p in points:
lat = p.get("lat")
lon = p.get("lon")
if lat is None or lon is None:
continue
lat_f, lon_f = float(lat), float(lon)
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
cleaned.append((lat_f, lon_f))
if not cleaned:
return []
if len(cleaned) == 1:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
cum = [0.0]
for i in range(1, len(cleaned)):
cum.append(
cum[-1]
+ haversine_m(
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
)
)
total = cum[-1]
if total < 1e-6:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
n = max(2, min(_MAX_PROFILE_POINTS, int(count)))
samples: list[dict[str, float]] = []
for i in range(n):
dist = (total * i) / (n - 1)
lat, lon = _interp_at_dist(cleaned, cum, dist)
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
return samples
def build_elevation_profile( def build_elevation_profile(
points: list[dict[str, Any]], step_m: float = 10.0 points: list[dict[str, Any]],
step_m: float = 10.0,
target_points: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Resample track and fetch terrain elevations.""" """Resample track and fetch terrain elevations."""
step_m = max(5.0, min(10.0, float(step_m))) if target_points is not None:
samples = resample_track_path(points, step_m) n = max(2, min(_MAX_PROFILE_POINTS, int(target_points)))
samples = resample_track_path_count(points, n)
if len(samples) > 1:
step_m = round(
(samples[-1]["dist_m"] - samples[0]["dist_m"]) / (len(samples) - 1),
2,
)
else:
step_m = 0.0
else:
step_m = max(5.0, min(10.0, float(step_m)))
samples = resample_track_path(points, step_m)
if not samples: if not samples:
return { return {
"step_m": step_m, "step_m": step_m,
+2 -1
View File
@@ -307,6 +307,7 @@ class ElevationPoint(BaseModel):
class ElevationProfileBody(BaseModel): class ElevationProfileBody(BaseModel):
points: list[ElevationPoint] = Field(default_factory=list) points: list[ElevationPoint] = Field(default_factory=list)
step_m: float = 10.0 step_m: float = 10.0
target_points: Optional[int] = Field(None, ge=2, le=500)
@app.post("/api/elevation/profile") @app.post("/api/elevation/profile")
@@ -314,7 +315,7 @@ def elevation_profile(body: ElevationProfileBody):
from core.elevation import build_elevation_profile from core.elevation import build_elevation_profile
pts = [p.model_dump(exclude_none=True) for p in body.points] pts = [p.model_dump(exclude_none=True) for p in body.points]
return build_elevation_profile(pts, body.step_m) return build_elevation_profile(pts, body.step_m, body.target_points)
@app.get("/api/tracks/{track_id}/elevation-profile") @app.get("/api/tracks/{track_id}/elevation-profile")
+6 -1
View File
@@ -248,7 +248,12 @@ def elevation_profile():
step = float(step_m) step = float(step_m)
except (TypeError, ValueError): except (TypeError, ValueError):
step = 10.0 step = 10.0
return jsonify(build_elevation_profile(points, step)) target_points = body.get("target_points")
try:
tp = int(target_points) if target_points is not None else None
except (TypeError, ValueError):
tp = None
return jsonify(build_elevation_profile(points, step, tp))
@app.get("/api/tracks/<int:track_id>/elevation-profile") @app.get("/api/tracks/<int:track_id>/elevation-profile")
+145 -11
View File
@@ -158,6 +158,14 @@
} }
#mapRulerTools button.active { background: #00ff88; color: #111; border-color: #00ff88; } #mapRulerTools button.active { background: #00ff88; color: #111; border-color: #00ff88; }
#mapRulerHint { font-size: 0.7rem; color: #aaa; margin-bottom: 4px; min-height: 1em; } #mapRulerHint { font-size: 0.7rem; color: #aaa; margin-bottom: 4px; min-height: 1em; }
#mapRulerPointsRow {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap;
font-size: 0.7rem; color: #ccc;
}
#mapRulerPointsRow label { display: flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; }
#mapRulerPointsSlider { flex: 1; min-width: 120px; accent-color: #00ff88; }
#mapRulerPointsSlider:disabled { opacity: 0.45; }
#mapRulerPointsLabel { min-width: 7em; color: #aaa; white-space: nowrap; }
</style> </style>
</head> </head>
<body> <body>
@@ -188,6 +196,11 @@
<button type="button" id="btnRulerClear">Сброс</button> <button type="button" id="btnRulerClear">Сброс</button>
</div> </div>
<div id="mapRulerHint">Клик на карте — точка A, затем точка B</div> <div id="mapRulerHint">Клик на карте — точка A, затем точка B</div>
<div id="mapRulerPointsRow">
<label><input type="checkbox" id="mapRulerPointsAuto" checked /> Авто</label>
<input type="range" id="mapRulerPointsSlider" min="20" max="500" value="100" disabled />
<span id="mapRulerPointsLabel">100 точек</span>
</div>
<canvas id="mapRulerCanvas" width="800" height="120"></canvas> <canvas id="mapRulerCanvas" width="800" height="120"></canvas>
</div> </div>
<div id="map"></div> <div id="map"></div>
@@ -368,6 +381,11 @@
let mapRulerChartHover = false; let mapRulerChartHover = false;
let mapRulerLineHover = false; let mapRulerLineHover = false;
let mapRulerLeaveTimer = null; let mapRulerLeaveTimer = null;
const RULER_POINTS_MIN = 20;
const RULER_POINTS_MAX = 500;
let mapRulerPointsAuto = true;
let mapRulerManualPoints = 100;
let mapRulerReloadTimer = null;
const DEVICE_POLL_MS = 1000; const DEVICE_POLL_MS = 1000;
const CHAT_POLL_MS = 2500; const CHAT_POLL_MS = 2500;
@@ -600,6 +618,48 @@
return pts; return pts;
} }
function autoRulerTargetPoints(distM) {
if (distM < 1) return RULER_POINTS_MIN;
if (distM <= 200) return Math.max(RULER_POINTS_MIN, Math.round(distM / 4));
if (distM <= 1000) return Math.max(50, Math.round(distM / 8));
if (distM <= 5000) return Math.max(80, Math.round(distM / 15));
return Math.min(RULER_POINTS_MAX, Math.max(100, Math.round(distM / 20)));
}
function getMapRulerTargetPoints(distM) {
if (mapRulerPointsAuto) return autoRulerTargetPoints(distM);
return Math.max(RULER_POINTS_MIN, Math.min(RULER_POINTS_MAX, mapRulerManualPoints));
}
function updateMapRulerPointsUi(distM) {
const autoEl = document.getElementById('mapRulerPointsAuto');
const slider = document.getElementById('mapRulerPointsSlider');
const label = document.getElementById('mapRulerPointsLabel');
if (!autoEl || !slider || !label) return;
const effective = getMapRulerTargetPoints(distM || 0);
const autoVal = autoRulerTargetPoints(distM || 0);
autoEl.checked = mapRulerPointsAuto;
slider.disabled = mapRulerPointsAuto;
slider.value = String(mapRulerPointsAuto ? autoVal : mapRulerManualPoints);
if (mapRulerPointsAuto && distM > 0) {
const step = effective > 1 ? distM / (effective - 1) : distM;
label.textContent = `${effective} точек · ~${step.toFixed(1)} м`;
} else if (mapRulerPointsAuto) {
label.textContent = `${autoVal} точек · авто`;
} else {
label.textContent = `${effective} точек`;
}
}
function scheduleMapRulerProfileReload() {
clearTimeout(mapRulerReloadTimer);
mapRulerReloadTimer = setTimeout(() => {
if (mapRulerPtA && mapRulerPtB) {
loadMapRulerProfileFromPoints(mapRulerPtA, mapRulerPtB);
}
}, 350);
}
function makeRulerPointIcon(label, color) { function makeRulerPointIcon(label, color) {
return L.divIcon({ return L.divIcon({
className: '', className: '',
@@ -762,16 +822,21 @@
setMapRulerStatus('загрузка…'); setMapRulerStatus('загрузка…');
updateMapRulerLineLayer(a, b); updateMapRulerLineLayer(a, b);
drawMapRulerChart(); drawMapRulerChart();
const linePts = buildDirectLinePoints(a, b, 10);
elevProfileMapLine = await fetchElevationProfile(linePts);
mapRulerLoadState = 'done';
const dist = haversineM(a.lat, a.lon, b.lat, b.lon); const dist = haversineM(a.lat, a.lon, b.lat, b.lon);
const targetPoints = getMapRulerTargetPoints(dist);
updateMapRulerPointsUi(dist);
const linePts = [{ lat: a.lat, lon: a.lon }, { lat: b.lat, lon: b.lon }];
elevProfileMapLine = await fetchElevationProfile(linePts, null, { targetPoints });
mapRulerLoadState = 'done';
const n = elevationPointCount(elevProfileMapLine); const n = elevationPointCount(elevProfileMapLine);
if (n > 0) { if (n > 0) {
const src = elevProfileMapLine.source === 'elevation' ? 'высоты' const src = elevProfileMapLine.source === 'elevation' ? 'высоты'
: elevProfileMapLine.source === 'server' ? 'сервер' : elevProfileMapLine.source === 'server' ? 'сервер'
: elevProfileMapLine.source || 'данные'; : elevProfileMapLine.source || 'данные';
mapRulerBaseStatus = `${dist.toFixed(0)} m · ${src} · ${n} точек`; const step = elevProfileMapLine.step_m != null
? elevProfileMapLine.step_m
: (n > 1 ? dist / (n - 1) : dist);
mapRulerBaseStatus = `${dist.toFixed(0)} m · ${src} · ${n} точек · ~${Number(step).toFixed(1)} m`;
setMapRulerStatus(mapRulerBaseStatus); setMapRulerStatus(mapRulerBaseStatus);
setMapRulerHint('Наведите на линию или график — высота и позиция'); setMapRulerHint('Наведите на линию или график — высота и позиция');
} else { } else {
@@ -848,6 +913,34 @@
return samples; return samples;
} }
function resampleTrackPathCount(points, count) {
const cleaned = [];
for (const p of points) {
if (p.lat == null || p.lon == null) continue;
const lat = Number(p.lat);
const lon = Number(p.lon);
if (!cleaned.length || haversineM(cleaned[cleaned.length - 1][0], cleaned[cleaned.length - 1][1], lat, lon) > 0.5) {
cleaned.push([lat, lon]);
}
}
if (!cleaned.length || count < 2) return [];
if (cleaned.length === 1) return [{ lat: cleaned[0][0], lon: cleaned[0][1], dist_m: 0 }];
const cum = [0];
for (let i = 1; i < cleaned.length; i++) {
cum.push(cum[i - 1] + haversineM(cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]));
}
const total = cum[cum.length - 1];
if (total < 1e-6) return [{ lat: cleaned[0][0], lon: cleaned[0][1], dist_m: 0 }];
const n = Math.max(2, Math.min(RULER_POINTS_MAX, Math.round(count)));
const samples = [];
for (let i = 0; i < n; i++) {
const dist = (total * i) / (n - 1);
const [lat, lon] = interpTrackAtDist(cleaned, cum, dist);
samples.push({ lat, lon, dist_m: Math.round(dist * 10) / 10 });
}
return samples;
}
function nearestElevation(points, lat, lon) { function nearestElevation(points, lat, lon) {
let best = null; let best = null;
let bestD = Infinity; let bestD = Infinity;
@@ -866,8 +959,10 @@
return profile.points.filter(p => p.elevation_m != null && !Number.isNaN(p.elevation_m)).length; return profile.points.filter(p => p.elevation_m != null && !Number.isNaN(p.elevation_m)).length;
} }
function buildLocalElevationProfile(points, stepM = 10) { function buildLocalElevationProfile(points, stepM = 10, targetPoints = null) {
const samples = resampleTrackPath(points, stepM); const samples = targetPoints != null
? resampleTrackPathCount(points, targetPoints)
: resampleTrackPath(points, stepM);
if (!samples.length) return null; if (!samples.length) return null;
const profilePts = samples.map(s => ({ const profilePts = samples.map(s => ({
dist_m: s.dist_m, dist_m: s.dist_m,
@@ -877,8 +972,11 @@
})); }));
const elevVals = profilePts.map(p => p.elevation_m).filter(v => v != null); const elevVals = profilePts.map(p => p.elevation_m).filter(v => v != null);
if (!elevVals.length) return null; if (!elevVals.length) return null;
const effStep = targetPoints != null && profilePts.length > 1
? (profilePts[profilePts.length - 1].dist_m - profilePts[0].dist_m) / (profilePts.length - 1)
: stepM;
return { return {
step_m: stepM, step_m: Math.round(effStep * 10) / 10,
total_m: profilePts[profilePts.length - 1].dist_m, total_m: profilePts[profilePts.length - 1].dist_m,
min_elevation_m: Math.min(...elevVals), min_elevation_m: Math.min(...elevVals),
max_elevation_m: Math.max(...elevVals), max_elevation_m: Math.max(...elevVals),
@@ -931,13 +1029,14 @@
return elevationPointCount(profile) > 0 ? profile : profile; return elevationPointCount(profile) > 0 ? profile : profile;
} }
async function fetchElevationProfile(points, trackId) { async function fetchElevationProfile(points, trackId, options = {}) {
if (!points || !points.length) return null; if (!points || !points.length) return null;
const { targetPoints = null, stepM = 10 } = options;
let lastError = null; let lastError = null;
const health = await ensureElevationApi(); const health = await ensureElevationApi();
if (!health.ok) { if (!health.ok) {
const cached = buildLocalElevationProfile(points, 10); const cached = buildLocalElevationProfile(points, stepM, targetPoints);
if (cached) return cached; if (cached) return cached;
return { return {
points: [], points: [],
@@ -964,10 +1063,13 @@
} }
try { try {
const body = targetPoints != null
? { points, target_points: targetPoints }
: { points, step_m: stepM };
const res = await fetch('/api/elevation/profile', { const res = await fetch('/api/elevation/profile', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ points, step_m: 10 }) body: JSON.stringify(body)
}); });
if (res.ok) { if (res.ok) {
const data = normalizeServerProfile(await res.json()); const data = normalizeServerProfile(await res.json());
@@ -980,7 +1082,7 @@
lastError = lastError || String(e.message || e); lastError = lastError || String(e.message || e);
} }
const cached = buildLocalElevationProfile(points, 10); const cached = buildLocalElevationProfile(points, stepM, targetPoints);
if (cached) return cached; if (cached) return cached;
return { return {
@@ -1211,6 +1313,7 @@
setMapRulerHint(''); setMapRulerHint('');
} else { } else {
setMapRulerMode(mapRulerMode); setMapRulerMode(mapRulerMode);
updateMapRulerPointsUi(0);
} }
setTimeout(() => map.invalidateSize(), 80); setTimeout(() => map.invalidateSize(), 80);
} }
@@ -1924,6 +2027,37 @@
}; };
document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick(); document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick();
(function bindMapRulerPointsControls() {
const autoEl = document.getElementById('mapRulerPointsAuto');
const slider = document.getElementById('mapRulerPointsSlider');
if (!autoEl || !slider) return;
autoEl.addEventListener('change', () => {
mapRulerPointsAuto = autoEl.checked;
if (!mapRulerPointsAuto) {
mapRulerManualPoints = Number(slider.value) || 100;
}
const dist = mapRulerPtA && mapRulerPtB
? haversineM(mapRulerPtA.lat, mapRulerPtA.lon, mapRulerPtB.lat, mapRulerPtB.lon)
: 0;
updateMapRulerPointsUi(dist);
scheduleMapRulerProfileReload();
});
slider.addEventListener('input', () => {
if (mapRulerPointsAuto) return;
mapRulerManualPoints = Number(slider.value) || RULER_POINTS_MIN;
updateMapRulerPointsUi(
mapRulerPtA && mapRulerPtB
? haversineM(mapRulerPtA.lat, mapRulerPtA.lon, mapRulerPtB.lat, mapRulerPtB.lon)
: 0
);
});
slider.addEventListener('change', () => {
if (mapRulerPointsAuto) return;
mapRulerManualPoints = Number(slider.value) || RULER_POINTS_MIN;
scheduleMapRulerProfileReload();
});
})();
(function bindMapRulerChartProbe() { (function bindMapRulerChartProbe() {
const canvas = document.getElementById('mapRulerCanvas'); const canvas = document.getElementById('mapRulerCanvas');
if (!canvas) return; if (!canvas) return;
+28
View File
@@ -64,6 +64,34 @@ def test_build_profile_reports_unreachable(monkeypatch):
assert "unreachable" in profile["api_error"] assert "unreachable" in profile["api_error"]
def test_resample_track_path_count_even_spacing():
pts = [{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}]
samples = elev.resample_track_path_count(pts, 50)
assert len(samples) == 50
assert samples[0]["dist_m"] == 0.0
assert samples[-1]["dist_m"] > samples[0]["dist_m"]
gaps = [samples[i]["dist_m"] - samples[i - 1]["dist_m"] for i in range(1, len(samples))]
assert max(gaps) - min(gaps) < 1.0
def test_build_profile_target_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [100.0 + i for i in range(len(lats))],
)
profile = elev.build_elevation_profile(
[{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}],
target_points=120,
)
assert len(profile["points"]) == 120
assert profile["step_m"] > 0
def test_find_nearest_hill_unreachable(monkeypatch): def test_find_nearest_hill_unreachable(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,