diff --git a/README.md b/README.md index 432174a..14714de 100644 --- a/README.md +++ b/README.md @@ -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`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки. diff --git a/docs/plans/2026-09-14-share-image.md b/docs/plans/2026-09-14-share-image.md new file mode 100644 index 0000000..bed18c9 --- /dev/null +++ b/docs/plans/2026-09-14-share-image.md @@ -0,0 +1,96 @@ +# План: генератор шэр-картинки трека («Похвастаться») + +## Context (зачем) +Пользователь хочет делиться красивой картинкой поездки: форма трека + крупные статы +(как на присланном скрине). Три части: (1) картинка с формой трека и статами, прозрачная +или на фото-подложке; (2) настраиваемая подложка (фото) в настройках; (3) кнопка +«Похвастаться» в детали трека, которая даёт **Поделиться** (системный шэр) и **Сохранить файл**. + +Итог решений (согласовано): экспорт — и «Поделиться», и «Сохранить»; в диалоге переключатель +прозрачный/фото; статы на картинке — дистанция, время, средняя скорость и (мельче) набор +высоты, ккал, дата. + +Рисуем на `` в 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)`: показывает ``, + переключатель **Прозрачный / С фото** (перерисовывает), кнопки **Поделиться**, **Сохранить**, + **Закрыть**. Паттерн модалки — как `appConfirm` (main.js:1040). + +## Часть B — Кнопка «Похвастаться» в детали трека +- В `showSessionDetail` рядом с `#sd-export` (main.js:5011-5015) добавить + `` → `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::` в `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/`, `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`) остаётся. diff --git a/docs/session-logs/2026-09-14_share-image.md b/docs/session-logs/2026-09-14_share-image.md new file mode 100644 index 0000000..3531810 --- /dev/null +++ b/docs/session-logs/2026-09-14_share-image.md @@ -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` — предпросмотр `` + переключатель Прозрачный/С фото + кнопки + Поделиться/Сохранить/Закрыть. `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; тест на теле. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 5f13816..c2c91e3 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -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; diff --git a/frontend/js/main.js b/frontend/js/main.js index b80a6ee..21bbf0a 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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 = ` + `; + 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) { + `, () => {}); @@ -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);