51 Commits
v2.2 ... v2.6.4

Author SHA1 Message Date
6d3f794bb7 README: карта и поведение меню 2026-09-29 08:14:57 +00:00
b5eed3284c Версия 2.6.4 2026-09-29 08:14:56 +00:00
5cdddcc9b3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:55 +00:00
36b59c2e2e Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:54 +00:00
66688630a3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:53 +00:00
9cda0ba3df Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:51 +00:00
c2505ea8f3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:50 +00:00
8daf3d01ce Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:49 +00:00
9c8d8b5db7 Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:48 +00:00
4f972d527b Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:46 +00:00
873a2b7cd7 Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:40 +00:00
ddc9cf2584 README: меню в возможностях и новые классы в структуре 2026-09-29 05:12:56 +00:00
a8ec1ce654 Страница: меню-окошко в шапке, свой экран «О приложении» 2026-09-29 05:12:55 +00:00
187de60f5c Манифест: тема без ActionBar, окно «О приложении» 2026-09-29 05:12:53 +00:00
c7a7726eb7 MainActivity: только главный экран, навигация уехала в страницу 2026-09-29 05:12:51 +00:00
ccbde3a9dd AboutActivity: окно «О приложении» на общей странице 2026-09-29 05:12:50 +00:00
828106a6fc BusBridge: JS-мост меню, стрелки назад и версии сборки 2026-09-29 05:12:49 +00:00
e7df71281d Webs: общий WebView для всех экранов 2026-09-29 05:12:31 +00:00
dbad63bc77 Строки: заголовки окон приложения 2026-09-29 05:11:02 +00:00
af1f215b8a Версия 2.6.3: versionCode 12, versionName 2.6.3 2026-09-29 05:11:01 +00:00
38adfdb34b Строки: заголовки окон приложения 2026-09-29 05:10:48 +00:00
5e15e685fa Версия 2.6.3: versionCode 12, versionName 2.6.3 2026-09-29 05:10:46 +00:00
baabcd31f8 README: рассылка через топики, топик не требует создания в консоли 2026-09-28 12:24:10 +00:00
83fc10878b README: раздел про RuStore Push и структура классов пуша 2026-09-28 12:19:43 +00:00
576f794ff7 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:42 +00:00
bd1db2a468 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:41 +00:00
905214b49e Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:40 +00:00
74d3f395ce Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:39 +00:00
034e7e1e27 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:38 +00:00
09bffa78a3 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:37 +00:00
f0088b08b6 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:36 +00:00
91a43a37b4 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:35 +00:00
4a2aa59c77 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:33 +00:00
3aab0b826b Версия 2.6.1
Сборка соответствует опубликованному коду: тег v2.6 остался на более
раннем коммите, где карточки ближайших рейсов ещё зависели от выбора
дня. Исправление (карточки всегда на сегодня) было закоммичено позже,
и тег на него не указывал. Тег v2.6.1 поставлен на актуальный код.

Само поведение не менялось — это та же исправленная версия, что была
выложена как 2.6, но теперь исходники, тег и APK соответствуют
друг другу.
2026-09-28 10:53:02 +00:00
6e03bc997c Кнопки дней влияют только на полное расписание
Карточки ближайших рейсов больше не зависят от выбора дня в полном
расписании: они всегда показывают рейсы на текущий день недели по
московскому времени. Кнопки «Будни / Суббота / Воскресенье»
переключают только таблицу.

День недели для карточек вычисляется заново при каждой отрисовке,
так что после полуночи приложение само переходит на расписание
нового дня, не сбрасывая выбор в таблице.

В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе: кнопки дней в полном расписании,
убрана надпись «Мурманская область», экран не гаснет.

versionCode поднят с 8 до 9, чтобы обновление поверх уже
установленной сборки 2.6 ставилось поверх неё.
2026-09-28 10:46:16 +00:00
f04bb1843c Кнопки дней влияют только на полное расписание
Карточки ближайших рейсов больше не зависят от выбора дня в полном
расписании: они всегда показывают рейсы на текущий день недели по
московскому времени. Кнопки «Будни / Суббота / Воскресенье»
переключают только таблицу.

День недели для карточек вычисляется заново при каждой отрисовке,
так что после полуночи приложение само переходит на расписание
нового дня, не сбрасывая выбор в таблице.

В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе: кнопки дней в полном расписании,
убрана надпись «Мурманская область», экран не гаснет.

versionCode поднят с 8 до 9, чтобы обновление поверх уже
установленной сборки 2.6 ставилось поверх неё.
2026-09-28 10:46:15 +00:00
ed621b7ce2 Убрана надпись «Мурманская область», экран не гаснет
В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе переключатель дней в полном расписании
(Будни / Суббота / Воскресенье) из предыдущих коммитов.
2026-09-28 10:12:38 +00:00
0e9150b5ce Убрана надпись «Мурманская область», экран не гаснет
В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе переключатель дней в полном расписании
(Будни / Суббота / Воскресенье) из предыдущих коммитов.
2026-09-28 10:12:37 +00:00
6f591c10d5 Убрана надпись «Мурманская область», экран не гаснет
В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе переключатель дней в полном расписании
(Будни / Суббота / Воскресенье) из предыдущих коммитов.
2026-09-28 10:12:35 +00:00
a15dccf3c2 Переключатель дней в полном расписании
В разделе «Полное расписание» появились кнопки «Будни», «Суббота»,
«Воскресенье». По умолчанию подсвечивается реальный день недели
по часовому поясу МСК, далее таблица переключается вручную.

Единый массив SCHEDULE заменён на SCHEDULES с расписанием по типам
дней: 13 рейсов в будни, 10 в субботу, 6 в воскресенье.
Карточки ближайших рейсов переключаются вместе с таблицей.

Важно: по субботе и воскресенью в репозитории хранились только
отправления из Полярных Зорь и прибытия в Зашеек (app/src/main/java/
ru/ua1zbe/bus127/MainActivity.kt). Первая и четвёртая колонки
восстановлены по правилам будних дней — кол0 = кол1 − 50 мин,
кол3 = кол2 + 20 мин — и требуют сверки с официальным расписанием.
Также исправлен перепуск субботы и воскресенья: Date#getDay()
возвращает 0 для воскресенья, а не для субботы.
2026-09-28 08:05:39 +00:00
f82fe1a524 Переключатель дней в полном расписании
В разделе «Полное расписание» появились кнопки «Будни», «Суббота»,
«Воскресенье». По умолчанию подсвечивается реальный день недели
по часовому поясу МСК, далее таблица переключается вручную.

Единый массив SCHEDULE заменён на SCHEDULES с расписанием по типам
дней: 13 рейсов в будни, 10 в субботу, 6 в воскресенье.
Карточки ближайших рейсов переключаются вместе с таблицей.

Важно: по субботе и воскресенью в репозитории хранились только
отправления из Полярных Зорь и прибытия в Зашеек (app/src/main/java/
ru/ua1zbe/bus127/MainActivity.kt). Первая и четвёртая колонки
восстановлены по правилам будних дней — кол0 = кол1 − 50 мин,
кол3 = кол2 + 20 мин — и требуют сверки с официальным расписанием.
Также исправлен перепуск субботы и воскресенья: Date#getDay()
возвращает 0 для воскресенья, а не для субботы.
2026-09-28 08:05:37 +00:00
0b4852c582 Переключатель дней в полном расписании
В разделе «Полное расписание» появились кнопки «Будни», «Суббота»,
«Воскресенье». По умолчанию подсвечивается реальный день недели
по часовому поясу МСК, далее таблица переключается вручную.

Единый массив SCHEDULE заменён на SCHEDULES с расписанием по типам
дней: 13 рейсов в будни, 10 в субботу, 6 в воскресенье.
Карточки ближайших рейсов переключаются вместе с таблицей.

Важно: по субботе и воскресенью в репозитории хранились только
отправления из Полярных Зорь и прибытия в Зашеек (app/src/main/java/
ru/ua1zbe/bus127/MainActivity.kt). Первая и четвёртая колонки
восстановлены по правилам будних дней — кол0 = кол1 − 50 мин,
кол3 = кол2 + 20 мин — и требуют сверки с официальным расписанием.
Также исправлен перепуск субботы и воскресенья: Date#getDay()
возвращает 0 для воскресенья, а не для субботы.
2026-09-28 08:05:36 +00:00
37ff82a6ef Релиз 2.5: versionCode 7, versionName 2.5 (надпись в шапке, установлено на телефон) 2026-09-28 07:57:13 +00:00
5d1236dd87 Надпись в шапке: только «Мурманская область»
Убрано «· регулярный маршрут» — надпись в шапке теперь просто «Мурманская область».
2026-09-28 07:53:27 +00:00
4642ca0115 Надпись в шапке: только «Мурманская область»
Убрано «· регулярный маршрут» — надпись в шапке теперь просто «Мурманская область».
2026-09-28 07:53:25 +00:00
0a3d7baf82 Релиз 2.4: versionCode 6, versionName 2.4 (прибытие в Полярные Зори из Кандалакши) 2026-09-28 06:52:40 +00:00
885a96dced Карточка Кандалакши: добавлено прибытие в Полярные Зори
Раньше в карточке было только «прибытие Зашеек», хотя рейс идёт
через Полярные Зори и время прибытия туда есть в таблице (колонка 1).
Теперь показываются оба прибытия: Полярные Зори, затем Зашеек.
Время берётся из таблицы, ничего не рассчитывается.
2026-09-28 06:51:39 +00:00
f437fb8c5a Карточка Кандалакши: добавлено прибытие в Полярные Зори
Раньше в карточке было только «прибытие Зашеек», хотя рейс идёт
через Полярные Зори и время прибытия туда есть в таблице (колонка 1).
Теперь показываются оба прибытия: Полярные Зори, затем Зашеек.
Время берётся из таблицы, ничего не рассчитывается.
2026-09-28 06:51:38 +00:00
366d0dd454 Релиз 2.3: versionCode 5, versionName 2.3 (расчёт прибытия в Кандалакшу) 2026-09-28 06:45:40 +00:00
80b1b2b0cc Считается прибытие в Кандалакшу
Для направления Полярные Зори → Кандалакша время прибытия в таблице
расписания отсутствует, поэтому оно теперь рассчитывается: отправление
из Полярных Зорь плюс 50 минут. Время в пути взято из самой таблицы —
в 12 строках из 13 Кандалакша → Полярные Зори занимает ровно 50 минут.

Заодно исправлена ошибка из v2.2: в карточке Зашейка колонка 3 подписана
как «прибытие Кандалакша», хотя это время в Полярных Зорях (20–30 минут
после прибытия в Зашеек, а не час до Кандалакши). Теперь в карточке
Зашейка показываются оба прибытия: Полярные Зори, затем Кандалакша.

Модель направлений переведена на список прибытий arrs, где время берётся
либо из колонки таблицы (col), либо считается как offset от отправления.
2026-09-28 06:44:57 +00:00
269a651325 Считается прибытие в Кандалакшу
Для направления Полярные Зори → Кандалакша время прибытия в таблице
расписания отсутствует, поэтому оно теперь рассчитывается: отправление
из Полярных Зорь плюс 50 минут. Время в пути взято из самой таблицы —
в 12 строках из 13 Кандалакша → Полярные Зори занимает ровно 50 минут.

Заодно исправлена ошибка из v2.2: в карточке Зашейка колонка 3 подписана
как «прибытие Кандалакша», хотя это время в Полярных Зорях (20–30 минут
после прибытия в Зашеек, а не час до Кандалакши). Теперь в карточке
Зашейка показываются оба прибытия: Полярные Зори, затем Кандалакша.

Модель направлений переведена на список прибытий arrs, где время берётся
либо из колонки таблицы (col), либо считается как offset от отправления.
2026-09-28 06:44:56 +00:00
20 changed files with 1342 additions and 76 deletions

View File

