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__)
|
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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Binary file not shown.
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user