Карта: экран на VK Картах с положением автобусов по расписанию

This commit is contained in:
2026-09-29 08:14:50 +00:00
parent 8daf3d01ce
commit c2505ea8f3

View File

@@ -94,6 +94,43 @@
.aboutcard p{margin:0 0 10px;font-size:14px;line-height:1.5} .aboutcard p{margin:0 0 10px;font-size:14px;line-height:1.5}
.aboutcard p.muted{color:var(--muted);font-size:13px} .aboutcard p.muted{color:var(--muted);font-size:13px}
/* экран «Карта»: страница целиком занята картой, шапка остаётся */
body.map{padding:24px 16px 16px;overflow:hidden}
body.map .wrap{height:100vh;height:100dvh;display:flex;flex-direction:column;max-width:none}
body.map header{margin-bottom:12px}
body.map .day,body.map .pickers,body.map .where,body.map .dir,
body.map .fullbtn,body.map #fullTable,body.map .aboutcard,body.map footer{display:none}
body.map #mapBox{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:8px}
.mapcanvas{flex:1 1 auto;min-height:0;border:1px solid var(--line);
border-radius:14px;overflow:hidden;background:var(--card);position:relative}
.mapcanvas.loading::after{content:"Загружаю карту…";position:absolute;inset:0;
display:flex;align-items:center;justify-content:center;
color:var(--muted);font-size:14px}
.mapstatus{flex:0 0 auto;background:var(--card);border:1px solid var(--line);
border-radius:14px;padding:11px 14px}
.mapstatus .head{font-size:14px;font-weight:700;margin-bottom:4px}
.mapstatus .head b{color:var(--accent)}
.mapstatus .rows{font-size:13px;color:var(--muted);line-height:1.55}
.mapstatus .rows div{display:flex;gap:8px;align-items:baseline}
.mapstatus .rows .when{color:var(--txt);font-variant-numeric:tabular-nums;
white-space:nowrap;font-size:12px}
.mapstatus .hint{margin-top:7px;font-size:11px;color:var(--muted);line-height:1.4}
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
.pin{transform:translate(-50%,-100%);white-space:nowrap;pointer-events:none}
.pin .box{display:flex;align-items:center;gap:6px;padding:4px 9px 4px 5px;
border-radius:999px;background:rgba(13,18,32,.92);border:1px solid var(--line);
box-shadow:0 4px 14px rgba(0,0,0,.5)}
.pin .num{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:10px;font-weight:700;color:#04101c;flex:0 0 auto}
.pin .txt{font-size:11px;font-weight:600;color:var(--txt)}
.pin.fwd .num{background:var(--accent)}
.pin.back .num{background:var(--accent2)}
.pin::after{content:"";position:absolute;left:50%;bottom:-5px;width:8px;height:8px;
margin-left:-4px;background:rgba(13,18,32,.92);
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
transform:rotate(45deg)}
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px; .tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;display:none;margin-bottom:8px} overflow:hidden;display:none;margin-bottom:8px}
/* переключатель типа дня в полном расписании */ /* переключатель типа дня в полном расписании */
@@ -131,6 +168,7 @@
</div> </div>
<nav class="menu" id="menuBox" hidden> <nav class="menu" id="menuBox" hidden>
<button class="menuitem" id="mMap">Карта</button>
<button class="menuitem" id="mAbout">О приложении</button> <button class="menuitem" id="mAbout">О приложении</button>
</nav> </nav>
</div> </div>
@@ -166,6 +204,16 @@
<div id="tableBody"></div> <div id="tableBody"></div>
</section> </section>
<section class="mapcard" id="mapBox" hidden>
<div class="mapcanvas loading" id="mapCanvas"></div>
<div class="mapstatus">
<div class="head" id="mapHead">Карта маршрута</div>
<div class="rows" id="mapRows"></div>
<div class="hint">Положение автобуса рассчитано по расписанию, а не по GPS:
между остановками интерполируется по дороге. Точность — до нескольких минут.</div>
</div>
</section>
<section class="aboutcard" id="aboutBox"> <section class="aboutcard" id="aboutBox">
<h2>О приложении</h2> <h2>О приложении</h2>
<div class="ver" id="aboutVer"></div> <div class="ver" id="aboutVer"></div>
@@ -173,8 +221,12 @@
<p>Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд. <p>Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд.
Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов, Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов,
с переключением между буднями, субботой и воскресеньем.</p> с переключением между буднями, субботой и воскресеньем.</p>
<p>В меню есть «Карта»: там видно, где сейчас едут автобусы, и как пройден
участок между остановками. Положение считается по расписанию — приложение
не получает данные с GPS, поэтому это оценка с точностью до нескольких минут.</p>
<p class="muted">Расписание действует с 30.09.2020. Время московское (МСК).</p> <p class="muted">Расписание действует с 30.09.2020. Время московское (МСК).</p>
<p class="muted">Пуш-уведомления приходят через RuStore Push — для этого нужен <p class="muted">Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap.
Пуш-уведомления приходят через RuStore Push — для этого нужен
установленный и авторизованный RuStore на устройстве.</p> установленный и авторизованный RuStore на устройстве.</p>
</section> </section>
@@ -410,12 +462,14 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
// Режим показа задаётся якорью в адресе: // Режим показа задаётся якорью в адресе:
// (пусто) — браузер; // (пусто) — браузер;
// #app — главный экран приложения; // #app — главный экран приложения;
// #map — карта с положением автобуса;
// #about — окно «О приложении». // #about — окно «О приложении».
// В приложении (не в браузере) страница дёргает нативный код через объект Bus. // В приложении (не в браузере) страница дёргает нативный код через объект Bus.
const MODE = location.hash.replace("#","") || "web"; const MODE = location.hash.replace("#","") || "web";
const NATIVE = MODE !== "web" && typeof Bus !== "undefined"; const NATIVE = MODE !== "web" && typeof Bus !== "undefined";
document.body.classList.add(MODE); document.body.classList.add(MODE);
document.getElementById("mapBox").hidden = MODE !== "map";
if (MODE === "about" && NATIVE) { if (MODE === "about" && NATIVE) {
// версию берём из приложения, чтобы не расходилась со сборкой // версию берём из приложения, чтобы не расходилась со сборкой
const v = document.getElementById("aboutVer"); const v = document.getElementById("aboutVer");
@@ -429,8 +483,13 @@ if (NATIVE) {
e.stopPropagation(); e.stopPropagation();
menuBox.hidden = !menuBox.hidden; menuBox.hidden = !menuBox.hidden;
}); });
document.getElementById("navBack").addEventListener("click",()=>Bus.goBack()); document.getElementById("navBack").addEventListener("click",()=>{
document.getElementById("mAbout").addEventListener("click",()=>Bus.openAbout()); // «назад» сначала сворачивает открытое меню и только потом уходит назад
if (!menuBox.hidden) { closeMenu(); return; }
Bus.goBack();
});
document.getElementById("mMap").addEventListener("click",()=>{ closeMenu(); Bus.openMap(); });
document.getElementById("mAbout").addEventListener("click",()=>{ closeMenu(); Bus.openAbout(); });
document.addEventListener("click",e=>{ document.addEventListener("click",e=>{
if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu(); if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu();
}); });
@@ -442,6 +501,255 @@ tickClock(); renderDay(); render();
setInterval(tickClock,1000); setInterval(tickClock,1000);
setInterval(render,30000); setInterval(render,30000);
setInterval(renderDay,60000); setInterval(renderDay,60000);
// ─────────────────────────── Карта ───────────────────────────
// Где едет автобус, приложение не знает — GPS у него нет. Но в расписании
// есть отправление из Кандалакши, отправление из Полярных Зорь в Зашеек,
// прибытие в Зашеек и отправление из Полярных Зорь обратно. Между соседними
// точками известно, сколько минут шёл автобус, поэтому положение внутри
// участка можно оценить. Дорога между точками лежит в route.js (OpenStreetMap),
// доля пути считается по длине ломаной — маркер идёт по трассе, а не по прямой.
//
// Координаты и геометрия дороги, стиль и тайлы — VK Карты (MMR GL JS),
// подключаются только на этом экране, ключ доступа — из кабинета RuStore.
const VK_MAP_KEY = "RS545b2f43f1096207eeb54edd762e325c17f1e0ccd5fdd234afb61cf93f7331";
const MAP_STYLE = "https://maps.vk.com/api/styles/main_style.json";
// как часто пересчитывать положение: точность всё равно ограничена расписанием
const MAP_TICK_MS = 20000;
// четыре направления дороги: туда до Зашейка и обратно. Заполняются после
// загрузки route.js — до этого карта не считает ничего.
let KAND_TO_PZ = null; // Кандалакша → Полярные Зори
let PZ_TO_ZASHEEK = null;// Полярные Зори → Зашеек
let PZ_TO_ZASHEEK_BACK = null;
let KAND_TO_PZ_BACK = null;
// Длина ломаной с накопленными отрезками. Расстояние считаем в
// равнопромежуточной проекции — на таком масштабе это точнее градусов.
function measure(line){
const cum = [0];
for (let i=1;i<line.length;i++){
const [x1,y1] = line[i-1], [x2,y2] = line[i];
const k = Math.cos((y1+y2)/2 * Math.PI/180); // сжатие по долготе
cum.push(cum[i-1] + Math.hypot((x2-x1)*k, y2-y1));
}
return {line, cum, len: cum[cum.length-1]};
}
// Тот же путь, но пройденный в обратную сторону
function reverse(p){
return {line: p.line.slice().reverse(),
cum: p.cum.map(v=>p.len-v).reverse(), len: p.len};
}
// Точка на ломаной по доле пройденного расстояния
function pointAt(p, f){
const target = Math.max(0, Math.min(1, f)) * p.len;
let i = 1;
while (i < p.cum.length-1 && p.cum[i] < target) i++;
const seg = p.cum[i]-p.cum[i-1] || 1;
const t = (target - p.cum[i-1]) / seg;
const [x1,y1] = p.line[i-1], [x2,y2] = p.line[i];
return [x1+(x2-x1)*t, y1+(y2-y1)*t];
}
// Рейс разложен на четыре участка: туда Кандалакша → ПЗ → Зашеек и обратно.
// Время в пути ПЗ → Кандалакша в таблице не указано, берём те же 50 минут,
// что и в DIRECTIONS.
function makeRun(row){
const t0=toMin(row[0]), t1=toMin(row[1]), t2=toMin(row[2]), t3=toMin(row[3]);
const back = t3 + RIDE_PZ_KAND; // прибытие в Кандалакшу
return {
t0, tEnd: back,
segs: [
{a:t0, b:t1, line:KAND_TO_PZ, to:"Полярные Зори", end:t1},
{a:t1, b:t2, line:PZ_TO_ZASHEEK, to:"Зашеек", end:t2},
{a:t2, b:t3, line:PZ_TO_ZASHEEK_BACK, to:"Полярные Зори", end:t3},
{a:t3, b:back, line:KAND_TO_PZ_BACK, to:"Кандалакша", end:back}
]
};
}
// Все рейсы, которые сейчас в пути, вместе с точкой и участком
function busesNow(){
const d = nowMs();
const T = d.hour*60 + d.minute + d.second/60;
const out = [];
SCHEDULES[todayKey()].forEach((row, idx) => {
const run = makeRun(row);
if (T < run.t0 || T > run.tEnd) return;
const seg = run.segs.find(s => T >= s.a && T <= s.b);
if (!seg) return;
const f = seg.b > seg.a ? (T - seg.a) / (seg.b - seg.a) : 0;
out.push({idx, seg, at: pointAt(seg.line, f)});
});
return out;
}
function pinElement(cls, num, text){
const root = document.createElement("div");
root.className = "pin " + cls;
const box = document.createElement("div");
box.className = "box";
box.innerHTML = `<span class="num">${num}</span><span class="txt">${text}</span>`;
root.appendChild(box);
return root;
}
function renderMapStatus(list){
const head = document.getElementById("mapHead");
const rows = document.getElementById("mapRows");
if (!list.length) {
head.innerHTML = "Сейчас автобусов нет";
rows.innerHTML = `<div>Первый рейс — в ${SCHEDULES[todayKey()][0][0]} из Кандалакши.</div>`;
return;
}
const many = list.length > 1;
head.innerHTML = `Сейчас в пути <b>${list.length}</b> ${many ? "автобуса" : "автобус"}`;
rows.innerHTML = list.map(b =>
`<div><span class="when">→ ${b.seg.to}</span><span>прибытие в ${fmtHM(b.seg.end)}</span></div>`
).join("");
}
function startMap(map, mmrgl){
const pins = new Map(); // индекс рейса → метка, чтобы не плодить новые
function update(){
const list = busesNow();
const seen = new Set();
list.forEach(b => {
seen.add(b.idx);
let p = pins.get(b.idx);
if (!p) {
const back = b.seg.to === "Кандалакша";
const marker = new mmrgl.Marker({element: pinElement(back ? "back" : "fwd", "127", "→ " + b.seg.to),
anchor: "bottom"});
// координаты обязаны быть заданы до addTo: при добавлении SDK сразу
// пересчитывает положение метки и падает на пустом LngLat
marker.setLngLat(b.at);
marker.addTo(map);
p = {marker, lastTo: null};
pins.set(b.idx, p);
}
// подпись у участка своя — обновляем, только если направление сменилось
if (p.lastTo !== b.seg.to) {
p.lastTo = b.seg.to;
const el = p.marker.getElement();
el.querySelector(".txt").textContent = "→ " + b.seg.to;
// именно classList, а не className: у метки есть классы SDK
// (mmrgl-marker, mmrgl-marker-anchor-bottom), и перезапись className
// убрала бы position:absolute — метка пропала бы с карты
const back = b.seg.to === "Кандалакша";
el.classList.toggle("back", back);
el.classList.toggle("fwd", !back);
}
p.marker.setLngLat(b.at);
});
// рейсы, закончившиеся, убираем
pins.forEach((p, idx) => {
if (!seen.has(idx)) { p.marker.remove(); pins.delete(idx); }
});
renderMapStatus(list);
}
update();
setInterval(update, MAP_TICK_MS);
document.addEventListener("visibilitychange", () => { if (!document.hidden) update(); });
}
function linkCss(href){
const l = document.createElement("link");
l.rel = "stylesheet"; l.href = href;
document.head.appendChild(l);
}
function loadScript(src){
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error("не загрузился " + src));
document.head.appendChild(s);
});
}
function initMap(){
const box = document.getElementById("mapCanvas");
let ready = false;
// если стиль так и не пришёл — говорим об этом прямо, а не оставляем пустой прямоугольник
const guard = setTimeout(() => {
if (ready) return;
box.classList.remove("loading");
box.innerHTML = `<div style="padding:16px;color:#8b98b8;font-size:13px;line-height:1.5">
Карта не загрузилась. Нужен интернет: стиль и тайлы VK Карт грузятся из сети.</div>`;
}, 25000);
Promise.all([loadScript("route.js"), loadScript("mmr-gl.js")])
.then(() => {
linkCss("mmr-gl.css");
const mmrgl = window.mmrgl;
mmrgl.accessToken = VK_MAP_KEY; // ключ добавится к стилю, тайлам, шрифтам
KAND_TO_PZ = measure(LEG_KP);
PZ_TO_ZASHEEK = measure(LEG_PZ);
PZ_TO_ZASHEEK_BACK = reverse(PZ_TO_ZASHEEK);
KAND_TO_PZ_BACK = reverse(KAND_TO_PZ);
const map = new mmrgl.Map({
container: box,
style: MAP_STYLE,
center: [32.479, 67.279],
zoom: 10,
attributionControl: true
});
map.on("load", () => {
ready = true;
clearTimeout(guard);
box.classList.remove("loading");
// линия маршрута целиком: Кандалакша → ПЗ → Зашеек
map.addSource("route", {type:"geojson", data:{
type:"Feature",
geometry:{type:"LineString", coordinates: LEG_KP.concat(LEG_PZ)},
properties:{}
}});
map.addLayer({id:"route", type:"line", source:"route",
layout:{"line-cap":"round", "line-join":"round"},
paint:{"line-color":"#4f9cff", "line-width":4, "line-opacity":.9}});
// остановки на карте не рисуем: на экране они только мешали разглядеть
// сами автобусы, а куда ехать — написано в списке под картой
// весь маршрут в кадр. Метки привязаны к точке снизу и уходят вверх,
// поэтому сверху отступ больше, иначе крайние метки срезает.
const pts = [STOPS.kandalaksha, STOPS.polyarnye, STOPS.zasheek];
map.fitBounds(
[[Math.min(...pts.map(p=>p[1])), Math.min(...pts.map(p=>p[0]))],
[Math.max(...pts.map(p=>p[1])), Math.max(...pts.map(p=>p[0]))]],
{padding: {top: 84, bottom: 64, left: 56, right: 56}, duration: 0});
startMap(map, mmrgl);
// размер контейнера после отрисовки может уточниться позже
setTimeout(() => map.resize(), 300);
});
map.on("error", e => {
// не показываем шторку ошибок: на телефоне она перекрывает карту
if (e && e.error) console.error("карта:", e.error.message || e.error);
});
})
.catch(err => {
clearTimeout(guard);
box.classList.remove("loading");
box.innerHTML = `<div style="padding:16px;color:#8b98b8;font-size:13px">
Не удалось загрузить карту: ${err.message}<br>Нужен интернет.</div>`;
});
}
if (MODE === "map") initMap();
</script> </script>
</body> </body>
</html> </html>