Tauri 2 + pure JS + SQLite personal health tracking app. Nutrition module: - 9299 products in DB (Magnit SSR scraping + KDV) - Product search with case-insensitive Cyrillic (name_lower column) - Food logging from product DB or manual entry - Supplement tracking with per-pill micronutrients - Micronutrient % strip on Today screen Fitness/GPS module: - FitoTrack .ftb and GPX import with seq-based ordering - Session detail view: map, speed/elevation charts (Chart.js) - Offline tile caching (IndexedDB) with region download - Track rendering with colored time-gradient points UI: - Fullscreen modals with back-button (history API) - Dark theme, desktop-responsive layout - 5 tabs: Today, Products, Track, Supplements, Profile Tools: - Magnit/KDV HTML parsers - Dedup import with discrepancy detection - FitoTrack backup analysis
161 lines
4.9 KiB
JavaScript
161 lines
4.9 KiB
JavaScript
// Offline tile cache — download OSM tiles to IndexedDB, serve locally
|
|
// Exposes window.TileCache
|
|
|
|
const TileCache = {};
|
|
const DB_NAME = "baldav_tiles";
|
|
const STORE = "tiles";
|
|
let _db = null;
|
|
|
|
function openDB() {
|
|
if (_db) return Promise.resolve(_db);
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
req.onsuccess = () => { _db = req.result; resolve(_db); };
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function putTile(key, blob) {
|
|
return openDB().then(db => new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, "readwrite");
|
|
tx.objectStore(STORE).put(blob, key);
|
|
tx.oncomplete = resolve;
|
|
tx.onerror = () => reject(tx.error);
|
|
}));
|
|
}
|
|
|
|
function getTile(key) {
|
|
return openDB().then(db => new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, "readonly");
|
|
const req = tx.objectStore(STORE).get(key);
|
|
req.onsuccess = () => resolve(req.result || null);
|
|
req.onerror = () => reject(req.error);
|
|
}));
|
|
}
|
|
|
|
// Count cached tiles
|
|
TileCache.count = async function() {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, "readonly");
|
|
const req = tx.objectStore(STORE).count();
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
};
|
|
|
|
// Clear all cached tiles
|
|
TileCache.clear = async function() {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, "readwrite");
|
|
tx.objectStore(STORE).clear();
|
|
tx.oncomplete = resolve;
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
};
|
|
|
|
// Calculate tile coordinates for a bounding box at a given zoom
|
|
function getTileRange(bounds, zoom) {
|
|
const n = Math.pow(2, zoom);
|
|
const toTile = (lat, lon) => ({
|
|
x: Math.floor((lon + 180) / 360 * n),
|
|
y: Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n),
|
|
});
|
|
const sw = toTile(bounds.getSouth(), bounds.getWest());
|
|
const ne = toTile(bounds.getNorth(), bounds.getEast());
|
|
return {
|
|
xMin: Math.min(sw.x, ne.x), xMax: Math.max(sw.x, ne.x),
|
|
yMin: Math.min(sw.y, ne.y), yMax: Math.max(sw.y, ne.y),
|
|
};
|
|
}
|
|
|
|
// Count tiles needed for area + zoom range
|
|
TileCache.countTilesForArea = function(bounds, zoomMin, zoomMax) {
|
|
let total = 0;
|
|
for (let z = zoomMin; z <= zoomMax; z++) {
|
|
const r = getTileRange(bounds, z);
|
|
total += (r.xMax - r.xMin + 1) * (r.yMax - r.yMin + 1);
|
|
}
|
|
return total;
|
|
};
|
|
|
|
// Download tiles for current map bounds
|
|
// onProgress(downloaded, total) callback
|
|
TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) {
|
|
const tiles = [];
|
|
for (let z = zoomMin; z <= zoomMax; z++) {
|
|
const r = getTileRange(bounds, z);
|
|
for (let x = r.xMin; x <= r.xMax; x++) {
|
|
for (let y = r.yMin; y <= r.yMax; y++) {
|
|
tiles.push({ z, x, y });
|
|
}
|
|
}
|
|
}
|
|
|
|
let done = 0;
|
|
const PARALLEL = 4;
|
|
|
|
async function downloadOne(tile) {
|
|
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
|
// Skip if already cached
|
|
const existing = await getTile(key);
|
|
if (existing) { done++; if (onProgress) onProgress(done, tiles.length); return; }
|
|
|
|
const url = `https://tile.openstreetmap.org/${key}.png`;
|
|
try {
|
|
const resp = await fetch(url);
|
|
if (resp.ok) {
|
|
const blob = await resp.blob();
|
|
await putTile(key, blob);
|
|
}
|
|
} catch { /* skip failed tiles */ }
|
|
done++;
|
|
if (onProgress) onProgress(done, tiles.length);
|
|
}
|
|
|
|
// Process in parallel batches
|
|
for (let i = 0; i < tiles.length; i += PARALLEL) {
|
|
const batch = tiles.slice(i, i + PARALLEL);
|
|
await Promise.all(batch.map(downloadOne));
|
|
}
|
|
|
|
return done;
|
|
};
|
|
|
|
// Custom Leaflet TileLayer that checks IndexedDB first
|
|
TileCache.createLayer = function() {
|
|
return L.TileLayer.extend({
|
|
createTile: function(coords, done) {
|
|
const tile = document.createElement("img");
|
|
const key = `${coords.z}/${coords.x}/${coords.y}`;
|
|
|
|
getTile(key).then(blob => {
|
|
if (blob) {
|
|
tile.src = URL.createObjectURL(blob);
|
|
done(null, tile);
|
|
} else {
|
|
// Fallback to network
|
|
tile.crossOrigin = "";
|
|
tile.src = `https://tile.openstreetmap.org/${key}.png`;
|
|
// Cache after download
|
|
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);
|
|
}
|
|
}).catch(() => {
|
|
tile.src = `https://tile.openstreetmap.org/${key}.png`;
|
|
tile.onload = () => done(null, tile);
|
|
tile.onerror = () => done(new Error("tile failed"), tile);
|
|
});
|
|
|
|
return tile;
|
|
}
|
|
});
|
|
};
|
|
|
|
window.TileCache = TileCache;
|
|
console.log("[tilecache.js] window.TileCache ready");
|