Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6386a3420e | |||
| eecb0ed95b | |||
| 99bb3b2981 | |||
| 5fb1462af6 | |||
| e90ea69d44 | |||
| 297d61b065 | |||
| af8107b34c | |||
| 0f72977cb9 | |||
| 886849b949 | |||
| 5f8a797a58 | |||
| 3859c22086 | |||
| 0b9af4b067 | |||
| 20ae4b0606 | |||
| 0dcaf486c0 | |||
| 588b826b42 | |||
| 5d78f40c3e | |||
| 362906b1ba | |||
| 93e4b2b065 | |||
| 35a43cb829 | |||
| 3e61ae7e74 |
93
README.md
93
README.md
@@ -1,78 +1,55 @@
|
|||||||
# Автобус №127 — Расписание (Android)
|
# Автобус №127 — Расписание (Android)
|
||||||
|
|
||||||
[](#лицензия)
|
Приложение с расписанием автобуса **№127** по маршруту
|
||||||
|
**Кандалакша — Полярные Зори — Зашеек** (Мурманская область).
|
||||||
Приложение для Android с расписанием автобуса **№127** по маршруту
|
|
||||||
**Полярные Зори ⇄ Зашеек** (Мурманская область, Кандалакшский район).
|
|
||||||
Написано на **Kotlin** с использованием **Android SDK** и Material Components.
|
|
||||||
Тёмная тема, аккуратные карточки и «чипы» расписания.
|
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Расписание на **будни**, **субботу** и **воскресенье** (переключатель вкладок).
|
- **Выбор остановки** — Кандалакша / Полярные Зори / Зашеек. При выборе
|
||||||
- Блок **«Ближайшие рейсы»** — три ближайших отправления с подсчётом
|
показываются доступные направления (куда можно уехать).
|
||||||
«через N минут» (время по **МСК**, обновляется каждые 30 сек).
|
- **Ближайшие рейсы** — по 3 на каждое направление, с подсчётом
|
||||||
- Полные списки рейсов **туда** (ПЗ → Зашеек) и **обратно** (Зашеек → ПЗ),
|
«через N ч M мин» (время по **МСК**, обновляется каждые 30 сек).
|
||||||
аккуратно переносящиеся по 5 в строке.
|
- **Автоопределение дня недели** — будни/выходные.
|
||||||
- Тёмная тема (#121212) с оранжево-красным акцентом.
|
- **Полное расписание** — по кнопке раскрывается вся таблица
|
||||||
- Адаптивная иконка-автобус (Adaptive Icon).
|
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
|
||||||
- Живые часы местного времени (МСК) в шапке.
|
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
|
||||||
|
|
||||||
## Скриншот
|
|
||||||
|
|
||||||
<sub>Иконка приложения — стилизованный автобус на градиентном фоне.</sub>
|
|
||||||
|
|
||||||
## Технические данные
|
## Технические данные
|
||||||
|
|
||||||
| Параметр | Значение |
|
| Параметр | Значение |
|
||||||
|-----------------|-----------------------------------|
|
|-----------------|------------------------------|
|
||||||
| Язык | Kotlin 1.9.22 |
|
| Тип | WebView-обёртка (HTML/JS) |
|
||||||
| Минимальный SDK | 26 (Android 8.0) |
|
| Минимальный SDK | 26 (Android 8.0) |
|
||||||
| Целевой SDK | 34 (Android 14) |
|
| Целевой SDK | 34 (Android 14) |
|
||||||
| Сборка | Gradle 8.6 + Android Gradle Plugin 8.2.2 |
|
| Сборка | Gradle 8.6 + AGP 8.2.2 |
|
||||||
| UI | Material Components, ConstraintLayout |
|
| Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
|
||||||
|
|
||||||
## Сборка
|
|
||||||
|
|
||||||
Проект собирается системным Gradle (на хосте `192.168.1.46` gradle распакован
|
|
||||||
в `~/BusSchedule127/gradle-8.6`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANDROID_HOME=~/Android/Sdk
|
|
||||||
export JAVA_HOME=/usr
|
|
||||||
export PATH=$PATH:~/BusSchedule127/gradle-8.6/bin
|
|
||||||
|
|
||||||
cd BusSchedule127
|
|
||||||
gradle clean assembleRelease --no-daemon
|
|
||||||
```
|
|
||||||
|
|
||||||
Подписанный APK появится в:
|
|
||||||
`app/build/outputs/apk/release/app-release.apk`
|
|
||||||
|
|
||||||
> Для подписи используется keystore `~/bus127.keystore` (alias `bus127`).
|
|
||||||
> Секретные ключи в репозиторий не включены — генерируйте свои
|
|
||||||
> через `keytool` перед релизной сборкой.
|
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```
|
```
|
||||||
BusSchedule127/
|
apk-webview/ # исходники APK (WebView)
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── build.gradle
|
│ ├── build.gradle
|
||||||
│ └── src/main/
|
│ └── src/main/
|
||||||
│ ├── AndroidManifest.xml
|
│ ├── AndroidManifest.xml
|
||||||
│ ├── java/ru/ua1zbe/bus127/MainActivity.kt
|
│ ├── java/ru/ua1zbe/bus127/MainActivity.java
|
||||||
│ └── res/
|
│ ├── assets/index.html # интерфейс (HTML/JS)
|
||||||
│ ├── drawable/ # фоны, чипы, иконка-автобус
|
│ └── res/values/strings.xml
|
||||||
│ ├── layout/ # activity_main.xml
|
└── settings.gradle, build.gradle, gradle.properties
|
||||||
│ ├── mipmap-anydpi-v26/ # адаптивная иконка
|
web/index.html # веб-версия (та же страница, для браузера)
|
||||||
│ └── values/ # colors, strings, themes
|
|
||||||
├── build.gradle
|
|
||||||
├── settings.gradle
|
|
||||||
├── gradle.properties
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=$HOME/jdk-17.0.20.1+1
|
||||||
|
export ANDROID_HOME=$HOME/Android/Sdk
|
||||||
|
cd apk-webview
|
||||||
|
~/gradle-8.6/bin/gradle assembleRelease --no-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовый APK — в [релизах](../../releases) и в репозитории `android_apk`.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
MIT — свободное использование и модификация.
|
MIT.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId "ru.ua1zbe.bus127"
|
applicationId "ru.ua1zbe.bus127"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 2
|
versionCode 4
|
||||||
versionName "2.0"
|
versionName "2.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
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">
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
||||||
|
|
||||||
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.dir .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||||
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
||||||
.card h3 .arr{color:var(--accent)}
|
.card h3 .arr{color:var(--accent)}
|
||||||
@@ -123,24 +124,55 @@ const SCHEDULE = [
|
|||||||
["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"]
|
||||||
];
|
];
|
||||||
|
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
|
||||||
const DIRECTIONS = {
|
const DIRECTIONS = {
|
||||||
|
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
|
||||||
"Кандалакша": [
|
"Кандалакша": [
|
||||||
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"},
|
{to:"Полярные Зори, Зашеек", dep:0, arr:2, arrLabel:"Зашеек", via:"через Полярные Зори"}
|
||||||
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"}
|
|
||||||
],
|
],
|
||||||
"Полярные Зори": [
|
"Полярные Зори": [
|
||||||
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
||||||
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
||||||
],
|
],
|
||||||
"Зашеек": [
|
"Зашеек": [
|
||||||
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"},
|
{to:"Полярные Зори, Кандалакша", dep:2, arr:3, arrLabel:"Кандалакша", via:"через Полярные Зори"}
|
||||||
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"}
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
||||||
|
|
||||||
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
||||||
function nowMs(){ return new Date(Date.now()+3*3600*1000); } // МСК = UTC+3
|
|
||||||
|
// Текущее время по МСК, независимо от часового пояса устройства
|
||||||
|
const MSK_TZ = "Europe/Moscow";
|
||||||
|
let mskFmt = null;
|
||||||
|
try {
|
||||||
|
mskFmt = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: MSK_TZ, hourCycle: "h23",
|
||||||
|
year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
||||||
|
});
|
||||||
|
} catch(e){ mskFmt = null; }
|
||||||
|
|
||||||
|
function nowMs(){
|
||||||
|
if (mskFmt) {
|
||||||
|
try {
|
||||||
|
const parts = {};
|
||||||
|
mskFmt.formatToParts(new Date()).forEach(x => parts[x.type] = x.value);
|
||||||
|
const hour = Number(parts.hour) % 24; // на части движков полночь приходит как "24"
|
||||||
|
return {
|
||||||
|
hour: hour,
|
||||||
|
minute: Number(parts.minute),
|
||||||
|
second: Number(parts.second),
|
||||||
|
day: new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1,
|
||||||
|
Number(parts.day))).getUTCDay()
|
||||||
|
};
|
||||||
|
} catch(e){ /* ниже — запасной вариант */ }
|
||||||
|
}
|
||||||
|
// Запасной вариант: МСК = UTC+3 (переход на летнее время отменён)
|
||||||
|
const d = new Date(Date.now() + 3*3600*1000);
|
||||||
|
return { hour: d.getUTCHours(), minute: d.getUTCMinutes(),
|
||||||
|
second: d.getUTCSeconds(), day: d.getUTCDay() };
|
||||||
|
}
|
||||||
|
|
||||||
function fmtWait(m){
|
function fmtWait(m){
|
||||||
if(m <= 0) return "отправление сейчас";
|
if(m <= 0) return "отправление сейчас";
|
||||||
@@ -154,17 +186,17 @@ let currentStop = "Полярные Зори";
|
|||||||
|
|
||||||
function render(){
|
function render(){
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const curMin = now.getHours()*60 + now.getMinutes();
|
const curMin = now.hour*60 + now.minute;
|
||||||
const dirs = DIRECTIONS[currentStop];
|
const dirs = DIRECTIONS[currentStop];
|
||||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||||
const upcoming = SCHEDULE
|
const upcoming = SCHEDULE
|
||||||
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
||||||
.filter(x=>x.dep >= curMin)
|
.filter(x=>x.dep >= curMin)
|
||||||
.sort((a,b)=>a.dep-b.dep)
|
.sort((a,b)=>a.dep-b.dep)
|
||||||
.slice(0,3)
|
.slice(0,TRIPS_SHOWN)
|
||||||
.map(x=>{
|
.map(x=>{
|
||||||
const w = x.dep - curMin;
|
const w = x.dep - curMin;
|
||||||
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : "";
|
const arr = x.arr ? `<small>прибытие${d.arrLabel ? " "+d.arrLabel : ""} ${x.arr}</small>` : "";
|
||||||
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">${SCHEDULE[x.ri][d.dep]}${arr}</span>
|
||||||
<span class="w">${fmtWait(w)}</span>
|
<span class="w">${fmtWait(w)}</span>
|
||||||
@@ -185,14 +217,14 @@ function renderTable(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDay(){
|
function renderDay(){
|
||||||
const dow = nowMs().getDay();
|
const dow = nowMs().day;
|
||||||
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
||||||
document.getElementById("clock").innerHTML = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} <small>МСК</small>`;
|
document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".pick").forEach(btn=>{
|
document.querySelectorAll(".pick").forEach(btn=>{
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#1E1E1E" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- bus body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M22,44 L82,44 a8,8 0 0 1 8,8 L90,80 a6,6 0 0 1 -6,6 L24,86 a6,6 0 0 1 -6,-6 L18,52 a8,8 0 0 1 8,-8 Z" />
|
||||||
|
<!-- windshield -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M74,48 L84,48 a4,4 0 0 1 4,4 L88,62 L74,62 Z" />
|
||||||
|
<!-- side windows -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M24,48 L70,48 L70,62 L24,62 Z" />
|
||||||
|
<!-- window dividers -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M38,48 L38,62 M52,48 L52,62 M62,48 L62,62" />
|
||||||
|
<!-- headlight -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFE0D6"
|
||||||
|
android:pathData="M84,70 L88,70 L88,76 L84,76 Z" />
|
||||||
|
<!-- wheels -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M34,86 m-8,0 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M72,86 m-8,0 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0" />
|
||||||
|
<!-- wheel hubs -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M34,86 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M72,86 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
||||||
|
|
||||||
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.dir .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||||
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
||||||
.card h3 .arr{color:var(--accent)}
|
.card h3 .arr{color:var(--accent)}
|
||||||
@@ -123,24 +124,55 @@ const SCHEDULE = [
|
|||||||
["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"]
|
||||||
];
|
];
|
||||||
|
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
|
||||||
const DIRECTIONS = {
|
const DIRECTIONS = {
|
||||||
|
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
|
||||||
"Кандалакша": [
|
"Кандалакша": [
|
||||||
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"},
|
{to:"Полярные Зори, Зашеек", dep:0, arr:2, arrLabel:"Зашеек", via:"через Полярные Зори"}
|
||||||
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"}
|
|
||||||
],
|
],
|
||||||
"Полярные Зори": [
|
"Полярные Зори": [
|
||||||
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
||||||
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
||||||
],
|
],
|
||||||
"Зашеек": [
|
"Зашеек": [
|
||||||
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"},
|
{to:"Полярные Зори, Кандалакша", dep:2, arr:3, arrLabel:"Кандалакша", via:"через Полярные Зори"}
|
||||||
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"}
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
||||||
|
|
||||||
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
||||||
function nowMs(){ return new Date(Date.now()+3*3600*1000); } // МСК = UTC+3
|
|
||||||
|
// Текущее время по МСК, независимо от часового пояса устройства
|
||||||
|
const MSK_TZ = "Europe/Moscow";
|
||||||
|
let mskFmt = null;
|
||||||
|
try {
|
||||||
|
mskFmt = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: MSK_TZ, hourCycle: "h23",
|
||||||
|
year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
||||||
|
});
|
||||||
|
} catch(e){ mskFmt = null; }
|
||||||
|
|
||||||
|
function nowMs(){
|
||||||
|
if (mskFmt) {
|
||||||
|
try {
|
||||||
|
const parts = {};
|
||||||
|
mskFmt.formatToParts(new Date()).forEach(x => parts[x.type] = x.value);
|
||||||
|
const hour = Number(parts.hour) % 24; // на части движков полночь приходит как "24"
|
||||||
|
return {
|
||||||
|
hour: hour,
|
||||||
|
minute: Number(parts.minute),
|
||||||
|
second: Number(parts.second),
|
||||||
|
day: new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1,
|
||||||
|
Number(parts.day))).getUTCDay()
|
||||||
|
};
|
||||||
|
} catch(e){ /* ниже — запасной вариант */ }
|
||||||
|
}
|
||||||
|
// Запасной вариант: МСК = UTC+3 (переход на летнее время отменён)
|
||||||
|
const d = new Date(Date.now() + 3*3600*1000);
|
||||||
|
return { hour: d.getUTCHours(), minute: d.getUTCMinutes(),
|
||||||
|
second: d.getUTCSeconds(), day: d.getUTCDay() };
|
||||||
|
}
|
||||||
|
|
||||||
function fmtWait(m){
|
function fmtWait(m){
|
||||||
if(m <= 0) return "отправление сейчас";
|
if(m <= 0) return "отправление сейчас";
|
||||||
@@ -154,17 +186,17 @@ let currentStop = "Полярные Зори";
|
|||||||
|
|
||||||
function render(){
|
function render(){
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const curMin = now.getHours()*60 + now.getMinutes();
|
const curMin = now.hour*60 + now.minute;
|
||||||
const dirs = DIRECTIONS[currentStop];
|
const dirs = DIRECTIONS[currentStop];
|
||||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||||
const upcoming = SCHEDULE
|
const upcoming = SCHEDULE
|
||||||
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
||||||
.filter(x=>x.dep >= curMin)
|
.filter(x=>x.dep >= curMin)
|
||||||
.sort((a,b)=>a.dep-b.dep)
|
.sort((a,b)=>a.dep-b.dep)
|
||||||
.slice(0,3)
|
.slice(0,TRIPS_SHOWN)
|
||||||
.map(x=>{
|
.map(x=>{
|
||||||
const w = x.dep - curMin;
|
const w = x.dep - curMin;
|
||||||
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : "";
|
const arr = x.arr ? `<small>прибытие${d.arrLabel ? " "+d.arrLabel : ""} ${x.arr}</small>` : "";
|
||||||
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">${SCHEDULE[x.ri][d.dep]}${arr}</span>
|
||||||
<span class="w">${fmtWait(w)}</span>
|
<span class="w">${fmtWait(w)}</span>
|
||||||
@@ -185,14 +217,14 @@ function renderTable(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDay(){
|
function renderDay(){
|
||||||
const dow = nowMs().getDay();
|
const dow = nowMs().day;
|
||||||
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
||||||
document.getElementById("clock").innerHTML = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} <small>МСК</small>`;
|
document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".pick").forEach(btn=>{
|
document.querySelectorAll(".pick").forEach(btn=>{
|
||||||
|
|||||||
Reference in New Issue
Block a user