Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d96793c27 | |||
| 4549506d6d | |||
| d07d9407cf | |||
| 20ed52384a | |||
| b5821a1fc7 | |||
| c639996ca8 | |||
| 9b2a45568e | |||
| 6d3f794bb7 | |||
| b5eed3284c | |||
| 5cdddcc9b3 | |||
| 36b59c2e2e | |||
| 66688630a3 | |||
| 9cda0ba3df | |||
| c2505ea8f3 | |||
| 8daf3d01ce | |||
| 9c8d8b5db7 | |||
| 4f972d527b | |||
| 873a2b7cd7 | |||
| ddc9cf2584 | |||
| a8ec1ce654 | |||
| 187de60f5c | |||
| c7a7726eb7 | |||
| ccbde3a9dd | |||
| 828106a6fc | |||
| e7df71281d | |||
| dbad63bc77 | |||
| af1f215b8a | |||
| 38adfdb34b | |||
| 5e15e685fa | |||
| baabcd31f8 | |||
| 83fc10878b | |||
| 576f794ff7 | |||
| bd1db2a468 | |||
| 905214b49e | |||
| 74d3f395ce | |||
| 034e7e1e27 | |||
| 09bffa78a3 | |||
| f0088b08b6 | |||
| 91a43a37b4 | |||
| 4a2aa59c77 | |||
| 3aab0b826b | |||
| 6e03bc997c | |||
| f04bb1843c | |||
| ed621b7ce2 | |||
| 0e9150b5ce | |||
| 6f591c10d5 | |||
| a15dccf3c2 | |||
| f82fe1a524 | |||
| 0b4852c582 | |||
| 37ff82a6ef | |||
| 5d1236dd87 | |||
| 4642ca0115 |
91
README.md
91
README.md
@@ -12,6 +12,17 @@
|
|||||||
- **Автоопределение дня недели** — будни/выходные.
|
- **Автоопределение дня недели** — будни/выходные.
|
||||||
- **Полное расписание** — по кнопке раскрывается вся таблица
|
- **Полное расписание** — по кнопке раскрывается вся таблица
|
||||||
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
|
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
|
||||||
|
- **Карта с автобусами** — пункт меню «Карта» открывает VK Карты: видно
|
||||||
|
пройденный участок маршрута и автобусы прямо сейчас (без подписей остановок —
|
||||||
|
они были бы только шумом). Положение считается **по расписанию** (приложение
|
||||||
|
не берёт координаты с GPS), поэтому это оценка с точностью до нескольких
|
||||||
|
минут; внизу перечислены автобусы с направлением и временем прибытия.
|
||||||
|
Обновление каждые 20 сек.
|
||||||
|
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
|
||||||
|
- **Меню** — кнопка `⋮` в шапке открывает выпадающее окошко с пунктами
|
||||||
|
«Карта» и «О приложении». Меню сворачивается после выбора пункта, по нажатию
|
||||||
|
вне его и по кнопке «‹» (которая в этом случае только закрывает меню,
|
||||||
|
а не уходит назад).
|
||||||
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
|
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
|
||||||
|
|
||||||
## Технические данные
|
## Технические данные
|
||||||
@@ -24,6 +35,65 @@
|
|||||||
| Сборка | Gradle 8.6 + AGP 8.2.2 |
|
| Сборка | Gradle 8.6 + AGP 8.2.2 |
|
||||||
| Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
|
| Подпись | 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
|
│ ├── build.gradle
|
||||||
│ └── src/main/
|
│ └── src/main/
|
||||||
│ ├── AndroidManifest.xml
|
│ ├── 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/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
|
│ └── res/values/strings.xml
|
||||||
└── settings.gradle, build.gradle, gradle.properties
|
└── settings.gradle, build.gradle, gradle.properties
|
||||||
web/index.html # веб-версия (та же страница, для браузера)
|
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 Картами.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId "ru.ua1zbe.bus127"
|
applicationId "ru.ua1zbe.bus127"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 6
|
versionCode 15
|
||||||
versionName "2.4"
|
versionName "2.6.6"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -35,3 +35,7 @@ android {
|
|||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "ru.rustore.sdk:pushclient:7.4.0"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,58 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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
|
<application
|
||||||
|
android:name=".Bus127App"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
||||||
.wrap{max-width:760px;margin:0 auto}
|
.wrap{max-width:760px;margin:0 auto}
|
||||||
header{text-align:center;margin-bottom:18px}
|
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:0 0 4px;font-weight:700}
|
||||||
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
|
|
||||||
.sub{color:var(--muted);font-size:14px}
|
.sub{color:var(--muted);font-size:14px}
|
||||||
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
||||||
.clock small{color:var(--muted);font-weight:400;font-size:12px}
|
.clock small{color:var(--muted);font-weight:400;font-size:12px}
|
||||||
@@ -57,8 +56,133 @@
|
|||||||
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
|
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
|
||||||
.fullbtn.open .chev{transform:rotate(180deg)}
|
.fullbtn.open .chev{transform:rotate(180deg)}
|
||||||
|
|
||||||
|
/* кнопка карты — под блоком полного расписания, только в приложении */
|
||||||
|
#mapBtn{display:none;margin:6px 0 6px}
|
||||||
|
body.app #mapBtn{display:block}
|
||||||
|
.mapbtn::before{content:"⌖ ";color:var(--accent2)}
|
||||||
|
|
||||||
|
/* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */
|
||||||
|
.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}
|
||||||
|
/* но в справке стрелка «назад» нужна и должна быть кликабельной */
|
||||||
|
body.app.showhelp .iconbtn.back{visibility:visible}
|
||||||
|
/* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */
|
||||||
|
#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}
|
||||||
|
/* внизу «О приложении» те же даты и источник уже написаны своим текстом,
|
||||||
|
общая сноска про расписание тут только дублирует их */
|
||||||
|
body.about footer{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}
|
||||||
|
.aboutcard p.dev{color:var(--muted);font-size:13px;text-align:center;margin:18px 0 0}
|
||||||
|
.aboutcard h3{margin:16px 0 6px;font-size:14px;color:var(--accent2)}
|
||||||
|
.aboutcard a{color:var(--accent2)}
|
||||||
|
|
||||||
|
/* «Справка» — тот же экран, что и расписание, просто другой блок */
|
||||||
|
.helpcard{display:none;background:var(--card);border:1px solid var(--line);
|
||||||
|
border-radius:14px;padding:16px;margin-bottom:10px}
|
||||||
|
body.app.showhelp .helpcard{display:block}
|
||||||
|
body.app.showhelp .pickers,body.app.showhelp .where,body.app.showhelp .dir,
|
||||||
|
body.app.showhelp .fullbtn,body.app.showhelp #fullTable,
|
||||||
|
body.app.showhelp #mapBtn,body.app.showhelp footer{display:none}
|
||||||
|
.helpcard h2{margin:0 0 4px;font-size:17px}
|
||||||
|
.helpcard h3{margin:16px 0 6px;font-size:14px;color:var(--accent2)}
|
||||||
|
.helpcard h3:first-of-type{margin-top:12px}
|
||||||
|
.helpcard p{margin:0 0 10px;font-size:14px;line-height:1.5}
|
||||||
|
|
||||||
|
/* экран «Карта»: страница целиком занята картой, шапка остаётся */
|
||||||
|
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}
|
||||||
|
|
||||||
|
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
|
||||||
|
.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;
|
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
||||||
overflow:hidden;display:none;margin-bottom:8px}
|
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);
|
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
|
||||||
font-size:11px;color:var(--muted);text-align:center}
|
font-size:11px;color:var(--muted);text-align:center}
|
||||||
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
||||||
@@ -77,8 +201,19 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
<div class="route">Мурманская область · регулярный маршрут</div>
|
<div class="headwrap">
|
||||||
|
<div class="headrow">
|
||||||
|
<button class="iconbtn back" id="navBack" aria-label="Назад">‹</button>
|
||||||
<h1>Автобус №127</h1>
|
<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="mHelp">Справка</button>
|
||||||
|
<button class="menuitem" id="mAbout">О приложении</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
||||||
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
||||||
<div class="day" id="dayBadge"></div>
|
<div class="day" id="dayBadge"></div>
|
||||||
@@ -96,53 +231,207 @@
|
|||||||
|
|
||||||
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
||||||
<section class="tablecard" id="fullTable">
|
<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 class="thead">
|
||||||
<div><b>Кандалакша</b>отпр.</div>
|
<div><b>Кандалакша</b>отпр.</div>
|
||||||
<div><b>Полярные Зори</b>→ Зашеек</div>
|
<div><b>Полярные Зори</b>отпр. в Зашеек</div>
|
||||||
<div><b>Зашеек</b>прибытие</div>
|
<div><b>Зашеек</b>отпр.</div>
|
||||||
<div><b>Полярные Зори</b>→ Кандалакша</div>
|
<div><b>Полярные Зори</b>отпр. в Канд.</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tableBody"></div>
|
<div id="tableBody"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer>
|
<button class="fullbtn mapbtn" id="mapBtn">Карта маршрута</button>
|
||||||
|
|
||||||
|
<section class="mapcard" id="mapBox" hidden>
|
||||||
|
<div class="mapcanvas loading" id="mapCanvas"></div>
|
||||||
|
<div class="mapsheet" id="mapSheet">
|
||||||
|
<div class="grab" id="sheetGrab">
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="cap" id="sheetCap">Автобусы в пути</span>
|
||||||
|
<span class="chev">▴</span>
|
||||||
|
</div>
|
||||||
|
<div class="mapstatus">
|
||||||
|
<div id="mapContent">
|
||||||
|
<div class="head" id="mapHead">Сейчас автобусов нет</div>
|
||||||
|
<div class="rows" id="mapRows"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="helpcard" id="helpBox">
|
||||||
|
<h2>Справка</h2>
|
||||||
|
|
||||||
|
<h3>Как читать расписание</h3>
|
||||||
|
<p>Таблица и списки показывают официальное время <b>отправлений</b>.
|
||||||
|
Время прибытия перевозчик не публикует, поэтому приложение считает его
|
||||||
|
само: до Зашейка — как выезд с Полярных Зорь плюс 20 минут, до
|
||||||
|
Кандалакши — плюс 50 минут.</p>
|
||||||
|
<p>Одна остановка — исключение. Когда автобус приезжает на Полярные Зори,
|
||||||
|
он сразу уходит дальше, поэтому время прибытия совпадает с отправлением
|
||||||
|
следующей остановки. В Зашейке автобус задерживается, и там прибытие
|
||||||
|
считается по времени в пути, а не по расписанию.</p>
|
||||||
|
<p>Из-за этого интервалы в обе стороны по одной дороге отличаются
|
||||||
|
на 5–10 минут. Так и есть в официальном расписании, это не ошибка
|
||||||
|
приложения.</p>
|
||||||
|
|
||||||
|
<h3>Время прибытия в карточках</h3>
|
||||||
|
<p>В списке рейсов под заголовком направления показано время прибытия
|
||||||
|
в Зашеек или в Кандалакшу — серым под временем отправления. Время
|
||||||
|
московское (МСК).</p>
|
||||||
|
|
||||||
|
<h3>Положение автобусов на карте</h3>
|
||||||
|
<p>GPS у приложения нет. Положение считается по расписанию: между
|
||||||
|
известными точками маршрута координаты интерполируются по времени рейса,
|
||||||
|
доля пути — по длине дороги, поэтому метка идёт по трассе, а не по прямой.
|
||||||
|
В Зашейке автобус стоит, пока не подошло время обратного выезда.</p>
|
||||||
|
<p>Точность — до нескольких минут. В пробки реальное отставание больше,
|
||||||
|
и метка может показывать автобус позади или впереди машины.</p>
|
||||||
|
<p>Список под картой обновляется каждые 20 секунд: для каждого автобуса
|
||||||
|
в пути указано направление и время прибытия на следующую остановку.</p>
|
||||||
|
|
||||||
|
<h3>Когда обновляется расписание</h3>
|
||||||
|
<p>Часы, ближайшие рейсы и положение автобусов обновляются автоматически.
|
||||||
|
Список рейсов — раз в минуту, часы — раз в секунду. В приложении нет
|
||||||
|
кнопки обновления: перезапускать ничего не нужно.</p>
|
||||||
|
|
||||||
|
<h3>Где брать точные данные</h3>
|
||||||
|
<p>Время отправления перевозчик меняет без предупреждения. Сроки
|
||||||
|
действия расписания и телефон диспетчерской службы указаны
|
||||||
|
в разделе «О приложении» — уточнять время лучше там.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="aboutcard" id="aboutBox">
|
||||||
|
<h2>О приложении</h2>
|
||||||
|
<div class="ver" id="aboutVer"></div>
|
||||||
|
<p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.
|
||||||
|
Ближайшие рейсы по вашей остановке, полное расписание по будням, субботам
|
||||||
|
и воскресеньям, а также карта с положением автобусов в пути.</p>
|
||||||
|
<p>Всё время — московское (МСК). Данные обновляются автоматически,
|
||||||
|
интернет нужен только для карты.</p>
|
||||||
|
|
||||||
|
<h3>Расписание</h3>
|
||||||
|
<p>Действует летнее расписание <b>с 1 мая по 31 октября 2026 года</b>.
|
||||||
|
Отправления взяты из данных диспетчерской службы перевозчика,
|
||||||
|
время прибытия приложение рассчитывает само — подробно в «Справке».</p>
|
||||||
|
<p>Источник: <a href="https://kandalaksha.org/static/auto.html" target="_blank" rel="noopener">kandalaksha.org/static/auto.html</a><br>
|
||||||
|
Телефон диспетчерской службы: 8(921) 174-26-00, 8(911) 303-63-28</p>
|
||||||
|
|
||||||
|
<h3>Что внутри</h3>
|
||||||
|
<p>Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap.
|
||||||
|
Пуш-уведомления приходят через RuStore Push: для этого нужен
|
||||||
|
установленный и авторизованный RuStore на устройстве.</p>
|
||||||
|
<p class="muted">Приложение не использует геолокацию и не передаёт
|
||||||
|
данные о вашем местоположении: положение автобусов вычисляется
|
||||||
|
по расписанию на устройстве.</p>
|
||||||
|
<p class="dev">Разработчик © UA1ZBE</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным
|
||||||
|
диспетчерской службы перевозчика · время московское (МСК)</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SCHEDULE = [
|
// Расписание по типам дней недели. Взято с официального расписания
|
||||||
|
// (данные диспетчерской службы перевозчика ООО «Гарант-Авто»,
|
||||||
|
// 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"],
|
["05:20","06:20","06:30","06:50"],
|
||||||
["06:10","07:00","07:25","07:45"],
|
["06:10","07:00","07:25","07:45"],
|
||||||
["07:40","08:30","09:00","09:20"],
|
["07:40","08:30","09:00","09:20"],
|
||||||
["08:00","08:50","09:20","09:50"],
|
|
||||||
["09:45","10:35","11:05","11:25"],
|
["09:45","10:35","11:05","11:25"],
|
||||||
["11:00","11:50","12:20","12:40"],
|
["11:00","11:50","12:20","12:40"],
|
||||||
["12:30","13:20","13:50","14:10"],
|
["11:30","12:20","12:50","13:20"],
|
||||||
|
["12:30","13:20","13:50","14:15"],
|
||||||
["13:30","14:20","14:50","15:10"],
|
["13:30","14:20","14:50","15:10"],
|
||||||
["14:00","14:50","15:25","15:50"],
|
["14:00","14:50","15:25","15:50"],
|
||||||
["15:30","16:20","17:00","17:25"],
|
["15:30","16:20","17:00","17:25"],
|
||||||
["16:50","17:40","18:10","18:35"],
|
["16:50","17:40","18:10","18:35"],
|
||||||
["18:20","19:10","19:40","20:05"],
|
["18:20","19:10","19:40","20:05"],
|
||||||
["20:20","21:10","21:40","22: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; // сколько ближайших рейсов показывать в карточке
|
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
|
||||||
|
|
||||||
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
|
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
|
||||||
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
|
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
|
||||||
// (в первой строке 60 — по всей видимости опечатка).
|
// (в первой строке 60 — по всей видимости опечатка в самом расписании).
|
||||||
const RIDE_PZ_KAND = 50;
|
const RIDE_PZ_KAND = 50;
|
||||||
|
|
||||||
// arrs — список прибытий: {label, col} — время из колонки таблицы,
|
// Полярные Зори → Зашеек. В расписании тоже нет: там опубликованы только
|
||||||
// {label, offset} — «время в пути» от отправления (если col не задан).
|
// отправления. Принимаем 20 минут — но не больше, чем есть между выездом
|
||||||
|
// из Полярных Зорь и выездом обратно, иначе на первом утреннем рейсе
|
||||||
|
// (06:20 → 06:30) автобус «прибыл» бы в Зашеек уже после своего выезда.
|
||||||
|
const RIDE_PZ_ZASHEEK = 20;
|
||||||
|
|
||||||
|
// Прибытие в Зашеек — оценка, потому что официального времени прибытия
|
||||||
|
// в расписании нет. Считаем от выезда из Полярных Зорь.
|
||||||
|
function arrZasheek(row){
|
||||||
|
const t1 = toMin(row[1]), t2 = toMin(row[2]);
|
||||||
|
return t1 + Math.max(0, Math.min(RIDE_PZ_ZASHEEK, t2 - t1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrs — список прибытий: {label, col} — время из колонки расписания,
|
||||||
|
// {label, offset} — «время в пути» от отправления (если col не задан),
|
||||||
|
// {label, arrZ:true} — оценка прибытия в Зашеек, см. arrZasheek().
|
||||||
|
// В расписании опубликованы только отправления, поэтому прибытия
|
||||||
|
// (кроме Зашейка, где есть своя оценка) берутся из отправлений следующей
|
||||||
|
// остановки: автобус приезжает на ПЗ и сразу уходит дальше.
|
||||||
const DIRECTIONS = {
|
const DIRECTIONS = {
|
||||||
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
|
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
|
||||||
"Кандалакша": [
|
"Кандалакша": [
|
||||||
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
|
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
|
||||||
arrs:[{label:"Полярные Зори", col:1},
|
arrs:[{label:"Полярные Зори", col:1},
|
||||||
{label:"Зашеек", col:2}]}
|
{label:"Зашеек", arrZ:true}]}
|
||||||
],
|
],
|
||||||
"Полярные Зори": [
|
"Полярные Зори": [
|
||||||
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
|
{to:"Зашеек", dep:1, via:"≈ 20 мин в пути",
|
||||||
arrs:[{label:"Зашеек", col:2}]},
|
arrs:[{label:"Зашеек", arrZ:true}]},
|
||||||
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
|
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
|
||||||
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
|
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
|
||||||
],
|
],
|
||||||
@@ -204,16 +493,28 @@ function fmtWait(m){
|
|||||||
|
|
||||||
let currentStop = "Полярные Зори";
|
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(){
|
function render(){
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const curMin = now.hour*60 + now.minute;
|
const curMin = now.hour*60 + now.minute;
|
||||||
const dirs = DIRECTIONS[currentStop];
|
const dirs = DIRECTIONS[currentStop];
|
||||||
|
const data = SCHEDULES[todayKey()];
|
||||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||||
const upcoming = SCHEDULE
|
const upcoming = data
|
||||||
.map((r,ri)=>{
|
.map((r,ri)=>{
|
||||||
const dep = toMin(r[d.dep]);
|
const dep = toMin(r[d.dep]);
|
||||||
const arrs = d.arrs.map(a=>{
|
const arrs = d.arrs.map(a=>{
|
||||||
const base = a.col!==undefined ? toMin(r[a.col]) : dep;
|
const base = a.col!==undefined ? toMin(r[a.col])
|
||||||
|
: a.arrZ ? arrZasheek(r)
|
||||||
|
: dep;
|
||||||
return {label:a.label, t:fmtHM(base + (a.offset||0))};
|
return {label:a.label, t:fmtHM(base + (a.offset||0))};
|
||||||
});
|
});
|
||||||
return {dep, arrs, ri};
|
return {dep, arrs, ri};
|
||||||
@@ -226,10 +527,10 @@ function render(){
|
|||||||
// .trip .t small имеет display:block, поэтому каждый <small> — своя строка
|
// .trip .t small имеет display:block, поэтому каждый <small> — своя строка
|
||||||
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
|
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
|
||||||
return `<div class="trip ${w<15?'soon':''}">
|
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>
|
<span class="w">${fmtWait(w)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</div>`;
|
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
|
||||||
return `<div class="card">
|
return `<div class="card">
|
||||||
<h3>→ <span class="arr">${d.to}</span></h3>
|
<h3>→ <span class="arr">${d.to}</span></h3>
|
||||||
<div class="via">${d.via}</div>
|
<div class="via">${d.via}</div>
|
||||||
@@ -239,7 +540,7 @@ function render(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(){
|
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>`
|
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
|
||||||
).join("");
|
).join("");
|
||||||
}
|
}
|
||||||
@@ -249,6 +550,15 @@ function renderDay(){
|
|||||||
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
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(){
|
function tickClock(){
|
||||||
const d = nowMs();
|
const d = nowMs();
|
||||||
const p = n=>String(n).padStart(2,"0");
|
const p = n=>String(n).padStart(2,"0");
|
||||||
@@ -267,6 +577,10 @@ document.querySelectorAll(".pick").forEach(btn=>{
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("#dayPick button").forEach(btn=>{
|
||||||
|
btn.addEventListener("click",()=>selectDay(btn.dataset.day));
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("fullBtn").addEventListener("click",()=>{
|
document.getElementById("fullBtn").addEventListener("click",()=>{
|
||||||
const t = document.getElementById("fullTable");
|
const t = document.getElementById("fullTable");
|
||||||
const b = document.getElementById("fullBtn");
|
const b = document.getElementById("fullBtn");
|
||||||
@@ -275,10 +589,424 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
|
|||||||
b.classList.toggle("open", !open);
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// «Справка» — блок на этом же экране. Снимаем её и кнопкой «‹»,
|
||||||
|
// и аппаратной «назад»: для этого кладём в историю браузера состояние,
|
||||||
|
// чтобы система отдала его нам, а не закрыла приложение.
|
||||||
|
const helpOn = () => document.body.classList.contains("showhelp");
|
||||||
|
const showHelp = on => {
|
||||||
|
document.body.classList.toggle("showhelp", on);
|
||||||
|
if (on) window.scrollTo(0, 0);
|
||||||
|
};
|
||||||
|
document.getElementById("mMap").addEventListener("click",()=>{ closeMenu(); Bus.openMap(); });
|
||||||
|
document.getElementById("mHelp").addEventListener("click",()=>{
|
||||||
|
closeMenu();
|
||||||
|
showHelp(true);
|
||||||
|
history.pushState({ help: 1 }, "");
|
||||||
|
});
|
||||||
|
window.addEventListener("popstate", () => { if (helpOn()) showHelp(false); });
|
||||||
|
const hideHelp = () => {
|
||||||
|
if (!helpOn()) return false;
|
||||||
|
showHelp(false);
|
||||||
|
if (history.state && history.state.help) history.back(); // убрать и из истории
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Один шаг «назад»: справка, потом меню. false — значит, уходить из приложения.
|
||||||
|
const backStep = () => {
|
||||||
|
if (hideHelp()) return true;
|
||||||
|
if (!menuBox.hidden) { closeMenu(); return true; }
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("navBack").addEventListener("click",()=>{
|
||||||
|
// «назад» сначала закрывает справку, потом сворачивает меню,
|
||||||
|
// и только потом уходит назад
|
||||||
|
if (backStep()) return;
|
||||||
|
Bus.goBack();
|
||||||
|
});
|
||||||
|
// Системная кнопка «назад» WebView не перехватывает, поэтому Android
|
||||||
|
// спрашивает у страницы то же самое, что и стрелка «‹» (MainActivity).
|
||||||
|
window.__busBack = backStep;
|
||||||
|
document.getElementById("mAbout").addEventListener("click",()=>{ closeMenu(); Bus.openAbout(); });
|
||||||
|
document.getElementById("mapBtn").addEventListener("click",()=>Bus.openMap());
|
||||||
|
document.addEventListener("click",e=>{
|
||||||
|
if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeDay = todayKey(); // по умолчанию — реальный день недели
|
||||||
|
selectDay(activeDay);
|
||||||
|
tickClock(); renderDay(); render();
|
||||||
setInterval(tickClock,1000);
|
setInterval(tickClock,1000);
|
||||||
setInterval(render,30000);
|
setInterval(render,30000);
|
||||||
setInterval(renderDay,60000);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свёрнутая шторка подстраивается под содержимое, а список автобусов
|
||||||
|
// обновляется каждые 20 секунд. Ширину/высоту слушаем и у DOM, и у окна:
|
||||||
|
// первый срабатывает при переносе строк, второй — при смене шрифта.
|
||||||
|
// Заполняется в initSheet, ниже по файлу.
|
||||||
|
let setSheetHeight = null;
|
||||||
|
const refitSheet = () => { if (setSheetHeight) setSheetHeight(null); };
|
||||||
|
if (typeof ResizeObserver === "function") {
|
||||||
|
const st = document.getElementById("mapContent");
|
||||||
|
if (st) new ResizeObserver(refitSheet).observe(st);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", refitSheet);
|
||||||
|
|
||||||
|
function renderMapStatus(list){
|
||||||
|
const head = document.getElementById("mapHead");
|
||||||
|
const rows = document.getElementById("mapRows");
|
||||||
|
if (!list.length) {
|
||||||
|
head.innerHTML = "Сейчас автобусов нет";
|
||||||
|
rows.innerHTML = `<div>Первый рейс — в ${SCHEDULES[todayKey()][0][0]} из Кандалакши.</div>`;
|
||||||
|
refitSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const many = list.length > 1;
|
||||||
|
head.innerHTML = `Сейчас в пути <b>${list.length}</b> ${many ? "автобуса" : "автобус"}`;
|
||||||
|
rows.innerHTML = list.map(b => b.seg.hold
|
||||||
|
? `<div><span class="when">Зашеек</span><span>стоит на остановке, выезд в ${fmtHM(b.seg.b)}</span></div>`
|
||||||
|
: `<div><span class="when">→ ${b.seg.to}</span><span>прибытие в ${fmtHM(b.seg.end)}</span></div>`
|
||||||
|
).join("");
|
||||||
|
refitSheet(); // строк могло стать больше или меньше
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMap(map, mmrgl){
|
||||||
|
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 status = sheet.querySelector(".mapstatus");
|
||||||
|
const content = document.getElementById("mapContent");
|
||||||
|
const max = () => Math.max(60, Math.round(box.clientHeight * 0.72));
|
||||||
|
// Свёрнутое состояние — ровно по содержимому: ручка, список автобусов и
|
||||||
|
// внутренние отступы прокручиваемого блока. Высоту берём у #mapContent,
|
||||||
|
// а не у .mapstatus: тот растягивается флексом, и его scrollHeight при
|
||||||
|
// раскрытой шторке равен clientHeight — из-за этого сворачивание
|
||||||
|
// «по содержимому» заканчивалось бы раздуванием до максимума.
|
||||||
|
const min = () => {
|
||||||
|
const cs = getComputedStyle(status);
|
||||||
|
const pad = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
|
||||||
|
return Math.min(max(), Math.round(grab.getBoundingClientRect().height
|
||||||
|
+ content.offsetHeight + pad));
|
||||||
|
};
|
||||||
|
let open = false, h = min(), startY = 0, startH = 0, moved = 0, dragged = false;
|
||||||
|
|
||||||
|
function set(next, expanded){
|
||||||
|
h = Math.max(min(), Math.min(max(), Math.round(next)));
|
||||||
|
sheet.style.height = h + "px";
|
||||||
|
open = !!expanded;
|
||||||
|
sheet.classList.toggle("open", open);
|
||||||
|
if (MAPREF) MAPREF.resize(); // холст карты стал ниже — пересчитать
|
||||||
|
}
|
||||||
|
|
||||||
|
// Захват указателя есть не во всех окружениях (и может не сработать при
|
||||||
|
// синтетическом событии), поэтому на него не опираемся критично.
|
||||||
|
const captured = e => { try { return grab.hasPointerCapture(e.pointerId); }
|
||||||
|
catch(_){ return true; } };
|
||||||
|
const capture = e => { try { grab.setPointerCapture(e.pointerId); } catch(_){} };
|
||||||
|
const release = e => { try { grab.releasePointerCapture(e.pointerId); } catch(_){} };
|
||||||
|
|
||||||
|
grab.addEventListener("pointerdown", e=>{
|
||||||
|
startY = e.clientY; startH = h; moved = 0; dragged = false;
|
||||||
|
capture(e);
|
||||||
|
});
|
||||||
|
grab.addEventListener("pointermove", e=>{
|
||||||
|
if (!captured(e)) return;
|
||||||
|
moved = startY - e.clientY;
|
||||||
|
if (Math.abs(moved) > 6) dragged = true;
|
||||||
|
const want = startH + moved;
|
||||||
|
set(want, want > min() + 8);
|
||||||
|
});
|
||||||
|
grab.addEventListener("pointerup", e=>{
|
||||||
|
release(e);
|
||||||
|
if (!dragged) { set(open ? min() : max(), !open); return; } // просто тап
|
||||||
|
const expanded = h > box.clientHeight * 0.4;
|
||||||
|
set(expanded ? max() : min(), expanded);
|
||||||
|
});
|
||||||
|
grab.addEventListener("pointercancel", ()=>{ set(open ? max() : min(), open); });
|
||||||
|
|
||||||
|
// Высота шторки. next === null — просто пересчитать по текущему содержимому,
|
||||||
|
// не трогая состояние «раскрыта/свёрнута»: этим пользуется список автобусов.
|
||||||
|
setSheetHeight = next => {
|
||||||
|
set(next === null ? (open ? max() : min()) : next, open);
|
||||||
|
};
|
||||||
|
|
||||||
|
// поворот экрана или смена высоты окна — пересчитать предел раскрытия
|
||||||
|
window.addEventListener("resize", ()=> set(open ? max() : min(), open));
|
||||||
|
|
||||||
|
set(min(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MODE === "map") { initMap(); initSheet(); }
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
150
apk-webview/app/src/main/assets/mmr-gl.LICENSE.txt
Normal file
150
apk-webview/app/src/main/assets/mmr-gl.LICENSE.txt
Normal 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.
|
||||||
1
apk-webview/app/src/main/assets/mmr-gl.css
Normal file
1
apk-webview/app/src/main/assets/mmr-gl.css
Normal file
File diff suppressed because one or more lines are too long
48
apk-webview/app/src/main/assets/mmr-gl.js
Normal file
48
apk-webview/app/src/main/assets/mmr-gl.js
Normal file
File diff suppressed because one or more lines are too long
7
apk-webview/app/src/main/assets/route.js
Normal file
7
apk-webview/app/src/main/assets/route.js
Normal 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]];
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 openMap() {
|
||||||
|
open(MapActivity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
public void openAbout() {
|
||||||
|
open(AboutActivity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «2.6.6 (сборка 15)» — чтобы текст не расходился со сборкой. */
|
||||||
|
@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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,21 +2,48 @@ package ru.ua1zbe.bus127;
|
|||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.webkit.WebSettings;
|
import android.view.WindowManager;
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
import android.webkit.WebViewClient;
|
|
||||||
|
|
||||||
public class MainActivity extends Activity {
|
public class MainActivity extends Activity {
|
||||||
|
|
||||||
|
private WebView web;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
WebView webView = new WebView(this);
|
// экран не гаснет, пока открыто расписание
|
||||||
WebSettings settings = webView.getSettings();
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
settings.setJavaScriptEnabled(true);
|
requestNotificationsPermission();
|
||||||
settings.setDomStorageEnabled(true);
|
web = Webs.create(this, Webs.URL_MAIN);
|
||||||
settings.setSupportZoom(false);
|
setContentView(web);
|
||||||
webView.setWebViewClient(new WebViewClient());
|
}
|
||||||
webView.loadUrl("file:///android_asset/index.html");
|
|
||||||
setContentView(webView);
|
/**
|
||||||
|
* WebView системную кнопку «назад» не перехватывает — без этого нажатие
|
||||||
|
* сразу закрывало бы приложение, даже когда открыта справка. Спрашиваем
|
||||||
|
* у страницы, закрыла ли она что-нибудь сама, и выходим только если нет.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
if (web == null) {
|
||||||
|
super.onBackPressed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
web.evaluateJavascript(
|
||||||
|
"(function(){return (window.__busBack && window.__busBack()) ? 1 : 0})()",
|
||||||
|
value -> {
|
||||||
|
String v = String.valueOf(value).replace("\"", "").trim();
|
||||||
|
if (!"1".equals(v)) finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
53
apk-webview/app/src/main/java/ru/ua1zbe/bus127/Webs.java
Normal file
53
apk-webview/app/src/main/java/ru/ua1zbe/bus127/Webs.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Автобус 127</string>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ pluginManagement {
|
|||||||
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
||||||
}
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositories { google(); mavenCentral() }
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url "https://nexus-external.rustore.ru/repository/maven-rustore-exposed/" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rootProject.name = "BusSchedule127"
|
rootProject.name = "BusSchedule127"
|
||||||
include ':app'
|
include ':app'
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
||||||
.wrap{max-width:760px;margin:0 auto}
|
.wrap{max-width:760px;margin:0 auto}
|
||||||
header{text-align:center;margin-bottom:18px}
|
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:0 0 4px;font-weight:700}
|
||||||
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
|
|
||||||
.sub{color:var(--muted);font-size:14px}
|
.sub{color:var(--muted);font-size:14px}
|
||||||
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
||||||
.clock small{color:var(--muted);font-weight:400;font-size:12px}
|
.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;
|
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
||||||
overflow:hidden;display:none;margin-bottom:8px}
|
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);
|
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
|
||||||
font-size:11px;color:var(--muted);text-align:center}
|
font-size:11px;color:var(--muted);text-align:center}
|
||||||
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
||||||
@@ -77,7 +85,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
<div class="route">Мурманская область · регулярный маршрут</div>
|
|
||||||
<h1>Автобус №127</h1>
|
<h1>Автобус №127</h1>
|
||||||
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
||||||
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
||||||
@@ -96,6 +103,11 @@
|
|||||||
|
|
||||||
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
||||||
<section class="tablecard" id="fullTable">
|
<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 class="thead">
|
||||||
<div><b>Кандалакша</b>отпр.</div>
|
<div><b>Кандалакша</b>отпр.</div>
|
||||||
<div><b>Полярные Зори</b>→ Зашеек</div>
|
<div><b>Полярные Зори</b>→ Зашеек</div>
|
||||||
@@ -109,7 +121,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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"],
|
["05:20","06:20","06:30","06:50"],
|
||||||
["06:10","07:00","07:25","07:45"],
|
["06:10","07:00","07:25","07:45"],
|
||||||
["07:40","08:30","09:00","09:20"],
|
["07:40","08:30","09:00","09:20"],
|
||||||
@@ -123,7 +147,31 @@ const SCHEDULE = [
|
|||||||
["16:50","17:40","18:10","18:35"],
|
["16:50","17:40","18:10","18:35"],
|
||||||
["18:20","19:10","19:40","20:05"],
|
["18:20","19:10","19:40","20:05"],
|
||||||
["20:20","21:10","21:40","22: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; // сколько ближайших рейсов показывать в карточке
|
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
|
||||||
|
|
||||||
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
|
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
|
||||||
@@ -204,12 +252,22 @@ function fmtWait(m){
|
|||||||
|
|
||||||
let currentStop = "Полярные Зори";
|
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(){
|
function render(){
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const curMin = now.hour*60 + now.minute;
|
const curMin = now.hour*60 + now.minute;
|
||||||
const dirs = DIRECTIONS[currentStop];
|
const dirs = DIRECTIONS[currentStop];
|
||||||
|
const data = SCHEDULES[todayKey()];
|
||||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||||
const upcoming = SCHEDULE
|
const upcoming = data
|
||||||
.map((r,ri)=>{
|
.map((r,ri)=>{
|
||||||
const dep = toMin(r[d.dep]);
|
const dep = toMin(r[d.dep]);
|
||||||
const arrs = d.arrs.map(a=>{
|
const arrs = d.arrs.map(a=>{
|
||||||
@@ -226,10 +284,10 @@ function render(){
|
|||||||
// .trip .t small имеет display:block, поэтому каждый <small> — своя строка
|
// .trip .t small имеет display:block, поэтому каждый <small> — своя строка
|
||||||
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
|
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
|
||||||
return `<div class="trip ${w<15?'soon':''}">
|
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>
|
<span class="w">${fmtWait(w)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</div>`;
|
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
|
||||||
return `<div class="card">
|
return `<div class="card">
|
||||||
<h3>→ <span class="arr">${d.to}</span></h3>
|
<h3>→ <span class="arr">${d.to}</span></h3>
|
||||||
<div class="via">${d.via}</div>
|
<div class="via">${d.via}</div>
|
||||||
@@ -239,7 +297,7 @@ function render(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(){
|
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>`
|
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
|
||||||
).join("");
|
).join("");
|
||||||
}
|
}
|
||||||
@@ -249,6 +307,15 @@ function renderDay(){
|
|||||||
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
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(){
|
function tickClock(){
|
||||||
const d = nowMs();
|
const d = nowMs();
|
||||||
const p = n=>String(n).padStart(2,"0");
|
const p = n=>String(n).padStart(2,"0");
|
||||||
@@ -267,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",()=>{
|
document.getElementById("fullBtn").addEventListener("click",()=>{
|
||||||
const t = document.getElementById("fullTable");
|
const t = document.getElementById("fullTable");
|
||||||
const b = document.getElementById("fullBtn");
|
const b = document.getElementById("fullBtn");
|
||||||
@@ -275,7 +346,9 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
|
|||||||
b.classList.toggle("open", !open);
|
b.classList.toggle("open", !open);
|
||||||
});
|
});
|
||||||
|
|
||||||
tickClock(); renderDay(); render(); renderTable();
|
let activeDay = todayKey(); // по умолчанию — реальный день недели
|
||||||
|
selectDay(activeDay);
|
||||||
|
tickClock(); renderDay(); render();
|
||||||
setInterval(tickClock,1000);
|
setInterval(tickClock,1000);
|
||||||
setInterval(render,30000);
|
setInterval(render,30000);
|
||||||
setInterval(renderDay,60000);
|
setInterval(renderDay,60000);
|
||||||
|
|||||||
Reference in New Issue
Block a user