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:
parent
1affadb05d
commit
ff12646844
3 changed files with 91 additions and 50 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = "◎";
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue