Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file commands, JS importFromSqlite, UI in settings (file + router download) - Road map: road_points table with 2m dedup, rebuild_roads + get_road_points Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown - Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees - CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern) - Tile errors: done(null, tile) instead of Error to prevent crash cascade - GPS placeholder: only show when state=waiting_gps (not on screen re-enter) - ureq body size: use into_reader() to bypass 10MB limit for tile downloads
This commit is contained in:
parent
4d9c0f8de2
commit
184b86b18c
8 changed files with 561 additions and 32 deletions
|
|
@ -1072,6 +1072,25 @@ select {
|
|||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.rec-orient-lock {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1000;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
color: var(--on-bg);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rec-orient-lock.locked {
|
||||
background: rgba(187, 134, 252, 0.4);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.gps-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
|
|||
|
|
@ -1119,11 +1119,83 @@ window.DB.getAllReservesDates = getAllReservesDates;
|
|||
window.DB.getDaysWithFood = getDaysWithFood;
|
||||
window.DB.getDailyWaterMl = getDailyWaterMl;
|
||||
|
||||
// All GPS tracks as arrays of [lat, lon] grouped by session_id.
|
||||
// minSpeedMs filters out stopped/slow points. minPoints filters out tiny sessions.
|
||||
// Fetches per-session to avoid Tauri SQL plugin row limit.
|
||||
// Road points — all GPS points merged into one flat table.
|
||||
// Rebuild: INSERT...SELECT runs entirely in SQLite (no JS iteration, no ID issues).
|
||||
// Reading: paginated to avoid Tauri SQL plugin row limit (~28K).
|
||||
|
||||
async function rebuildRoads() {
|
||||
const invoke = window.__TAURI__.core.invoke;
|
||||
const raw = await invoke("rebuild_roads", { minSpeedMs: 0.5 });
|
||||
const resp = JSON.parse(raw);
|
||||
console.log("[rebuildRoads]", resp);
|
||||
return resp.road_points;
|
||||
}
|
||||
|
||||
// Read road points via Rust command (Tauri SQL plugin has ~28K row limit)
|
||||
async function getRoadPoints() {
|
||||
const invoke = window.__TAURI__.core.invoke;
|
||||
const BATCH = 10000;
|
||||
let offset = 0;
|
||||
const all = [];
|
||||
while (true) {
|
||||
const raw = await invoke("get_road_points", { offset, batchSize: BATCH });
|
||||
const resp = JSON.parse(raw);
|
||||
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b" });
|
||||
console.log(`[getRoadPoints] batch offset=${offset} got=${resp.points.length} total_db=${resp.total}`);
|
||||
if (!resp.has_more) break;
|
||||
offset += BATCH;
|
||||
}
|
||||
console.log(`[getRoadPoints] total=${all.length}`);
|
||||
return all;
|
||||
}
|
||||
|
||||
window.DB.rebuildRoads = rebuildRoads;
|
||||
window.DB.getRoadPoints = getRoadPoints;
|
||||
|
||||
// JS-side migrations — safe for any DB state (checks before altering)
|
||||
async function runMigrations() {
|
||||
// Disable WAL — all writes go directly to main DB file (no -wal/-shm files)
|
||||
await execute("PRAGMA journal_mode=DELETE");
|
||||
|
||||
// Sync columns: sync_id (UUID), updated_at (ISO timestamp), sync_dirty (0/1)
|
||||
const syncTables = ["food_log","weight_log","supplement_log","sleep_log","activity_log",
|
||||
"gps_sessions","recipes","recipe_ingredients","supplements"];
|
||||
for (const tbl of syncTables) {
|
||||
const cols = (await select(`PRAGMA table_info(${tbl})`)).map(r => r.name);
|
||||
if (!cols.includes("sync_id")) {
|
||||
await execute(`ALTER TABLE ${tbl} ADD COLUMN sync_id TEXT`);
|
||||
await execute(`ALTER TABLE ${tbl} ADD COLUMN updated_at TEXT`);
|
||||
await execute(`ALTER TABLE ${tbl} ADD COLUMN sync_dirty INTEGER DEFAULT 0`);
|
||||
// Backfill sync_id for existing rows
|
||||
await execute(`UPDATE ${tbl} SET sync_id = lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(6))) WHERE sync_id IS NULL`);
|
||||
await execute(`UPDATE ${tbl} SET updated_at = datetime('now') WHERE updated_at IS NULL`);
|
||||
}
|
||||
// Triggers: auto-set sync_id, updated_at, sync_dirty on INSERT/UPDATE
|
||||
await execute(`CREATE TRIGGER IF NOT EXISTS ${tbl}_sync_insert
|
||||
AFTER INSERT ON ${tbl} WHEN NEW.sync_id IS NULL
|
||||
BEGIN UPDATE ${tbl} SET
|
||||
sync_id = lower(hex(randomblob(4))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(6))),
|
||||
updated_at = datetime('now'), sync_dirty = 1
|
||||
WHERE rowid = NEW.rowid; END`);
|
||||
await execute(`CREATE TRIGGER IF NOT EXISTS ${tbl}_sync_update
|
||||
AFTER UPDATE ON ${tbl} WHEN NEW.sync_dirty != 0 OR OLD.sync_dirty = 0
|
||||
BEGIN UPDATE ${tbl} SET
|
||||
updated_at = datetime('now'), sync_dirty = 1
|
||||
WHERE rowid = NEW.rowid AND sync_dirty = 0; END`);
|
||||
// First sync: mark all existing rows as dirty so they get pushed
|
||||
const dirtyCount = (await select(`SELECT COUNT(*) as c FROM ${tbl} WHERE sync_dirty = 1`))[0].c;
|
||||
if (dirtyCount === 0) {
|
||||
const total = (await select(`SELECT COUNT(*) as c FROM ${tbl}`))[0].c;
|
||||
if (total > 0) {
|
||||
await execute(`UPDATE ${tbl} SET sync_dirty = 1`);
|
||||
console.log(`[Sync] Marked ${total} rows in ${tbl} as dirty for first sync`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch column checks — one PRAGMA per table instead of N separate IPC calls
|
||||
const productCols = (await select("PRAGMA table_info(products)")).map(r => r.name);
|
||||
const addProdCol = async (col, type) => { if (!productCols.includes(col)) await execute(`ALTER TABLE products ADD COLUMN ${col} ${type}`); };
|
||||
|
|
@ -1463,6 +1535,15 @@ async function runMigrations() {
|
|||
reserves TEXT
|
||||
)`);
|
||||
|
||||
// Road points — deduplicated points from all GPS tracks
|
||||
await execute(`CREATE TABLE IF NOT EXISTS road_points (
|
||||
id INTEGER PRIMARY KEY,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
)`);
|
||||
// Spatial index for fast 2m proximity checks
|
||||
await execute(`CREATE INDEX IF NOT EXISTS idx_road_lat_lon ON road_points(lat, lon)`);
|
||||
|
||||
// Water content column for hydration tracking
|
||||
if (!productCols.includes("water_pct")) {
|
||||
await execute("ALTER TABLE products ADD COLUMN water_pct REAL");
|
||||
|
|
|
|||
|
|
@ -2743,9 +2743,7 @@ function renderRecordingInline(container) {
|
|||
<div class="rec-screen">
|
||||
<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>
|
||||
${GPS.recording.state === "waiting_gps" ? '<div id="gps-placeholder" class="gps-placeholder"><div class="gps-placeholder-text">Поиск GPS...</div></div>' : ''}
|
||||
</div>
|
||||
<div class="rec-panel">
|
||||
<div class="rec-hr-row" id="rec-hr-row" style="display:none">
|
||||
|
|
@ -2794,34 +2792,54 @@ function renderRecordingInline(container) {
|
|||
|
||||
// Compass heading + landscape detection from device orientation
|
||||
let currentLandscape = null; // null | 'right' | 'left'
|
||||
let orientationLocked = false; // lock button prevents landscape switching
|
||||
const recScreen = document.querySelector('.rec-screen');
|
||||
|
||||
// Lock button — top-left corner of the map
|
||||
const lockBtn = document.createElement("button");
|
||||
lockBtn.className = "rec-orient-lock";
|
||||
lockBtn.textContent = "A";
|
||||
lockBtn.title = "Авто/Заблокировать ориентацию";
|
||||
lockBtn.onclick = () => {
|
||||
orientationLocked = !orientationLocked;
|
||||
lockBtn.textContent = orientationLocked ? "L" : "A";
|
||||
lockBtn.classList.toggle("locked", orientationLocked);
|
||||
};
|
||||
mapEl.parentElement.appendChild(lockBtn);
|
||||
|
||||
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);
|
||||
// Landscape detection with hysteresis: enter at 65, exit at 35
|
||||
if (!orientationLocked) {
|
||||
const enterThreshold = 65;
|
||||
const exitThreshold = 35;
|
||||
let newLandscape = currentLandscape;
|
||||
if (currentLandscape === null) {
|
||||
// Currently portrait — need strong tilt to enter landscape
|
||||
if (gamma > enterThreshold) newLandscape = 'right';
|
||||
else if (gamma < -enterThreshold) newLandscape = 'left';
|
||||
} else if (currentLandscape === 'right') {
|
||||
// Currently landscape-right — need low tilt to exit
|
||||
if (gamma < exitThreshold) newLandscape = null;
|
||||
} else if (currentLandscape === 'left') {
|
||||
if (gamma > -exitThreshold) newLandscape = null;
|
||||
}
|
||||
|
||||
if (newLandscape !== currentLandscape) {
|
||||
currentLandscape = newLandscape;
|
||||
if (recScreen) {
|
||||
recScreen.classList.remove('landscape-right', 'landscape-left');
|
||||
if (newLandscape) recScreen.classList.add('landscape-' + newLandscape);
|
||||
}
|
||||
const nav = document.querySelector('.bottom-nav');
|
||||
if (nav) nav.style.display = newLandscape ? 'none' : '';
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
}
|
||||
// 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
|
||||
// Map bearing always updates regardless of orientation lock
|
||||
let bearingOffset = 0;
|
||||
if (currentLandscape === 'right') bearingOffset = 90;
|
||||
else if (currentLandscape === 'left') bearingOffset = -90;
|
||||
|
|
@ -4347,6 +4365,33 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Импорт тайлов</div>
|
||||
<div class="settings-item-desc">Загрузить .db файл с тайлами региона</div>
|
||||
</div>
|
||||
<button class="btn-secondary" id="s-import-tiles" style="padding:6px 12px;font-size:13px;">Файл</button>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Скачать с роутера</div>
|
||||
<div class="settings-item-desc">LAN: 192.168.1.1</div>
|
||||
</div>
|
||||
<button class="btn-secondary" id="s-download-tiles" style="padding:6px 12px;font-size:13px;">Регионы</button>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Кэш тайлов</div>
|
||||
<div class="settings-item-desc" id="s-tile-count">...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Просмотр дорог</div>
|
||||
<div class="settings-item-desc">Все проезженные маршруты на карте</div>
|
||||
</div>
|
||||
<button class="btn-secondary" id="s-roads-map" style="padding:6px 12px;font-size:13px;">Открыть</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
|
|
@ -4558,6 +4603,110 @@ async function renderSettings(container) {
|
|||
offToggle.classList.toggle("on", newVal);
|
||||
};
|
||||
|
||||
// Tile cache count
|
||||
TileCache.count().then(n => {
|
||||
const el = container.querySelector("#s-tile-count");
|
||||
if (el) el.textContent = `${n} тайлов в кэше`;
|
||||
});
|
||||
|
||||
// Import tiles from SQLite file
|
||||
// User pushes .db to app dir: adb push tiles_region.db /data/user/0/dev.baldav.app/files/
|
||||
container.querySelector("#s-import-tiles").onclick = async () => {
|
||||
const btn = container.querySelector("#s-import-tiles");
|
||||
// Prompt for path — user can type full path or just filename in app files dir
|
||||
let filePath = prompt("Путь к файлу тайлов (.db).\nНа Android: скопируйте файл через\nadb push file.db /data/user/0/dev.baldav.app/files/\nи введите имя файла:");
|
||||
if (!filePath) return;
|
||||
// If just a filename, resolve relative to app files dir
|
||||
if (!filePath.includes("/")) {
|
||||
try {
|
||||
const appDir = await window.__TAURI__.path.appDataDir();
|
||||
// appDataDir on Android = /data/user/0/pkg/files/dev.baldav.app/
|
||||
// files dir = /data/user/0/pkg/files/
|
||||
// Try both locations
|
||||
const candidates = [
|
||||
appDir + "/" + filePath,
|
||||
appDir + "/../../files/" + filePath,
|
||||
appDir + "/../" + filePath,
|
||||
];
|
||||
filePath = candidates[0]; // try appDataDir first
|
||||
} catch {
|
||||
// desktop: use as-is
|
||||
}
|
||||
}
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "0%...";
|
||||
try {
|
||||
const count = await TileCache.importFromSqlite(filePath, (done, total) => {
|
||||
const pct = Math.round(done / total * 100);
|
||||
btn.textContent = `${pct}% (${done}/${total})`;
|
||||
});
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
showToast(`Импортировано ${count} тайлов`);
|
||||
const el = container.querySelector("#s-tile-count");
|
||||
TileCache.count().then(n => { if (el) el.textContent = `${n} тайлов в кэше`; });
|
||||
} catch (e) {
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
showToast("Ошибка: " + String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// Download tiles from router
|
||||
container.querySelector("#s-download-tiles").onclick = async () => {
|
||||
const regions = [
|
||||
["novocherkassk", "region"],
|
||||
["rostov_city", "Rostov-on-Don"],
|
||||
["rostov_oblast", "Rostov Oblast"],
|
||||
["moscow_city", "Moscow"],
|
||||
["moscow_oblast", "Moscow Oblast"],
|
||||
["spb", "Saint Petersburg"],
|
||||
["krasnodar_city", "Krasnodar"],
|
||||
["krasnodar_krai", "Krasnodar Krai"],
|
||||
["kazan", "Kazan"],
|
||||
["novosibirsk", "Novosibirsk"],
|
||||
["ekaterinburg", "Ekaterinburg"],
|
||||
];
|
||||
const list = regions.map((r, i) => `${i + 1}. ${r[1]}`).join("\n");
|
||||
const choice = prompt("Выберите регион (номер):\n" + list);
|
||||
if (!choice) return;
|
||||
const idx = parseInt(choice) - 1;
|
||||
if (idx < 0 || idx >= regions.length) { showToast("Неверный номер"); return; }
|
||||
const regionKey = regions[idx][0];
|
||||
const regionName = regions[idx][1];
|
||||
const url = `http://192.168.1.1:88/baldav/tiles/tiles_${regionKey}.db`;
|
||||
const invoke = window.__TAURI__.core.invoke;
|
||||
const btn = container.querySelector("#s-download-tiles");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Скачивание...";
|
||||
try {
|
||||
const appDir = await window.__TAURI__.path.appDataDir();
|
||||
const dest = appDir + `/tiles_${regionKey}.db`;
|
||||
const bytes = await invoke("download_file", { url, dest });
|
||||
const sizeMB = (parseInt(bytes) / 1024 / 1024).toFixed(1);
|
||||
btn.textContent = `Импорт (${sizeMB} MB)...`;
|
||||
const count = await TileCache.importFromSqlite(dest, (done, total) => {
|
||||
const pct = Math.round(done / total * 100);
|
||||
btn.textContent = `${pct}% (${done}/${total})`;
|
||||
});
|
||||
btn.textContent = "Регионы";
|
||||
btn.disabled = false;
|
||||
showToast(`${regionName}: ${count} тайлов импортировано`);
|
||||
TileCache.count().then(n => {
|
||||
const el = container.querySelector("#s-tile-count");
|
||||
if (el) el.textContent = `${n} тайлов в кэше`;
|
||||
});
|
||||
} catch (e) {
|
||||
btn.textContent = "Регионы";
|
||||
btn.disabled = false;
|
||||
showToast("Ошибка: " + String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// Roads map
|
||||
container.querySelector("#s-roads-map").onclick = () => showRoadsMap();
|
||||
|
||||
// GPS config inputs
|
||||
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed"];
|
||||
for (const id of gpsInputs) {
|
||||
|
|
@ -5447,6 +5596,101 @@ function formatDate(iso) {
|
|||
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
async function showRoadsMap() {
|
||||
const container = document.querySelector(".screen-content");
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;height:100%;">
|
||||
<div style="display:flex;align-items:center;padding:8px 12px;padding-top:calc(8px + var(--safe-area-top));background:var(--surface);border-bottom:1px solid var(--border);gap:8px;">
|
||||
<button id="roads-back" class="btn-secondary" style="padding:6px 12px;">Назад</button>
|
||||
<span style="flex:1;font-weight:600;">Дороги</span>
|
||||
<span id="roads-info" style="font-size:12px;color:var(--muted);"></span>
|
||||
<button id="roads-rebuild" class="btn-secondary" style="padding:6px 12px;font-size:12px;">Перестроить</button>
|
||||
</div>
|
||||
<div id="roads-map" style="flex:1;"></div>
|
||||
</div>`;
|
||||
|
||||
document.querySelector("#roads-back").onclick = () => renderScreen("settings");
|
||||
|
||||
const mapEl = document.querySelector("#roads-map");
|
||||
L.Icon.Default.imagePath = "lib/";
|
||||
const map = L.map(mapEl, { attributionControl: false }).setView([47.42, 40.08], 13);
|
||||
addZoomDisplay(map);
|
||||
const CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
|
||||
const infoEl = document.querySelector("#roads-info");
|
||||
infoEl.textContent = "Загрузка...";
|
||||
|
||||
// Rebuild button — always visible
|
||||
const rebuildBtn = document.querySelector("#roads-rebuild");
|
||||
async function doRebuild() {
|
||||
rebuildBtn.disabled = true;
|
||||
rebuildBtn.textContent = "...";
|
||||
infoEl.textContent = "Построение (может занять минуту)...";
|
||||
try {
|
||||
const count = await DB.rebuildRoads();
|
||||
infoEl.textContent = `${count} точек`;
|
||||
rebuildBtn.textContent = "Перестроить";
|
||||
rebuildBtn.disabled = false;
|
||||
showRoadsMap();
|
||||
} catch (e) {
|
||||
infoEl.textContent = "Ошибка: " + e;
|
||||
rebuildBtn.textContent = "Перестроить";
|
||||
rebuildBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
rebuildBtn.onclick = doRebuild;
|
||||
|
||||
let pts;
|
||||
try {
|
||||
pts = await DB.getRoadPoints();
|
||||
} catch (e) {
|
||||
// Table missing or schema mismatch — need rebuild
|
||||
infoEl.textContent = "Нужно построить дороги";
|
||||
return;
|
||||
}
|
||||
|
||||
if (pts.length === 0) {
|
||||
infoEl.textContent = "Нет дорог — нажмите Перестроить";
|
||||
return;
|
||||
}
|
||||
|
||||
// Split points into segments by gap (>100m) and activity type
|
||||
const segments = []; // { coords: [[lat,lon],...], atype: "b"|"w" }
|
||||
let seg = [];
|
||||
let segType = pts[0]?.atype || "b";
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const newType = pts[i].atype || "b";
|
||||
if (seg.length > 0) {
|
||||
const prev = seg[seg.length - 1];
|
||||
const dlat = (pts[i].lat - prev[0]) * 111000;
|
||||
const dlon = (pts[i].lon - prev[1]) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||||
if (dlat * dlat + dlon * dlon > 10000 || newType !== segType) {
|
||||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||||
seg = [];
|
||||
segType = newType;
|
||||
}
|
||||
}
|
||||
seg.push([pts[i].lat, pts[i].lon]);
|
||||
}
|
||||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||||
|
||||
// Draw: bike = blue, walk = brown
|
||||
const allCoords = [];
|
||||
for (const s of segments) {
|
||||
const color = s.atype === "w" ? "#8B5E3C" : "#2266aa";
|
||||
L.polyline(s.coords, { color, weight: 4, opacity: 1 }).addTo(map);
|
||||
allCoords.push(...s.coords);
|
||||
}
|
||||
|
||||
if (allCoords.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allCoords).pad(0.05));
|
||||
}
|
||||
const bikeSegs = segments.filter(s => s.atype !== "w").length;
|
||||
const walkSegs = segments.filter(s => s.atype === "w").length;
|
||||
infoEl.textContent = `${pts.length} точек (вело: ${bikeSegs}, пешие: ${walkSegs})`;
|
||||
}
|
||||
|
||||
function addZoomDisplay(map) {
|
||||
const ctrl = L.control({ position: "bottomright" });
|
||||
ctrl.onAdd = () => {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) {
|
|||
const existing = await getTile(key);
|
||||
if (existing) { done++; if (onProgress) onProgress(done, tiles.length); return; }
|
||||
|
||||
const url = `https://tile.openstreetmap.org/${key}.png`;
|
||||
const url = `https://a.tile.openstreetmap.org/${key}.png`;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) {
|
||||
|
|
@ -141,18 +141,18 @@ TileCache.createLayer = function() {
|
|||
} else {
|
||||
// Fetch from network
|
||||
tile.crossOrigin = "";
|
||||
tile.src = `https://tile.openstreetmap.org/${key}.png`;
|
||||
tile.src = `https://a.tile.openstreetmap.org/${key}.png`;
|
||||
tile.onload = () => {
|
||||
fetch(tile.src).then(r => r.blob()).then(b => putTile(key, b)).catch(() => {});
|
||||
done(null, tile);
|
||||
};
|
||||
tile.onerror = () => done(new Error("tile failed"), tile);
|
||||
tile.onerror = () => done(null, tile);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (TileCache.offlineMode) { done(null, tile); return; }
|
||||
tile.src = `https://tile.openstreetmap.org/${key}.png`;
|
||||
tile.src = `https://a.tile.openstreetmap.org/${key}.png`;
|
||||
tile.onload = () => done(null, tile);
|
||||
tile.onerror = () => done(new Error("tile failed"), tile);
|
||||
tile.onerror = () => done(null, tile);
|
||||
});
|
||||
|
||||
return tile;
|
||||
|
|
@ -174,5 +174,36 @@ TileCache.loadOfflineMode = function() {
|
|||
|
||||
TileCache.loadOfflineMode();
|
||||
|
||||
// Import tiles from a SQLite file (packed by tools/pack_tiles.js) into IndexedDB
|
||||
// path: filesystem path to the .db file
|
||||
// onProgress(done, total): callback
|
||||
TileCache.importFromSqlite = async function(path, onProgress) {
|
||||
const invoke = window.__TAURI__.core.invoke;
|
||||
const BATCH = 100;
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
let imported = 0;
|
||||
|
||||
while (true) {
|
||||
const raw = await invoke("import_tiles", { path, offset, batchSize: BATCH });
|
||||
const resp = JSON.parse(raw);
|
||||
total = resp.total;
|
||||
|
||||
for (const [key, b64] of resp.tiles) {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
await putTile(key, new Blob([bytes], { type: "image/png" }));
|
||||
imported++;
|
||||
}
|
||||
|
||||
if (onProgress) onProgress(imported, total);
|
||||
if (!resp.has_more) break;
|
||||
offset += BATCH;
|
||||
}
|
||||
|
||||
return imported;
|
||||
};
|
||||
|
||||
window.TileCache = TileCache;
|
||||
console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);
|
||||
|
|
|
|||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
|
|
@ -139,8 +139,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.270"
|
||||
version = "0.1.279"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
"lazy_static",
|
||||
"rusqlite",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.270"
|
||||
version = "0.1.279"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
@ -19,6 +19,7 @@ rusqlite = { version = "0.31", features = ["bundled"] }
|
|||
|
||||
lazy_static = "1"
|
||||
ureq = "3"
|
||||
base64 = "0.22"
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
btleplug = { version = "0.11", features = ["serde"] }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,134 @@ use serde::Serialize;
|
|||
#[allow(unused, dead_code)]
|
||||
mod ble;
|
||||
|
||||
// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content)
|
||||
#[tauri::command]
|
||||
fn http_request(url: String, method: String, body: Option<String>) -> Result<String, String> {
|
||||
let mut resp = match method.to_uppercase().as_str() {
|
||||
"POST" => {
|
||||
let b = body.unwrap_or_default();
|
||||
ureq::post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.send(b.as_bytes())
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
_ => ureq::get(&url).call().map_err(|e| e.to_string())?,
|
||||
};
|
||||
resp.body_mut().read_to_string().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// Download binary file from URL to local path (for tile DB import from router)
|
||||
// read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads
|
||||
#[tauri::command]
|
||||
fn download_file(url: String, dest: String) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let resp = ureq::get(&url).call().map_err(|e| format!("fetch: {e}"))?;
|
||||
let mut reader = resp.into_body().into_reader();
|
||||
let mut data = Vec::new();
|
||||
reader.read_to_end(&mut data).map_err(|e| format!("read: {e}"))?;
|
||||
std::fs::write(&dest, &data).map_err(|e| format!("write: {e}"))?;
|
||||
Ok(format!("{}", data.len()))
|
||||
}
|
||||
|
||||
// Rebuild road_points from all GPS tracks with 2m deduplication.
|
||||
// Runs entirely in Rust/SQLite — no Tauri SQL plugin row limits.
|
||||
#[tauri::command]
|
||||
fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, String> {
|
||||
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}"))?;
|
||||
|
||||
db.execute("DROP TABLE IF EXISTS road_points", []).ok();
|
||||
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT)", [])
|
||||
.map_err(|e| format!("create: {e}"))?;
|
||||
db.execute("CREATE INDEX idx_road_lat_lon ON road_points(lat, lon)", [])
|
||||
.map_err(|e| format!("index: {e}"))?;
|
||||
|
||||
// 2m in degrees: lat ~0.000018, lon ~0.000027 at 47N
|
||||
let d_lat: f64 = 0.000018;
|
||||
let d_lon: f64 = 0.000027;
|
||||
|
||||
let mut read_stmt = db.prepare(
|
||||
"SELECT p.lat, p.lon, s.activity_type FROM gps_points p
|
||||
JOIN gps_sessions s ON s.id = p.session_id
|
||||
WHERE p.speed_ms >= ?1
|
||||
ORDER BY p.session_id, p.seq"
|
||||
).map_err(|e| format!("prepare read: {e}"))?;
|
||||
|
||||
let points: Vec<(f64, f64, String)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
|
||||
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default()))
|
||||
}).map_err(|e| format!("query: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
drop(read_stmt);
|
||||
|
||||
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
|
||||
|
||||
let mut inserted = 0u32;
|
||||
for (lat, lon, atype) in &points {
|
||||
let exists: bool = db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM road_points WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4)",
|
||||
rusqlite::params![lat - d_lat, lat + d_lat, lon - d_lon, lon + d_lon],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
db.execute(
|
||||
"INSERT INTO road_points (lat, lon, atype) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![lat, lon, atype],
|
||||
).map_err(|e| format!("insert: {e}"))?;
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
|
||||
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
|
||||
}
|
||||
|
||||
// Read road_points in batches — bypasses Tauri SQL plugin's ~28K row limit
|
||||
#[tauri::command]
|
||||
fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
// Find main DB — same path resolution as import_seed_products
|
||||
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}"))?;
|
||||
let total: u32 = db.query_row(
|
||||
"SELECT count(*) FROM road_points", [], |r| r.get(0)
|
||||
).unwrap_or(0);
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT lat, lon, COALESCE(atype,'bike') FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
|
||||
).map_err(|e| format!("prepare: {e}"))?;
|
||||
let mut points = Vec::new();
|
||||
let rows = stmt.query_map(rusqlite::params![batch_size, offset], |row| {
|
||||
let lat: f64 = row.get(0)?;
|
||||
let lon: f64 = row.get(1)?;
|
||||
let atype: String = row.get(2)?;
|
||||
Ok((lat, lon, atype))
|
||||
}).map_err(|e| format!("query: {e}"))?;
|
||||
for row in rows {
|
||||
let (lat, lon, atype) = row.map_err(|e| format!("row: {e}"))?;
|
||||
let t = if atype == "walk" { "w" } else { "b" };
|
||||
points.push(format!("[{:.7},{:.7},\"{}\"]", lat, lon, t));
|
||||
}
|
||||
let has_more = offset + batch_size < total;
|
||||
Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppInfo {
|
||||
version: String,
|
||||
|
|
@ -100,6 +228,30 @@ fn save_file(app: tauri::AppHandle, filename: String, content: String) -> Result
|
|||
Ok("save_requested".to_string())
|
||||
}
|
||||
|
||||
// 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<String, String> {
|
||||
let db = rusqlite::Connection::open(&path).map_err(|e| format!("open: {e}"))?;
|
||||
let total: u32 = db.query_row("SELECT count(*) FROM tiles", [], |r| r.get(0))
|
||||
.map_err(|e| format!("count: {e}"))?;
|
||||
let mut stmt = db.prepare("SELECT key, data FROM tiles LIMIT ?1 OFFSET ?2")
|
||||
.map_err(|e| format!("prepare: {e}"))?;
|
||||
let mut tiles = Vec::new();
|
||||
let rows = stmt.query_map(rusqlite::params![batch_size, offset], |row| {
|
||||
let key: String = row.get(0)?;
|
||||
let data: Vec<u8> = row.get(1)?;
|
||||
Ok((key, data))
|
||||
}).map_err(|e| format!("query: {e}"))?;
|
||||
for row in rows {
|
||||
let (key, data) = row.map_err(|e| format!("row: {e}"))?;
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
|
||||
tiles.push(format!("[\"{}\",\"{}\"]", key, b64));
|
||||
}
|
||||
let has_more = offset + batch_size < total;
|
||||
Ok(format!("{{\"tiles\":[{}],\"total\":{},\"has_more\":{}}}", tiles.join(","), total, has_more))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_seed_products(app: tauri::AppHandle) -> Result<String, String> {
|
||||
// Find seed DB — try multiple locations
|
||||
|
|
@ -533,7 +685,7 @@ pub fn run() {
|
|||
.add_migrations("sqlite:baldav.db", migrations)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, 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, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.270",
|
||||
"version": "0.1.279",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue