Share image: pick which fields go on the picture (remembered), add max speed

This commit is contained in:
4aiman 2026-09-14 13:07:32 +03:00
commit d9a3254ffb
4 changed files with 62 additions and 13 deletions

View file

@ -23,7 +23,7 @@
- **Непроезжие места (nogo)** — красные круги, которые роутинг ОБЪЕЗЖАЕТ. Ставятся кнопкой **⛔**: в планировщике — тапом по карте/линии, на карте записи — «здесь нет проезда» (текущее место). Показываются на картах планировщика, записи и детали трека; тап по кругу — убрать. Избегание работает и через домашний RouteServer, и встроенным движком офлайн (BRouter nogopoints). Web-BRouter умеет nogo сам.
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё».
- **Похвастаться (шэр-картинка)** — в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. Модалка предпросмотра с переключателем и кнопками **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
- **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.

View file

@ -43,6 +43,19 @@
- Побочно: `formatDistance` теперь округляет метры (было «966.145...м»).
- На теле (встроенный движок + реальный walk) - после пересборки APK.
## Доработки по фидбеку с телефона (0.1.455+)
- Детект ходьбы: перекраска на треке подтверждена. Калории за пеший кусок уже честные -
`detectWalk` и калорийная ветка `computeStats` (walkBikeMs) используют один порог 30-rpm,
так что крашеный кусок считается пешим (net walking MET), не вело-физикой.
- Привязка к проеханному на теле подтверждена.
- «Похвастаться» вне стека окон (свайп «назад» листал экраны позади) - `showShareModal`
теперь через `winPush("share", ...)`; «Закрыть» и системный «назад» снимают именно её.
- «Похвастаться»: выбор полей картинки. `buildShareImage` принимает `opts.fields`; раскладка
адаптивная (крупные статы стопкой, при выключенном треке блок центрируется). В модалке 8
галочек (форма трека, дистанция, время, ср./макс. скорость, набор, калории, дата), выбор в
`localStorage.baldav_share_fields`. Проверено на десктопе (Xvfb): галочки рисуются, перерисовка
и адаптив работают, «Закрыть» возвращает в деталь без листания.
## Замечания на будущее
- Ридден-нога без высот (тонкая); при желании нести среднюю высоту узла для оковки.
- `walk` на десктопе не заполнен (не пересчитывал) - граф пока включает все точки; на реальном

View file

