Карта в меню, чистка повторов, подпись разработчика

This commit is contained in:
2026-09-30 09:53:44 +00:00
parent b5821a1fc7
commit 20ed52384a

View File

@@ -55,7 +55,11 @@
.fullbtn:hover{border-color:var(--accent2)} .fullbtn:hover{border-color:var(--accent2)}
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)} .fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
.fullbtn.open .chev{transform:rotate(180deg)} .fullbtn.open .chev{transform:rotate(180deg)}
.fulltitle{display:none;margin:0;padding:14px 12px 2px;font-size:16px;font-weight:700}
/* кнопка карты — под блоком полного расписания, только в приложении */
#mapBtn{display:none;margin:6px 0 6px}
body.app #mapBtn{display:block}
.mapbtn::before{content:"⌖ ";color:var(--accent2)}
/* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */ /* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */
.headwrap{position:relative} .headwrap{position:relative}
@@ -68,6 +72,8 @@
body.app .iconbtn,body.about .iconbtn,body.map .iconbtn{display:inline-flex} body.app .iconbtn,body.about .iconbtn,body.map .iconbtn{display:inline-flex}
/* на главном экране стрелки нет, но место под неё оставляем, иначе съедет центр */ /* на главном экране стрелки нет, но место под неё оставляем, иначе съедет центр */
body.app .iconbtn.back{visibility:hidden} body.app .iconbtn.back{visibility:hidden}
/* но в справке стрелка «назад» нужна и должна быть кликабельной */
body.app.showhelp .iconbtn.back{visibility:visible}
/* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */ /* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */
#navMenu{padding-bottom:7px} #navMenu{padding-bottom:7px}
.iconbtn:active{border-color:var(--accent2)} .iconbtn:active{border-color:var(--accent2)}
@@ -89,10 +95,28 @@
body.about .aboutcard{display:block} body.about .aboutcard{display:block}
body.about .pickers,body.about .where,body.about .dir, body.about .pickers,body.about .where,body.about .dir,
body.about #fullTable,body.about .fullbtn{display:none} body.about #fullTable,body.about .fullbtn{display:none}
/* внизу «О приложении» те же даты и источник уже написаны своим текстом,
общая сноска про расписание тут только дублирует их */
body.about footer{display:none}
.aboutcard h2{margin:0 0 4px;font-size:17px} .aboutcard h2{margin:0 0 4px;font-size:17px}
.aboutcard .ver{color:var(--muted);font-size:14px;margin-bottom:12px} .aboutcard .ver{color:var(--muted);font-size:14px;margin-bottom:12px}
.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}
.aboutcard p.dev{color:var(--muted);font-size:13px;text-align:center;margin:18px 0 0}
.aboutcard h3{margin:16px 0 6px;font-size:14px;color:var(--accent2)}
.aboutcard a{color:var(--accent2)}
/* «Справка» — тот же экран, что и расписание, просто другой блок */
.helpcard{display:none;background:var(--card);border:1px solid var(--line);
border-radius:14px;padding:16px;margin-bottom:10px}
body.app.showhelp .helpcard{display:block}
body.app.showhelp .pickers,body.app.showhelp .where,body.app.showhelp .dir,
body.app.showhelp .fullbtn,body.app.showhelp #fullTable,
body.app.showhelp #mapBtn,body.app.showhelp footer{display:none}
.helpcard h2{margin:0 0 4px;font-size:17px}
.helpcard h3{margin:16px 0 6px;font-size:14px;color:var(--accent2)}
.helpcard h3:first-of-type{margin-top:12px}
.helpcard p{margin:0 0 10px;font-size:14px;line-height:1.5}
/* экран «Карта»: страница целиком занята картой, шапка остаётся */ /* экран «Карта»: страница целиком занята картой, шапка остаётся */
body.map{height:100vh;height:100dvh;min-height:0;padding:24px 16px 16px;overflow:hidden} body.map{height:100vh;height:100dvh;min-height:0;padding:24px 16px 16px;overflow:hidden}
@@ -132,12 +156,6 @@
.mapstatus .rows div{display:flex;gap:8px;align-items:baseline} .mapstatus .rows div{display:flex;gap:8px;align-items:baseline}
.mapstatus .rows .when{color:var(--txt);font-variant-numeric:tabular-nums; .mapstatus .rows .when{color:var(--txt);font-variant-numeric:tabular-nums;
white-space:nowrap;font-size:12px} white-space:nowrap;font-size:12px}
.mapstatus .hint{margin-top:7px;font-size:11px;color:var(--muted);line-height:1.4}
.note{margin-top:10px;padding-top:10px;border-top:1px solid var(--line);
font-size:12px;line-height:1.55;color:var(--muted)}
.note p{margin:0 0 8px}
.note p:last-child{margin-bottom:0}
.note b{color:var(--txt)}
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */ /* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
.pin{transform:translate(-50%,-100%);white-space:nowrap;pointer-events:none} .pin{transform:translate(-50%,-100%);white-space:nowrap;pointer-events:none}
@@ -191,7 +209,8 @@
</div> </div>
<nav class="menu" id="menuBox" hidden> <nav class="menu" id="menuBox" hidden>
<button class="menuitem" id="mMap">Карта</button> <button class="menuitem" id="mMap">Карта маршрута</button>
<button class="menuitem" id="mHelp">Справка</button>
<button class="menuitem" id="mAbout">О приложении</button> <button class="menuitem" id="mAbout">О приложении</button>
</nav> </nav>
</div> </div>
@@ -212,7 +231,6 @@
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button> <button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
<section class="tablecard" id="fullTable"> <section class="tablecard" id="fullTable">
<h2 class="fulltitle">Полное расписание</h2>
<div class="days" id="dayPick"> <div class="days" id="dayPick">
<button data-day="weekday" class="active">Будни</button> <button data-day="weekday" class="active">Будни</button>
<button data-day="saturday">Суббота</button> <button data-day="saturday">Суббота</button>
@@ -227,56 +245,91 @@
<div id="tableBody"></div> <div id="tableBody"></div>
</section> </section>
<button class="fullbtn mapbtn" id="mapBtn">Карта маршрута</button>
<section class="mapcard" id="mapBox" hidden> <section class="mapcard" id="mapBox" hidden>
<div class="mapcanvas loading" id="mapCanvas"></div> <div class="mapcanvas loading" id="mapCanvas"></div>
<div class="mapsheet" id="mapSheet"> <div class="mapsheet" id="mapSheet">
<div class="grab" id="sheetGrab"> <div class="grab" id="sheetGrab">
<span class="bar"></span> <span class="bar"></span>
<span class="cap" id="sheetCap">Автобусы и расписание</span> <span class="cap" id="sheetCap">Автобусы в пути</span>
<span class="chev">▴</span> <span class="chev">▴</span>
</div> </div>
<div class="mapstatus"> <div class="mapstatus">
<div class="head" id="mapHead">Карта маршрута</div> <div id="mapContent">
<div class="rows" id="mapRows"></div> <div class="head" id="mapHead">Сейчас автобусов нет</div>
<div class="hint">Положение автобуса рассчитано по расписанию, а не по GPS: <div class="rows" id="mapRows"></div>
между остановками интерполируется по дороге. Точность — до нескольких минут.</div>
<div class="note">
<p><b>Как читать расписание.</b> Таблица и списки показывают официальное время
<b>отправлений</b>. Время прибытия в расписании не публикуется: оно берётся
из отправления следующей остановки, потому что автобус приезжает
на Полярные Зори и сразу уходит дальше. Исключение — Зашеек: там
прибытие посчитано как выезд из Полярных Зорь плюс 20 минут.
Получившиеся интервалы в обе стороны по одной дороге отличаются
на 5–10 минут — так и есть в официальном расписании, это не ошибка.</p>
<p><b>Положение автобусов.</b> GPS у приложения нет, координаты
интерполируются по дороге между остановками по времени рейса.
В Зашейке автобус стоит, пока не подошло время обратного выезда.
Точность — до нескольких минут, в пробки отставание больше.</p>
<p class="muted">Расписание сверено с официальным: отправления взяты из данных
диспетчерской службы перевозчика, летнее расписание
с 01.05.2026 по 31.10.2026, время московское. Карта и тайлы — VK Карты,
геометрия дороги — OpenStreetMap.</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="helpcard" id="helpBox">
<h2>Справка</h2>
<h3>Как читать расписание</h3>
<p>Таблица и списки показывают официальное время <b>отправлений</b>.
Время прибытия перевозчик не публикует, поэтому приложение считает его
само: до Зашейка — как выезд с Полярных Зорь плюс 20 минут, до
Кандалакши — плюс 50 минут.</p>
<p>Одна остановка — исключение. Когда автобус приезжает на Полярные Зори,
он сразу уходит дальше, поэтому время прибытия совпадает с отправлением
следующей остановки. В Зашейке автобус задерживается, и там прибытие
считается по времени в пути, а не по расписанию.</p>
<p>Из-за этого интервалы в обе стороны по одной дороге отличаются
на 5–10 минут. Так и есть в официальном расписании, это не ошибка
приложения.</p>
<h3>Время прибытия в карточках</h3>
<p>В списке рейсов под заголовком направления показано время прибытия
в Зашеек или в Кандалакшу — серым под временем отправления. Время
московское (МСК).</p>
<h3>Положение автобусов на карте</h3>
<p>GPS у приложения нет. Положение считается по расписанию: между
известными точками маршрута координаты интерполируются по времени рейса,
доля пути — по длине дороги, поэтому метка идёт по трассе, а не по прямой.
В Зашейке автобус стоит, пока не подошло время обратного выезда.</p>
<p>Точность — до нескольких минут. В пробки реальное отставание больше,
и метка может показывать автобус позади или впереди машины.</p>
<p>Список под картой обновляется каждые 20 секунд: для каждого автобуса
в пути указано направление и время прибытия на следующую остановку.</p>
<h3>Когда обновляется расписание</h3>
<p>Часы, ближайшие рейсы и положение автобусов обновляются автоматически.
Список рейсов — раз в минуту, часы — раз в секунду. В приложении нет
кнопки обновления: перезапускать ничего не нужно.</p>
<h3>Где брать точные данные</h3>
<p>Время отправления перевозчик меняет без предупреждения. Сроки
действия расписания и телефон диспетчерской службы указаны
в разделе «О приложении» — уточнять время лучше там.</p>
</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>
<p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.</p> <p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.
<p>Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд. Ближайшие рейсы по вашей остановке, полное расписание по будням, субботам
Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов, и воскресеньям, а также карта с положением автобусов в пути.</p>
с переключением между буднями, субботой и воскресеньем.</p> <p>Всё время — московское (МСК). Данные обновляются автоматически,
<p>В меню есть «Карта»: там видно, где сейчас едут автобусы, и как пройден интернет нужен только для карты.</p>
участок между остановками. Положение считается по расписанию — приложение
не получает данные с GPS, поэтому это оценка с точностью до нескольких минут.</p> <h3>Расписание</h3>
<p class="muted">Расписание сверено с официальным: отправления взяты из данных <p>Действует летнее расписание <b>с 1 мая по 31 октября 2026 года</b>.
диспетчерской службы перевозчика, летнее расписание Отправления взяты из данных диспетчерской службы перевозчика,
с 01.05.2026 по 31.10.2026. Время московское (МСК).</p> время прибытия приложение рассчитывает само — подробно в «Справке».</p>
<p class="muted">Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap. <p>Источник: <a href="https://kandalaksha.org/static/auto.html" target="_blank" rel="noopener">kandalaksha.org/static/auto.html</a><br>
Пуш-уведомления приходят через RuStore Push — для этого нужен Телефон диспетчерской службы: 8(921) 174-26-00, 8(911) 303-63-28</p>
<h3>Что внутри</h3>
<p>Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap.
Пуш-уведомления приходят через RuStore Push: для этого нужен
установленный и авторизованный RuStore на устройстве.</p> установленный и авторизованный RuStore на устройстве.</p>
<p class="muted">Приложение не использует геолокацию и не передаёт
данные о вашем местоположении: положение автобусов вычисляется
по расписанию на устройстве.</p>
<p class="dev">Разработчик © UA1ZBE</p>
</section> </section>
<footer>Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным <footer>Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным
@@ -560,13 +613,47 @@ if (NATIVE) {
e.stopPropagation(); e.stopPropagation();
menuBox.hidden = !menuBox.hidden; menuBox.hidden = !menuBox.hidden;
}); });
// «Справка» — блок на этом же экране. Снимаем её и кнопкой «‹»,
// и аппаратной «назад»: для этого кладём в историю браузера состояние,
// чтобы система отдала его нам, а не закрыла приложение.
const helpOn = () => document.body.classList.contains("showhelp");
const showHelp = on => {
document.body.classList.toggle("showhelp", on);
if (on) window.scrollTo(0, 0);
};
document.getElementById("mMap").addEventListener("click",()=>{ closeMenu(); Bus.openMap(); });
document.getElementById("mHelp").addEventListener("click",()=>{
closeMenu();
showHelp(true);
history.pushState({ help: 1 }, "");
});
window.addEventListener("popstate", () => { if (helpOn()) showHelp(false); });
const hideHelp = () => {
if (!helpOn()) return false;
showHelp(false);
if (history.state && history.state.help) history.back(); // убрать и из истории
return true;
};
// Один шаг «назад»: справка, потом меню. false — значит, уходить из приложения.
const backStep = () => {
if (hideHelp()) return true;
if (!menuBox.hidden) { closeMenu(); return true; }
return false;
};
document.getElementById("navBack").addEventListener("click",()=>{ document.getElementById("navBack").addEventListener("click",()=>{
// «назад» сначала сворачивает открытое меню и только потом уходит назад // «назад» сначала закрывает справку, потом сворачивает меню,
if (!menuBox.hidden) { closeMenu(); return; } // и только потом уходит назад
if (backStep()) return;
Bus.goBack(); Bus.goBack();
}); });
document.getElementById("mMap").addEventListener("click",()=>{ closeMenu(); Bus.openMap(); }); // Системная кнопка «назад» WebView не перехватывает, поэтому Android
// спрашивает у страницы то же самое, что и стрелка «‹» (MainActivity).
window.__busBack = backStep;
document.getElementById("mAbout").addEventListener("click",()=>{ closeMenu(); Bus.openAbout(); }); document.getElementById("mAbout").addEventListener("click",()=>{ closeMenu(); Bus.openAbout(); });
document.getElementById("mapBtn").addEventListener("click",()=>Bus.openMap());
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();
}); });
@@ -679,12 +766,25 @@ function pinElement(cls, num, text){
return root; return root;
} }
// Свёрнутая шторка подстраивается под содержимое, а список автобусов
// обновляется каждые 20 секунд. Ширину/высоту слушаем и у DOM, и у окна:
// первый срабатывает при переносе строк, второй — при смене шрифта.
// Заполняется в initSheet, ниже по файлу.
let setSheetHeight = null;
const refitSheet = () => { if (setSheetHeight) setSheetHeight(null); };
if (typeof ResizeObserver === "function") {
const st = document.getElementById("mapContent");
if (st) new ResizeObserver(refitSheet).observe(st);
}
window.addEventListener("resize", refitSheet);
function renderMapStatus(list){ function renderMapStatus(list){
const head = document.getElementById("mapHead"); const head = document.getElementById("mapHead");
const rows = document.getElementById("mapRows"); const rows = document.getElementById("mapRows");
if (!list.length) { if (!list.length) {
head.innerHTML = "Сейчас автобусов нет"; head.innerHTML = "Сейчас автобусов нет";
rows.innerHTML = `<div>Первый рейс — в ${SCHEDULES[todayKey()][0][0]} из Кандалакши.</div>`; rows.innerHTML = `<div>Первый рейс — в ${SCHEDULES[todayKey()][0][0]} из Кандалакши.</div>`;
refitSheet();
return; return;
} }
const many = list.length > 1; const many = list.length > 1;
@@ -693,6 +793,7 @@ function renderMapStatus(list){
? `<div><span class="when">Зашеек</span><span>стоит на остановке, выезд в ${fmtHM(b.seg.b)}</span></div>` ? `<div><span class="when">Зашеек</span><span>стоит на остановке, выезд в ${fmtHM(b.seg.b)}</span></div>`
: `<div><span class="when">→ ${b.seg.to}</span><span>прибытие в ${fmtHM(b.seg.end)}</span></div>` : `<div><span class="when">→ ${b.seg.to}</span><span>прибытие в ${fmtHM(b.seg.end)}</span></div>`
).join(""); ).join("");
refitSheet(); // строк могло стать больше или меньше
} }
function startMap(map, mmrgl){ function startMap(map, mmrgl){
@@ -842,12 +943,24 @@ function initSheet(){
const grab = document.getElementById("sheetGrab"); const grab = document.getElementById("sheetGrab");
if (!box || !sheet || !grab) return; if (!box || !sheet || !grab) return;
const MIN = 118; const status = sheet.querySelector(".mapstatus");
const max = () => Math.max(MIN, Math.round(box.clientHeight * 0.72)); const content = document.getElementById("mapContent");
let open = false, h = MIN, startY = 0, startH = 0, moved = 0, dragged = false; const max = () => Math.max(60, Math.round(box.clientHeight * 0.72));
// Свёрнутое состояние — ровно по содержимому: ручка, список автобусов и
// внутренние отступы прокручиваемого блока. Высоту берём у #mapContent,
// а не у .mapstatus: тот растягивается флексом, и его scrollHeight при
// раскрытой шторке равен clientHeight — из-за этого сворачивание
// «по содержимому» заканчивалось бы раздуванием до максимума.
const min = () => {
const cs = getComputedStyle(status);
const pad = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
return Math.min(max(), Math.round(grab.getBoundingClientRect().height
+ content.offsetHeight + pad));
};
let open = false, h = min(), startY = 0, startH = 0, moved = 0, dragged = false;
function set(next, expanded){ function set(next, expanded){
h = Math.max(MIN, Math.min(max(), Math.round(next))); h = Math.max(min(), Math.min(max(), Math.round(next)));
sheet.style.height = h + "px"; sheet.style.height = h + "px";
open = !!expanded; open = !!expanded;
sheet.classList.toggle("open", open); sheet.classList.toggle("open", open);
@@ -870,20 +983,26 @@ function initSheet(){
moved = startY - e.clientY; moved = startY - e.clientY;
if (Math.abs(moved) > 6) dragged = true; if (Math.abs(moved) > 6) dragged = true;
const want = startH + moved; const want = startH + moved;
set(want, want > MIN + 8); set(want, want > min() + 8);
}); });
grab.addEventListener("pointerup", e=>{ grab.addEventListener("pointerup", e=>{
release(e); release(e);
if (!dragged) { set(open ? MIN : max(), !open); return; } // просто тап if (!dragged) { set(open ? min() : max(), !open); return; } // просто тап
const expanded = h > box.clientHeight * 0.4; const expanded = h > box.clientHeight * 0.4;
set(expanded ? max() : MIN, expanded); set(expanded ? max() : min(), expanded);
}); });
grab.addEventListener("pointercancel", ()=>{ set(open ? max() : MIN, open); }); grab.addEventListener("pointercancel", ()=>{ set(open ? max() : min(), open); });
// Высота шторки. next === null — просто пересчитать по текущему содержимому,
// не трогая состояние «раскрыта/свёрнута»: этим пользуется список автобусов.
setSheetHeight = next => {
set(next === null ? (open ? max() : min()) : next, open);
};
// поворот экрана или смена высоты окна — пересчитать предел раскрытия // поворот экрана или смена высоты окна — пересчитать предел раскрытия
window.addEventListener("resize", ()=> set(open ? max() : MIN, open)); window.addEventListener("resize", ()=> set(open ? max() : min(), open));
set(MIN, false); set(min(), false);
} }
if (MODE === "map") { initMap(); initSheet(); } if (MODE === "map") { initMap(); initSheet(); }