@@ -12,6 +12,17 @@
- **Автоопределение дня недели** — будни/выходные.
- **Полное расписание** — по кнопке раскрывается вся таблица
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
- **Карта с автобусами** — пункт меню «Карта» открывает VK Карты: видно
пройденный участок маршрута и автобусы прямо сейчас (без подписей остановок —
они были бы только шумом). Положение считается **по расписанию** (приложение
не берёт координаты с GPS), поэтому это оценка с точностью до нескольких
минут; внизу перечислены автобусы с направлением и временем прибытия.
Обновление каждые 20 сек.
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
- **Меню** — кнопка `⋮` в шапке открывает выпадающее окошко с пунктами
«Карта» и «О приложении». Меню сворачивается после выбора пункта, по нажатию
вне его и по кнопке «‹» (которая в этом случае только закрывает меню,
а не уходит назад).
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
## Технические данные
@@ -24,6 +35,65 @@
| Сборка | Gradle 8.6 + AGP 8.2.2 |
| Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
## Пуш-уведомления (RuStore)
Приложение интегрировано с [RuStore Push SDK](https://www.rustore.ru/help/sdk/push-notifications)
(аналог Firebase Cloud Messaging). Проект в консоли RuStore привязан к пакету
`ru.ua1zbe.bus127` и подписи release-keystore; пока подпись не меняется, перерегистрация не нужна.
Для доставки на устройстве должен быть установлен и авторизован **RuStore**.
Рассылка выполняется с сервера через API RuStore:
```
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
Authorization: Bearer {service-token}
Content-Type: application/json
{
"message": {
"token": "{push-token устройства}",
"notification": {
"title": "Автобус 127",
"body": "Ближайший рейс через 5 мин"
}
}
}
```
- `project_id` и `service-token` берутся в консоли RuStore: **Push-уведомления > Проекты**.
В приложение попадает только `project_id`; сервисный токен в APK не хранится.
- `push-token` устройства приходит в `onNewToken` (лог `RuStorePush`) и сохраняется
в `SharedPreferences` `bus127_push`.
- Приложение подписывается на топик `bus127` при старте. Отдельного создания топика
в консоли не требуется — публикация в него проходит и для несуществующего.
- Если в запросе есть секция `notification` — уведомление показывает сам SDK;
если переданы только `data` (`title`/`body`, необязательный `url`) — приложение
показывает его само и открывает ссылку по клику.
### Рассылка
**Всем подписчикам топика** (все устройства, открывшие приложение):
```
POST https://vkpns-topics.rustore.ru/v1/projects/{project_id}/topics/{topic}/publish
Authorization: Bearer {service-token}
{"message":{"notification":{"title":"...","body":"..."}}}
```
**Одному устройству** — по его push-токену:
```
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
Authorization: Bearer {service-token}
{"message":{"token":"...","notification":{"title":"...","body":"..."}}}
```
Если устройства не подписались сами, их токены можно подписать разом:
`POST .../topics/{topic}/batch_subscribe` с `{"push_tokens":[...]}`.
Ответ `400` с текстом про `registration token` означает, что адресат не указан
или токен устройства неверен; `401`/`403` — неверный сервисный токен или его пара с `project_id`.
## Структура
```
@@ -32,8 +102,20 @@ apk-webview/ # исходники APK (WebView)
│ ├── build.gradle
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/ru/ua1zbe/bus127/MainActivity.java
│ ├── java/ru/ua1zbe/bus127/
│ │ ├── MainActivity.java # главный экран: WebView + запрос разрешения
│ │ ├── AboutActivity.java # окно «О приложении»
│ │ ├── MapActivity.java # экран карты (свой WebView на тот же index.html#map)
│ │ ├── Webs.java # общий WebView для всех экранов
│ │ ├── BusBridge.java # мост страницы и приложения (меню, назад, версия)
│ │ ├── Bus127App.java # инициализация RuStore Push SDK
│ │ ├── PushMessagingService.java # приём уведомлений
│ │ ├── PushIntents.java # переход по клику
│ │ └── push/PushStore.java # хранение push-токена
│ ├── assets/index.html # интерфейс (HTML/JS)
│ ├── assets/route.js # геометрия маршрута OSRM + координаты остановок ([lat,lon])
│ ├── assets/mmr-gl.js # VK Maps SDK (UMD, глобаль mmrgl), BSD-3-Clause
│ ├── assets/mmr-gl.css # стили SDK
│ └── res/values/strings.xml
└── settings.gradle, build.gradle, gradle.properties
web/index.html # веб-версия (та же страница, для браузера)
@@ -52,4 +134,9 @@ cd apk-webview
## Лицензия
MIT.
MIT. В `app/src/main/assets/` лежит сторонний код со своей лицензией:
- `mmr-gl.js`, `mmr-gl.css` — VK Maps SDK, BSD-3-Clause,
текст в `mmr-gl.LICENSE.txt`;
- `route.js` — геометрия дороги и координаты остановок из OpenStreetMap
(ODbL), атрибуция также выводится самими VK Картами.

View File

@@ -19,8 +19,8 @@ android {
applicationId "ru.ua1zbe.bus127"
minSdk 26
targetSdk 34
versionCode 4
versionName "2.2"
versionCode 13
versionName "2.6.4"
}
buildTypes {
@@ -35,3 +35,7 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
implementation "ru.rustore.sdk:pushclient:7.4.0"
}

View File

@@ -1,22 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".Bus127App"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<service
android:name=".PushMessagingService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="ru.rustore.sdk.pushclient.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_icon"
android:resource="@mipmap/ic_launcher" />
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_channel_id"
android:value="@string/push_channel_id" />
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.NoActionBar"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MapActivity"
android:exported="false"
android:label="@string/map_title"
android:parentActivityName=".MainActivity"
android:theme="@android:style/Theme.Material.NoActionBar" />
<activity
android:name=".AboutActivity"
android:exported="false"
android:label="@string/about_title"
android:parentActivityName=".MainActivity"
android:theme="@android:style/Theme.Material.NoActionBar" />
</application>
</manifest>

View File

@@ -16,8 +16,7 @@
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
.wrap{max-width:760px;margin:0 auto}
header{text-align:center;margin-bottom:18px}
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase}
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
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}
@@ -56,9 +55,93 @@
.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{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{padding:24px 16px 16px;overflow:hidden}
body.map .wrap{height:100vh;height:100dvh;display:flex;flex-direction:column;max-width:none}
body.map header{margin-bottom:12px}
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}
.mapstatus{flex:0 0 auto;background:var(--card);border:1px solid var(--line);
border-radius:14px;padding:11px 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}
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
.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)}
@@ -77,8 +160,18 @@
<body>
<div class="wrap">
<header>
<div class="route">Мурманская область · регулярный маршрут</div>
<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>
@@ -96,6 +189,12 @@
<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>
@@ -105,11 +204,49 @@
<div id="tableBody"></div>
</section>
<section class="mapcard" id="mapBox" hidden>
<div class="mapcanvas loading" id="mapCanvas"></div>
<div class="mapstatus">
<div class="head" id="mapHead">Карта маршрута</div>
<div class="rows" id="mapRows"></div>
<div class="hint">Положение автобуса рассчитано по расписанию, а не по GPS:
между остановками интерполируется по дороге. Точность — до нескольких минут.</div>
</div>
</section>
<section class="aboutcard" id="aboutBox">
<h2>О приложении</h2>
<div class="ver" id="aboutVer"></div>
<p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.</p>
<p>Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд.
Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов,
с переключением между буднями, субботой и воскресеньем.</p>
<p>В меню есть «Карта»: там видно, где сейчас едут автобусы, и как пройден
участок между остановками. Положение считается по расписанию — приложение
не получает данные с GPS, поэтому это оценка с точностью до нескольких минут.</p>
<p class="muted">Расписание действует с 30.09.2020. Время московское (МСК).</p>
<p class="muted">Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap.
Пуш-уведомления приходят через RuStore Push — для этого нужен
установленный и авторизованный RuStore на устройстве.</p>
</section>
<footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer>
</div>
<script>
const SCHEDULE = [
// Расписание по типам дней недели.
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
// прибытие в Зашеек · Полярные Зори (обратно).
//
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
// хранились только отправления из Полярных Зорь и прибытия в Зашеек
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
// первая и четвёртая колонки восстановлены по тем же правилам, что и для
// будней, и требуют сверки с официальным расписанием:
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
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"],
@@ -123,19 +260,57 @@ const SCHEDULE = [
["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:05"],
["08:45","09:35","10:05","10:25"],
["10:10","11:00","11:30","11:50"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:45","16:05"],
["15:30","16:20","16:45","17:05"],
["17:00","17:50","18:20","18:40"],
["18:10","19:00","19:30","19:50"],
["20:20","21:10","21:40","22:00"]
],
sunday: [
["06:10","07:00","07:25","07:45"],
["08:45","09:35","10:05","10:25"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:25","15:45"],
["16:50","17:40","18:10","18:30"],
["20:20","21:10","21:40","22:00"]
]
};
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;
// arrs — список прибытий: {label, col} — время из колонки таблицы,
// {label, offset} — «время в пути» от отправления (если col не задан).
const DIRECTIONS = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [
{to:"Полярные Зори, Зашеек", dep:0, arr:2, arrLabel:"Зашеек", via:"через Полярные Зори"}
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]}
],
"Полярные Зори": [
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
arrs:[{label:"Зашеек", col:2}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
],
"Зашеек": [
{to:"Полярные Зори, Кандалакша", dep:2, arr:3, arrLabel:"Кандалакша", via:"через Полярные Зори"}
{to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:3},
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
]
};
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
@@ -174,6 +349,12 @@ function nowMs(){
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;
@@ -184,24 +365,42 @@ function fmtWait(m){
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 = SCHEDULE
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
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]) : 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;
const arr = x.arr ? `<small>прибытие${d.arrLabel ? " "+d.arrLabel : ""} ${x.arr}</small>` : "";
// .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">${SCHEDULE[x.ri][d.dep]}${arr}</span>
<span class="t">${data[x.ri][d.dep]}${arr}</span>
<span class="w">${fmtWait(w)}</span>
</div>`;
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</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>
@@ -211,7 +410,7 @@ function render(){
}
function renderTable(){
document.getElementById("tableBody").innerHTML = SCHEDULE.map(row =>
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("");
}
@@ -221,6 +420,15 @@ function renderDay(){
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");
@@ -239,6 +447,10 @@ document.querySelectorAll(".pick").forEach(btn=>{
});
});
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");
@@ -247,10 +459,297 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open);
});
tickClock(); renderDay(); render(); renderTable();
// Режим показа задаётся якорью в адресе:
// (пусто) — браузер;
// #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;
// Длина ломаной с накопленными отрезками. Расстояние считаем в
// равнопромежуточной проекции — на таком масштабе это точнее градусов.
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];
}
// Рейс разложен на четыре участка: туда Кандалакша → ПЗ → Зашеек и обратно.
// Время в пути ПЗ → Кандалакша в таблице не указано, берём те же 50 минут,
// что и в DIRECTIONS.
function makeRun(row){
const t0=toMin(row[0]), t1=toMin(row[1]), t2=toMin(row[2]), t3=toMin(row[3]);
const back = t3 + RIDE_PZ_KAND; // прибытие в Кандалакшу
return {
t0, tEnd: back,
segs: [
{a:t0, b:t1, line:KAND_TO_PZ, to:"Полярные Зори", end:t1},
{a:t1, b:t2, line:PZ_TO_ZASHEEK, to:"Зашеек", end:t2},
{a:t2, b:t3, line:PZ_TO_ZASHEEK_BACK, to:"Полярные Зори", end:t3},
{a:t3, b:back, line:KAND_TO_PZ_BACK, to:"Кандалакша", end:back}
]
};
}
// Все рейсы, которые сейчас в пути, вместе с точкой и участком
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.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 =>
`<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
});
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>`;
});
}
if (MODE === "map") initMap();
</script>
</body>
</html>

