Files
BusSchedule127/apk-webview/app/src/main/assets/index.html
ua1zbe 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

894 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Автобус №127 · Кандалакша — Полярные Зори — Зашеек</title>
<style>
:root{
--bg:#0d1220; --bg2:#131a2e; --card:#182238; --card2:#1e2a45;
--line:#2a3757; --txt:#e8edf7; --muted:#8b98b8;
--accent:#38d39f; --accent2:#4f9cff; --warn:#ffb648;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,sans-serif;
background:radial-gradient(1200px 600px at 70% -10%,#1b2a4d 0%,var(--bg) 55%);
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
.wrap{max-width:760px;margin:0 auto}
header{text-align:center;margin-bottom:18px}
h1{font-size:clamp(22px,4.5vw,30px);margin:0 0 4px;font-weight:700}
.sub{color:var(--muted);font-size:14px}
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
.clock small{color:var(--muted);font-weight:400;font-size:12px}
.day{display:inline-block;margin-top:8px;padding:4px 12px;border-radius:999px;
font-size:12px;border:1px solid var(--line);color:var(--muted)}
.day b{color:var(--accent2)}
.pickers{display:flex;gap:8px;margin:16px 0}
.pick{flex:1;padding:13px 8px;border-radius:12px;border:1px solid var(--line);
background:var(--card);color:var(--muted);font-size:14px;cursor:pointer;
transition:.15s;text-align:center;font-weight:600}
.pick:hover{border-color:var(--accent2);color:var(--txt)}
.pick.active{background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#04101c;border-color:transparent;box-shadow:0 6px 20px rgba(79,156,255,.25)}
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.dir .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
.card h3 .arr{color:var(--accent)}
.card .via{color:var(--muted);font-size:11px;margin-bottom:10px;letter-spacing:.03em}
.trip{display:flex;align-items:baseline;justify-content:space-between;padding:8px 0;
border-bottom:1px dashed var(--line);font-size:15px}
.trip:last-child{border-bottom:none}
.trip .t{font-variant-numeric:tabular-nums;font-weight:700;font-size:19px}
.trip .t small{font-weight:400;color:var(--muted);font-size:11px;display:block}
.trip .w{color:var(--accent);font-size:12px;font-weight:600;white-space:nowrap}
.trip.soon .w{color:var(--warn)}
.empty{color:var(--muted);font-size:13px;padding:6px 0}
/* кнопка полного расписания */
.fullbtn{display:block;width:100%;margin:16px 0 6px;padding:12px;border-radius:12px;
border:1px solid var(--line);background:var(--card2);color:var(--txt);
font-size:14px;font-weight:600;cursor:pointer;transition:.15s}
.fullbtn:hover{border-color:var(--accent2)}
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
.fullbtn.open .chev{transform:rotate(180deg)}
.fulltitle{display:none;margin:0;padding:14px 12px 2px;font-size:16px;font-weight:700}
/* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */
.headwrap{position:relative}
.headrow{display:grid;grid-template-columns:38px 1fr 38px;align-items:center;gap:8px}
.headrow h1{text-align:center;margin:0;min-width:0}
.iconbtn{display:none;background:var(--card2);border:1px solid var(--line);
color:var(--txt);border-radius:10px;width:38px;height:38px;
font-size:22px;line-height:1;font-family:inherit;cursor:pointer;
align-items:center;justify-content:center;padding:0}
body.app .iconbtn,body.about .iconbtn,body.map .iconbtn{display:inline-flex}
/* на главном экране стрелки нет, но место под неё оставляем, иначе съедет центр */
body.app .iconbtn.back{visibility:hidden}
/* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */
#navMenu{padding-bottom:7px}
.iconbtn:active{border-color:var(--accent2)}
/* выпадающее окошко: висит поверх содержимого, прижато к кнопке меню */
.menu{position:absolute;top:calc(100% + 8px);right:0;z-index:60;
width:min(260px,100%);background:var(--card);border:1px solid var(--line);
border-radius:14px;overflow:hidden;margin:0;
box-shadow:0 18px 44px rgba(0,0,0,.6)}
.menu[hidden]{display:none}
.menuitem{display:block;width:100%;text-align:left;padding:13px 14px;border:0;
background:transparent;color:var(--txt);font-size:15px;font-weight:600;
font-family:inherit;cursor:pointer}
.menuitem:hover{background:var(--card2)}
/* экран «О приложении» */
.aboutcard{display:none;background:var(--card);border:1px solid var(--line);
border-radius:14px;padding:16px;margin-bottom:10px}
body.about .aboutcard{display:block}
body.about .pickers,body.about .where,body.about .dir,
body.about #fullTable,body.about .fullbtn{display:none}
.aboutcard h2{margin:0 0 4px;font-size:17px}
.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.muted{color:var(--muted);font-size:13px}
/* экран «Карта»: страница целиком занята картой, шапка остаётся */
body.map{height:100vh;height:100dvh;min-height:0;padding:24px 16px 16px;overflow:hidden}
body.map .wrap{height:100%;display:flex;flex-direction:column;max-width:none}
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 .fullbtn,body.map #fullTable,body.map .aboutcard,body.map footer{display:none}
body.map #mapBox{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:8px}
.mapcanvas{flex:1 1 auto;min-height:0;border:1px solid var(--line);
border-radius:14px;overflow:hidden;background:var(--card);position:relative}
.mapcanvas.loading::after{content:"Загружаю карту…";position:absolute;inset:0;
display:flex;align-items:center;justify-content:center;
color:var(--muted);font-size:14px}
/* нижняя шторка: свёрнута до ручки с заголовком, тянется вверх,
а текст внутри прокручивается отдельно от карты */
.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 b{color:var(--accent)}
.mapstatus .rows{font-size:13px;color:var(--muted);line-height:1.55}
.mapstatus .rows div{display:flex;gap:8px;align-items:baseline}
.mapstatus .rows .when{color:var(--txt);font-variant-numeric:tabular-nums;
white-space:nowrap;font-size:12px}
.mapstatus .hint{margin-top:7px;font-size:11px;color:var(--muted);line-height:1.4}
.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 .box{display:flex;align-items:center;gap:6px;padding:4px 9px 4px 5px;
border-radius:999px;background:rgba(13,18,32,.92);border:1px solid var(--line);
box-shadow:0 4px 14px rgba(0,0,0,.5)}
.pin .num{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:10px;font-weight:700;color:#04101c;flex:0 0 auto}
.pin .txt{font-size:11px;font-weight:600;color:var(--txt)}
.pin.fwd .num{background:var(--accent)}
.pin.back .num{background:var(--accent2)}
.pin::after{content:"";position:absolute;left:50%;bottom:-5px;width:8px;height:8px;
margin-left:-4px;background:rgba(13,18,32,.92);
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
transform:rotate(45deg)}
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;display:none;margin-bottom:8px}
/* переключатель типа дня в полном расписании */
.days{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:12px 12px 10px;
border-bottom:1px solid var(--line)}
.days button{padding:9px 4px;border-radius:10px;border:1px solid var(--line);
background:var(--card2);color:var(--muted);font-size:13px;font-weight:600;
cursor:pointer;transition:.15s;font-family:inherit}
.days button:hover{border-color:var(--accent2);color:var(--txt)}
.days button.active{background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#04101c;border-color:transparent}
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
font-size:11px;color:var(--muted);text-align:center}
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
.thead div:first-child{border-left:none}
.thead b{display:block;color:var(--txt);font-size:12px;font-weight:600}
.row{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;text-align:center;border-top:1px solid var(--line);
font-variant-numeric:tabular-nums;font-size:15px}
.row div{padding:10px 6px;border-left:1px solid var(--line)}
.row div:first-child{border-left:none}
.row:nth-child(even){background:#15203a}
footer{margin-top:22px;text-align:center;color:var(--muted);font-size:12px}
@media(max-width:560px){.dir{grid-template-columns:1fr}.pickers{gap:6px}}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="headwrap">
<div class="headrow">
<button class="iconbtn back" id="navBack" aria-label="Назад">‹</button>
<h1>Автобус №127</h1>
<button class="iconbtn" id="navMenu" aria-label="Меню">⋮</button>
</div>
<nav class="menu" id="menuBox" hidden>
<button class="menuitem" id="mMap">Карта</button>
<button class="menuitem" id="mAbout">О приложении</button>
</nav>
</div>
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
<div class="day" id="dayBadge"></div>
</header>
<div class="pickers">
<button class="pick" data-stop="Кандалакша">Кандалакша</button>
<button class="pick active" data-stop="Полярные Зори">Полярные Зори</button>
<button class="pick" data-stop="Зашеек">Зашеек</button>
</div>
<div class="where" id="whereLabel">Вы в Полярных Зорях — доступные направления:</div>
<section class="dir" id="dirBox"></section>
<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>
<button data-day="sunday">Воскресенье</button>
</div>
<div class="thead">
<div><b>Кандалакша</b>отпр.</div>
<div><b>Полярные Зори</b>отпр. в Зашеек</div>
<div><b>Зашеек</b>отпр.</div>
<div><b>Полярные Зори</b>отпр. в Канд.</div>
</div>
<div id="tableBody"></div>
</section>
<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="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>
</div>
</div>
</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 — для этого нужен
установленный и авторизованный RuStore на устройстве.</p>
</section>
<footer>Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным
диспетчерской службы перевозчика · время московское (МСК)</footer>
</div>
<script>
// Расписание по типам дней недели. Взято с официального расписания
// (данные диспетчерской службы перевозчика ООО «Гарант-Авто»,
// kandalaksha.org/static/auto.html), летнее расписание
// с 01.05.2026 по 31.10.2026.
//
// Опубликованы только ОТПРАВЛЕНИЯ, и четыре колонки склеиваются
// в один круг рейса попарно, 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"],
["09:45","10:35","11:05","11:25"],
["11:00","11:50","12:20","12:40"],
["11:30","12:20","12:50","13:20"],
["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"],
["16:50","17:40","18:10","18:35"],
["18:20","19:10","19:40","20:05"],
["20:20","21:10","21:40","22:05"]
],
saturday: [
["06:10","07:00","07:25","07:45"],
["07:30","08:20","08:45","09:10"],
["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:10"],
["15:30","16:20","16:45","17:10"],
["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: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"];
const DAY_TITLES = {weekday:"Будни", saturday:"Суббота", sunday:"Воскресенье"};
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
// (в первой строке 60 — по всей видимости опечатка в самом расписании).
const RIDE_PZ_KAND = 50;
// Полярные Зори → Зашеек. В расписании тоже нет: там опубликованы только
// отправления. Принимаем 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:"Зашеек", arrZ:true}]}
],
"Полярные Зори": [
{to:"Зашеек", dep:1, via:"≈ 20 мин в пути",
arrs:[{label:"Зашеек", arrZ:true}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
],
"Зашеек": [
{to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:3},
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
]
};
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
// Текущее время по МСК, независимо от часового пояса устройства
const MSK_TZ = "Europe/Moscow";
let mskFmt = null;
try {
mskFmt = new Intl.DateTimeFormat("en-US", {
timeZone: MSK_TZ, hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit"
});
} catch(e){ mskFmt = null; }
function nowMs(){
if (mskFmt) {
try {
const parts = {};
mskFmt.formatToParts(new Date()).forEach(x => parts[x.type] = x.value);
const hour = Number(parts.hour) % 24; // на части движков полночь приходит как "24"
return {
hour: hour,
minute: Number(parts.minute),
second: Number(parts.second),
day: new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1,
Number(parts.day))).getUTCDay()
};
} catch(e){ /* ниже — запасной вариант */ }
}
// Запасной вариант: МСК = UTC+3 (переход на летнее время отменён)
const d = new Date(Date.now() + 3*3600*1000);
return { hour: d.getUTCHours(), minute: d.getUTCMinutes(),
second: d.getUTCSeconds(), day: d.getUTCDay() };
}
function fmtHM(m){
const p = n=>String(n).padStart(2,"0");
m = ((m % 1440) + 1440) % 1440; // на случай перехода через полночь
return `${p(Math.floor(m/60))}:${p(m%60)}`;
}
function fmtWait(m){
if(m <= 0) return "отправление сейчас";
const h = Math.floor(m/60), mm = m%60;
if(h === 0) return `через ${mm} мин`;
if(mm === 0) return `через ${h} ч`;
return `через ${h} ч ${mm} мин`;
}
let currentStop = "Полярные Зори";
// Date#getDay(): 0 — воскресенье, 6 — суббота
function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; }
// Карточки ближайших рейсов всегда показывают реальный сегодняшний день
function todayKey(){ return dayKeyFor(nowMs().day); }
// Выбор пользователя — только для полного расписания
function sched(){ return SCHEDULES[activeDay]; }
function render(){
const now = nowMs();
const curMin = now.hour*60 + now.minute;
const dirs = DIRECTIONS[currentStop];
const data = SCHEDULES[todayKey()];
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
const upcoming = data
.map((r,ri)=>{
const dep = toMin(r[d.dep]);
const arrs = d.arrs.map(a=>{
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};
})
.filter(x=>x.dep >= curMin)
.sort((a,b)=>a.dep-b.dep)
.slice(0,TRIPS_SHOWN)
.map(x=>{
const w = x.dep - curMin;
// .trip .t small имеет display:block, поэтому каждый <small> — своя строка
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
return `<div class="trip ${w<15?'soon':''}">
<span class="t">${data[x.ri][d.dep]}${arr}</span>
<span class="w">${fmtWait(w)}</span>
</div>`;
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
return `<div class="card">
<h3>→ <span class="arr">${d.to}</span></h3>
<div class="via">${d.via}</div>
${upcoming}
</div>`;
}).join("");
}
function renderTable(){
document.getElementById("tableBody").innerHTML = sched().map(row =>
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
).join("");
}
function renderDay(){
const dow = nowMs().day;
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
}
// Подсветка выбранного типа дня. Таблица полного расписания перерисовывается,
// карточки ближайших рейсов не трогаем — они всегда на сегодня.
function selectDay(key){
activeDay = key;
document.querySelectorAll("#dayPick button").forEach(b=>
b.classList.toggle("active", b.dataset.day === key));
renderTable();
}
function tickClock(){
const d = nowMs();
const p = n=>String(n).padStart(2,"0");
document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
}
document.querySelectorAll(".pick").forEach(btn=>{
btn.addEventListener("click",()=>{
document.querySelectorAll(".pick").forEach(b=>b.classList.remove("active"));
btn.classList.add("active");
currentStop = btn.dataset.stop;
const w = {Кандалакша:"в Кандалакше", "Полярные Зори":"в Полярных Зорях", Зашеек:"в Зашейке"};
document.getElementById("whereLabel").textContent =
`Вы ${w[currentStop]} — доступные направления:`;
render();
});
});
document.querySelectorAll("#dayPick button").forEach(btn=>{
btn.addEventListener("click",()=>selectDay(btn.dataset.day));
});
document.getElementById("fullBtn").addEventListener("click",()=>{
const t = document.getElementById("fullTable");
const b = document.getElementById("fullBtn");
const open = t.style.display === "block";
t.style.display = open ? "none" : "block";
b.classList.toggle("open", !open);
});
// Режим показа задаётся якорью в адресе:
// (пусто) — браузер;
// #app — главный экран приложения;
// #map — карта с положением автобуса;
// #about — окно «О приложении».
// В приложении (не в браузере) страница дёргает нативный код через объект Bus.
const MODE = location.hash.replace("#","") || "web";
const NATIVE = MODE !== "web" && typeof Bus !== "undefined";
document.body.classList.add(MODE);
document.getElementById("mapBox").hidden = MODE !== "map";
if (MODE === "about" && NATIVE) {
// версию берём из приложения, чтобы не расходилась со сборкой
const v = document.getElementById("aboutVer");
if (Bus.version && Bus.version()) v.textContent = "Версия " + Bus.version();
}
if (NATIVE) {
const menuBox = document.getElementById("menuBox");
const closeMenu = () => { menuBox.hidden = true; };
document.getElementById("navMenu").addEventListener("click",e=>{
e.stopPropagation();
menuBox.hidden = !menuBox.hidden;
});
document.getElementById("navBack").addEventListener("click",()=>{
// «назад» сначала сворачивает открытое меню и только потом уходит назад
if (!menuBox.hidden) { closeMenu(); return; }
Bus.goBack();
});
document.getElementById("mMap").addEventListener("click",()=>{ closeMenu(); Bus.openMap(); });
document.getElementById("mAbout").addEventListener("click",()=>{ closeMenu(); Bus.openAbout(); });
document.addEventListener("click",e=>{
if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu();
});
}
let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay);
tickClock(); renderDay(); render();
setInterval(tickClock,1000);
setInterval(render,30000);
setInterval(renderDay,60000);
// ─────────────────────────── Карта ───────────────────────────
// Где едет автобус, приложение не знает — GPS у него нет. Но в расписании
// есть отправление из Кандалакши, отправление из Полярных Зорь в Зашеек,
// прибытие в Зашеек и отправление из Полярных Зорь обратно. Между соседними
// точками известно, сколько минут шёл автобус, поэтому положение внутри
// участка можно оценить. Дорога между точками лежит в route.js (OpenStreetMap),
// доля пути считается по длине ломаной — маркер идёт по трассе, а не по прямой.
//
// Координаты и геометрия дороги, стиль и тайлы — VK Карты (MMR GL JS),
// подключаются только на этом экране, ключ доступа — из кабинета RuStore.
const VK_MAP_KEY = "RS545b2f43f1096207eeb54edd762e325c17f1e0ccd5fdd234afb61cf93f7331";
const MAP_STYLE = "https://maps.vk.com/api/styles/main_style.json";
// как часто пересчитывать положение: точность всё равно ограничена расписанием
const MAP_TICK_MS = 20000;
// четыре направления дороги: туда до Зашейка и обратно. Заполняются после
// загрузки route.js — до этого карта не считает ничего.
let KAND_TO_PZ = null; // Кандалакша → Полярные Зори
let PZ_TO_ZASHEEK = null;// Полярные Зори → Зашеек
let PZ_TO_ZASHEEK_BACK = null;
let KAND_TO_PZ_BACK = null;
let MAPREF = null; // карта нужна шторке, чтобы пересчитать размер холста
// Длина ломаной с накопленными отрезками. Расстояние считаем в
// равнопромежуточной проекции — на таком масштабе это точнее градусов.
function measure(line){
const cum = [0];
for (let i=1;i<line.length;i++){
const [x1,y1] = line[i-1], [x2,y2] = line[i];
const k = Math.cos((y1+y2)/2 * Math.PI/180); // сжатие по долготе
cum.push(cum[i-1] + Math.hypot((x2-x1)*k, y2-y1));
}
return {line, cum, len: cum[cum.length-1]};
}
// Тот же путь, но пройденный в обратную сторону
function reverse(p){
return {line: p.line.slice().reverse(),
cum: p.cum.map(v=>p.len-v).reverse(), len: p.len};
}
// Точка на ломаной по доле пройденного расстояния
function pointAt(p, f){
const target = Math.max(0, Math.min(1, f)) * p.len;
let i = 1;
while (i < p.cum.length-1 && p.cum[i] < target) i++;
const seg = p.cum[i]-p.cum[i-1] || 1;
const t = (target - p.cum[i-1]) / seg;
const [x1,y1] = p.line[i-1], [x2,y2] = p.line[i];
return [x1+(x2-x1)*t, y1+(y2-y1)*t];
}
// Рейс разложен на участки круга: Кандалакша → ПЗ → Зашеек → ПЗ → Кандалакша.
// Колонка кол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; // прибытие в Кандалакшу
const segs = [
{a:t0, b:t1, line:KAND_TO_PZ, to:"Полярные Зори", end:t1},
{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)};
}
// Все рейсы, которые сейчас в пути, вместе с точкой и участком
function busesNow(){
const d = nowMs();
const T = d.hour*60 + d.minute + d.second/60;
const out = [];
SCHEDULES[todayKey()].forEach((row, idx) => {
const run = makeRun(row);
if (T < run.t0 || T > run.tEnd) return;
const seg = run.segs.find(s => T >= s.a && T <= s.b);
if (!seg) return;
// на участке стоянки в Зашейке точка не двигается — держим конец подъезда
const f = seg.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;
}
function pinElement(cls, num, text){
const root = document.createElement("div");
root.className = "pin " + cls;
const box = document.createElement("div");
box.className = "box";
box.innerHTML = `<span class="num">${num}</span><span class="txt">${text}</span>`;
root.appendChild(box);
return root;
}
function renderMapStatus(list){
const head = document.getElementById("mapHead");
const rows = document.getElementById("mapRows");
if (!list.length) {
head.innerHTML = "Сейчас автобусов нет";
rows.innerHTML = `<div>Первый рейс — в ${SCHEDULES[todayKey()][0][0]} из Кандалакши.</div>`;
return;
}
const many = list.length > 1;
head.innerHTML = `Сейчас в пути <b>${list.length}</b> ${many ? "автобуса" : "автобус"}`;
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("");
}
function startMap(map, mmrgl){
const pins = new Map(); // индекс рейса → метка, чтобы не плодить новые
function update(){
const list = busesNow();
const seen = new Set();
list.forEach(b => {
seen.add(b.idx);
let p = pins.get(b.idx);
if (!p) {
const back = b.seg.to === "Кандалакша";
const marker = new mmrgl.Marker({element: pinElement(back ? "back" : "fwd", "127", "→ " + b.seg.to),
anchor: "bottom"});
// координаты обязаны быть заданы до addTo: при добавлении SDK сразу
// пересчитывает положение метки и падает на пустом LngLat
marker.setLngLat(b.at);
marker.addTo(map);
p = {marker, lastTo: null};
pins.set(b.idx, p);
}
// подпись у участка своя — обновляем, только если направление сменилось
if (p.lastTo !== b.seg.to) {
p.lastTo = b.seg.to;
const el = p.marker.getElement();
el.querySelector(".txt").textContent = "→ " + b.seg.to;
// именно classList, а не className: у метки есть классы SDK
// (mmrgl-marker, mmrgl-marker-anchor-bottom), и перезапись className
// убрала бы position:absolute — метка пропала бы с карты
const back = b.seg.to === "Кандалакша";
el.classList.toggle("back", back);
el.classList.toggle("fwd", !back);
}
p.marker.setLngLat(b.at);
});
// рейсы, закончившиеся, убираем
pins.forEach((p, idx) => {
if (!seen.has(idx)) { p.marker.remove(); pins.delete(idx); }
});
renderMapStatus(list);
}
update();
setInterval(update, MAP_TICK_MS);
document.addEventListener("visibilitychange", () => { if (!document.hidden) update(); });
}
function linkCss(href){
const l = document.createElement("link");
l.rel = "stylesheet"; l.href = href;
document.head.appendChild(l);
}
function loadScript(src){
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error("не загрузился " + src));
document.head.appendChild(s);
});
}
function initMap(){
const box = document.getElementById("mapCanvas");
let ready = false;
// если стиль так и не пришёл — говорим об этом прямо, а не оставляем пустой прямоугольник
const guard = setTimeout(() => {
if (ready) return;
box.classList.remove("loading");
box.innerHTML = `<div style="padding:16px;color:#8b98b8;font-size:13px;line-height:1.5">
Карта не загрузилась. Нужен интернет: стиль и тайлы VK Карт грузятся из сети.</div>`;
}, 25000);
Promise.all([loadScript("route.js"), loadScript("mmr-gl.js")])
.then(() => {
linkCss("mmr-gl.css");
const mmrgl = window.mmrgl;
mmrgl.accessToken = VK_MAP_KEY; // ключ добавится к стилю, тайлам, шрифтам
KAND_TO_PZ = measure(LEG_KP);
PZ_TO_ZASHEEK = measure(LEG_PZ);
PZ_TO_ZASHEEK_BACK = reverse(PZ_TO_ZASHEEK);
KAND_TO_PZ_BACK = reverse(KAND_TO_PZ);
const map = new mmrgl.Map({
container: box,
style: MAP_STYLE,
center: [32.479, 67.279],
zoom: 10,
attributionControl: true
});
MAPREF = map;
map.on("load", () => {
ready = true;
clearTimeout(guard);
box.classList.remove("loading");
// линия маршрута целиком: Кандалакша → ПЗ → Зашеек
map.addSource("route", {type:"geojson", data:{
type:"Feature",
geometry:{type:"LineString", coordinates: LEG_KP.concat(LEG_PZ)},
properties:{}
}});
map.addLayer({id:"route", type:"line", source:"route",
layout:{"line-cap":"round", "line-join":"round"},
paint:{"line-color":"#4f9cff", "line-width":4, "line-opacity":.9}});
// остановки на карте не рисуем: на экране они только мешали разглядеть
// сами автобусы, а куда ехать — написано в списке под картой
// весь маршрут в кадр. Метки привязаны к точке снизу и уходят вверх,
// поэтому сверху отступ больше, иначе крайние метки срезает.
const pts = [STOPS.kandalaksha, STOPS.polyarnye, STOPS.zasheek];
map.fitBounds(
[[Math.min(...pts.map(p=>p[1])), Math.min(...pts.map(p=>p[0]))],
[Math.max(...pts.map(p=>p[1])), Math.max(...pts.map(p=>p[0]))]],
{padding: {top: 84, bottom: 64, left: 56, right: 56}, duration: 0});
startMap(map, mmrgl);
// размер контейнера после отрисовки может уточниться позже
setTimeout(() => map.resize(), 300);
});
map.on("error", e => {
// не показываем шторку ошибок: на телефоне она перекрывает карту
if (e && e.error) console.error("карта:", e.error.message || e.error);
});
})
.catch(err => {
clearTimeout(guard);
box.classList.remove("loading");
box.innerHTML = `<div style="padding:16px;color:#8b98b8;font-size:13px">
Не удалось загрузить карту: ${err.message}<br>Нужен интернет.</div>`;
});
}
// Нижняя шторка карты. За ручку её тянут вверх, короткое касание — переключает.
// Внутри своя прокрутка, поэтому карта под текстом не ездит, а карта
// VK перерисовывается под изменившуюся высоту холста.
function initSheet(){
const box = document.getElementById("mapBox");
const sheet = document.getElementById("mapSheet");
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;
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); });
// поворот экрана или смена высоты окна — пересчитать предел раскрытия
window.addEventListener("resize", ()=> set(open ? max() : MIN, open));
set(MIN, false);
}
if (MODE === "map") { initMap(); initSheet(); }
</script>
</body>
</html>