GPS track rendering + offline maps + UI improvements

Track rendering: seq-based ordering matching FitoTrack v15.6
(insertion order, not relativeTime sort which scatters AGPS points).
Colored time-gradient points with dark contour on track map.

Offline maps: tile cache with online/offline toggle in settings,
download script (tools/download_tiles.js) with parallel curl.

UI: removed useless map from GPS screen, click-to-zoom on detail map,
locate-me button, zoom level display, desktop layout improvements.
This commit is contained in:
4aiman 2026-05-13 17:53:19 +03:00
commit ff12646844
3 changed files with 91 additions and 50 deletions

View file

@ -879,20 +879,12 @@ body {
z-index: 1;
}
.gps-controls {
.gps-header {
padding: 10px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.gps-stats {
display: flex;
justify-content: space-around;
padding: 6px 0 10px;
font-size: 16px;
font-weight: 600;
}
.gps-btns {
display: flex;
gap: 8px;
@ -1040,6 +1032,15 @@ body {
font-size: 16px;
}
.zoom-display {
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
.gps-download-status {
text-align: center;
font-size: 12px;

View file

@ -674,13 +674,7 @@ async function renderGpsScreen(container) {
container.innerHTML = `
<div class="gps-screen">
<div id="gps-map" class="gps-map"></div>
<div class="gps-controls">
<div class="gps-stats" id="gps-stats">
<span id="gs-dist">0 м</span>
<span id="gs-speed">0 км/ч</span>
<span id="gs-time">0:00</span>
</div>
<div class="gps-header">
<div class="gps-btns">
<select id="gps-type" class="gps-type-sel">
<option value="bike">Вело</option>
@ -720,32 +714,6 @@ async function renderGpsScreen(container) {
</div>
</div>`;
// Init Leaflet map
setTimeout(() => {
const mapEl = container.querySelector("#gps-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
gpsMap = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 13);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(gpsMap);
L.polyline([], { color: "#ffffff", weight: 6, opacity: 0.8 }).addTo(gpsMap); // border
gpsTrailLine = L.polyline([], { color: "#bb86fc", weight: 3 }).addTo(gpsMap);
// If recording, restore trail
if (GPS.recording.active && GPS.recording.points.length > 0) {
const coords = GPS.recording.points.map(p => [p.lat, p.lon]);
gpsTrailLine.setLatLngs(coords);
gpsMap.fitBounds(gpsTrailLine.getBounds().pad(0.1));
} else {
// Try to center on user location
navigator.geolocation?.getCurrentPosition(
pos => gpsMap.setView([pos.coords.latitude, pos.coords.longitude], 14),
() => {}, { timeout: 5000 }
);
}
}, 100);
// Recording button
container.querySelector("#gps-rec-btn").onclick = async () => {
@ -890,11 +858,11 @@ function showMapDownloadModal() {
</div>
<div class="form-group">
<label>Мин. зум</label>
<input id="m-zmin" type="number" value="10" min="1" max="18" />
<input id="m-zmin" type="number" value="12" min="1" max="18" />
</div>
<div class="form-group">
<label>Макс. зум</label>
<input id="m-zmax" type="number" value="16" min="1" max="18" />
<input id="m-zmax" type="number" value="15" min="1" max="18" />
</div>
<div id="m-tile-count" style="color:var(--muted);font-size:12px;margin-top:4px"></div>`,
async overlay => {
@ -1069,6 +1037,7 @@ async function showSessionDetail(sessionId) {
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, zoomControl: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
@ -1142,6 +1111,33 @@ async function showSessionDetail(sessionId) {
return fsDiv;
};
fsBtn.addTo(map);
// Locate me button
const locBtn = L.control({ position: "topleft" });
locBtn.onAdd = () => {
const div = L.DomUtil.create("div", "sd-map-fs-btn");
div.innerHTML = "&#x25CE;";
div.title = "Моё местоположение";
div.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => {
const ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, 15);
L.circleMarker(ll, { radius: 8, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 0.8, weight: 2 }).addTo(map);
},
err => alert("GPS: " + err.message),
{ enableHighAccuracy: true, timeout: 10000 }
);
};
return div;
};
locBtn.addTo(map);
// Click map to toggle fullscreen (same as button)
map.on("click", () => {
if (!mapEl.classList.contains("sd-map-full")) toggleMapFs();
});
}, 200);
// Render speed chart
@ -1444,18 +1440,34 @@ async function renderSettings(container) {
<input id="s-bmr-est" type="number" inputmode="numeric"
value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>
<div class="form-group" style="margin-top:20px">
<label>Карты</label>
<div class="type-chips">
<button class="type-chip ${TileCache.offlineMode ? "" : "selected"}" data-map="online">Онлайн</button>
<button class="type-chip ${TileCache.offlineMode ? "selected" : ""}" data-map="offline">Оффлайн</button>
</div>
<span style="font-size:11px;color:var(--muted)">Оффлайн: тайлы только из кэша, интернет не используется</span>
</div>
<button id="save-profile" class="btn-primary full-width">Сохранить</button>
</div>`;
let selectedSex = p.sex ?? "m";
container.querySelectorAll(".type-chip").forEach(btn => {
container.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
container.querySelectorAll(".type-chip").forEach(b => b.classList.remove("selected"));
container.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
container.querySelectorAll("[data-map]").forEach(btn => {
btn.onclick = () => {
container.querySelectorAll("[data-map]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
TileCache.setOfflineMode(btn.dataset.map === "offline");
};
});
container.querySelector("#save-profile").onclick = async () => {
const height = parseInt(container.querySelector("#s-height").value);
const year = parseInt(container.querySelector("#s-year").value);
@ -1564,6 +1576,17 @@ function formatDate(iso) {
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
}
function addZoomDisplay(map) {
const ctrl = L.control({ position: "bottomright" });
ctrl.onAdd = () => {
const div = L.DomUtil.create("div", "zoom-display");
div.textContent = map.getZoom();
map.on("zoomend", () => { div.textContent = map.getZoom(); });
return div;
};
ctrl.addTo(map);
}
function pickFile(accept, callback, asBinary) {
const input = document.createElement("input");
input.type = "file";

View file

@ -95,7 +95,7 @@ TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) {
}
let done = 0;
const PARALLEL = 4;
const PARALLEL = 12;
async function downloadOne(tile) {
const key = `${tile.z}/${tile.x}/${tile.y}`;
@ -135,11 +135,13 @@ TileCache.createLayer = function() {
if (blob) {
tile.src = URL.createObjectURL(blob);
done(null, tile);
} else if (TileCache.offlineMode) {
// Offline: show empty tile
done(null, tile);
} else {
// Fallback to network
// Fetch from 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);
@ -147,6 +149,7 @@ TileCache.createLayer = function() {
tile.onerror = () => done(new Error("tile failed"), tile);
}
}).catch(() => {
if (TileCache.offlineMode) { done(null, tile); return; }
tile.src = `https://tile.openstreetmap.org/${key}.png`;
tile.onload = () => done(null, tile);
tile.onerror = () => done(new Error("tile failed"), tile);
@ -157,5 +160,19 @@ TileCache.createLayer = function() {
});
};
// Offline mode — when true, tiles only served from cache, never fetched from network
TileCache.offlineMode = false;
TileCache.setOfflineMode = function(val) {
TileCache.offlineMode = !!val;
localStorage.setItem("baldav_offline_maps", val ? "1" : "0");
};
TileCache.loadOfflineMode = function() {
TileCache.offlineMode = localStorage.getItem("baldav_offline_maps") === "1";
};
TileCache.loadOfflineMode();
window.TileCache = TileCache;
console.log("[tilecache.js] window.TileCache ready");
console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);