7 Commits
v2.6.4 ... main

Author SHA1 Message Date
7d96793c27 Версия 2.6.6, сборка 15 2026-09-30 09:53:49 +00:00
4549506d6d Комментарий к версии: 2.6.6 (сборка 15) 2026-09-30 09:53:48 +00:00
d07d9407cf Системная «назад» спрашивает страницу, а не закрывает приложение 2026-09-30 09:53:47 +00:00
20ed52384a Карта в меню, чистка повторов, подпись разработчика 2026-09-30 09:53:44 +00:00
b5821a1fc7 Версия 2.6.5 (сборка 14) 2026-09-29 12:04:52 +00:00
c639996ca8 Мост JS→Java: добавлен openMap()
index.html вызывает Bus.openMap(), но метода не было в репозитории.
Сборка из исходников давала бы приложение, где пункт меню «Карта»
ничего не делает. APK 2.6.4 собирался из локальной копии, поэтому у
пользователей дефект не проявлялся. Здесь же актуализирован комментарий
к version(), значение он по-прежнему берёт из менеджера пакетов.
2026-09-29 12:04:51 +00:00
9b2a45568e Расписание по официальным данным лета 2026, прибытие и шторка на карте
Данные диспетчерской службы ООО «Гарант-Авто» на 01.05–31.10.2026,
источник kandalaksha.org/static/auto.html. Опубликованы четыре списка
ОТПОРЯЖЕНИЙ, 13/10/6 рейсов; колонка 2 — это выезд из Зашейка,
а не прибытие, из-за чего приложение показывало нелогичные интервалы.

Расписание:
- убран рейс 08:00/08:50/09:20/09:50, которого нет в источнике;
- добавлен рейс 11:30/12:20/12:50/13:20;
- в рейсе на Кандалакшу 14:10 → 14:15;
- суббота и воскресенье на 5 минут (09:05 → 09:10 и далее по списку);
- 14:20, 14:50, 15:10 и 15:50 подтверждены источником и оставлены.

Время прибытия в карточках направлений считается, а не берётся
из таблицы: Зашеек = отправление + 20 мин, но не дольше интервала
до выезда оттуда; Кандалакша = + 50 мин.

Карта:
- нижняя шторка с ручкой: перетаскивание, тап для сворачивания,
  автопрокрутка текста, карта перестраивается при изменении размера;
- заголовок «Автобус №127» в одну строку на всех экранах, раньше
  на карте он переносился, а на остальных терял пробел перед №127;
- кнопка «‹» на карте возвращает на главный экран;
- между прибытием в Зашеек и выездом автобус стоит на остановке;
- убраны 40px лишней прокрутки страницы на вкладке карты.
2026-09-29 12:04:50 +00:00
4 changed files with 371 additions and 85 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId "ru.ua1zbe.bus127" applicationId "ru.ua1zbe.bus127"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 13 versionCode 15
versionName "2.6.4" versionName "2.6.6"
} }
buildTypes { buildTypes {

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}
@@ -65,9 +69,11 @@
color:var(--txt);border-radius:10px;width:38px;height:38px; color:var(--txt);border-radius:10px;width:38px;height:38px;
font-size:22px;line-height:1;font-family:inherit;cursor:pointer; font-size:22px;line-height:1;font-family:inherit;cursor:pointer;
align-items:center;justify-content:center;padding:0} align-items:center;justify-content:center;padding:0}
body.app .iconbtn,body.about .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,15 +95,36 @@
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{padding:24px 16px 16px;overflow:hidden} body.map{height:100vh;height:100dvh;min-height:0;padding:24px 16px 16px;overflow:hidden}
body.map .wrap{height:100vh;height:100dvh;display:flex;flex-direction:column;max-width:none} body.map .wrap{height:100%;display:flex;flex-direction:column;max-width:none}
body.map header{margin-bottom:12px} body.map header{margin-bottom:12px}
/* заголовок всегда в одну строку, в том числе на карте */
body.map h1{font-size:20px;line-height:1.2;white-space:nowrap}
body.map .clock{font-size:18px;margin-top:6px}
body.map .day,body.map .pickers,body.map .where,body.map .dir, 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 .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} body.map #mapBox{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:8px}
@@ -106,15 +133,29 @@
.mapcanvas.loading::after{content:"Загружаю карту…";position:absolute;inset:0; .mapcanvas.loading::after{content:"Загружаю карту…";position:absolute;inset:0;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
color:var(--muted);font-size:14px} 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} /* нижняя шторка: свёрнута до ручки с заголовком, тянется вверх,
а текст внутри прокручивается отдельно от карты */
.mapsheet{flex:0 0 auto;position:relative;display:flex;flex-direction:column;
background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;box-shadow:0 -6px 20px rgba(0,0,0,.35)}
.grab{position:relative;flex:0 0 auto;display:flex;align-items:center;gap:8px;
padding:14px 14px 8px;cursor:grab;touch-action:none;user-select:none;
border-bottom:1px solid var(--line)}
.grab .bar{position:absolute;top:6px;left:50%;width:34px;height:4px;margin-left:-17px;
border-radius:2px;background:#3a4a70}
.grab .cap{flex:1 1 auto;min-width:0;font-size:12px;font-weight:700;color:var(--muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.grab .chev{color:var(--muted);font-size:11px;transition:transform .2s}
.mapsheet.open .grab .chev{transform:rotate(180deg)}
.mapstatus{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;
overscroll-behavior:contain;padding:11px 14px 14px}
.mapstatus .head{font-size:14px;font-weight:700;margin-bottom:4px} .mapstatus .head{font-size:14px;font-weight:700;margin-bottom:4px}
.mapstatus .head b{color:var(--accent)} .mapstatus .head b{color:var(--accent)}
.mapstatus .rows{font-size:13px;color:var(--muted);line-height:1.55} .mapstatus .rows{font-size:13px;color:var(--muted);line-height:1.55}
.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}
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */ /* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
.pin{transform:translate(-50%,-100%);white-space:nowrap;pointer-events:none} .pin{transform:translate(-50%,-100%);white-space:nowrap;pointer-events:none}
@@ -168,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>
@@ -189,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>
@@ -197,63 +238,133 @@
</div> </div>
<div class="thead"> <div class="thead">
<div><b>Кандалакша</b>отпр.</div> <div><b>Кандалакша</b>отпр.</div>
<div><b>Полярные Зори</b>→ Зашеек</div> <div><b>Полярные Зори</b>отпр. в Зашеек</div>
<div><b>Зашеек</b>прибытие</div> <div><b>Зашеек</b>отпр.</div>
<div><b>Полярные Зори</b>→ Кандалакша</div> <div><b>Полярные Зори</b>отпр. в Канд.</div>
</div> </div>
<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="mapstatus"> <div class="mapsheet" id="mapSheet">
<div class="head" id="mapHead">Карта маршрута</div> <div class="grab" id="sheetGrab">
<div class="rows" id="mapRows"></div> <span class="bar"></span>
<div class="hint">Положение автобуса рассчитано по расписанию, а не по GPS: <span class="cap" id="sheetCap">Автобусы в пути</span>
между остановками интерполируется по дороге. Точность — до нескольких минут.</div> <span class="chev">▴</span>
</div>
<div class="mapstatus">
<div id="mapContent">
<div class="head" id="mapHead">Сейчас автобусов нет</div>
<div class="rows" id="mapRows"></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">Расписание действует с 30.09.2020. Время московское (МСК).</p> <p>Действует летнее расписание <b>с 1 мая по 31 октября 2026 года</b>.
<p class="muted">Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap. Отправления взяты из данных диспетчерской службы перевозчика,
Пуш-уведомления приходят через RuStore Push — для этого нужен время прибытия приложение рассчитывает само — подробно в «Справке».</p>
<p>Источник: <a href="https://kandalaksha.org/static/auto.html" target="_blank" rel="noopener">kandalaksha.org/static/auto.html</a><br>
Телефон диспетчерской службы: 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>Расписание действует с 30.09.2020 · время московское (МСК)</footer> <footer>Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным
диспетчерской службы перевозчика · время московское (МСК)</footer>
</div> </div>
<script> <script>
// Расписание по типам дней недели. // Расписание по типам дней недели. Взято с официального расписания
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) · // (данные диспетчерской службы перевозчика ООО «Гарант-Авто»,
// прибытие в Зашеек · Полярные Зори (обратно). // kandalaksha.org/static/auto.html), летнее расписание
// с 01.05.2026 по 31.10.2026.
// //
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории // Опубликованы только ОТПРАВЛЕНИЯ, и четыре колонки склеиваются
// хранились только отправления из Полярных Зорь и прибытия в Зашеек // в один круг рейса попарно, 1:1 — проверено, что моменты строго
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому // возрастают в каждой строке:
// первая и четвёртая колонки восстановлены по тем же правилам, что и для // кол0 — отправление из Кандалакши
// будней, и требуют сверки с официальным расписанием: // кол1 — отправление из Полярных Зорь в Зашеек
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин) // кол2 — отправление ИЗ Зашейка (обратно в Полярные Зори)
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13) // кол3 — отправление из Полярных Зорь в Кандалакшу
//
// Важно: кол2 — это выезд из Зашейка, а не прибытие в него.
// Раньше колонка была подписана как прибытие, и из-за этого один и тот же
// автобус «прибывал» и «уезжал» в Зашеек в одну минуту, а интервалы
// туда и обратно выходили разными на одной и той же дороге.
// Прибытие в Зашеек в расписании не публикуется — оно считается
// отдельно, см. arrZasheek ниже.
const SCHEDULES = { const SCHEDULES = {
weekday: [ weekday: [
["05:20","06:20","06:30","06:50"], ["05:20","06:20","06:30","06:50"],
["06:10","07:00","07:25","07:45"], ["06:10","07:00","07:25","07:45"],
["07:40","08:30","09:00","09:20"], ["07:40","08:30","09:00","09:20"],
["08:00","08:50","09:20","09:50"],
["09:45","10:35","11:05","11:25"], ["09:45","10:35","11:05","11:25"],
["11:00","11:50","12:20","12:40"], ["11:00","11:50","12:20","12:40"],
["12:30","13:20","13:50","14:10"], ["11:30","12:20","12:50","13:20"],
["12:30","13:20","13:50","14:15"],
["13:30","14:20","14:50","15:10"], ["13:30","14:20","14:50","15:10"],
["14:00","14:50","15:25","15:50"], ["14:00","14:50","15:25","15:50"],
["15:30","16:20","17:00","17:25"], ["15:30","16:20","17:00","17:25"],
@@ -263,23 +374,23 @@ const SCHEDULES = {
], ],
saturday: [ saturday: [
["06:10","07:00","07:25","07:45"], ["06:10","07:00","07:25","07:45"],
["07:30","08:20","08:45","09:05"], ["07:30","08:20","08:45","09:10"],
["08:45","09:35","10:05","10:25"], ["08:45","09:35","10:05","10:30"],
["10:10","11:00","11:30","11:50"], ["10:10","11:00","11:30","11:55"],
["12:30","13:20","13:50","14:10"], ["12:30","13:20","13:50","14:15"],
["14:00","14:50","15:45","16:05"], ["14:00","14:50","15:45","16:10"],
["15:30","16:20","16:45","17:05"], ["15:30","16:20","16:45","17:10"],
["17:00","17:50","18:20","18:40"], ["17:00","17:50","18:20","18:45"],
["18:10","19:00","19:30","19:50"], ["18:10","19:00","19:30","19:55"],
["20:20","21:10","21:40","22:00"] ["20:20","21:10","21:40","22:05"]
], ],
sunday: [ sunday: [
["06:10","07:00","07:25","07:45"], ["06:10","07:00","07:25","07:45"],
["08:45","09:35","10:05","10:25"], ["08:45","09:35","10:05","10:30"],
["12:30","13:20","13:50","14:10"], ["12:30","13:20","13:50","14:15"],
["14:00","14:50","15:25","15:45"], ["14:00","14:50","15:25","15:50"],
["16:50","17:40","18:10","18:30"], ["16:50","17:40","18:10","18:35"],
["20:20","21:10","21:40","22:00"] ["20:20","21:10","21:40","22:05"]
] ]
}; };
const DAY_KEYS = ["weekday","saturday","sunday"]; const DAY_KEYS = ["weekday","saturday","sunday"];
@@ -289,21 +400,38 @@ const TRIPS_SHOWN = 5; // сколько ближайших рейсов по
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет: // Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут // в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
// (в первой строке 60 — по всей видимости опечатка). // (в первой строке 60 — по всей видимости опечатка в самом расписании).
const RIDE_PZ_KAND = 50; const RIDE_PZ_KAND = 50;
// arrs — список прибытий: {label, col} — время из колонки таблицы, // Полярные Зори → Зашеек. В расписании тоже нет: там опубликованы только
// {label, offset} — «время в пути» от отправления (если col не задан). // отправления. Принимаем 20 минут — но не больше, чем есть между выездом
// из Полярных Зорь и выездом обратно, иначе на первом утреннем рейсе
// (06:20 → 06:30) автобус «прибыл» бы в Зашеек уже после своего выезда.
const RIDE_PZ_ZASHEEK = 20;
// Прибытие в Зашеек — оценка, потому что официального времени прибытия
// в расписании нет. Считаем от выезда из Полярных Зорь.
function arrZasheek(row){
const t1 = toMin(row[1]), t2 = toMin(row[2]);
return t1 + Math.max(0, Math.min(RIDE_PZ_ZASHEEK, t2 - t1));
}
// arrs — список прибытий: {label, col} — время из колонки расписания,
// {label, offset} — «время в пути» от отправления (если col не задан),
// {label, arrZ:true} — оценка прибытия в Зашеек, см. arrZasheek().
// В расписании опубликованы только отправления, поэтому прибытия
// (кроме Зашейка, где есть своя оценка) берутся из отправлений следующей
// остановки: автобус приезжает на ПЗ и сразу уходит дальше.
const DIRECTIONS = { const DIRECTIONS = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка // От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [ "Кандалакша": [
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори", {to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:1}, arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]} {label:"Зашеек", arrZ:true}]}
], ],
"Полярные Зори": [ "Полярные Зори": [
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути", {to:"Зашеек", dep:1, via:"≈ 20 мин в пути",
arrs:[{label:"Зашеек", col:2}]}, arrs:[{label:"Зашеек", arrZ:true}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути", {to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]} arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
], ],
@@ -384,7 +512,9 @@ function render(){
.map((r,ri)=>{ .map((r,ri)=>{
const dep = toMin(r[d.dep]); const dep = toMin(r[d.dep]);
const arrs = d.arrs.map(a=>{ const arrs = d.arrs.map(a=>{
const base = a.col!==undefined ? toMin(r[a.col]) : dep; const base = a.col!==undefined ? toMin(r[a.col])
: a.arrZ ? arrZasheek(r)
: dep;
return {label:a.label, t:fmtHM(base + (a.offset||0))}; return {label:a.label, t:fmtHM(base + (a.offset||0))};
}); });
return {dep, arrs, ri}; return {dep, arrs, ri};
@@ -483,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();
}); });
@@ -523,6 +687,7 @@ let KAND_TO_PZ = null; // Кандалакша → Полярные Зори
let PZ_TO_ZASHEEK = null;// Полярные Зори → Зашеек let PZ_TO_ZASHEEK = null;// Полярные Зори → Зашеек
let PZ_TO_ZASHEEK_BACK = null; let PZ_TO_ZASHEEK_BACK = null;
let KAND_TO_PZ_BACK = null; let KAND_TO_PZ_BACK = null;
let MAPREF = null; // карта нужна шторке, чтобы пересчитать размер холста
// Длина ломаной с накопленными отрезками. Расстояние считаем в // Длина ломаной с накопленными отрезками. Расстояние считаем в
// равнопромежуточной проекции — на таком масштабе это точнее градусов. // равнопромежуточной проекции — на таком масштабе это точнее градусов.
@@ -553,21 +718,25 @@ function pointAt(p, f){
return [x1+(x2-x1)*t, y1+(y2-y1)*t]; return [x1+(x2-x1)*t, y1+(y2-y1)*t];
} }
// Рейс разложен на четыре участка: туда Кандалакша → ПЗ → Зашеек и обратно. // Рейс разложен на участки круга: Кандалакша → ПЗ → Зашеек → ПЗ → Кандалакша.
// Время в пути ПЗ → Кандалакша в таблице не указано, берём те же 50 минут, // Колонка кол2 — это выезд ИЗ Зашейка, поэтому между прибытием туда
// что и в DIRECTIONS. // (оценка arrZasheek) и выездом автобус стоит на остановке: отдельный
// участок hold, на котором точка не двигается. Время в пути ПЗ → Кандалакша
// в расписании не указано, берём те же 50 минут, что и в DIRECTIONS.
function makeRun(row){ function makeRun(row){
const t0=toMin(row[0]), t1=toMin(row[1]), t2=toMin(row[2]), t3=toMin(row[3]); const t0=toMin(row[0]), t1=toMin(row[1]), t2=toMin(row[2]), t3=toMin(row[3]);
const back = t3 + RIDE_PZ_KAND; // прибытие в Кандалакшу const z = arrZasheek(row); // прибытие в Зашеек (оценка)
return { const back = t3 + RIDE_PZ_KAND; // прибытие в Кандалакшу
t0, tEnd: back, const segs = [
segs: [ {a:t0, b:t1, line:KAND_TO_PZ, to:"Полярные Зори", end:t1},
{a:t0, b:t1, line:KAND_TO_PZ, to:"Полярные Зори", end:t1}, {a:t1, b:z, line:PZ_TO_ZASHEEK, to:"Зашеек", end:z}
{a:t1, b:t2, line:PZ_TO_ZASHEEK, to:"Зашеек", end:t2}, ];
{a:t2, b:t3, line:PZ_TO_ZASHEEK_BACK, to:"Полярные Зори", end:t3}, if (t2 > z) segs.push({a:z, b:t2, line:PZ_TO_ZASHEEK, to:"Зашеек", end:t2, hold:true});
{a:t3, b:back, line:KAND_TO_PZ_BACK, to:"Кандалакша", end:back} segs.push(
] {a:t2, b:t3, line:PZ_TO_ZASHEEK_BACK, to:"Полярные Зори", end:t3},
}; {a:t3, b:back, line:KAND_TO_PZ_BACK, to:"Кандалакша", end:back}
);
return {t0, tEnd: back, segs: segs.filter(s=>s.b > s.a)};
} }
// Все рейсы, которые сейчас в пути, вместе с точкой и участком // Все рейсы, которые сейчас в пути, вместе с точкой и участком
@@ -580,7 +749,8 @@ function busesNow(){
if (T < run.t0 || T > run.tEnd) return; if (T < run.t0 || T > run.tEnd) return;
const seg = run.segs.find(s => T >= s.a && T <= s.b); const seg = run.segs.find(s => T >= s.a && T <= s.b);
if (!seg) return; if (!seg) return;
const f = seg.b > seg.a ? (T - seg.a) / (seg.b - seg.a) : 0; // на участке стоянки в Зашейке точка не двигается — держим конец подъезда
const f = seg.hold ? 1 : (seg.b > seg.a ? (T - seg.a) / (seg.b - seg.a) : 0);
out.push({idx, seg, at: pointAt(seg.line, f)}); out.push({idx, seg, at: pointAt(seg.line, f)});
}); });
return out; return out;
@@ -596,19 +766,34 @@ 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;
head.innerHTML = `Сейчас в пути <b>${list.length}</b> ${many ? "автобуса" : "автобус"}`; head.innerHTML = `Сейчас в пути <b>${list.length}</b> ${many ? "автобуса" : "автобус"}`;
rows.innerHTML = list.map(b => rows.innerHTML = list.map(b => b.seg.hold
`<div><span class="when">→ ${b.seg.to}</span><span>прибытие в ${fmtHM(b.seg.end)}</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>`
).join(""); ).join("");
refitSheet(); // строк могло стать больше или меньше
} }
function startMap(map, mmrgl){ function startMap(map, mmrgl){
@@ -703,6 +888,7 @@ function initMap(){
zoom: 10, zoom: 10,
attributionControl: true attributionControl: true
}); });
MAPREF = map;
map.on("load", () => { map.on("load", () => {
ready = true; ready = true;
@@ -748,7 +934,78 @@ function initMap(){
}); });
} }
if (MODE === "map") initMap(); // Нижняя шторка карты. За ручку её тянут вверх, короткое касание — переключает.
// Внутри своя прокрутка, поэтому карта под текстом не ездит, а карта
// VK перерисовывается под изменившуюся высоту холста.
function initSheet(){
const box = document.getElementById("mapBox");
const sheet = document.getElementById("mapSheet");
const grab = document.getElementById("sheetGrab");
if (!box || !sheet || !grab) return;
const status = sheet.querySelector(".mapstatus");
const content = document.getElementById("mapContent");
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){
h = Math.max(min(), Math.min(max(), Math.round(next)));
sheet.style.height = h + "px";
open = !!expanded;
sheet.classList.toggle("open", open);
if (MAPREF) MAPREF.resize(); // холст карты стал ниже — пересчитать
}
// Захват указателя есть не во всех окружениях (и может не сработать при
// синтетическом событии), поэтому на него не опираемся критично.
const captured = e => { try { return grab.hasPointerCapture(e.pointerId); }
catch(_){ return true; } };
const capture = e => { try { grab.setPointerCapture(e.pointerId); } catch(_){} };
const release = e => { try { grab.releasePointerCapture(e.pointerId); } catch(_){} };
grab.addEventListener("pointerdown", e=>{
startY = e.clientY; startH = h; moved = 0; dragged = false;
capture(e);
});
grab.addEventListener("pointermove", e=>{
if (!captured(e)) return;
moved = startY - e.clientY;
if (Math.abs(moved) > 6) dragged = true;
const want = startH + moved;
set(want, want > min() + 8);
});
grab.addEventListener("pointerup", e=>{
release(e);
if (!dragged) { set(open ? min() : max(), !open); return; } // просто тап
const expanded = h > box.clientHeight * 0.4;
set(expanded ? max() : min(), expanded);
});
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));
set(min(), false);
}
if (MODE === "map") { initMap(); initSheet(); }
</script> </script>
</body> </body>

View File

@@ -25,12 +25,17 @@ public class BusBridge {
activity.runOnUiThread(activity::finish); activity.runOnUiThread(activity::finish);
} }
@JavascriptInterface
public void openMap() {
open(MapActivity.class);
}
@JavascriptInterface @JavascriptInterface
public void openAbout() { public void openAbout() {
open(AboutActivity.class); open(AboutActivity.class);
} }
/** «2.6.3 (сборка 12)» — чтобы текст не расходился со сборкой. */ /** «2.6.6 (сборка 15)» — чтобы текст не расходился со сборкой. */
@JavascriptInterface @JavascriptInterface
public String version() { public String version() {
try { try {

View File

@@ -3,15 +3,39 @@ package ru.ua1zbe.bus127;
import android.app.Activity; import android.app.Activity;
import android.os.Bundle; import android.os.Bundle;
import android.view.WindowManager; import android.view.WindowManager;
import android.webkit.WebView;
public class MainActivity extends Activity { public class MainActivity extends Activity {
private WebView web;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
// экран не гаснет, пока открыто расписание // экран не гаснет, пока открыто расписание
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requestNotificationsPermission(); requestNotificationsPermission();
setContentView(Webs.create(this, Webs.URL_MAIN)); web = Webs.create(this, Webs.URL_MAIN);
setContentView(web);
}
/**
* WebView системную кнопку «назад» не перехватывает — без этого нажатие
* сразу закрывало бы приложение, даже когда открыта справка. Спрашиваем
* у страницы, закрыла ли она что-нибудь сама, и выходим только если нет.
*/
@Override
public void onBackPressed() {
if (web == null) {
super.onBackPressed();
return;
}
web.evaluateJavascript(
"(function(){return (window.__busBack && window.__busBack()) ? 1 : 0})()",
value -> {
String v = String.valueOf(value).replace("\"", "").trim();
if (!"1".equals(v)) finish();
});
} }
// Android 13+ требует явного согласия на показ уведомлений // Android 13+ требует явного согласия на показ уведомлений