View File

@@ -0,0 +1,150 @@
Copyright (c) 2022, VK
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MMR GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from maplibre-gl-js v3.6.2 and earlier
Version v3.6.2 of maplibre-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2023, MapLibre contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MapLibre GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from mapbox-gl-js v1.13 and earlier
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2020, Mapbox
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Mapbox GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from glfx.js
Copyright (C) 2011 by Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Contains a portion of d3-color https://github.com/d3/d3-color
Copyright 2010-2016 Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
const STOPS = {
kandalaksha: [67.15079, 32.40827],
polyarnye: [67.36592, 32.50103],
zasheek: [67.4066, 32.54991]
};
const LEG_KP = [[32.40830,67.15086],[32.40919,67.15123],[32.40530,67.15211],[32.40164,67.15592],[32.40263,67.15627],[32.40234,67.15671],[32.40365,67.16009],[32.40439,67.16077],[32.40083,67.16153],[32.39347,67.16167],[32.37834,67.16738],[32.39686,67.17485],[32.41082,67.17577],[32.41996,67.18286],[32.42404,67.18468],[32.43127,67.18609],[32.43515,67.19157],[32.43787,67.19296],[32.43467,67.19463],[32.43517,67.20077],[32.44001,67.20499],[32.44209,67.21166],[32.42869,67.22053],[32.42562,67.22192],[32.42445,67.22492],[32.41760,67.22676],[32.41025,67.23310],[32.40865,67.23699],[32.41144,67.24032],[32.42732,67.24995],[32.43369,67.25293],[32.43263,67.25523],[32.43282,67.26107],[32.44726,67.27306],[32.45055,67.27809],[32.45315,67.28023],[32.45327,67.28199],[32.45202,67.28569],[32.44925,67.29019],[32.44998,67.29238],[32.45527,67.29506],[32.46552,67.29702],[32.48543,67.30690],[32.49115,67.31659],[32.49412,67.31822],[32.49437,67.31942],[32.49295,67.32024],[32.47807,67.32475],[32.46672,67.33477],[32.46639,67.33576],[32.46686,67.33634],[32.47102,67.33898],[32.46966,67.34397],[32.46744,67.34585],[32.46606,67.34825],[32.46588,67.35182],[32.46764,67.35447],[32.47301,67.35645],[32.48066,67.36185],[32.48653,67.36436],[32.48729,67.36651],[32.48942,67.36782],[32.49137,67.36657],[32.49723,67.36592],[32.50077,67.36628],[32.50049,67.36658],[32.50172,67.36733],[32.50322,67.36675],[32.50112,67.36633],[32.50144,67.36598]];
const LEG_PZ = [[32.50144,67.36598],[32.50112,67.36633],[32.50322,67.36675],[32.50749,67.37359],[32.50798,67.37512],[32.50647,67.37756],[32.50977,67.38124],[32.51700,67.38728],[32.52852,67.39091],[32.53133,67.39241],[32.53116,67.39391],[32.52513,67.39734],[32.53544,67.39912],[32.54459,67.39914],[32.55436,67.40345],[32.55208,67.40677],[32.54915,67.40679]];

