diff --git a/apk-webview/app/src/main/assets/index.html b/apk-webview/app/src/main/assets/index.html index 36b98e3..e53d201 100644 --- a/apk-webview/app/src/main/assets/index.html +++ b/apk-webview/app/src/main/assets/index.html @@ -94,6 +94,43 @@ .aboutcard p{margin:0 0 10px;font-size:14px;line-height:1.5} .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; overflow:hidden;display:none;margin-bottom:8px} /* переключатель типа дня в полном расписании */ @@ -131,6 +168,7 @@ @@ -166,6 +204,16 @@
+ +

О приложении

@@ -173,8 +221,12 @@

Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд. Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов, с переключением между буднями, субботой и воскресеньем.

+

В меню есть «Карта»: там видно, где сейчас едут автобусы, и как пройден + участок между остановками. Положение считается по расписанию — приложение + не получает данные с GPS, поэтому это оценка с точностью до нескольких минут.

Расписание действует с 30.09.2020. Время московское (МСК).

-

Пуш-уведомления приходят через RuStore Push — для этого нужен +

Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap. + Пуш-уведомления приходят через RuStore Push — для этого нужен установленный и авторизованный RuStore на устройстве.

@@ -410,12 +462,14 @@ document.getElementById("fullBtn").addEventListener("click",()=>{ // Режим показа задаётся якорью в адресе: // (пусто) — браузер; // #app — главный экран приложения; +// #map — карта с положением автобуса; // #about — окно «О приложении». // В приложении (не в браузере) страница дёргает нативный код через объект Bus. const MODE = location.hash.replace("#","") || "web"; const NATIVE = MODE !== "web" && typeof Bus !== "undefined"; document.body.classList.add(MODE); +document.getElementById("mapBox").hidden = MODE !== "map"; if (MODE === "about" && NATIVE) { // версию берём из приложения, чтобы не расходилась со сборкой const v = document.getElementById("aboutVer"); @@ -429,8 +483,13 @@ if (NATIVE) { e.stopPropagation(); menuBox.hidden = !menuBox.hidden; }); - document.getElementById("navBack").addEventListener("click",()=>Bus.goBack()); - document.getElementById("mAbout").addEventListener("click",()=>Bus.openAbout()); + document.getElementById("navBack").addEventListener("click",()=>{ + // «назад» сначала сворачивает открытое меню и только потом уходит назад + 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=>{ if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu(); }); @@ -442,6 +501,255 @@ tickClock(); renderDay(); render(); setInterval(tickClock,1000); setInterval(render,30000); 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;ip.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 = `${num}${text}`; + 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 = `
Первый рейс — в ${SCHEDULES[todayKey()][0][0]} из Кандалакши.
`; + return; + } + const many = list.length > 1; + head.innerHTML = `Сейчас в пути ${list.length} ${many ? "автобуса" : "автобус"}`; + rows.innerHTML = list.map(b => + `
→ ${b.seg.to}прибытие в ${fmtHM(b.seg.end)}
` + ).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 = `
+ Карта не загрузилась. Нужен интернет: стиль и тайлы VK Карт грузятся из сети.
`; + }, 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 = `
+ Не удалось загрузить карту: ${err.message}
Нужен интернет.
`; + }); +} + +if (MODE === "map") initMap(); +