baldav/frontend/js/poi.js
4aiman 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

108 lines
5.3 KiB
JavaScript

// 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;
})();