View File

@@ -0,0 +1,16 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
/**
* Описание приложения и версия. Рисуется той же страницей расписания,
* поэтому тема оформления совпадает с остальными экранами.
*/
public class AboutActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(Webs.create(this, Webs.URL_ABOUT));
}
}

View File

@@ -0,0 +1,60 @@
package ru.ua1zbe.bus127;
import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.util.Log;
import ru.rustore.sdk.pushclient.RuStorePushClient;
import ru.ua1zbe.bus127.push.PushStore;
/**
* Инициализация RuStore Push SDK.
*
* project_id и topic берутся из консоли RuStore (Push-уведомления > Проекты).
* Сервисный токен в приложении не хранится — он нужен только серверу
* для вызова https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
*/
public class Bus127App extends Application {
public static final String CHANNEL_ID = "bus127_push";
@Override
public void onCreate() {
super.onCreate();
createPushChannel();
initRuStorePush();
}
private void createPushChannel() {
NotificationManager nm = getSystemService(NotificationManager.class);
nm.createNotificationChannel(new NotificationChannel(
CHANNEL_ID,
getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_HIGH));
}
private void initRuStorePush() {
try {
RuStorePushClient.INSTANCE.init(this, RUSTORE_PROJECT_ID);
RuStorePushClient.INSTANCE.getToken()
.addOnSuccessListener(token -> {
Log.i(TAG, "push token: " + token);
PushStore.setToken(this, token);
RuStorePushClient.INSTANCE.subscribeToTopic(PUSH_TOPIC)
.addOnSuccessListener(v ->
Log.i(TAG, "subscribed to " + PUSH_TOPIC))
.addOnFailureListener(e ->
Log.w(TAG, "subscribe to " + PUSH_TOPIC + " failed", e));
})
.addOnFailureListener(e ->
Log.w(TAG, "getToken failed", e));
} catch (Exception e) {
Log.w(TAG, "init failed", e);
}
}
private static final String TAG = "RuStorePush";
private static final String RUSTORE_PROJECT_ID = "upSEOtZyXP96tskl4hf-442AqciCUTxs";
private static final String PUSH_TOPIC = "bus127";
}

View File

@@ -0,0 +1,51 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.webkit.JavascriptInterface;
/**
* Мост между страницей расписания и приложением: кнопки заголовка
* (стрелка назад и меню) вызывают сюда. В браузерной версии страницы
* объект не существует, и кнопки там не показываются.
*/
public class BusBridge {
private final Activity activity;
BusBridge(Activity activity) {
this.activity = activity;
}
@JavascriptInterface
public void goBack() {
activity.runOnUiThread(activity::finish);
}
@JavascriptInterface
public void openAbout() {
open(AboutActivity.class);
}
/** «2.6.3 (сборка 12)» — чтобы текст не расходился со сборкой. */
@JavascriptInterface
public String version() {
try {
PackageInfo info = activity.getPackageManager()
.getPackageInfo(activity.getPackageName(), 0);
long code = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? info.getLongVersionCode() : info.versionCode;
return info.versionName + " (сборка " + code + ")";
} catch (PackageManager.NameNotFoundException e) {
return "";
}
}
private void open(Class<?> target) {
activity.runOnUiThread(() ->
activity.startActivity(new Intent(activity, target)));
}
}

