generated from Grigo/AndroidTemplate
added linear slider
This commit is contained in:
Binary file not shown.
@@ -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,10 +252,64 @@ 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."""
|
||||
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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+6
-1
@@ -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/<int:track_id>/elevation-profile")
|
||||
|
||||
+145
-11
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -188,6 +196,11 @@
|
||||
<button type="button" id="btnRulerClear">Сброс</button>
|
||||
</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>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user