diff --git a/server/core/__pycache__/elevation.cpython-313.pyc b/server/core/__pycache__/elevation.cpython-313.pyc index 5c91fbf..6184a65 100644 Binary files a/server/core/__pycache__/elevation.cpython-313.pyc and b/server/core/__pycache__/elevation.cpython-313.pyc differ diff --git a/server/core/elevation.py b/server/core/elevation.py index ed04106..5daa5c1 100644 --- a/server/core/elevation.py +++ b/server/core/elevation.py @@ -18,6 +18,7 @@ from .config import ( logger = logging.getLogger(__name__) _BATCH_SIZE = 100 +_MAX_PROFILE_POINTS = 500 _CACHE: dict[tuple[float, float], Optional[float]] = {} _probe_checked_at = 0.0 _probe_ok = False @@ -251,12 +252,66 @@ def resample_track_path( 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( - 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]: """Resample track and fetch terrain elevations.""" - step_m = max(5.0, min(10.0, float(step_m))) - samples = resample_track_path(points, step_m) + if target_points is not None: + 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: return { "step_m": step_m, diff --git a/server/fastapi_app.py b/server/fastapi_app.py index a3980f1..a8057b7 100644 --- a/server/fastapi_app.py +++ b/server/fastapi_app.py @@ -307,6 +307,7 @@ class ElevationPoint(BaseModel): class ElevationProfileBody(BaseModel): points: list[ElevationPoint] = Field(default_factory=list) step_m: float = 10.0 + target_points: Optional[int] = Field(None, ge=2, le=500) @app.post("/api/elevation/profile") @@ -314,7 +315,7 @@ def elevation_profile(body: ElevationProfileBody): from core.elevation import build_elevation_profile 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") diff --git a/server/flask_app.py b/server/flask_app.py index 800368a..dba4bfb 100644 --- a/server/flask_app.py +++ b/server/flask_app.py @@ -248,7 +248,12 @@ def elevation_profile(): step = float(step_m) except (TypeError, ValueError): 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//elevation-profile") diff --git a/server/static/index.html b/server/static/index.html index 66915a8..fc5fb31 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -158,6 +158,14 @@ } #mapRulerTools button.active { background: #00ff88; color: #111; border-color: #00ff88; } #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; } @@ -188,6 +196,11 @@
Клик на карте — точка A, затем точка B
+
+ + + 100 точек +
@@ -368,6 +381,11 @@ let mapRulerChartHover = false; let mapRulerLineHover = false; 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 CHAT_POLL_MS = 2500; @@ -600,6 +618,48 @@ 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) { return L.divIcon({ className: '', @@ -762,16 +822,21 @@ setMapRulerStatus('загрузка…'); updateMapRulerLineLayer(a, b); 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 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); if (n > 0) { const src = elevProfileMapLine.source === 'elevation' ? 'высоты' : elevProfileMapLine.source === 'server' ? 'сервер' : 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); setMapRulerHint('Наведите на линию или график — высота и позиция'); } else { @@ -848,6 +913,34 @@ 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) { let best = null; let bestD = Infinity; @@ -866,8 +959,10 @@ return profile.points.filter(p => p.elevation_m != null && !Number.isNaN(p.elevation_m)).length; } - function buildLocalElevationProfile(points, stepM = 10) { - const samples = resampleTrackPath(points, stepM); + function buildLocalElevationProfile(points, stepM = 10, targetPoints = null) { + const samples = targetPoints != null + ? resampleTrackPathCount(points, targetPoints) + : resampleTrackPath(points, stepM); if (!samples.length) return null; const profilePts = samples.map(s => ({ dist_m: s.dist_m, @@ -877,8 +972,11 @@ })); const elevVals = profilePts.map(p => p.elevation_m).filter(v => v != 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 { - step_m: stepM, + step_m: Math.round(effStep * 10) / 10, total_m: profilePts[profilePts.length - 1].dist_m, min_elevation_m: Math.min(...elevVals), max_elevation_m: Math.max(...elevVals), @@ -931,13 +1029,14 @@ return elevationPointCount(profile) > 0 ? profile : profile; } - async function fetchElevationProfile(points, trackId) { + async function fetchElevationProfile(points, trackId, options = {}) { if (!points || !points.length) return null; + const { targetPoints = null, stepM = 10 } = options; let lastError = null; const health = await ensureElevationApi(); if (!health.ok) { - const cached = buildLocalElevationProfile(points, 10); + const cached = buildLocalElevationProfile(points, stepM, targetPoints); if (cached) return cached; return { points: [], @@ -964,10 +1063,13 @@ } try { + const body = targetPoints != null + ? { points, target_points: targetPoints } + : { points, step_m: stepM }; const res = await fetch('/api/elevation/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ points, step_m: 10 }) + body: JSON.stringify(body) }); if (res.ok) { const data = normalizeServerProfile(await res.json()); @@ -980,7 +1082,7 @@ lastError = lastError || String(e.message || e); } - const cached = buildLocalElevationProfile(points, 10); + const cached = buildLocalElevationProfile(points, stepM, targetPoints); if (cached) return cached; return { @@ -1211,6 +1313,7 @@ setMapRulerHint(''); } else { setMapRulerMode(mapRulerMode); + updateMapRulerPointsUi(0); } setTimeout(() => map.invalidateSize(), 80); } @@ -1924,6 +2027,37 @@ }; 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() { const canvas = document.getElementById('mapRulerCanvas'); if (!canvas) return; diff --git a/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc index 3a6630b..b768e60 100644 Binary files a/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc and b/server/tests/__pycache__/test_elevation.cpython-313-pytest-9.0.3.pyc differ diff --git a/server/tests/test_elevation.py b/server/tests/test_elevation.py index 0346b99..fe49c9f 100644 --- a/server/tests/test_elevation.py +++ b/server/tests/test_elevation.py @@ -64,6 +64,34 @@ def test_build_profile_reports_unreachable(monkeypatch): 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): monkeypatch.setattr( elev,