Данные диспетчерской службы ООО «Гарант-Авто» на 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 лишней прокрутки страницы на вкладке карты.
894 lines
46 KiB
HTML
894 lines
46 KiB
HTML
<!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>
|