@@ -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 }
@@ -68,6 +72,8 @@
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,10 +95,28 @@
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 { height : 100 vh ; height : 100 dvh ; min-height : 0 ; padding : 24 px 16 px 16 px ; overflow : hidden }
@@ -132,12 +156,6 @@
. 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 }
. note { margin-top : 10 px ; padding-top : 10 px ; border-top : 1 px solid var ( - - line ) ;
font-size : 12 px ; line-height : 1.55 ; color : var ( - - muted ) }
. note p { margin : 0 0 8 px }
. note p : last-child { margin-bottom : 0 }
. note b { color : var ( - - txt ) }
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
. pin { transform : translate ( -50 % , -100 % ) ; white-space : nowrap ; pointer-events : none }
@@ -191,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 >
@@ -212,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 >
@@ -227,56 +245,91 @@
< 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 = "mapsheet" id = "mapSheet" >
< div class = "grab" id = "sheetGrab" >
< span class = "bar" > < / span >
< span class = "cap" id = "sheetCap" > Автобусы и расписание < / span >
< span class = "cap" id = "sheetCap" > Автобусы в пути < / span >
< span class = "chev" > ▴< / span >
< / div >
< div class = "mapstatus" >
< div class = "head" id = "mapHead" > Карта маршрута< / div >
< div class = "rows " id = "mapRows " > < / div >
< div class = "hint" > Положение автобуса рассчитано по расписанию, а не по GPS:
между остановками интерполируется по дороге. Точность — до нескольких минут.< / 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 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" > Расписание сверено с официальным: отправления взяты из данных
диспетчерской службы перевозчика, летнее расписание
с 01.05.2026 по 31.10.2026. Время московское (М С К ) .< / 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 > Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным
@@ -560,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 ( ) ;
} ) ;
@@ -679,12 +766,25 @@ 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 ;
@@ -693,6 +793,7 @@ function renderMapStatus(list){
? ` <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 ) {
@@ -842,12 +943,24 @@ function initSheet(){
const grab = document . getElementById ( "sheetGrab" ) ;
if ( ! box || ! sheet || ! grab ) return ;
const MIN = 118 ;
const max = ( ) => Math . max ( MIN , Math . round ( box . clientHeight * 0.72 ) ) ;
let open = false , h = MIN , startY = 0 , startH = 0 , moved = 0 , dragged = false ;
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 ) ) ) ;
h = Math . max ( min ( ) , Math . min ( max ( ) , Math . round ( next ) ) ) ;
sheet . style . height = h + "px" ;
open = ! ! expanded ;
sheet . classList . toggle ( "open" , open ) ;
@@ -870,20 +983,26 @@ function initSheet(){
moved = startY - e . clientY ;
if ( Math . abs ( moved ) > 6 ) dragged = true ;
const want = startH + moved ;
set ( want , want > MIN + 8 ) ;
set ( want , want > min ( ) + 8 ) ;
} ) ;
grab . addEventListener ( "pointerup" , 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 ;
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 ( ) ; }