@@ -55,7 +55,11 @@
. fullbtn : hover { border-color : var ( - - accent2 ) }
. fullbtn . chev { float : right ; transition : .2 s ; color : var ( - - muted ) }
. fullbtn . open . chev { transform : rotate ( 180 deg ) }
. fulltitle { display : none ; margin : 0 ; padding : 14 px 12 px 2 px ; font-size : 16 px ; font-weight : 700 }
/* кнопка карты — под блоком полного расписания, только в приложении */
# mapBtn { display : none ; margin : 6 px 0 6 px }
body . app # mapBtn { display : block }
. mapbtn :: before { content : "⌖ " ; color : var ( - - accent2 ) }
/* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */
. headwrap { position : relative }
@@ -65,9 +69,11 @@
color : var ( - - txt ) ; border-radius : 10 px ; width : 38 px ; height : 38 px ;
font-size : 22 px ; line-height : 1 ; font-family : inherit ; cursor : pointer ;
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 . showhelp . iconbtn . back { visibility : visible }
/* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */
# navMenu { padding-bottom : 7 px }
. iconbtn : active { border-color : var ( - - accent2 ) }
@@ -89,15 +95,36 @@
body . about . aboutcard { display : block }
body . about . pickers , body . about . where , body . about . dir ,
body . about # fullTable , body . about . fullbtn { display : none }
/* внизу «О приложении» те же даты и источник уже написаны своим текстом,
общая сноска про расписание тут только дублирует их */
body . about footer { display : none }
. aboutcard h2 { margin : 0 0 4 px ; font-size : 17 px }
. aboutcard . ver { color : var ( - - muted ) ; font-size : 14 px ; margin-bottom : 12 px }
. aboutcard p { margin : 0 0 10 px ; font-size : 14 px ; line-height : 1.5 }
. aboutcard p . muted { color : var ( - - muted ) ; font-size : 13 px }
. aboutcard p . dev { color : var ( - - muted ) ; font-size : 13 px ; text-align : center ; margin : 18 px 0 0 }
. aboutcard h3 { margin : 16 px 0 6 px ; font-size : 14 px ; color : var ( - - accent2 ) }
. aboutcard a { color : var ( - - accent2 ) }
/* «Справка» — тот же экран, что и расписание, просто другой блок */
. helpcard { display : none ; background : var ( - - card ) ; border : 1 px solid var ( - - line ) ;
border-radius : 14 px ; padding : 16 px ; margin-bottom : 10 px }
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 4 px ; font-size : 17 px }
. helpcard h3 { margin : 16 px 0 6 px ; font-size : 14 px ; color : var ( - - accent2 ) }
. helpcard h3 : first-of-type { margin-top : 12 px }
. helpcard p { margin : 0 0 10 px ; font-size : 14 px ; line-height : 1.5 }
/* экран «Карта»: страница целиком занята картой, шапка остаётся */
body . map { padding : 24 px 16 px 16 px ; overflow : hidden }
body . map . wrap { height : 100 vh ; height : 100 dvh ; display : flex ; flex-direction : column ; max-width : none }
body . map { height : 100 vh ; height : 100 dvh ; min-height : 0 ; padding: 24 px 16 px 16 px ; overflow : hidden }
body . map . wrap { height : 100 % ; display : flex ; flex-direction : column ; max-width : none }
body . map header { margin-bottom : 12 px }
/* заголовок всегда в одну строку, в том числе на карте */
body . map h1 { font-size : 20 px ; line-height : 1.2 ; white-space : nowrap }
body . map . clock { font-size : 18 px ; margin-top : 6 px }
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 : 8 px }
@@ -106,15 +133,29 @@
. mapcanvas . loading :: after { content : "Загружаю карту…" ; position : absolute ; inset : 0 ;
display : flex ; align-items : center ; justify-content : center ;
color : var ( - - muted ) ; font-size : 14 px }
. mapstatus { flex : 0 0 auto ; background : var ( - - card ) ; border : 1 px solid var ( - - line ) ;
border-radius : 14 px ; padding : 11 px 14 px }
/* нижняя шторка: свёрнута до ручки с заголовком, тянется вверх,
а текст внутри прокручивается отдельно от карты */
. mapsheet { flex : 0 0 auto ; position : relative ; display : flex ; flex-direction : column ;
background : var ( - - card ) ; border : 1 px solid var ( - - line ) ; border-radius : 14 px ;
overflow : hidden ; box-shadow : 0 -6 px 20 px rgba ( 0 , 0 , 0 , .35 ) }
. grab { position : relative ; flex : 0 0 auto ; display : flex ; align-items : center ; gap : 8 px ;
padding : 14 px 14 px 8 px ; cursor : grab ; touch-action : none ; user-select : none ;
border-bottom : 1 px solid var ( - - line ) }
. grab . bar { position : absolute ; top : 6 px ; left : 50 % ; width : 34 px ; height : 4 px ; margin-left : -17 px ;
border-radius : 2 px ; background : #3a4a70 }
. grab . cap { flex : 1 1 auto ; min-width : 0 ; font-size : 12 px ; font-weight : 700 ; color : var ( - - muted ) ;
overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap }
. grab . chev { color : var ( - - muted ) ; font-size : 11 px ; transition : transform .2 s }
. mapsheet . open . grab . chev { transform : rotate ( 180 deg ) }
. mapstatus { flex : 1 1 auto ; min-height : 0 ; overflow-y : auto ; -webkit- overflow-scrolling : touch ;
overscroll-behavior : contain ; padding : 11 px 14 px 14 px }
. mapstatus . head { font-size : 14 px ; font-weight : 700 ; margin-bottom : 4 px }
. mapstatus . head b { color : var ( - - accent ) }
. mapstatus . rows { font-size : 13 px ; color : var ( - - muted ) ; line-height : 1.55 }
. mapstatus . rows div { display : flex ; gap : 8 px ; align-items : baseline }
. mapstatus . rows . when { color : var ( - - txt ) ; font-variant-numeric : tabular-nums ;
white-space : nowrap ; font-size : 12 px }
. mapstatus . hint { margin-top : 7 px ; font-size : 11 px ; color : var ( - - muted ) ; line-height : 1.4 }
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
. pin { transform : translate ( -50 % , -100 % ) ; white-space : nowrap ; pointer-events : none }
@@ -168,7 +209,8 @@
< / div >
< 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 >
< / nav >
< / div >
@@ -189,7 +231,6 @@
< button class = "fullbtn" id = "fullBtn" > Полное расписание < span class = "chev" > ▾< / span > < / button >
< section class = "tablecard" id = "fullTable" >
< h2 class = "fulltitle" > Полное расписание< / h2 >
< div class = "days" id = "dayPick" >
< button data-day = "weekday" class = "active" > Будни< / button >
< button data-day = "saturday" > Суббота< / button >
@@ -197,63 +238,133 @@
< / div >
< 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 >
< div id = "tableBody" > < / div >
< / section >
< button class = "fullbtn mapbtn" id = "mapBtn" > Карта маршрута< / button >
< 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 class = "mapsheet" id = "mapSheet " >
< div class = "grab " id = "sheetGrab" >
< span class = "bar " > < / span >
< span class = "cap" id = "sheetCap" > Автобусы в пути< / span >
< 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 >
< / 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" >
< h2 > О приложении< / h2 >
< div class = "ver" id = "aboutVer" > < / div >
< p > Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.< / p >
< p > Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд.
Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов,
с переключением между буднями, субботой и воскресеньем.< / p >
< p > В меню есть «Карта»: там видно, где сейчас едут автобусы, и как пройден
участок между остановками. Положение считается по расписанию — приложение
не получает данные с GPS, поэтому это оценка с точностью до нескольких минут. < / p >
< p class = "muted" > Расписание действует с 30.09.2020. Время московское (М С К ). < / p >
< p class = "muted" > Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap.
Пуш-уведомления приходят через RuStore Push — для этого нужен
< p > Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.
Ближайшие рейсы по вашей остановке, полное расписание по будням, субботам
и воскресеньям, а также карта с положением автобусов в пути.< / p >
< p > Всё время — московское (М С К ). Данные обновляются автоматически,
интернет нужен только для карты.< / p >
< h3 > Расписание< / h3 >
< p > Действует летнее расписание < b > с 1 мая по 31 октября 2026 года< / b > .
Отправления взяты из данных диспетчерской службы перевозчика,
время прибытия приложение рассчитывает само — подробно в «Справке».< / 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 >
< p class = "muted" > Приложение не использует геолокацию и не передаёт
данные о вашем местоположении: положение автобусов вычисляется
по расписанию на устройстве.< / p >
< p class = "dev" > Разработчик © UA1ZBE< / p >
< / section >
< footer > Р асписание действует с 30.09 .2020 · время московское (М С К )< / footer >
< footer > Летнее р асписание с 01.05.2026 по 31.10 .2026 · отправления по данным
диспетчерской службы перевозчика · время московское (М С К )< / footer >
< / div >
< script >
// Расписание по типам дней недели.
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
// прибытие в Зашеек · Полярные Зори (обратно).
// Расписание по типам дней недели. Взято с официального расписания
// (данные диспетчерской службы перевозчика О О О «Гарант-Авто»,
// kandalaksha.org/static/auto.html), летнее расписание
// с 01.05.2026 по 31.10.2026.
//
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
// хранились только отправления из Полярных Зорь и прибытия в Зашеек
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
// первая и четвёртая колонки восстановлены по тем же правилам, что и для
// будней, и требуют сверки с официальным расписанием:
// кол0 = ко л1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин )
// ко л3 = ко л2 + 20 мин (так разность ко л3− ко л2 выглядит в 8 строках из 13)
// Опубликованы только ОТПРАВЛЕНИЯ, и четыре колонки склеиваются
// в один круг рейса попарно, 1:1 — проверено, что моменты строго
// возрастают в каждой строке:
// ко л0 — отправление из Кандалакши
// ко л1 — отправление из Полярных Зорь в Зашеек
// кол2 — отправление ИЗ Зашейка (обратно в Полярные Зори )
// ко л3 — отправление из Полярных Зорь в Кандалакшу
//
// Важно: ко л2 — это выезд из Зашейка, а не прибытие в него.
// Раньше колонка была подписана как прибытие, и из-за этого один и тот же
// автобус «прибывал» и «уезжал» в Зашеек в одну минуту, а интервалы
// туда и обратно выходили разными на одной и той же дороге.
// Прибытие в Зашеек в расписании не публикуется — оно считается
// отдельно, см. arrZasheek ниже.
const SCHEDULES = {
weekday : [
[ "05:20" , "06:20" , "06:30" , "06:50" ] ,
[ "06:10" , "07:00" , "07:25" , "07:45" ] ,
[ "07:40" , "08:30" , "09:00" , "09:20" ] ,
[ "08:00" , "08:50" , "09:20" , "09:50" ] ,
[ "09:45" , "10:35" , "11:05" , "11:25" ] ,
[ "11:00" , "11:50" , "12:20" , "12:40" ] ,
[ "12 :30" , "13 :20" , "13 :50" , "14:1 0" ] ,
[ "11 :30" , "12 :20" , "12 :50" , "13:2 0" ] ,
[ "12:30" , "13:20" , "13:50" , "14:15" ] ,
[ "13:30" , "14:20" , "14:50" , "15:10" ] ,
[ "14:00" , "14:50" , "15:25" , "15:50" ] ,
[ "15:30" , "16:20" , "17:00" , "17:25" ] ,
@@ -263,23 +374,23 @@ const SCHEDULES = {
] ,
saturday : [
[ "06:10" , "07:00" , "07:25" , "07:45" ] ,
[ "07:30" , "08:20" , "08:45" , "09:05 " ] ,
[ "08:45" , "09:35" , "10:05" , "10:25 " ] ,
[ "10:10" , "11:00" , "11:30" , "11:50 " ] ,
[ "12:30" , "13:20" , "13:50" , "14:10 " ] ,
[ "14:00" , "14:50" , "15:45" , "16:05 " ] ,
[ "15:30" , "16:20" , "16:45" , "17:05 " ] ,
[ "17:00" , "17:50" , "18:20" , "18:40 " ] ,
[ "18:10" , "19:00" , "19:30" , "19:50 " ] ,
[ "20:20" , "21:10" , "21:40" , "22:00 " ]
[ "07:30" , "08:20" , "08:45" , "09:1 0" ] ,
[ "08:45" , "09:35" , "10:05" , "10:30 " ] ,
[ "10:10" , "11:00" , "11:30" , "11:55 " ] ,
[ "12:30" , "13:20" , "13:50" , "14:15 " ] ,
[ "14:00" , "14:50" , "15:45" , "16:1 0" ] ,
[ "15:30" , "16:20" , "16:45" , "17:1 0" ] ,
[ "17:00" , "17:50" , "18:20" , "18:45 " ] ,
[ "18:10" , "19:00" , "19:30" , "19:55 " ] ,
[ "20:20" , "21:10" , "21:40" , "22:05 " ]
] ,
sunday : [
[ "06:10" , "07:00" , "07:25" , "07:45" ] ,
[ "08:45" , "09:35" , "10:05" , "10:25 " ] ,
[ "12:30" , "13:20" , "13:50" , "14:10 " ] ,
[ "14:00" , "14:50" , "15:25" , "15:4 5" ] ,
[ "16:50" , "17:40" , "18:10" , "18:30 " ] ,
[ "20:20" , "21:10" , "21:40" , "22:00 " ]
[ "08:45" , "09:35" , "10:05" , "10:30 " ] ,
[ "12:30" , "13:20" , "13:50" , "14:15 " ] ,
[ "14:00" , "14:50" , "15:25" , "15:50 " ] ,
[ "16:50" , "17:40" , "18:10" , "18:35 " ] ,
[ "20:20" , "21:10" , "21:40" , "22:05 " ]
]
} ;
const DAY _KEYS = [ "weekday" , "saturday" , "sunday" ] ;
@@ -289,21 +400,38 @@ const TRIPS_SHOWN = 5; // сколько ближайших рейсов по
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания е г о нет:
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
// (в первой строке 60 — по всей видимости опечатка).
// (в первой строке 60 — по всей видимости опечатка в самом расписании ).
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 = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша" : [
{ to : "Полярные Зори, Зашеек" , dep : 0 , via : "через Полярные Зори" ,
arrs : [ { label : "Полярные Зори" , col : 1 } ,
{ label : "Зашеек" , col : 2 } ] }
{ label : "Зашеек" , arrZ : true } ] }
] ,
"Полярные Зори" : [
{ to : "Зашеек" , dep : 1 , via : "≈ 3 0 мин в пути" ,
arrs : [ { label : "Зашеек" , col : 2 } ] } ,
{ to : "Зашеек" , dep : 1 , via : "≈ 2 0 мин в пути" ,
arrs : [ { label : "Зашеек" , arrZ : true } ] } ,
{ to : "Кандалакша" , dep : 3 , via : "≈ 50 мин в пути" ,
arrs : [ { label : "Кандалакша" , offset : RIDE _PZ _KAND } ] }
] ,
@@ -384,7 +512,9 @@ function render(){
. map ( ( r , ri ) => {
const dep = toMin ( r [ d . dep ] ) ;
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 { dep , arrs , ri } ;
@@ -483,13 +613,47 @@ if (NATIVE) {
e . stopPropagation ( ) ;
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" , ( ) => {
// «назад» сначала сворачивает открытое меню и только потом уходит назад
if ( ! menuBox . hidden ) { closeMenu ( ) ; return ; }
// «назад» сначала закрывает справку, потом сворачивает меню,
// и только потом уходит назад
if ( backStep ( ) ) return ;
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 ( "mapBtn" ) . addEventListener ( "click" , ( ) => Bus . openMap ( ) ) ;
document . addEventListener ( "click" , e => {
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 _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 ] ;
}
// Рейс разложен на четыре участка: туда Кандалакша → ПЗ → Зашеек и обратно .
// Время в пути ПЗ → Кандалакша в таблице не указано, берём те же 50 минут,
// что и в DIRECTIONS.
// Рейс разложен на участки круга: Кандалакша → ПЗ → Зашеек → ПЗ → Кандалакша .
// Колонка ко л2 — это выезд ИЗ Зашейка, поэтому между прибытием туда
// (оценка arrZasheek) и выездом автобус стоит на остановке: отдельный
// участок hold, на котором точка не двигается. Время в пути ПЗ → Кандалакша
// в расписании не указано, берём те же 50 минут, что и в DIRECTIONS.
function makeRun ( row ) {
const t0 = toMin ( row [ 0 ] ) , t1 = toMin ( row [ 1 ] ) , t2 = toMin ( row [ 2 ] ) , t3 = toMin ( row [ 3 ] ) ;
const z = arrZasheek ( row ) ; // прибытие в Зашеек (оценка)
const back = t3 + RIDE _PZ _KAND ; // прибытие в Кандалакшу
return {
t0 , tEnd : back ,
segs : [
const segs = [
{ a : t0 , b : t1 , line : KAND _TO _PZ , to : "Полярные Зори" , end : t1 } ,
{ a : t1 , b : t2 , line : PZ _TO _ZASHEEK , to : "Зашеек" , end : t2 } ,
{ a : t1 , b : z , line : PZ _TO _ZASHEEK , to : "Зашеек" , end : z }
] ;
if ( t2 > z ) segs . push ( { a : z , b : t2 , line : PZ _TO _ZASHEEK , to : "Зашеек" , end : t2 , hold : true } ) ;
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 ;
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 ;
// на участке стоянки в Зашейке точка не двигается — держим конец подъезда
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 ) } ) ;
} ) ;
return out ;
@@ -596,19 +766,34 @@ function pinElement(cls, num, text){
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 ) {
const head = document . getElementById ( "mapHead" ) ;
const rows = document . getElementById ( "mapRows" ) ;
if ( ! list . length ) {
head . innerHTML = "Сейчас автобусов нет" ;
rows . innerHTML = ` <div>Первый рейс — в ${ SCHEDULES [ todayKey ( ) ] [ 0 ] [ 0 ] } из Кандалакши.</div> ` ;
refitSheet ( ) ;
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> `
rows . innerHTML = list . map ( b => b . seg . hold
? ` <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 ( "" ) ;
refitSheet ( ) ; // строк могло стать больше или меньше
}
function startMap ( map , mmrgl ) {
@@ -703,6 +888,7 @@ function initMap(){
zoom : 10 ,
attributionControl : true
} ) ;
MAPREF = map ;
map . on ( "load" , ( ) => {
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 >
< / body >