Share image (Похвастаться): render + preview modal + detail button

Stage 1 (pure JS). buildShareImage() draws a 9:16 story PNG: track shape (colored
glowing line), big Distance/Time/Speed + small elevation/kcal/date, on a
transparent or photo background. showShareModal() previews it with a
transparent/photo toggle and Share/Save buttons; a 'Похвастаться' button in the
track detail opens it. Native export (SAF save + share intent) and the settings
background picker come in Stage 2 (needs an APK rebuild); on desktop the export
buttons toast an error.
This commit is contained in:
4aiman 2026-09-14 10:25:34 +03:00
commit 69f6fdac18
5 changed files with 281 additions and 0 deletions

View file

@ -22,6 +22,7 @@
- **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Достраиваются только реальные длинные потери сигнала (по умолчанию разрыв > 60с и хорда > 150м); пропуск не достраивается, если дорога заметно длиннее хорды (крюк, > 2×) или почти совпадает с прямой (мостить нечего) — чтобы не перерисовывать нормальные участки объездами.
- **Непроезжие места (nogo)** — красные круги, которые роутинг ОБЪЕЗЖАЕТ. Ставятся кнопкой **⛔**: в планировщике — тапом по карте/линии, на карте записи — «здесь нет проезда» (текущее место). Показываются на картах планировщика, записи и детали трека; тап по кругу — убрать. Избегание работает и через домашний RouteServer, и встроенным движком офлайн (BRouter nogopoints). Web-BRouter умеет nogo сам.
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
- **Похвастаться (шэр-картинка)** — в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (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

@ -0,0 +1,96 @@
# План: генератор шэр-картинки трека («Похвастаться»)
## Context (зачем)
Пользователь хочет делиться красивой картинкой поездки: форма трека + крупные статы
(как на присланном скрине). Три части: (1) картинка с формой трека и статами, прозрачная
или на фото-подложке; (2) настраиваемая подложка (фото) в настройках; (3) кнопка
«Похвастаться» в детали трека, которая даёт **Поделиться** (системный шэр) и **Сохранить файл**.
Итог решений (согласовано): экспорт — и «Поделиться», и «Сохранить»; в диалоге переключатель
прозрачный/фото; статы на картинке — дистанция, время, средняя скорость и (мельче) набор
высоты, ккал, дата.
Рисуем на `<canvas>` в WebView (чистый JS). Бинарный вывод PNG в WebView невозможен (blob
download не работает), поэтому сохранение/шэр — нативно (Kotlin), плюс чтение выбранного фото
в base64 — Rust. Нужна пересборка APK. FileProvider уже есть в манифесте, `file_paths` содержит
`cache-path "."` — годится для шэра.
## Часть A — Рендер картинки на canvas (JS, `frontend/js/main.js` + `styles.css`)
- Новая функция `buildShareImage(session, stats, points, opts)` → возвращает dataURL PNG.
Холст 1080×1920 (сторис 9:16):
- Фон: `opts.bg==="photo"` и есть сохранённая подложка → нарисовать её cover-fit + лёгкий
тёмный градиент снизу для читаемости; иначе прозрачный (холст не заливать).
- Форма трека: точки трека спроецировать (равнопрямоугольная проекция в масштабе трека,
паддинг), нарисовать линией цвета трека (`GPS.config.trackColor`) с мягким свечением, в
верхней ~40% части.
- Статы: крупно по центру — Дистанция / Время / Средняя скорость (значение + подпись
English: Distance/Time/Speed, «km»/«km/h», 1 знак) как на скрине; мелкой строкой снизу —
«↑ N m · K kcal · дата». Белый текст с тенью/обводкой (читаемо и на прозрачном, и на фото).
- Мелкий локальный форматтер времени «1h 29min»/«29 min» (не `GPS.formatDuration`, там h:mm:ss).
- Модалка предпросмотра `showShareModal(session, stats, points)`: показывает `<img src=dataURL>`,
переключатель **Прозрачный / С фото** (перерисовывает), кнопки **Поделиться**, **Сохранить**,
**Закрыть**. Паттерн модалки — как `appConfirm` (main.js:1040).
## Часть B — Кнопка «Похвастаться» в детали трека
- В `showSessionDetail` рядом с `#sd-export` (main.js:5011-5015) добавить
`<button id="sd-share">Похвастаться</button>` → `showShareModal(session, stats, points)`
(переменные `session`/`stats`/`points` уже в области видимости деталей).
## Часть C — Нативный вывод PNG (Kotlin + Rust, пересборка APK)
- Rust `export_image(app, filename, base64, action)` (action `save`|`share`): пишет base64 в
`export_image.b64` (+ app root) и команду `exportimg:<action>:<filename>` в `gps_command.txt`
(тот же файловый мост, что `save_file`, lib.rs:515). Зарегистрировать в `generate_handler`.
- Kotlin (`MainActivity.kt`): в обработчике команд ветка `exportimg:` — прочитать
`export_image.b64`, `Base64.decode`:
- `save` → новый `saveImageLauncher = CreateDocument("image/png")` пишет байты (по образцу
`saveFileLauncher`, MainActivity.kt:55).
- `share` → записать в `cacheDir/<filename>`, `FileProvider.getUriForFile(ctx,
"${applicationId}.fileprovider", f)`, `Intent(ACTION_SEND) type=image/png EXTRA_STREAM=uri
FLAG_GRANT_READ_URI_PERMISSION`, `startActivity(createChooser)`.
- JS: `BRouter`-подобных нет; звать напрямую `invoke("export_image", {filename, base64, action})`.
base64 = `dataURL.split(",")[1]`.
## Часть D — Настраиваемая подложка (настройки)
- Rust `read_file_base64(app, path)` → вернуть содержимое файла base64 (для чтения выбранного
фото; `pick_file_open` уже копирует выбранное в `picked_import.db`, lib.rs:167/183).
- Настройки (`renderSettings` / секция «Карты» или новая «Картинка»): строка «Фон для картинки»
с кнопками **Выбрать** и **Сбросить**.
- Выбрать: `pick_file_open("image/*")` → поллинг `get_picked_file` → путь → `read_file_base64`
→ загрузить в `Image`, **уменьшить** до ширины ≤1080 на офскрин-canvas → `toDataURL("image/
jpeg",0.85)` → сохранить в `localStorage.baldav_share_bg` (после даунскейла ~200-400КБ, влезает).
- Сбросить: удалить ключ.
- `buildShareImage` берёт подложку из `localStorage.baldav_share_bg`.
## Файлы
- `frontend/js/main.js` — `buildShareImage`, `showShareModal`, кнопка `#sd-share`, пикер фона
в настройках, `invoke("export_image"/"read_file_base64")`.
- `frontend/css/styles.css` — стиль модалки предпросмотра.
- `src-tauri/src/lib.rs` — команды `export_image`, `read_file_base64` (+ регистрация).
- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt` — `exportimg:`
(saveImageLauncher + share-интент).
## Переиспользование (не изобретать)
- Файловый мост `save_file`/`gps_command.txt` (lib.rs:515, MainActivity.kt:457) — образец для
`export_image`.
- `saveFileLauncher` (CreateDocument, MainActivity.kt:55) — образец `saveImageLauncher`.
- `pick_file_open`/`get_picked_file` (lib.rs:167/183) — выбор фото.
- FileProvider (`AndroidManifest.xml:50`, `file_paths.xml` cache-path) — шэр.
- `appConfirm` модалка (main.js:1040), `showToast`, `GPS.formatDistance`, `state.profile`.
- Точки/статы трека уже посчитаны в `showSessionDetail`.
## Верификация
Десктоп (Xvfb :99): кнопка «Похвастаться» → модалка с картинкой (форма трека + статы),
переключатель прозрачный/фото рисует корректно; «Сохранить» на десктопе — через `save_file`-путь
(проверить, что dataURL строится; нативный SAF/шэр — только на теле). Скрин модалки.
Устройство (`./build.sh android -d -i`, по явной просьбе): «Поделиться» открывает системный
чужой чузер с PNG; «Сохранить» — SAF, файл открывается как картинка; в настройках выбор фото →
на картинке подложка; прозрачный вариант — PNG с альфой (проверить в галерее на светлом фоне
через тень текста).
## Стадии
1. A+B (рендер + модалка + кнопка) с «Сохранить»/«Поделиться» заглушены тостом на десктопе —
проверить рендер визуально.
2. C (нативный save/share) + D (подложка) → пересборка APK, тест на теле.
## Не трогаем
- Карту/треки не меняем; экспорт GPX (`sd-export`) остаётся.

View file

@ -0,0 +1,27 @@
# Сессия 2026-09-14 — шэр-картинка трека («Похвастаться»)
## Контекст
Пользователь хочет делиться картинкой поездки: форма трека + крупные статы (как на скрине),
прозрачная или на фото-подложке. План `docs/plans/2026-09-14-share-image.md`. Согласовано:
кнопка даёт «Поделиться» и «Сохранить»; в диалоге переключатель прозрачный/фото; статы —
дистанция, время, средняя скорость + набор/ккал/дата.
Разведка: `save_file` (SAF, текст) — не годится для бинаря PNG; нужен нативный бинарный сейв +
ACTION_SEND (FileProvider уже в манифесте, `file_paths` с cache-path есть) + чтение фото в
base64 (Rust). Canvas в WebView доступен.
## Сделано — Стадия 1 (рендер + модалка + кнопка), чистый JS
- `buildShareImage(session, stats, points, opts)` (main.js): холст 1080×1920; фон прозрачный
или фото (cover-fit + тёмный градиент); форма трека (проекция точек, линия цвета трека со
свечением); статы крупно Distance/Time/Speed + мелко ↑набор/ккал/дата; белый текст с тенью.
- `showShareModal` — предпросмотр `<img>` + переключатель Прозрачный/С фото + кнопки
Поделиться/Сохранить/Закрыть. `exportShareImage(dataUrl, filename, action)` зовёт
`invoke("export_image")` (нативной команды пока нет — на десктопе тост-ошибка, это ок).
- Кнопка `#sd-share` «Похвастаться» в детали трека. CSS `.share-modal` (шахматка под прозрачность).
## Проверка
- Синтаксис main.js OK. Рендер модалки на десктопе (Xvfb) — следующий шаг.
## Дальше
- Стадия 2: Rust `export_image`/`read_file_base64`, Kotlin `exportimg:` (SAF-сейв PNG +
ACTION_SEND), выбор подложки в настройках; пересборка APK; тест на теле.

View file

@ -1168,6 +1168,42 @@ select {
/* Invisible fat hit target for route legs — clickable despite zero opacity. */
.re-hit { pointer-events: stroke; }
/* Share image ("Похвастаться") preview modal. */
.share-modal {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(0,0,0,0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
padding-top: calc(16px + var(--safe-area-top));
padding-bottom: calc(16px + var(--safe-area-bottom));
}
.share-box {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
max-width: 100%;
max-height: 100%;
}
.share-preview {
max-width: 100%;
max-height: 68vh;
object-fit: contain;
border-radius: 10px;
/* checkerboard so a transparent PNG reads as transparent */
background-image:
linear-gradient(45deg, #3a3a3a 25%, transparent 25%, transparent 75%, #3a3a3a 75%),
linear-gradient(45deg, #3a3a3a 25%, #262626 25%, #262626 75%, #3a3a3a 75%);
background-size: 24px 24px;
background-position: 0 0, 12px 12px;
}
.share-toggle, .share-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.share-toggle button, .share-actions button { padding: 9px 14px; }
.rec-map-wrap {
flex: 1;
min-height: 200px;

View file

@ -3246,6 +3246,123 @@ async function rejoinReroute(cur, bearingDeg, routePts, nogos) {
return null;
}
// ---------------------------------------------------------------------------
// Share image ("Похвастаться"): track shape + stats as a story-format PNG
// ---------------------------------------------------------------------------
// Story-format duration: "1h 29min" / "29 min" (GPS.formatDuration is h:mm:ss).
function _shareDuration(sec) {
let h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
if (m === 60) { h += 1; m = 0; }
return h > 0 ? (m > 0 ? h + "h " + m + "min" : h + "h") : m + " min";
}
// Project track points into a WxH box (equirectangular at track scale, padded, Y down).
function _projectTrack(points, w, h, pad) {
let lats = points.map(p => p.lat), lons = points.map(p => p.lon);
let minLa = Math.min(...lats), maxLa = Math.max(...lats), minLo = Math.min(...lons), maxLo = Math.max(...lons);
let kx = Math.cos((minLa + maxLa) / 2 * Math.PI / 180); // lon → same metric scale as lat
let spanX = ((maxLo - minLo) * kx) || 1e-6, spanY = (maxLa - minLa) || 1e-6;
let scale = Math.min((w - 2 * pad) / spanX, (h - 2 * pad) / spanY);
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".
async function buildShareImage(session, stats, points, opts) {
opts = opts || {};
let W = 1080, H = 1920;
let cv = document.createElement("canvas"); cv.width = W; cv.height = H;
let ctx = cv.getContext("2d");
if (opts.bg === "photo") {
let bg = localStorage.getItem("baldav_share_bg");
if (bg) {
await new Promise((res) => {
let img = new Image();
img.onload = () => { let s = Math.max(W / img.width, H / img.height); let dw = img.width * s, dh = img.height * s; ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); res(); };
img.onerror = () => res();
img.src = bg;
});
}
let g = ctx.createLinearGradient(0, H * 0.4, 0, H);
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);
}
// Track shape (top ~42%).
if (points && points.length >= 2) {
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";
ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.shadowColor = col; ctx.shadowBlur = 30; ctx.strokeStyle = col; ctx.lineWidth = 12;
ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y + boxTop) : ctx.moveTo(p.x, p.y + boxTop));
ctx.stroke(); ctx.shadowBlur = 0;
}
// Stats.
let cx = W / 2;
function bigStat(y, value, label) {
ctx.textAlign = "center";
ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 3;
ctx.fillStyle = "#fff"; ctx.font = "700 100px system-ui, -apple-system, sans-serif"; ctx.fillText(value, cx, y);
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");
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 (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;
}
return cv.toDataURL("image/png");
}
// Send the generated PNG to the native layer: action "share" (system chooser) or "save" (SAF).
async function exportShareImage(dataUrl, filename, action) {
if (!dataUrl) return;
let base64 = dataUrl.split(",")[1];
try {
await window.__TAURI__.core.invoke("export_image", { filename, base64, action });
showToast(action === "share" ? "Открываю «Поделиться»…" : "Сохранение…");
} catch (e) { showToast("Экспорт картинки: " + (e.message || e)); }
}
// Preview modal with a transparent/photo toggle + Share/Save.
function showShareModal(session, stats, points) {
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-toggle">
<button id="share-bg-t" class="btn-secondary active">Прозрачный</button>
<button id="share-bg-p" class="btn-secondary">С фото</button>
</div>
<div class="share-actions">
<button id="share-do" class="btn-primary">Поделиться</button>
<button id="share-save" class="btn-secondary">Сохранить</button>
<button id="share-close" class="btn-secondary" style="color:#cf6679">Закрыть</button>
</div>
</div>`;
document.body.appendChild(ov);
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; }
bt.onclick = () => { bg = "transparent"; bt.classList.add("active"); bp.classList.remove("active"); render(); };
bp.onclick = () => {
if (!localStorage.getItem("baldav_share_bg")) { showToast("Подложка не выбрана — задайте в Настройках"); return; }
bg = "photo"; bp.classList.add("active"); bt.classList.remove("active"); render();
};
let filename = () => "baldav-" + ((session && session.date) || "track") + ".png";
ov.querySelector("#share-do").onclick = () => exportShareImage(dataUrl, filename(), "share");
ov.querySelector("#share-save").onclick = () => exportShareImage(dataUrl, filename(), "save");
ov.querySelector("#share-close").onclick = () => ov.remove();
render();
}
// Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries
// kept — one colour per segment): green -> yellow -> red -> brown -> black.
// <1%=0 .. 9–10%=9, >=10% = black. Both up- and downhill coloured by magnitude.
@ -5012,6 +5129,7 @@ async function showSessionDetail(sessionId) {
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
<button id="sd-share" class="btn-secondary full-width" style="margin-top:8px">Похвастаться</button>
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
`, () => {});
@ -5601,6 +5719,9 @@ async function showSessionDetail(sessionId) {
let exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
let shareBtn = document.querySelector("#sd-share");
if (shareBtn) shareBtn.onclick = () => showShareModal(session, stats, points);
// Track ID — click to copy to clipboard
let idEl = document.querySelector("#sd-track-id");
if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId);