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.
This commit is contained in:
parent
d7b77f5bc5
commit
456322bf75
7 changed files with 141 additions and 1 deletions
|
|
@ -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, но хочет тёмное). ВАЖНО (проверено
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
1
frontend/data/pois.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
108
frontend/js/poi.js
Normal 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;
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue