Compare commits

...

2 commits

Author SHA1 Message Date
7554146772 brouter-web: broaden dark theme (sidebar tabs, inputs, dropdowns, elevation chart text/footer)
Cover the elements that stayed light: right vertical sidebar tab strip, search
fields, the profile dropdown and dropdown menus, light-styled buttons, the
elevation chart's SVG axis text (was black on dark) and its background/footer/
stats. Injected as a <style> keyed on body.bw-dark.
2026-09-09 16:51:58 +03:00
456322bf75 POI overlay on maps: circular contrasting icons + category filter (offline)
Points of interest (cafe, shops, water, toilets, bank/ATM, bus, shelter,
viewpoint, ...) as circular contrasting badges (emoji in a white/dark circle,
readable on light and dark tiles) — not pins. A 📍 control toggles the layer
and opens a per-category filter (checkboxes, remembered). Markers are culled to
the visible bounds and shown from zoom 13 (cap 500/view), on the recording,
track-detail and roads maps.

Data is bundled and fully offline: frontend/data/pois.json ([{la,lo,c,n}], 358KB,
5662 POIs) fetched once from Overpass for ~100km around Novocherkassk (the whole
bbox returned in one query; adaptive subdivision was ready if it had truncated).

Verified functionally on device: 124 badges rendered in view at z16 over
Novocherkassk with correct bounds/category culling; the live visual could not be
screenshotted (the WebView surface is black under CDP). frontend/data + poi.js.
2026-09-09 16:51:58 +03:00
8 changed files with 167 additions and 5 deletions

View file

@ -110,7 +110,15 @@ brouter-web overlays, см. скрины). С фильтром категори
- UI: панель категорий с чекбоксами (как в brouter-web), сохранение выбора (localStorage),
z-order (POI над тайлами/дорогами, под треком/паком).
- Перф: сотни-тысячи точек — кластеризация/культинг по зуму и bbox видимой области.
**Статус:** not started (анализ).
**Статус:** v1 СДЕЛАНО (проверено функционально на устройстве). Данные: Overpass по ~100км вокруг
Новочеркасска (адаптивная выгрузка — дробить не понадобилось, весь bbox отдал 5662 POI одним
запросом), компактный `frontend/data/pois.json` [{la,lo,c,n}] (358КБ, бандл, оффлайн). Отрисовка:
`frontend/js/poi.js` — круглые контрастные значки (эмодзи в кружке, `.poi-badge`, светлый/тёмный
под `body.dark-map`), куллинг по видимым границам + от zoom 13, cap 500/кадр; Leaflet-контрол 📍 с
панелью категорий (чекбоксы), выбор в localStorage. Привязан к картам записи/деталей/дорог
(`POI.attach`). Проверено: 124 значка в кадре z16 над Новочеркасском, границы/фильтр корректны.
ОСТАЛОСЬ: визуальная проверка на живом экране (WebView в CDP не рисует — скрин чёрный); при желании —
расширить область за 100км и добавить недостающие категории (сейчас все 21 категория есть).
## Stage 7 — Тёмные тайлы карты в BalDav (запрошено 2026-09-09)
**Идея:** заменить фон OSM на тёмный (пользователю нравится ÖPNV, но хочет тёмное). ВАЖНО (проверено

View file

@ -19,6 +19,7 @@
- **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон дискретными полосами по 1% (зелёный→жёлтый→красный→коричневый→чёрный, ≥10% чёрный) или гипсометрия по высоте (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека и во время записи; маршрут рисуется ПОД треком. Высоты (`<ele>` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM).
- **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, тап по линии — вставить точку в это место, тащить точку — двигать, тап по точке — удалить. Высоты подтягиваются онлайн после отрисовки (из предпросмотра).
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг Новочеркасска, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
- **Метки ветра** — кнопка «Ветер в лицо» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее.
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).

View file

@ -1152,6 +1152,23 @@ body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightne
/* Darken the map backdrop too, else the light Leaflet container (#ddd) flashes bright
between/behind tiles while zooming or where a tile is missing. */
body.dark-map .leaflet-container { background: #1b1f24; }
/* POI overlay: circular contrasting icon badges (readable on light & dark tiles). */
.poi-badge-wrap { background: none; border: none; }
.poi-badge {
width: 26px; height: 26px; line-height: 22px; text-align: center; font-size: 15px;
background: #fff; border: 2px solid #222; border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,.5);
}
body.dark-map .poi-badge { background: #111; border-color: #eee; }
.poi-panel {
margin-top: 4px; max-height: 55vh; overflow: auto; min-width: 150px;
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,.45);
}
.poi-row { display: block; font-size: 13px; color: var(--on-bg); padding: 2px 0; cursor: pointer; white-space: nowrap; }
.poi-head { font-weight: 600; border-bottom: 1px solid var(--border); margin-bottom: 4px; padding-bottom: 4px; }
.poi-btn.active { background: rgba(255,255,255,0.5); color: #1a1a1a; }
/* Always-on compass needle: red half = North, grey = South. Rotates to point to real north.
Top-RIGHT (zoom controls are top-left), below the status-bar safe area. */
/* Position/direction puck (SVG divIcon) — no default leaflet-div-icon box. */

1
frontend/data/pois.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -24,6 +24,7 @@
<script src="js/tilecache.js"></script>
<script src="js/gps.js"></script>
<script src="js/router.js"></script>
<script src="js/poi.js"></script>
<script src="js/sync.js"></script>
<script src="js/chzn.js"></script>
<script src="js/main.js"></script>

View file

@ -4111,6 +4111,8 @@ async function renderRecordingInline(container) {
darkBtn.title = "Тёмная карта";
darkBtn.onclick = () => toggleDarkMap();
btnWrap.appendChild(darkBtn);
POI.attach(map); // POI overlay + category filter on the recording map
// The default-ON draw is deferred to the END of this function (after all the
// critical wiring), where it awaits whenMapReady — so it can't delay setup.
@ -4733,6 +4735,7 @@ async function showSessionDetail(sessionId) {
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
addDarkMapControl(map); // dark-tiles toggle on the track view
POI.attach(map); // POI overlay + category filter
// Active saved-route overlay on the detail map + a toggle button (default ON when
// a route is selected). Shares baldav_route_shown with the recording screen.
@ -7359,6 +7362,7 @@ async function showRoadsMap() {
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
addDarkMapControl(map); // dark-tiles toggle on the roads map
POI.attach(map); // POI overlay + category filter
let infoEl = document.querySelector("#roads-info");
infoEl.textContent = "Загрузка...";

108
frontend/js/poi.js Normal file
View file

@ -0,0 +1,108 @@
// Offline POI overlay for BalDav maps.
// Circular, contrasting icon markers (emoji-in-a-circle, readable on light AND dark
// tiles), culled to the visible bounds and shown only from a min zoom. Data is bundled
// (frontend/data/pois.json = [{la,lo,c,n}]) so it works fully offline. A Leaflet control
// toggles the layer and opens a per-category filter. Exposes window.POI.
(function () {
let MINZOOM = 13, CAP = 500; // don't render below this zoom; cap markers per view
// category -> emoji + Russian label (order defines the filter panel order)
let CATS = {
supermarket: { e: "🛒", l: "Супермаркет" }, bakery: { e: "🥖", l: "Пекарня" },
beverages: { e: "🥤", l: "Напитки" }, coffee: { e: "☕", l: "Кофе" },
cafe: { e: "☕", l: "Кафе" }, restaurant: { e: "🍴", l: "Ресторан" },
fast_food: { e: "🍔", l: "Фастфуд" }, food_court: { e: "🍽", l: "Фудкорт" },
pub: { e: "🍺", l: "Паб" }, bar: { e: "🍸", l: "Бар" }, bbq: { e: "🍖", l: "Мангал" },
drinking_water: { e: "🚰", l: "Вода" }, toilets: { e: "🚻", l: "Туалет" },
atm: { e: "🏧", l: "Банкомат" }, bank: { e: "🏦", l: "Банк" },
bench: { e: "🪑", l: "Скамейка" }, shelter: { e: "⛺", l: "Укрытие" },
picnic_site: { e: "🧺", l: "Пикник" }, viewpoint: { e: "🔭", l: "Смотровая" },
bicycle_parking: { e: "🚲", l: "Велопарк." }, bus_station: { e: "🚌", l: "Остановка" },
};
let POI = { CATS: CATS, _data: null, _loading: null };
function enabledSet() {
try { let s = localStorage.getItem("baldav_poi_cats"); if (s) return new Set(JSON.parse(s)); } catch (e) {}
return new Set(Object.keys(CATS)); // all on by default
}
function saveEnabled(set) { try { localStorage.setItem("baldav_poi_cats", JSON.stringify([...set])); } catch (e) {} }
POI.load = function () {
if (POI._data) return Promise.resolve(POI._data);
if (POI._loading) return POI._loading;
POI._loading = fetch("data/pois.json").then(r => r.json())
.then(d => { POI._data = d; return d; })
.catch(() => { POI._data = []; return []; });
return POI._loading;
};
function icon(cat) {
let c = CATS[cat] || { e: "•" };
return L.divIcon({ className: "poi-badge-wrap", html: '<div class="poi-badge">' + c.e + "</div>", iconSize: [26, 26], iconAnchor: [13, 13] });
}
// Attach the POI overlay + a control to a map. Renders on move/zoom, culled to view.
POI.attach = function (map) {
let group = L.layerGroup();
let shown = localStorage.getItem("baldav_poi_shown") === "1";
let enabled = enabledSet();
function render() {
group.clearLayers();
if (!shown || map.getZoom() < MINZOOM || !POI._data) return;
let b = map.getBounds(), n = 0;
for (let i = 0; i < POI._data.length; i++) {
let p = POI._data[i];
if (!enabled.has(p.c)) continue;
if (p.la < b.getSouth() || p.la > b.getNorth() || p.lo < b.getWest() || p.lo > b.getEast()) continue;
let m = L.marker([p.la, p.lo], { icon: icon(p.c) });
let c = CATS[p.c] || { l: p.c };
m.bindPopup((p.n ? "<b>" + p.n + "</b><br>" : "") + c.l);
group.addLayer(m);
if (++n >= CAP) break;
}
}
map.on("moveend zoomend", render);
if (shown) group.addTo(map);
POI.load().then(render);
function setShown(v) {
shown = v;
try { localStorage.setItem("baldav_poi_shown", v ? "1" : "0"); } catch (e) {}
if (v) group.addTo(map); else map.removeLayer(group);
render();
}
// --- Leaflet control: 📍 button + collapsible category filter ---
let Ctl = L.Control.extend({
options: { position: "topright" },
onAdd: function () {
let wrap = L.DomUtil.create("div", "poi-ctl");
let btn = L.DomUtil.create("button", "rec-map-btn poi-btn" + (shown ? " active" : ""), wrap);
btn.textContent = "📍"; btn.title = "Точки интереса"; btn.style.position = "static";
let panel = L.DomUtil.create("div", "poi-panel", wrap); panel.hidden = true;
// master row
let head = L.DomUtil.create("label", "poi-row poi-head", panel);
let master = document.createElement("input"); master.type = "checkbox"; master.checked = shown;
head.appendChild(master); head.appendChild(document.createTextNode(" Показать POI"));
master.onchange = () => { setShown(master.checked); btn.classList.toggle("active", master.checked); };
// per-category rows
Object.keys(CATS).forEach(cat => {
let row = L.DomUtil.create("label", "poi-row", panel);
let cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = enabled.has(cat);
cb.onchange = () => { if (cb.checked) enabled.add(cat); else enabled.delete(cat); saveEnabled(enabled); render(); };
row.appendChild(cb);
row.appendChild(document.createTextNode(" " + (CATS[cat].e) + " " + CATS[cat].l));
});
btn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); panel.hidden = !panel.hidden; };
L.DomEvent.disableClickPropagation(wrap);
L.DomEvent.disableScrollPropagation(panel);
return wrap;
},
});
map.addControl(new Ctl());
return { layer: group, render: render, setShown: setShown };
};
window.POI = POI;
})();

View file

@ -19,11 +19,33 @@
// left toolbar + all leaflet control buttons (zoom, draw, etc.)
"body.bw-dark .leaflet-bar a,body.bw-dark .leaflet-control-zoom a,body.bw-dark .leaflet-control a,body.bw-dark .leaflet-control button{background:#2b3038!important;color:#fff!important;border-color:#454b54!important}",
"body.bw-dark .leaflet-bar a:hover,body.bw-dark .leaflet-control a:hover{background:#3a4048!important}",
// bottom elevation profile panel
"body.bw-dark .elevation,body.bw-dark #elevation-chart,body.bw-dark .elevation-summary{background:#20242a!important;color:#dfe3e8!important}",
"body.bw-dark .elevation text{fill:#dfe3e8!important}",
// bottom elevation profile panel + footer/stats
"body.bw-dark #elevation-chart,body.bw-dark #elevation-chart svg,body.bw-dark .elevation,body.bw-dark .elevation svg{background:#20242a!important;color:#dfe3e8!important}",
"body.bw-dark .elevation .background{background-color:#20242a!important}",
"body.bw-dark .elevation rect.background,body.bw-dark .elevation .background rect{fill:#20242a!important}",
"body.bw-dark footer,body.bw-dark #stats-info,body.bw-dark #stats-container,body.bw-dark #stats,body.bw-dark #track_statistics{background:#20242a!important;color:#dfe3e8!important}",
"body.bw-dark .elevation text,body.bw-dark .elevation .axis text{fill:#dfe3e8!important}",
"body.bw-dark .elevation .domain,body.bw-dark .elevation .tick line,body.bw-dark .elevation .grid line{stroke:#55606a!important}",
"body.bw-dark .elevation path.area,body.bw-dark .elevation .area{fill:#2b3038!important;opacity:.6}"
"body.bw-dark .elevation path.area,body.bw-dark .elevation .area{fill:#2b3038!important;opacity:.6}",
// elevation SVG axis text (was black on dark)
"body.bw-dark #elevation-chart text,body.bw-dark #elevation-chart .tick text,body.bw-dark #elevation-chart .axis text{fill:#dfe3e8!important}",
"body.bw-dark #elevation-chart .tick line,body.bw-dark #elevation-chart .domain,body.bw-dark #elevation-chart path.grid,body.bw-dark #elevation-chart line{stroke:#3a4048!important}",
// right vertical sidebar tab strip (layers/settings/data/stats icons)
"body.bw-dark .leaflet-sidebar-tabs,body.bw-dark .leaflet-sidebar-tabs>ul,body.bw-dark .leaflet-sidebar-tabs li,body.bw-dark .leaflet-sidebar-tabs li a{background:#20242a!important;color:#dfe3e8!important}",
"body.bw-dark .leaflet-sidebar-tabs li.active a,body.bw-dark .leaflet-sidebar-tabs li a:hover{background:#2b3038!important;color:#fff!important}",
"body.bw-dark .leaflet-sidebar.collapsed,body.bw-dark .leaflet-sidebar-header{background:#20242a!important;color:#dfe3e8!important}",
// Bootstrap form controls: search fields, the profile dropdown/select, etc.
"body.bw-dark input,body.bw-dark textarea,body.bw-dark select,body.bw-dark .form-control,body.bw-dark .custom-select{background:#2b3038!important;color:#dfe3e8!important;border-color:#454b54!important}",
"body.bw-dark ::placeholder{color:#9aa4ae!important}",
// dropdown menus (Load, profile chooser)
"body.bw-dark .dropdown-menu{background:#20242a!important;color:#dfe3e8!important;border-color:#454b54!important}",
"body.bw-dark .dropdown-item{color:#dfe3e8!important}",
"body.bw-dark .dropdown-item:hover,body.bw-dark .dropdown-item:focus{background:#2b3038!important;color:#fff!important}",
// light-styled buttons (incl. those above the elevation chart) → dark; keep coloured btns
"body.bw-dark .btn-light,body.bw-dark .btn-outline-secondary,body.bw-dark .btn-secondary,body.bw-dark .btn-default,body.bw-dark button.btn:not([class*=btn-primary]):not([class*=btn-success]):not([class*=btn-danger]){background:#2b3038!important;color:#dfe3e8!important;border-color:#454b54!important}",
"body.bw-dark .card,body.bw-dark .modal-content,body.bw-dark .list-group-item,body.bw-dark .popover,body.bw-dark .popover-body{background:#20242a!important;color:#dfe3e8!important}",
// brouter-web bottom footer buttons / export-image control
"body.bw-dark footer .btn,body.bw-dark footer button,body.bw-dark #export-to-image,body.bw-dark .leaflet-control button{background:#2b3038!important;color:#dfe3e8!important;border-color:#454b54!important}"
].join("\n");
(document.head || document.documentElement).appendChild(st);
}