View File

@@ -2,21 +2,24 @@ package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.view.WindowManager;
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
WebView webView = new WebView(this);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setSupportZoom(false);
webView.setWebViewClient(new WebViewClient());
webView.loadUrl("file:///android_asset/index.html");
setContentView(webView);
// экран не гаснет, пока открыто расписание
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requestNotificationsPermission();
setContentView(Webs.create(this, Webs.URL_MAIN));
}
// Android 13+ требует явного согласия на показ уведомлений
private void requestNotificationsPermission() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU
&& checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{android.Manifest.permission.POST_NOTIFICATIONS}, 1);
}
}
}

View File

@@ -0,0 +1,19 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
import android.view.WindowManager;
/**
* Карта маршрута с положением автобуса. Рисуется той же страницей расписания
* в режиме #map, поэтому шапка, меню и тема совпадают с остальными экранами.
* Сеть нужна только здесь: стиль и тайлы VK Карт грузятся извне.
*/
public class MapActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
setContentView(Webs.create(this, Webs.URL_MAP));
}
}

View File

@@ -0,0 +1,20 @@
package ru.ua1zbe.bus127;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
/** Открытие расписания по клику на уведомление. */
final class PushIntents {
private PushIntents() {
}
static PendingIntent web(Context context, String url) {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
}

View File

@@ -0,0 +1,75 @@
package ru.ua1zbe.bus127;
import android.app.Notification;
import android.app.NotificationManager;
import android.util.Log;
import java.util.List;
import java.util.Map;
import ru.rustore.sdk.pushclient.messaging.exception.RuStorePushClientException;
import ru.rustore.sdk.pushclient.messaging.model.RemoteMessage;
import ru.rustore.sdk.pushclient.messaging.service.RuStoreMessagingService;
import ru.ua1zbe.bus127.push.PushStore;
/**
* Приём пуш-уведомлений RuStore.
*
* Если сервер прислал секцию notification — SDK показывает уведомление сам.
* Если пришли только data (title/body) — показываем уведомление сами.
*/
public class PushMessagingService extends RuStoreMessagingService {
@Override
public void onNewToken(String token) {
Log.i(TAG, "onNewToken: " + token);
PushStore.setToken(this, token);
}
@Override
public void onMessageReceived(RemoteMessage message) {
if (message.getNotification() != null) {
Log.i(TAG, "push displayed by SDK: " + message.getData());
return;
}
Map<String, String> data = message.getData();
String title = data.get("title");
if (title == null || title.isEmpty()) {
title = getString(R.string.app_name);
}
String body = data.get("body");
if (body == null) {
body = "";
}
if (!title.isEmpty() || !body.isEmpty()) {
showPush(title, body, data.get("url"));
}
}
@Override
public void onDeletedMessages() {
Log.i(TAG, "onDeletedMessages");
}
@Override
public void onError(List<? extends RuStorePushClientException> errors) {
for (RuStorePushClientException e : errors) {
Log.w(TAG, "error", e);
}
}
private void showPush(String title, String body, String url) {
Notification.Builder b = new Notification.Builder(this, Bus127App.CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.mipmap.ic_launcher)
.setAutoCancel(true);
if (url != null && !url.isEmpty()) {
b.setContentIntent(PushIntents.web(this, url));
}
getSystemService(NotificationManager.class).notify(NOTIFICATION_ID, b.build());
}
private static final String TAG = "RuStorePush";
private static final int NOTIFICATION_ID = 127;
}

View File

@@ -0,0 +1,53 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.util.Log;
import android.webkit.ConsoleMessage;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
/** Создание WebView с одинаковыми настройками для всех экранов. */
final class Webs {
/** Главный экран: полное расписание раскрывается прямо на странице. */
static final String URL_MAIN = "file:///android_asset/index.html#app";
/** Карта маршрута с положением автобуса. */
static final String URL_MAP = "file:///android_asset/index.html#map";
/** Описание приложения и версия. */
static final String URL_ABOUT = "file:///android_asset/index.html#about";
private Webs() {
}
static WebView create(Activity activity, String url) {
WebView webView = new WebView(activity);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setSupportZoom(false);
// Страница лежит в assets и грузится по file://, а карта (#map) тянет
// стиль, тайлы и шрифты VK Карт с https. Без этого file://-страница
// не может обращаться к внешним адресам, и карта остаётся пустой.
settings.setAllowUniversalAccessFromFileURLs(true);
webView.setWebViewClient(new WebViewClient());
webView.setWebChromeClient(new WebChromeClient() {
// страница — это весь интерфейс, поэтому её ошибки и предупреждения
// полезно видеть в logcat: иначе сбой на экране выглядит как «ничего не работает»
@Override
public boolean onConsoleMessage(ConsoleMessage m) {
if (m.messageLevel() == ConsoleMessage.MessageLevel.ERROR
|| m.messageLevel() == ConsoleMessage.MessageLevel.WARNING) {
Log.w("Bus127JS", m.messageLevel() + " " + m.message()
+ " (" + m.sourceId() + ":" + m.lineNumber() + ")");
return true;
}
return false;
}
});
webView.addJavascriptInterface(new BusBridge(activity), "Bus");
webView.loadUrl(url);
return webView;
}
}

View File

@@ -0,0 +1,27 @@
package ru.ua1zbe.bus127.push;
import android.content.Context;
import android.content.SharedPreferences;
/** Хранение push-токена устройства, чтобы отправлять ему уведомления с сервера. */
public final class PushStore {
private static final String PREFS = "bus127_push";
private static final String KEY_TOKEN = "push_token";
private PushStore() {
}
private static SharedPreferences sp(Context context) {
return context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
public static String getToken(Context context) {
return sp(context).getString(KEY_TOKEN, "");
}
public static void setToken(Context context, String token) {
sp(context).edit().putString(KEY_TOKEN, token).apply();
}
}

View File

@@ -1,4 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Автобус 127</string>
<string name="push_channel_id">bus127_push</string>
<string name="push_channel_name">Пуш-уведомления</string>
<string name="map_title">Карта</string>
<string name="about_title">О приложении</string>
</resources>

View File

@@ -2,7 +2,11 @@ pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() }
}
dependencyResolutionManagement {
repositories { google(); mavenCentral() }
repositories {
google()
mavenCentral()
maven { url "https://nexus-external.rustore.ru/repository/maven-rustore-exposed/" }
}
}
rootProject.name = "BusSchedule127"
include ':app'