@ -1191,7 +1191,7 @@ select {
}
.share-preview {
max-width: 100%;
max-height: 68vh;
max-height: 54vh;
object-fit: contain;
border-radius: 10px;
/* checkerboard so a transparent PNG reads as transparent */
@ -1203,6 +1203,15 @@ select {
}
.share-toggle, .share-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.share-toggle button, .share-actions button { padding: 9px 14px; }
.share-fields {
display: flex; flex-wrap: wrap; gap: 6px 12px; justify-content: center;
max-width: 520px;
}
.share-field {
display: inline-flex; align-items: center; gap: 5px;
font-size: 13px; color: #ddd; white-space: nowrap; cursor: pointer;
}
.share-field input { width: 16px; height: 16px; flex: none; }
.rec-map-wrap {
flex: 1;

View file

@ -3338,9 +3338,17 @@ function _projectTrack(points, w, h, pad) {
let ox = (w - spanX * scale) / 2, oy = (h - spanY * scale) / 2;
return points.map(p => ({ x: ox + (p.lon - minLo) * kx * scale, y: oy + (maxLa - p.lat) * scale }));
}
// Build the share PNG dataURL. opts.bg: "transparent" | "photo".
// Which fields the share image can show, and their defaults. `f` is the per-image
// selection (opts.fields), falling back to defaults so an unset key stays on.
let SHARE_FIELDS = ["track", "distance", "time", "speed", "maxspeed", "elev", "kcal", "date"];
let SHARE_FIELD_LABELS = { track: "Форма трека", distance: "Дистанция", time: "Время", speed: "Ср. скорость", maxspeed: "Макс. скорость", elev: "Набор высоты", kcal: "Калории", date: "Дата" };
let SHARE_FIELD_DEFAULTS = { track: true, distance: true, time: true, speed: true, maxspeed: false, elev: true, kcal: true, date: true };
function shareFieldOn(fields, key) { return fields && key in fields ? !!fields[key] : SHARE_FIELD_DEFAULTS[key]; }
// Build the share PNG dataURL. opts.bg: "transparent" | "photo"; opts.fields: per-field on/off.
async function buildShareImage(session, stats, points, opts) {
opts = opts || {};
let f = opts.fields;
let W = 1080, H = 1920;
let cv = document.createElement("canvas"); cv.width = W; cv.height = H;
let ctx = cv.getContext("2d");
@ -3358,8 +3366,9 @@ async function buildShareImage(session, stats, points, opts) {
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.55)");
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
let hasTrack = shareFieldOn(f, "track") && points && points.length >= 2;
// Track shape (top ~42%).
if (points && points.length >= 2) {
if (hasTrack) {
let boxTop = 90, boxH = Math.round(H * 0.42);
let pts = _projectTrack(points.map(p => ({ lat: p.lat, lon: p.lon })), W, boxH, 130);
let col = (window.GPS && GPS.config && GPS.config.trackColor) || "#2f6bff";
@ -3377,18 +3386,24 @@ async function buildShareImage(session, stats, points, opts) {
ctx.shadowBlur = 5; ctx.fillStyle = "rgba(255,255,255,0.85)"; ctx.font = "400 42px system-ui, -apple-system, sans-serif"; ctx.fillText(label, cx, y + 56);
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
}
let y0 = Math.round(H * 0.55);
bigStat(y0, (stats.distance / 1000).toFixed(1) + " km", "Distance");
bigStat(y0 + 250, _shareDuration(stats.duration || 0), "Time");
bigStat(y0 + 500, (stats.avgSpeed || 0).toFixed(1) + " km/h", "Speed");
// Collect enabled stats; lay them out adaptively so turning some off leaves no gaps.
let big = [];
if (shareFieldOn(f, "distance")) big.push([(stats.distance / 1000).toFixed(1) + " km", "Distance"]);
if (shareFieldOn(f, "time")) big.push([_shareDuration(stats.duration || 0), "Time"]);
if (shareFieldOn(f, "speed")) big.push([(stats.avgSpeed || 0).toFixed(1) + " km/h", "Speed"]);
if (shareFieldOn(f, "maxspeed") && stats.maxSpeed != null) big.push([(stats.maxSpeed || 0).toFixed(1) + " km/h", "Max speed"]);
let footer = [];
if (stats.elevGain != null) footer.push("↑ " + Math.round(stats.elevGain) + " m");
if (stats.kcal != null) footer.push(Math.round(stats.kcal) + " kcal");
if (session && session.date) footer.push(session.date);
if (shareFieldOn(f, "elev") && stats.elevGain != null) footer.push("↑ " + Math.round(stats.elevGain) + " m");
if (shareFieldOn(f, "kcal") && stats.kcal != null) footer.push(Math.round(stats.kcal) + " kcal");
if (shareFieldOn(f, "date") && session && session.date) footer.push(session.date);
// Position the stats+footer block: just under the track when it's shown, else centred.
let blockH = big.length * 250 + (footer.length ? 90 : 0);
let y0 = hasTrack ? Math.round(H * 0.55) : Math.round((H - blockH) / 2 + 90);
big.forEach((s, i) => bigStat(y0 + i * 250, s[0], s[1]));
if (footer.length) {
ctx.textAlign = "center"; ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 6;
ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "400 36px system-ui, -apple-system, sans-serif";
ctx.fillText(footer.join(" · "), cx, y0 + 650); ctx.shadowBlur = 0;
ctx.fillText(footer.join(" · "), cx, y0 + big.length * 250 + 40); ctx.shadowBlur = 0;
}
return cv.toDataURL("image/png");
}
@ -3403,11 +3418,18 @@ async function exportShareImage(dataUrl, filename, action) {
}
// Preview modal with a transparent/photo toggle + Share/Save.
function showShareModal(session, stats, points) {
// Per-field on/off, remembered across shares. Missing keys fall back to defaults.
let fields = {};
try { fields = JSON.parse(localStorage.getItem("baldav_share_fields") || "{}") || {}; } catch { fields = {}; }
let fieldChecks = SHARE_FIELDS.map(k =>
`<label class="share-field"><input type="checkbox" data-field="${k}" ${shareFieldOn(fields, k) ? "checked" : ""}>${SHARE_FIELD_LABELS[k]}</label>`
).join("");
let ov = document.createElement("div");
ov.className = "share-modal";
ov.innerHTML = `
<div class="share-box">
<img id="share-img" class="share-preview" alt="">
<div class="share-fields">${fieldChecks}</div>
<div class="share-toggle">
<button id="share-bg-t" class="btn-secondary active">Прозрачный</button>
<button id="share-bg-p" class="btn-secondary">С фото</button>
@ -3435,7 +3457,12 @@ function showShareModal(session, stats, points) {
let bg = "transparent", dataUrl = null;
let img = ov.querySelector("#share-img");
let bt = ov.querySelector("#share-bg-t"), bp = ov.querySelector("#share-bg-p");
async function render() { dataUrl = await buildShareImage(session, stats, points, { bg }); img.src = dataUrl; }
async function render() { dataUrl = await buildShareImage(session, stats, points, { bg, fields }); img.src = dataUrl; }
ov.querySelectorAll(".share-field input").forEach(cb => cb.onchange = () => {
fields[cb.dataset.field] = cb.checked;
try { localStorage.setItem("baldav_share_fields", JSON.stringify(fields)); } catch {}
render();
});
bt.onclick = () => { bg = "transparent"; bt.classList.add("active"); bp.classList.remove("active"); render(); };
bp.onclick = () => {
if (!localStorage.getItem("baldav_share_bg")) { showToast("Подложка не выбрана - задайте в Настройках"); return; }