From ab2a3bb035312ac6a4f85dd2d0c722a7fc248b45 Mon Sep 17 00:00:00 2001 From: grigo Date: Mon, 15 Jun 2026 07:50:41 +0300 Subject: [PATCH] added linear slider --- .../__pycache__/elevation.cpython-313.pyc | Bin 20490 -> 22688 bytes server/core/elevation.py | 61 ++++++- server/fastapi_app.py | 3 +- server/flask_app.py | 7 +- server/static/index.html | 156 ++++++++++++++++-- ...est_elevation.cpython-313-pytest-9.0.3.pyc | Bin 15132 -> 21009 bytes server/tests/test_elevation.py | 28 ++++ 7 files changed, 239 insertions(+), 16 deletions(-) diff --git a/server/core/__pycache__/elevation.cpython-313.pyc b/server/core/__pycache__/elevation.cpython-313.pyc index 5c91fbf77223655f36993872ab7b7cef3a558b1b..6184a65165f454b553e7ae73b23a14278ca57d33 100644 GIT binary patch delta 4126 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 E1!zV;v;Y7A delta 2679 zcmZ`*YiwIZ7M`*1^((fM*m>2>!)cwz?IRDTB_wLMacK**aSEB-v|EF^CUMe4$xSkL zB{pc4z_x#2wIoatMLaeu%3Dal1(jWP5k-K+5BAk1+qHIAlol58ha$>K%Mak3xryym zWvu(%bIy0>%(>^xjJ)&;y?=@NzxMe&1V$U%H2&9~MSqp;bGq1k-&`Bv0p_|R$lOi9 zGS5sIE1D^1_L&Oi1*=b5>zF@8#uZ-4!mLsf>arBt0~Yv%}SU}e%=Q(%?>SR*T!Ol>V;O?j3zvkJ7s!gbd$8mnAk!fX?(lC$gC zI=+EzW+AC<%*R1V)Y_c8wy^b*i?FS%T1pMbv6i*I($>mq&;!cM@*$#%$T>$Mh}_X{~J3U5rKf9qvDys|LD)m zCxXkA-eYFUBThPRK3YAc(DzJteX~L}5p8%!{T1|R+5CUQOLV;(L^~n$>o;tKJWLW4 zbU7FmUv^iBhSr)n=c?^6-40$j44ZCiwnQ@QB1EUHgxQ(GROV<=BP4-w<=DRRHtf2c zIaSbd^MVEQT)?`K^^*=;Lhdn~$UH5Mxd_p1K{BYMZLK5?(k+nC)E-uZ?zh?Euv8HV z6GJtW>-u@G@pBkT!iT|e93ISllCzZYyvk zhvAujHOv}LrpSt~%gMCzobF*IEs9a3yZWIng+nY=Dph*X_LbIziS8MKG~%~H`P^cm zvdpG?#8+*>xNEhtN$iM=1!QJ2zk`(74X;jhukOxy6%mqv-djU=uPy+-r4ab`_DZ(a z=3+`gXM!zG!gbp^OwrT7`JCy`}xX)|zEKD?+4)d%?t` z0J9!rDx6MEPEVxLn#xb2{e;*Sjkj1L>I(>q2oE9bN5G!&0KzX29OCI{V{sp!1x0gO zVHU*Sqm7OeNH0Frv3Va();wAJWb$r+F@xWM6rK)#GlCN#g)oLtf{*~v{9Fc+3EeLq z?dUGWjIs`%N*|xdr2U|X&pNh7p9JAMc+zvwym@lfMf{};!Ba!0BWKj5k*9p;D_YL` zTIcuu%U{3Lc;4SWf3taZ+fiC{uED=Ipq%US@2wQ|H@xBcCB)Da{)o7E!y)wyC^w3o zou}!8V!3m#`W)ynGkoJ=T9vhr85uqf23`e_Rge9AdMd-uh=s0l$FpcYDPHW-=mX-T zu5R^JFx+aEb$?7dehuPEFMirH3JQ#?yEHe);QSGU(+ILaa1IE6RW$9`x*GGyjz;w? zSno7X?%1MK3v}27@VuSlZj-XrrPsuVJ!yJM#A2P1Q%FStU>$VJI)+k4CdIjxDY7TT zljW=WRhtm+PcNi%)uYQl|*VHyp$$16DO%mNzvEgJUl`bf5 zzN{{*DlcGYS(+EbV!XNhnhLKJ_fPSgw&XgLErD}$bed1WD`gC&j!yDkjPfqRdkF7~ zoqaX2f?A{TE(A=O{{tYa45cv3e3-0qS1O(1%jofEfb=>sQ(Yy@z8du+s4tlx^@Z&8 zg1B<~O`!|ugd0<%`=C<0QT->v1rhJx>bPu)^Zo5n^qeg|IGq`rGLjRCk%_5f=1$GQ zC(uRuP39yS^;2=FfAdDk$V)|Tf606TjvD0;;>!Wsw4H^D<#mvAnH5Li&%y}|_!+`l zmR}O31EI}J$jB+b%}M$Aiq$MDr0@>ZmCL$Iy&#z41D)FS z=zp(G<`Sq{v)H5GBi_~Hn!F(KYU5`RUPqAk1YRgyoS4V3ihiRD&9HtpC15gKR7o4 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 3a6630be518ff5a6a07f8bde5aaa90dd65f90680..b768e60e7fdadf3c783e629eecd27043699386b4 100644 GIT binary patch delta 4259 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 C 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,