View File

@@ -16,8 +16,7 @@
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
.wrap{max-width:760px;margin:0 auto}
header{text-align:center;margin-bottom:18px}
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase}
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
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}
@@ -59,6 +58,15 @@
.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)}
@@ -77,7 +85,6 @@
<body>
<div class="wrap">
<header>
<div class="route">Мурманская область · регулярный маршрут</div>
<h1>Автобус №127</h1>
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
@@ -96,6 +103,11 @@
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
<section class="tablecard" id="fullTable">
<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>
@@ -109,7 +121,19 @@
</div>
<script>
const SCHEDULE = [
// Расписание по типам дней недели.
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
// прибытие в Зашеек · Полярные Зори (обратно).
//
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
// хранились только отправления из Полярных Зорь и прибытия в Зашеек
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
// первая и четвёртая колонки восстановлены по тем же правилам, что и для
// будней, и требуют сверки с официальным расписанием:
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
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"],
@@ -123,19 +147,57 @@ const SCHEDULE = [
["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:05"],
["08:45","09:35","10:05","10:25"],
["10:10","11:00","11:30","11:50"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:45","16:05"],
["15:30","16:20","16:45","17:05"],
["17:00","17:50","18:20","18:40"],
["18:10","19:00","19:30","19:50"],
["20:20","21:10","21:40","22:00"]
],
sunday: [
["06:10","07:00","07:25","07:45"],
["08:45","09:35","10:05","10:25"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:25","15:45"],
["16:50","17:40","18:10","18:30"],
["20:20","21:10","21:40","22:00"]
]
};
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;
// arrs — список прибытий: {label, col} — время из колонки таблицы,
// {label, offset} — «время в пути» от отправления (если col не задан).
const DIRECTIONS = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [
{to:"Полярные Зори, Зашеек", dep:0, arr:2, arrLabel:"Зашеек", via:"через Полярные Зори"}
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]}
],
"Полярные Зори": [
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
arrs:[{label:"Зашеек", col:2}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
],
"Зашеек": [
{to:"Полярные Зори, Кандалакша", dep:2, arr:3, arrLabel:"Кандалакша", via:"через Полярные Зори"}
{to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:3},
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
]
};
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
@@ -174,6 +236,12 @@ function nowMs(){
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;
@@ -184,24 +252,42 @@ function fmtWait(m){
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 = SCHEDULE
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
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]) : 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;
const arr = x.arr ? `<small>прибытие${d.arrLabel ? " "+d.arrLabel : ""} ${x.arr}</small>` : "";
// .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">${SCHEDULE[x.ri][d.dep]}${arr}</span>
<span class="t">${data[x.ri][d.dep]}${arr}</span>
<span class="w">${fmtWait(w)}</span>
</div>`;
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</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>
@@ -211,7 +297,7 @@ function render(){
}
function renderTable(){
document.getElementById("tableBody").innerHTML = SCHEDULE.map(row =>
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("");
}
@@ -221,6 +307,15 @@ function renderDay(){
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");
@@ -239,6 +334,10 @@ document.querySelectorAll(".pick").forEach(btn=>{
});
});
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");
@@ -247,7 +346,9 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open);
});
tickClock(); renderDay(); render(); renderTable();
let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay);
tickClock(); renderDay(); render();
setInterval(tickClock,1000);
setInterval(render,30000);
setInterval(renderDay,60000);