diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 0f8352e..3f87437 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -22,12 +22,12 @@ | Файл | Строк | Назначение | |------|-------|------------| | `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; `${SHARE_FIELD_LABELS[k]}` + ).join(""); + let ov = document.createElement("div"); + ov.className = "share-modal"; + ov.innerHTML = ` +
+ +
${fieldChecks}
+
+ + +
+
+ + + +
+
`; + document.body.appendChild(ov); + // Register in the global window stack so the hardware/gesture back closes THIS + // modal instead of paging the screens behind it (winPush adds a history entry). + let base = winStack.length, closed = false; + winPush("share", () => ov.remove()); + let closeShare = () => { + if (closed) return; + closed = true; + let count = winStack.length - base; + for (let i = 0; i < count; i++) winStack.pop().close(); + _skipPops = count; + if (count > 0) history.go(-count); + ov.remove(); + }; + let bg = "transparent", dataUrl = null; + let img = ov.querySelector("#share-img"); + let bt = ov.querySelector("#share-bg-t"), bp = ov.querySelector("#share-bg-p"); + async function render() { dataUrl = await buildShareImage(session, stats, points, { bg, fields }); img.src = dataUrl; } + ov.querySelectorAll(".share-field input").forEach(cb => cb.onchange = () => { + fields[cb.dataset.field] = cb.checked; + try { localStorage.setItem("baldav_share_fields", JSON.stringify(fields)); } catch {} + render(); + }); + bt.onclick = () => { bg = "transparent"; bt.classList.add("active"); bp.classList.remove("active"); render(); }; + bp.onclick = () => { + if (!localStorage.getItem("baldav_share_bg")) { showToast("Подложка не выбрана - задайте в Настройках"); return; } + bg = "photo"; bp.classList.add("active"); bt.classList.remove("active"); render(); + }; + let filename = () => "baldav-" + ((session && session.date) || "track") + ".png"; + ov.querySelector("#share-do").onclick = () => exportShareImage(dataUrl, filename(), "share"); + ov.querySelector("#share-save").onclick = () => exportShareImage(dataUrl, filename(), "save"); + ov.querySelector("#share-close").onclick = () => closeShare(); + render(); +} + // Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries // kept — one colour per segment): green -> yellow -> red -> brown -> black. // <1%=0 .. 9–10%=9, >=10% = black. Both up- and downhill coloured by magnitude. @@ -3505,6 +3811,9 @@ async function showRouteEditor(container, routeId) { let defaultMode = "road"; let routeTimer = null; let dirty = new Set(); + let nogos = []; // [{lat,lon,radius}] impassable circles the route must avoid + let nogoLayer = null; // red circles overlay + let nogoMode = false; // when on, a map tap drops a nogo instead of a waypoint container.innerHTML = `
@@ -3582,7 +3891,7 @@ async function showRouteEditor(container, routeId) { let line = L.polyline(legPts(i).map(p => [p.lat, p.lon]), { color: "#000", weight: 26, opacity: 0, interactive: true, className: "re-hit" }).addTo(map); (function (idx) { - line.on("click", (e) => { L.DomEvent.stop(e); insertInLeg(idx, e.latlng); }); // tap → insert + line.on("click", (e) => { L.DomEvent.stop(e); if (nogoMode) placeNogo(e.latlng); else insertInLeg(idx, e.latlng); }); // tap → insert (or nogo) line.on("contextmenu", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // long-press → road/straight })(i); hitLines.push(line); @@ -3595,8 +3904,13 @@ async function showRouteEditor(container, routeId) { let idxs = [...dirty]; dirty.clear(); for (let i of idxs) { if (i >= points.length - 1 || legMode[i] !== "road") continue; + let a = points[i], b = points[i + 1]; + // Prefer already-ridden roads; use BRouter (public OSM) only where there is no ridden path. + let ridden = null; + try { ridden = await rideRoute(a.lat, a.lon, b.lat, b.lon); } catch (e) { /* fall back */ } + if (ridden && ridden.length >= 2) { legGeom[i] = ridden.map(p => ({ lat: p.lat, lon: p.lon })); continue; } try { - let gpx = await BRouter.route([[points[i].lon, points[i].lat], [points[i+1].lon, points[i+1].lat]], "trekking"); + let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "trekking", nogos); let rp = (GPS.parseRouteGPX(gpx) || {}).points || []; legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon, ele: p.ele })) : null; // keep ele for slope } catch (e) { legGeom[i] = null; } // silent fallback to straight (no road / no segments) @@ -3691,6 +4005,37 @@ async function showRouteEditor(container, routeId) { }; locCtl.addTo(map); + // Nogo (impassable) circles: toggle the ⛔ button, then a map/line tap drops a red circle + // the routing avoids. Persist in DB; every road leg re-routes to go around them. + async function refreshNogos(reroute) { + nogos = await getNogosForRouter(); + nogoLayer = await drawNogoAreas(map, nogoLayer, () => refreshNogos(true)); + if (reroute) { + for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); } + redraw(); updateStats(); scheduleRoute(); + } + } + async function placeNogo(latlng) { + await DB.addNogoArea(latlng.lat, latlng.lng, 25, null); + await refreshNogos(true); + } + let nogoCtl = L.control({ position: "topleft" }); + nogoCtl.onAdd = () => { + let d = L.DomUtil.create("div", "sd-map-fs-btn"); + d.innerHTML = "⛔"; + d.title = "Пометить непроезжее: тап по карте"; + L.DomEvent.disableClickPropagation(d); + d.onclick = (e) => { + e.stopPropagation(); + nogoMode = !nogoMode; + d.classList.toggle("active", nogoMode); + showToast(nogoMode ? "Тап по карте — пометить непроезжее" : "Режим отметки выключен"); + }; + return d; + }; + nogoCtl.addTo(map); + refreshNogos(false); + // Existing route: load waypoints. Planner-built → its saved leg modes (re-route road legs // to get fresh geometry + elevations). Imported/legacy dense → all legs straight. if (points.length) { @@ -3708,7 +4053,7 @@ async function showRouteEditor(container, routeId) { () => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 }); } - map.on("click", e => addPoint(e.latlng)); + map.on("click", e => { if (nogoMode) placeNogo(e.latlng); else addPoint(e.latlng); }); document.querySelector("#re-undo").onclick = () => { if (!points.length) return; @@ -4244,6 +4589,60 @@ async function renderRecordingInline(container) { darkBtn.onclick = () => toggleDarkMap(); btnWrap.appendChild(darkBtn); + // Nogo: mark the current spot impassable while riding (the planner + on-the-fly reroute + // will avoid it). Shows existing nogo circles too. + let recNogoLayer = null; + async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); } + refreshRecNogos(); + let recNogoBtn = document.createElement("button"); + recNogoBtn.className = "rec-map-btn"; + recNogoBtn.textContent = "⛔"; + recNogoBtn.title = "Здесь нет проезда (отметить текущее место)"; + recNogoBtn.onclick = async () => { + let p = GPS.recording.lastMovingPoint; + if (!p) { showToast("Нет позиции"); return; } + await DB.addNogoArea(p.lat, p.lon, 25, null); + await refreshRecNogos(); + showToast("Отмечено: нет проезда"); + }; + btnWrap.appendChild(recNogoBtn); + + // Reroute back onto the active route past a blockage (nogo), from the current position, + // avoiding nogos and 180° turns. Transient navigation overlay — does NOT touch the saved + // route or the recording. Tap again to rebuild from the new position; long-press to clear. + let rerouteLayer = null; + let rerouteBtn = document.createElement("button"); + rerouteBtn.className = "rec-map-btn"; + rerouteBtn.textContent = "↪"; + rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом"; + rerouteBtn.onclick = async () => { + let rid = localStorage.getItem("baldav_active_route"); + if (!rid) { showToast("Нет активного маршрута"); return; } + let cur = GPS.recording.lastMovingPoint; + if (!cur) { showToast("Нет позиции"); return; } + rerouteBtn.disabled = true; rerouteBtn.classList.add("active"); + try { + let route = await DB.getRoute(rid); + if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); return; } + let ng = await getNogosForRouter(); + let bearing = (typeof smoothedHeading === "number") ? smoothedHeading : null; + let res = await rejoinReroute(cur, bearing, route.points, ng); + if (!res) { showToast("Объезд не найден"); rerouteBtn.classList.remove("active"); return; } + if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; } + let ll = res.points.map(p => [p.lat, p.lon]); + let g = L.layerGroup(); + L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(g); + L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(g); + L.polyline(ll, { color: "#ff00ff", weight: 3, opacity: 1 }).addTo(g); + L.circleMarker([res.target.lat, res.target.lon], { radius: 7, color: "#ff00ff", fillColor: "#fff", fillOpacity: 1, weight: 3 }).addTo(g); + g.addTo(map); rerouteLayer = g; + showToast("Объезд построен"); + } catch (e) { showToast("Объезд: " + (e.message || e)); } + finally { rerouteBtn.disabled = false; } + }; + rerouteBtn.oncontextmenu = (e) => { e.preventDefault(); if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; rerouteBtn.classList.remove("active"); showToast("Объезд убран"); } }; + btnWrap.appendChild(rerouteBtn); + POI.attach(map); // POI overlay + category filter on the recording map // The default-ON draw is deferred to the END of this function (after all the // critical wiring), where it awaits whenMapReady — so it can't delay setup. @@ -4785,6 +5184,7 @@ async function showSessionDetail(sessionId) {
${stats.avgSpeed} км/чСредняя
${stats.maxSpeed} км/чМакс.
+
@@ -4812,8 +5212,8 @@ async function showSessionDetail(sessionId) { ` : ""} ${(stats.segments && stats.segments.length > 1) ? ` -
-
Сегменты
+
+ Сегменты ${stats.segments.length}
${stats.segments.map(seg => { let icon = seg.type === "climb" ? "^" : seg.type === "descent" ? "v" : "-"; @@ -4829,7 +5229,7 @@ async function showSessionDetail(sessionId) {
`; }).join("")}
- ` : ""} + ` : ""} +
+
+
Фон для картинки «Похвастаться»
+
Фото-подложка под форму трека и статы
+
+
+ + +
+
Очистить продукты без магазина
@@ -6626,6 +7059,40 @@ async function renderSettings(container) { } }; + // Share-image background: pick a photo, downscale, store as a data URL in localStorage. + let sbgBtn = container.querySelector("#s-share-bg"); + if (sbgBtn) sbgBtn.onclick = async () => { + let prev = sbgBtn.textContent, path; + if (/Android/i.test(navigator.userAgent)) { + sbgBtn.disabled = true; sbgBtn.textContent = "Выбор..."; + try { path = await pickLocalFile("image/*"); } catch (e) { showToast("Ошибка выбора: " + (e.message || e)); } + sbgBtn.disabled = false; sbgBtn.textContent = prev; + } else { + path = await appPrompt("Путь к фото-подложке:", ""); + } + if (!path) return; + sbgBtn.disabled = true; sbgBtn.textContent = "Загрузка..."; + try { + let b64 = await window.__TAURI__.core.invoke("read_file_base64", { path }); + let mime = /\.png$/i.test(path) ? "image/png" : "image/jpeg"; + await new Promise((res, rej) => { + let img = new Image(); + img.onload = () => { + let s = Math.min(1, 1080 / img.width); + let c = document.createElement("canvas"); c.width = Math.round(img.width * s); c.height = Math.round(img.height * s); + c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); + try { localStorage.setItem("baldav_share_bg", c.toDataURL("image/jpeg", 0.85)); res(); } catch (e) { rej(e); } + }; + img.onerror = () => rej(new Error("не удалось прочитать изображение")); + img.src = "data:" + mime + ";base64," + b64; + }); + showToast("Подложка сохранена"); + } catch (e) { showToast("Подложка: " + (e.message || e)); } + sbgBtn.disabled = false; sbgBtn.textContent = prev; + }; + let sbgClear = container.querySelector("#s-share-bg-clear"); + if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); }; + // Download tiles from router — the list comes from the router's directory // listing, so a newly uploaded .db shows up without rebuilding the app. container.querySelector("#s-download-tiles").onclick = async () => { diff --git a/frontend/js/router.js b/frontend/js/router.js index 2a04dca..1d8be2f 100644 --- a/frontend/js/router.js +++ b/frontend/js/router.js @@ -11,11 +11,20 @@ window.BRouter = { _servers: ["http://192.168.1.18:17777", "http://mirror.invalid:17777"], - async _routeServer(pts, profile) { + // nogos: [{lat,lon,radius}] — impassable circles the router must avoid. + _nogoParam(nogos) { + if (!nogos || !nogos.length) return ""; + // RouteServer expects nogos=lon,lat,radiusMeters|... (radius in metres). + let s = nogos.filter(n => n && n.lat != null && n.lon != null) + .map(n => n.lon + "," + n.lat + "," + Math.round(n.radius || 25)).join("|"); + return s ? "&nogos=" + encodeURIComponent(s) : ""; + }, + + async _routeServer(pts, profile, nogos) { let lonlats = pts.map(p => p[0] + "," + p[1]).join("|"); for (let base of this._servers) { try { - let url = base + "/brouter?lonlats=" + encodeURIComponent(lonlats) + "&profile=" + profile + "&alternativeidx=0&format=gpx"; + let url = base + "/brouter?lonlats=" + encodeURIComponent(lonlats) + "&profile=" + profile + "&alternativeidx=0&format=gpx" + this._nogoParam(nogos); let gpx = await window.__TAURI__.core.invoke("http_request", { url, method: "GET" }); if (gpx && gpx.indexOf("= 0) return gpx; } catch (e) { /* try next server */ } @@ -23,25 +32,26 @@ window.BRouter = { return null; }, - async route(points, profile) { + async route(points, profile, nogos) { let pts = (points || []).map(p => [p[0], p[1]]); profile = profile || "trekking"; + nogos = (nogos || []).filter(n => n && n.lat != null && n.lon != null); let isAndroid = /Android/i.test(navigator.userAgent || ""); // Desktop has no embedded engine (that's Kotlin/Android) — the native command would // just time out; go straight to the PC RouteServer. Android uses the engine, and // falls back to the RouteServer (reachable at home) only if the engine errors. if (!isAndroid) { - let gpx = await this._routeServer(pts, profile); + let gpx = await this._routeServer(pts, profile, nogos); if (gpx) return gpx; throw new Error("brouter: RouteServer unreachable"); } try { - let raw = await window.__TAURI__.core.invoke("brouter_route", { lonlats: JSON.stringify(pts), profile }); + let raw = await window.__TAURI__.core.invoke("brouter_route", { lonlats: JSON.stringify(pts), profile, nogos: JSON.stringify(nogos) }); let res = JSON.parse(raw); if (res.ok) return res.gpx; throw new Error(res.error || "engine error"); } catch (e) { - let gpx = await this._routeServer(pts, profile); + let gpx = await this._routeServer(pts, profile, nogos); if (gpx) return gpx; throw new Error("brouter: " + (e.message || e)); } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 32a30c1..9601b68 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.451" +version = "0.1.457" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c015dca..24e8bc2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.451" +version = "0.1.457" edition = "2021" [lib] diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index 525c29e..f69328f 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -24,6 +24,7 @@ import javax.net.ssl.HttpsURLConnection class MainActivity : TauriActivity() { private var commandObserver: FileObserver? = null private var pendingSaveContent: String? = null + private var pendingImageBytes: ByteArray? = null private var backWebView: WebView? = null // Disable WryActivity's generated back handler: its mWebView.goBack() does NOT @@ -66,6 +67,33 @@ class MainActivity : TauriActivity() { pendingSaveContent = null } + // SAF "create document" for the share PNG (bytes, image/png mime). + private val saveImageLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("image/png") + ) { uri: Uri? -> + if (uri != null && pendingImageBytes != null) { + try { contentResolver.openOutputStream(uri)?.use { it.write(pendingImageBytes!!) } } + catch (e: Exception) { Log.e("BalDav", "Failed to save image", e) } + } + pendingImageBytes = null + } + + // Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND). + private fun shareImage(bytes: ByteArray, filename: String) { + try { + val dir = File(cacheDir, "shared"); dir.mkdirs() + val f = File(dir, if (filename.isBlank()) "baldav.png" else filename) + f.writeBytes(bytes) + val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.fileprovider", f) + val send = Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(send, "Поделиться").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + } catch (e: Exception) { Log.e("BalDav", "share image failed", e) } + } + // SAF "open document" picker. A content:// URI has no filesystem path, so the // picked file is copied into the app dir and its path is written to // open_file_result.txt, which the JS side polls via the Rust get_picked_file. @@ -302,9 +330,16 @@ class MainActivity : TauriActivity() { val pair = arr.getJSONArray(i) pts.add(doubleArrayOf(pair.getDouble(0), pair.getDouble(1))) } + // Optional nogos: [{lat,lon,radius}] impassable circles to avoid → [lon,lat,radius]. + val nogos = ArrayList() + val na = req.optJSONArray("nogos") + if (na != null) for (i in 0 until na.length()) { + val o = na.getJSONObject(i) + nogos.add(doubleArrayOf(o.getDouble("lon"), o.getDouble("lat"), o.optDouble("radius", 25.0))) + } val profilePath = File(filesDir, "brouter/profiles2/$profile.brf").absolutePath val segmentsDir = File(filesDir, "brouter/segments4") - val gpx = routeBrouter(segmentsDir, profilePath, pts) + val gpx = routeBrouter(segmentsDir, profilePath, pts, nogos) out.put("ok", true) out.put("gpx", gpx) } catch (e: Exception) { @@ -320,11 +355,28 @@ class MainActivity : TauriActivity() { // RoutingContext pointing at the profile (.brf; the engine parses it via ProfileCache // and loads lookups.dat from the same dir), waypoints as OsmNodeNamed (ilon/ilat are // micro-degrees), run the engine, return the track as GPX. - private fun routeBrouter(segmentsDir: File, profilePath: String, pts: List): String { + private fun routeBrouter(segmentsDir: File, profilePath: String, pts: List, nogos: List = emptyList()): String { val rc = btools.router.RoutingContext() rc.memoryclass = 128 rc.localFunction = profilePath rc.setAlternativeIdx(0) + // Nogo circles (impassable): each a hard block (nogoWeight = NaN). prepareNogoPoints + // converts the metre radius to the engine's internal units before routing. + if (nogos.isNotEmpty()) { + val nogoList = ArrayList() + for (n in nogos) { + val nn = btools.router.OsmNodeNamed() + nn.ilon = ((n[0] + 180.0) * 1000000.0 + 0.5).toInt() + nn.ilat = ((n[1] + 90.0) * 1000000.0 + 0.5).toInt() + nn.radius = n[2] + nn.isNogo = true + nn.nogoWeight = Double.NaN + nn.name = "nogo" + nogoList.add(nn) + } + btools.router.RoutingContext.prepareNogoPoints(nogoList) + rc.nogopoints = nogoList + } val wps = ArrayList() for (p in pts) { val n = btools.router.OsmNodeNamed() @@ -448,6 +500,21 @@ class MainActivity : TauriActivity() { Log.d("BalDav", "Opening file picker for mime=$mime") openFileLauncher.launch(arrayOf(mime)) } + cmd.startsWith("exportimg:") -> { + // exportimg:: — decode export_image.b64, then SAF-save or share. + val rest = cmd.substringAfter("exportimg:") + val action = rest.substringBefore(":") + val filename = rest.substringAfter(":") + val b64File = File(appDataDir, "export_image.b64") + if (b64File.exists()) { + val bytes = try { android.util.Base64.decode(b64File.readText().trim(), android.util.Base64.DEFAULT) } catch (e: Exception) { null } + b64File.delete() + if (bytes != null) { + if (action == "save") { pendingImageBytes = bytes; saveImageLauncher.launch(filename) } + else shareImage(bytes, filename) + } + } + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3120ae7..9466747 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -264,8 +264,11 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result= ?1 ORDER BY p.session_id, p.seq" @@ -338,6 +341,70 @@ fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Resul Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more)) } +// Build a routable graph from the user's RIDDEN tracks (excludes walk=1 points), so the planner +// can prefer real roads before public OSM data. Points are snapped to a ~20 m grid (nodes); +// consecutive points of the same session make an edge (weight = metres). Returns compact JSON +// { "nodes": [[lat,lon],...], "edges": [[a,b,dist],...] } (node index = position in nodes). +#[tauri::command] +fn get_ride_graph(app: tauri::AppHandle, min_speed_ms: f64) -> Result { + use std::collections::HashMap; + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = { + let p1 = data_dir.join("../../baldav.db"); + let p2 = data_dir.join("baldav.db"); + let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db"); + if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 } + }; + let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?; + fn hav(la1: f64, lo1: f64, la2: f64, lo2: f64) -> f64 { + let r = 6371000.0_f64; + let dla = (la2 - la1).to_radians(); + let dlo = (lo2 - lo1).to_radians(); + let a = (dla / 2.0).sin().powi(2) + la1.to_radians().cos() * la2.to_radians().cos() * (dlo / 2.0).sin().powi(2); + r * 2.0 * a.sqrt().asin() + } + let d_lat = 0.00018_f64; // ~20 m + let d_lon = 0.00027_f64; + let mut stmt = db.prepare( + "SELECT lat, lon, session_id, seq FROM gps_points + WHERE COALESCE(walk,0)=0 AND speed_ms >= ?1 + ORDER BY session_id, COALESCE(seq,999999), ts" + ).map_err(|e| format!("prepare: {e}"))?; + let rows = stmt.query_map(rusqlite::params![min_speed_ms], |r| { + Ok((r.get::<_, f64>(0)?, r.get::<_, f64>(1)?, r.get::<_, i64>(2)?)) + }).map_err(|e| format!("query: {e}"))?; + + let mut node_idx: HashMap<(i64, i64), usize> = HashMap::new(); + let mut nodes: Vec<(f64, f64)> = Vec::new(); + let mut edges: HashMap<(usize, usize), f64> = HashMap::new(); + let mut prev: Option<(i64, usize, f64, f64)> = None; // (session_id, node, lat, lon) + for row in rows { + let (lat, lon, sid) = row.map_err(|e| format!("row: {e}"))?; + let c = ((lat / d_lat).round() as i64, (lon / d_lon).round() as i64); + let ni = match node_idx.get(&c) { + Some(&i) => i, + None => { let i = nodes.len(); nodes.push((lat, lon)); node_idx.insert(c, i); i } + }; + if let Some((psid, pn, _plat, _plon)) = prev { + if psid == sid && pn != ni { + // Edge weight = distance between the two NODES (not the consecutive points), + // else intra-cell travel is lost and the path is badly under-weighted. + let (nla, nlo) = nodes[pn]; + let d = hav(nla, nlo, lat, lon); + if d <= 200.0 { // skip absurd jumps (GPS gap) between consecutive points + let key = if pn < ni { (pn, ni) } else { (ni, pn) }; + let e = edges.entry(key).or_insert(f64::INFINITY); + if d < *e { *e = d; } + } + } + } + prev = Some((sid, ni, lat, lon)); + } + let nodes_json = nodes.iter().map(|(la, lo)| format!("[{:.6},{:.6}]", la, lo)).collect::>().join(","); + let edges_json = edges.iter().map(|((a, b), d)| format!("[{},{},{:.1}]", a, b, d)).collect::>().join(","); + Ok(format!("{{\"nodes\":[{}],\"edges\":[{}]}}", nodes_json, edges_json)) +} + #[derive(Serialize)] struct AppInfo { version: String, @@ -398,16 +465,19 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result.json. `lonlats` is a JSON // array of [lon,lat] pairs; returns the Kotlin result JSON ({ok,gpx}|{ok:false,error}). #[tauri::command] -fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String) -> Result { +fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String, nogos: Option) -> Result { let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; std::fs::create_dir_all(&data_dir).ok(); let id = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())? .as_millis().to_string(); + // nogos is a JSON array string [{lat,lon,radius}] from JS; default to empty array. + let nogos_json = nogos.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[]".to_string()); let req = format!( - "{{\"profile\":{},\"lonlats\":{}}}", + "{{\"profile\":{},\"lonlats\":{},\"nogos\":{}}}", serde_json::to_string(&profile).map_err(|e| e.to_string())?, - lonlats + lonlats, + nogos_json ); let result_name = format!("brouter_result_{}.json", id); // Write request + command to app_data_dir AND app root (where Kotlin watches). @@ -530,6 +600,30 @@ fn save_file(app: tauri::AppHandle, filename: String, content: String) -> Result Ok("save_requested".to_string()) } +// Export a PNG (base64) via the file bridge → Kotlin decodes + SAF-saves ("save") or shares it +// via a chooser ("share"). Mirrors save_file but carries binary as base64. +#[tauri::command] +fn export_image(app: tauri::AppHandle, filename: String, base64: String, action: String) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&data_dir).ok(); + let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()); + let write_both = |name: &str, content: &str| { + let _ = std::fs::write(data_dir.join(name), content); + if let Some(r) = &root { let _ = std::fs::write(r.join(name), content); } + }; + write_both("export_image.b64", &base64); + write_both("gps_command.txt", &format!("exportimg:{}:{}", action, filename)); + Ok("export_requested".to_string()) +} + +// Read any file and return its bytes base64-encoded (for loading a picked image into the WebView). +#[tauri::command] +fn read_file_base64(path: String) -> Result { + use base64::Engine; + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + // Read tiles from a SQLite file in batches for import into IndexedDB #[tauri::command] fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result { @@ -994,7 +1088,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bff4f58..44ddaed 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.451", + "version": "0.1.457", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend"