Compass map rotation, landscape layout, GPS placeholder

- leaflet-rotate plugin: map rotates by compass heading via deviceorientation
- Landscape detection: CSS viewport rotation when phone held sideways (gamma > 50)
- Bottom nav hidden in landscape mode
- GPS placeholder: pixel art overlay shown until GPS signal arrives
- invalidateSize() after map init to prevent black screen
This commit is contained in:
4aiman 2026-06-25 16:54:45 +03:00
commit fda820568e
8 changed files with 2284 additions and 24 deletions

View file

@ -1059,9 +1059,53 @@ select {
height: 100%;
}
.rec-map {
.rec-panel {
flex-shrink: 0;
}
.rec-map-wrap {
flex: 1;
min-height: 300px;
min-height: 200px;
position: relative;
}
.rec-map {
position: absolute;
inset: 0;
}
.gps-placeholder {
position: absolute;
inset: 0;
z-index: 1000;
background: url('../img/gps-placeholder.png') center/cover;
display: flex;
align-items: center;
justify-content: center;
}
.gps-placeholder-text {
background: rgba(0,0,0,0.7);
color: var(--on-bg);
padding: 12px 24px;
border-radius: 8px;
font-size: 18px;
}
/* Landscape: CSS rotation compensates physical rotation.
flex-direction stays column: element-bottom = visual-right after rotate.
No font-size changes — everything stays portrait-sized, just rearranged. */
.rec-screen.landscape-right,
.rec-screen.landscape-left {
position: fixed;
width: 100vh;
height: 100vw;
top: calc((100vh - 100vw) / 2);
left: calc((100vw - 100vh) / 2);
z-index: 9999;
}
.rec-screen.landscape-right {
transform: rotate(-90deg);
}
.rec-screen.landscape-left {
transform: rotate(90deg);
}
.rec-stats {

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -13,6 +13,7 @@
<!-- Load order matters: db.js and bmr.js expose window.DB / window.BMR,
then main.js uses them. Tauri injects window.__TAURI__ before page scripts. -->
<script src="lib/leaflet.js"></script>
<script src="lib/leaflet-rotate.js"></script>
<script src="lib/hammer.min.js"></script>
<script src="lib/chart.min.js"></script>
<script src="lib/chartjs-plugin-zoom.min.js"></script>
@ -21,6 +22,7 @@
<script src="js/bmr.js"></script>
<script src="js/tilecache.js"></script>
<script src="js/gps.js"></script>
<script src="js/sync.js"></script>
<script src="js/main.js"></script>
</body>
</html>

View file

@ -1,6 +1,10 @@
// BalDav main app — pure JS, no framework, no bundler.
// Depends on window.DB (db.js) and window.BMR (bmr.js) loaded before this script.
// Disable leaflet-rotate's compass control on all maps by default;
// only the recording map enables rotate: true explicitly.
if (L && L.Map) L.Map.mergeOptions({ rotateControl: false });
const app = document.getElementById("app");
const UPDATE_BASE_URL = "https://4aiman.top/baldav";
@ -176,6 +180,9 @@ async function init() {
// Restore last screen (fromPopstate=true to avoid duplicate navStack push)
renderScreen(navStack[navStack.length - 1], true);
// Initialize sync
try { await Sync.init(); if (Sync.userId) Sync.now(); } catch {}
// Check for updates in background (non-blocking)
checkForUpdate();
}
@ -185,8 +192,9 @@ async function init() {
// ---------------------------------------------------------------------------
function renderScreen(name, fromPopstate) {
// Release screen wake lock when leaving recording screen
// Release screen wake lock and compass listener when leaving recording screen
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; }
if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; }
const prevScreen = state.screen;
state.screen = name;
@ -2733,23 +2741,32 @@ function renderRecordingInline(container) {
container.innerHTML = `
<div class="rec-screen">
<div id="rec-map" class="rec-map"></div>
<div class="rec-hr-row" id="rec-hr-row" style="display:none">
<span id="rec-hr-val" class="rec-hr-val">--</span>
<span class="rec-hr-unit">bpm</span>
<span id="rec-spo2" class="rec-hr-extra"></span>
<span id="rec-bp" class="rec-hr-extra"></span>
<div class="rec-map-wrap">
<div id="rec-map" class="rec-map"></div>
<div id="gps-placeholder" class="gps-placeholder">
<div class="gps-placeholder-text">Поиск GPS...</div>
</div>
</div>
<div class="rec-stats">
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
</div>
<div class="rec-bottom">
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
<div class="rec-panel">
<div class="rec-hr-row" id="rec-hr-row" style="display:none">
<span id="rec-hr-val" class="rec-hr-val">--</span>
<span class="rec-hr-unit">bpm</span>
<span id="rec-spo2" class="rec-hr-extra"></span>
<span id="rec-bp" class="rec-hr-extra"></span>
</div>
<div class="rec-stats">
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
<div class="rec-stat"><span id="rec-clock" class="rec-val">${new Date().toTimeString().slice(0,5)}</span><span class="rec-lbl">Часы</span></div>
<div class="rec-stat"><span id="rec-temp" class="rec-val">—</span><span class="rec-lbl">t&deg;C</span></div>
</div>
<div class="rec-bottom">
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
</div>
</div>
</div>`;
@ -2758,21 +2775,75 @@ function renderRecordingInline(container) {
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false }).setView([55.75, 37.62], 15);
// Use last known position from profile (reliable across sessions)
const lastPt = GPS.recording.lastMovingPoint;
const initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47;
const initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07;
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], 15);
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
// Compass heading + landscape detection from device orientation
let currentLandscape = null; // null | 'right' | 'left'
const recScreen = document.querySelector('.rec-screen');
function handleOrientation(e) {
// Compass heading
const heading = e.webkitCompassHeading || e.alpha || 0;
const gamma = e.gamma || 0;
// Landscape detection: |gamma| > 50 = phone held sideways
const threshold = 50;
let newLandscape = null;
if (gamma > threshold) newLandscape = 'right';
else if (gamma < -threshold) newLandscape = 'left';
if (newLandscape !== currentLandscape) {
currentLandscape = newLandscape;
if (recScreen) {
recScreen.classList.remove('landscape-right', 'landscape-left');
if (newLandscape) recScreen.classList.add('landscape-' + newLandscape);
}
// Hide/show bottom nav — it's outside rec-screen, CSS can't reach it
const nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = newLandscape ? 'none' : '';
// Leaflet needs to recalculate container size after CSS change
setTimeout(() => map.invalidateSize(), 100);
}
// Set map bearing: compass heading + compensation for CSS viewport rotation
// CSS rotate(-90deg) for landscape-right, rotate(90deg) for landscape-left
// The CSS rotation rotates the visual output, so the map bearing must compensate
let bearingOffset = 0;
if (currentLandscape === 'right') bearingOffset = 90;
else if (currentLandscape === 'left') bearingOffset = -90;
map.setBearing(heading + bearingOffset);
}
const throttledOrientation = L.Util.throttle(handleOrientation, 100);
const orientEvent = 'ondeviceorientationabsolute' in window
? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(orientEvent, throttledOrientation);
GPS.recording._compassCleanup = () => {
window.removeEventListener(orientEvent, throttledOrientation);
if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left');
const nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = '';
};
let posMarker = null;
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
const ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
statusEl.textContent = "GPS OK. Начните движение...";
statusEl.style.color = "#03dac6";
map.setView([p.lat, p.lon], 16);
@ -2796,10 +2867,15 @@ function renderRecordingInline(container) {
const speedBuf = [];
GPS.recording.onPoint = (p) => {
const ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
const ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
map.panTo(ll);
// Save position for next session start (avoid black screen)
DB.setProfileValue("last_lat", String(p.lat));
DB.setProfileValue("last_lon", String(p.lon));
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
@ -2871,8 +2947,23 @@ function renderRecordingInline(container) {
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
const sec = Math.max(0, Math.round(activeMs / 1000));
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
// Update clock
const clockEl = document.querySelector("#rec-clock");
if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5);
}, 1000);
// Fetch temperature
(async () => {
try {
const pos = GPS.recording.lastMovingPoint || { lat: 55.75, lon: 37.62 };
const url = `https://api.open-meteo.com/v1/forecast?latitude=${pos.lat.toFixed(2)}&longitude=${pos.lon.toFixed(2)}&current=temperature_2m`;
const resp = await window.__TAURI__.core.invoke("http_request", { url, method: "GET", body: null });
const data = JSON.parse(resp);
const tempEl = document.querySelector("#rec-temp");
if (tempEl && data.current) tempEl.textContent = Math.round(data.current.temperature_2m) + "°";
} catch (e) { console.log("[Weather]", e.message || e); }
})();
// Pause button
container.querySelector("#rec-pause").onclick = () => {
if (GPS.recording.manualPaused) {
@ -2890,6 +2981,7 @@ function renderRecordingInline(container) {
// Stop button
container.querySelector("#rec-stop").onclick = async () => {
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
clearInterval(timer);
renderScreen("gps");
@ -4407,6 +4499,31 @@ async function renderSettings(container) {
<div id="s-ble-results" style="padding:0 14px"></div>
</div>
<div class="settings-section">
<div class="settings-section-title">Синхронизация</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title" id="s-sync-status">${Sync.userId ? "Подключено" : "Не настроено"}</div>
<div class="settings-item-desc" id="s-sync-desc">${Sync.userId ? (state.profile.sync_last_time || "Не синхронизировано") : "Привяжите устройство"}</div>
</div>
<button class="btn-primary" id="s-sync-now" style="padding:6px 12px;font-size:13px">${Sync.userId ? "Синхр." : "Настроить"}</button>
</div>
<div id="s-sync-setup" style="padding:0 14px;display:${Sync.userId ? "none" : ""}">
<div class="form-group" style="margin-top:8px">
<label>Код привязки (с другого устройства)</label>
<div style="display:flex;gap:6px">
<input id="s-sync-code" type="text" maxlength="6" placeholder="ABC123" style="flex:1;text-transform:uppercase" />
<button id="s-sync-pair" class="btn-primary" style="padding:6px 12px;font-size:13px">Привязать</button>
</div>
</div>
<button id="s-sync-register" class="btn-secondary full-width" style="margin-top:8px;font-size:13px">Создать новый аккаунт</button>
</div>
<div id="s-sync-info" style="padding:0 14px;display:${Sync.userId ? "" : "none"}">
<button id="s-sync-show-code" class="btn-secondary full-width" style="margin-top:8px;font-size:13px">Показать код для привязки</button>
<div id="s-sync-code-display" style="display:none;margin-top:8px;text-align:center;font-size:28px;font-weight:bold;letter-spacing:4px;color:var(--primary)"></div>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">О приложении</div>
<div class="settings-item" id="s-about">
@ -4599,6 +4716,57 @@ async function renderSettings(container) {
};
}
// Sync handlers
const syncNowBtn = container.querySelector("#s-sync-now");
if (syncNowBtn) {
syncNowBtn.onclick = async () => {
if (!Sync.userId) {
// Show setup
container.querySelector("#s-sync-setup").style.display = "";
return;
}
syncNowBtn.textContent = "...";
syncNowBtn.disabled = true;
const r = await Sync.now();
container.querySelector("#s-sync-desc").textContent = `Отправлено: ${r.pushed}, получено: ${r.pulled}`;
syncNowBtn.textContent = "Синхр.";
syncNowBtn.disabled = false;
};
}
container.querySelector("#s-sync-register")?.addEventListener("click", async () => {
try {
const code = await Sync.register();
container.querySelector("#s-sync-status").textContent = "Подключено";
container.querySelector("#s-sync-setup").style.display = "none";
container.querySelector("#s-sync-info").style.display = "";
container.querySelector("#s-sync-code-display").style.display = "";
container.querySelector("#s-sync-code-display").textContent = code;
syncNowBtn.textContent = "Синхр.";
showToast("Аккаунт создан. Код: " + code);
} catch (e) { showToast("Ошибка: " + e.message); }
});
container.querySelector("#s-sync-pair")?.addEventListener("click", async () => {
const code = container.querySelector("#s-sync-code").value.trim().toUpperCase();
if (code.length !== 6) { showToast("Введите 6-символьный код"); return; }
try {
await Sync.pair(code);
container.querySelector("#s-sync-status").textContent = "Подключено";
container.querySelector("#s-sync-setup").style.display = "none";
container.querySelector("#s-sync-info").style.display = "";
syncNowBtn.textContent = "Синхр.";
showToast("Устройство привязано!");
await Sync.now();
} catch (e) { showToast("Ошибка: " + e.message); }
});
container.querySelector("#s-sync-show-code")?.addEventListener("click", async () => {
try {
const code = await Sync.newCode();
const el = container.querySelector("#s-sync-code-display");
el.style.display = "";
el.textContent = code;
} catch (e) { showToast("Ошибка: " + e.message); }
});
// App version info + update check
try {
const info = await window.__TAURI__.core.invoke("get_app_info");

File diff suppressed because it is too large Load diff

111
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.244"
version = "0.1.262"
dependencies = [
"btleplug",
"lazy_static",
@ -151,6 +151,7 @@ dependencies = [
"tauri-plugin-sql",
"tokio",
"tokio-stream",
"ureq",
"uuid",
]
@ -3198,6 +3199,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
@ -3306,6 +3321,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@ -4893,6 +4943,41 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@ -4924,6 +5009,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@ -5198,6 +5289,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@ -5447,6 +5547,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.244"
version = "0.1.262"
edition = "2021"
[lib]
@ -18,6 +18,7 @@ serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
lazy_static = "1"
ureq = "3"
[target.'cfg(not(target_os = "android"))'.dependencies]
btleplug = { version = "0.11", features = ["serde"] }

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.244",
"version": "0.1.262",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"
@ -9,7 +9,7 @@
"app": {
"withGlobalTauri": true,
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com https://api.open-meteo.com; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com"
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com https://api.open-meteo.com http://192.168.1.1:88; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com"
},
"windows": [
{