feat: add LiveMapPage for real-time vehicle tracking and ScheduleCenterPage for schedule management

- Implemented LiveMapPage with vehicle tracking using Leaflet and real-time updates.
- Added vehicle icons, status labels, and filtering options for routes.
- Created ScheduleCenterPage to display schedule versions, conflicts, and gaps.
- Introduced responsive design for both pages with dedicated CSS styles.
- Integrated API calls for fetching routes and vehicle data.
This commit is contained in:
2026-09-18 02:58:20 +03:00
parent 4552062b0a
commit 863ac603bc
16 changed files with 1070 additions and 37 deletions

View File

@@ -65,6 +65,38 @@ class CitiesController {
}
}
async updateCity(req, res) {
const cityId = Number(req.params.cityId);
if (!Number.isInteger(cityId) || cityId <= 0) {
return res.status(400).json({ error: 'Некорректный идентификатор города.' });
}
const { name, slug, emblem, center_lat, center_lon } = req.body;
const validationError = validateCity({ name, slug, emblem, center_lat, center_lon });
if (validationError) return res.status(400).json({ error: validationError });
try {
const result = await pool.query(`
UPDATE cities
SET name = $1, slug = $2, emblem = $3, center_lat = $4, center_lon = $5
WHERE id = $6
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
`, [
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
center_lon === '' || center_lon === undefined ? null : Number(center_lon),
cityId
]);
if (!result.rows.length) return res.status(404).json({ error: 'Город не найден.' });
res.json(result.rows[0]);
} catch (error) {
if (error.code === '23505') return res.status(409).json({ error: 'Город с таким названием или кодом уже есть.' });
logger.error('Error updating city', { cityId, message: error.message });
res.status(500).json({ error: 'Не удалось сохранить изменения города.' });
}
}
async uploadCityAvatar(req, res) {
const cityId = Number(req.params.cityId);
if (!Number.isInteger(cityId) || cityId <= 0) {

View File

@@ -0,0 +1,48 @@
const logger = require('../config/logger');
const { buildScheduleCenter, formatMinskDate, parseDate } = require('../services/scheduleCenterReport');
const SCHEDULE_CENTER_QUERY = `
SELECT
r.id AS route_id,
r.route_number,
r.name AS route_name,
r.color,
s.id AS schedule_id,
s.day_type,
s.valid_from,
s.valid_until,
GREATEST(
COALESCE(cardinality(s.departure_times), 0),
CASE WHEN jsonb_typeof(s.trips) = 'array' THEN jsonb_array_length(s.trips) ELSE 0 END
)::integer AS departure_count
FROM routes r
LEFT JOIN schedules s ON s.route_id = r.id
WHERE r.city_id = $1 AND r.is_active = true
ORDER BY r.route_number, r.name, s.day_type, s.valid_from NULLS FIRST, s.id
`;
class ScheduleCenterController {
constructor({ pool, report = buildScheduleCenter } = {}) {
this.pool = pool || require('../config/database');
this.report = report;
}
async getOverview(req, res) {
const targetDate = req.query.date || formatMinskDate();
if (!parseDate(targetDate)) {
return res.status(400).json({ error: 'Дата должна быть в формате ГГГГ-ММ-ДД.' });
}
try {
const { rows } = await this.pool.query(SCHEDULE_CENTER_QUERY, [req.city.id]);
return res.json(this.report(rows, targetDate));
} catch (error) {
logger.error('Error building schedule center report', { message: error.message, cityId: req.city?.id });
return res.status(500).json({ error: 'Не удалось загрузить центр расписаний. Повторите попытку.' });
}
}
}
module.exports = new ScheduleCenterController();
module.exports.ScheduleCenterController = ScheduleCenterController;
module.exports.SCHEDULE_CENTER_QUERY = SCHEDULE_CENTER_QUERY;

View File

@@ -15,6 +15,7 @@ const { createPassengerTelemetryRateLimiter } = require('../middleware/passenger
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { resolveCity } = require('../middleware/city');
const qualityController = require('../controllers/qualityController');
const scheduleCenterController = require('../controllers/scheduleCenterController');
// City catalogue is global. Every transport-data endpoint below receives a
// verified city context from X-City-Id (or falls back to the legacy Polotsk city).
@@ -23,6 +24,7 @@ router.use(resolveCity);
router.use('/schedule-import', require('./scheduleImport'));
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
router.get('/schedule-center', authenticateToken, requireAdmin, scheduleCenterController.getOverview.bind(scheduleCenterController));
// Routes
router.get('/routes', routesController.getAllRoutes.bind(routesController));

View File

@@ -7,6 +7,7 @@ const router = express.Router();
router.get('/', citiesController.getAllCities.bind(citiesController));
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController));
router.put('/:cityId', authenticateToken, requireAdmin, citiesController.updateCity.bind(citiesController));
router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController));
module.exports = router;

View File

@@ -0,0 +1,194 @@
const DAY_TYPES = [
{ value: 'weekday', label: 'Будние дни', shortLabel: 'Пн–Пт' },
{ value: 'saturday', label: 'Суббота', shortLabel: 'Сб' },
{ value: 'sunday', label: 'Воскресенье', shortLabel: 'Вс' },
];
const DAY_TYPE_BY_VALUE = new Map(DAY_TYPES.map((dayType) => [dayType.value, dayType]));
function toDateKey(value) {
if (!value) return null;
if (typeof value === 'string') return value.slice(0, 10);
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
return null;
}
function parseDate(date) {
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
const parsed = new Date(`${date}T00:00:00.000Z`);
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date ? null : parsed;
}
function addDays(date, amount) {
const parsed = parseDate(date);
parsed.setUTCDate(parsed.getUTCDate() + amount);
return parsed.toISOString().slice(0, 10);
}
function formatMinskDate(date = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(date);
}
function versionState(version, targetDate) {
if (version.valid_from && version.valid_until && version.valid_until < version.valid_from) return 'invalid';
if ((!version.valid_from || version.valid_from <= targetDate)
&& (!version.valid_until || version.valid_until >= targetDate)) return 'active';
if (version.valid_from && version.valid_from > targetDate) return 'upcoming';
return 'past';
}
function overlaps(left, right) {
return (!left.valid_until || !right.valid_from || left.valid_until >= right.valid_from)
&& (!right.valid_until || !left.valid_from || right.valid_until >= left.valid_from);
}
function buildCoverageGaps(versions, targetDate, windowEnd) {
const periods = versions
.filter((version) => version.state !== 'invalid')
.filter((version) => !version.valid_until || version.valid_until >= targetDate)
.filter((version) => !version.valid_from || version.valid_from <= windowEnd)
.map((version) => ({
start: version.valid_from && version.valid_from > targetDate ? version.valid_from : targetDate,
end: version.valid_until && version.valid_until < windowEnd ? version.valid_until : windowEnd,
}))
.sort((left, right) => left.start.localeCompare(right.start));
const gaps = [];
let cursor = targetDate;
for (const period of periods) {
if (period.start > cursor) {
gaps.push({ starts_on: cursor, ends_on: addDays(period.start, -1) });
}
if (period.end >= cursor) cursor = addDays(period.end, 1);
if (cursor > windowEnd) break;
}
if (cursor <= windowEnd) gaps.push({ starts_on: cursor, ends_on: windowEnd });
return gaps;
}
function makeIssue({ code, severity, route, dayType, description, versionIds = [], range = null }) {
const idParts = [code, route.route_id, dayType?.value || 'all', ...versionIds, range?.starts_on || ''];
return {
id: idParts.join(':'),
code,
severity,
title: code === 'schedule_overlap' ? 'Пересекаются версии'
: code === 'invalid_period' ? 'Некорректный период версии'
: 'Нет действующей версии',
description,
route_id: route.route_id,
route_label: `№${route.route_number} · ${route.route_name}`,
day_type: dayType?.value || null,
day_type_label: dayType?.label || null,
version_ids: versionIds,
range,
};
}
function createRoute(row) {
return {
route_id: row.route_id,
route_number: row.route_number,
route_name: row.route_name,
color: row.color || '#525252',
day_types: DAY_TYPES.map((dayType) => ({
...dayType,
versions: [],
gaps: [],
status: 'gap',
})),
};
}
function buildScheduleCenter(rows, targetDate = formatMinskDate(), { windowDays = 90 } = {}) {
if (!parseDate(targetDate)) throw new Error('Invalid target date');
const windowEnd = addDays(targetDate, windowDays);
const routesById = new Map();
for (const row of rows) {
if (!routesById.has(row.route_id)) routesById.set(row.route_id, createRoute(row));
if (!row.schedule_id || !DAY_TYPE_BY_VALUE.has(row.day_type)) continue;
const route = routesById.get(row.route_id);
const dayType = route.day_types.find((item) => item.value === row.day_type);
const version = {
id: row.schedule_id,
valid_from: toDateKey(row.valid_from),
valid_until: toDateKey(row.valid_until),
departure_count: Number(row.departure_count || 0),
state: 'past',
has_conflict: false,
};
version.state = versionState(version, targetDate);
dayType.versions.push(version);
}
const routes = [...routesById.values()]
.sort((left, right) => String(left.route_number).localeCompare(String(right.route_number), 'ru', { numeric: true })
|| left.route_name.localeCompare(right.route_name, 'ru'));
const issues = [];
let active = 0;
let upcoming = 0;
let conflicts = 0;
let gaps = 0;
for (const route of routes) {
for (const dayType of route.day_types) {
dayType.versions.sort((left, right) => (left.valid_from || '').localeCompare(right.valid_from || '') || left.id - right.id);
for (const version of dayType.versions) {
if (version.state === 'active') active += 1;
if (version.state === 'upcoming') upcoming += 1;
if (version.state !== 'invalid') continue;
issues.push(makeIssue({
code: 'invalid_period', severity: 'critical', route, dayType, versionIds: [version.id],
description: `Версия №${version.id}: дата окончания раньше даты начала.`,
}));
}
for (let index = 0; index < dayType.versions.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < dayType.versions.length; otherIndex += 1) {
const left = dayType.versions[index];
const right = dayType.versions[otherIndex];
if (left.state === 'invalid' || right.state === 'invalid' || !overlaps(left, right)) continue;
left.has_conflict = true;
right.has_conflict = true;
conflicts += 1;
issues.push(makeIssue({
code: 'schedule_overlap', severity: 'critical', route, dayType, versionIds: [left.id, right.id],
description: `Версии №${left.id} и №${right.id} действуют одновременно.`,
}));
}
}
dayType.gaps = buildCoverageGaps(dayType.versions, targetDate, windowEnd);
if (dayType.versions.some((version) => version.state === 'active')) dayType.status = 'active';
else if (dayType.versions.some((version) => version.state === 'upcoming')) dayType.status = 'upcoming';
for (const gap of dayType.gaps) {
gaps += 1;
issues.push(makeIssue({
code: 'schedule_gap', severity: 'warning', route, dayType, range: gap,
description: gap.starts_on === gap.ends_on
? `На ${gap.starts_on} нет версии расписания.`
: `С ${gap.starts_on} по ${gap.ends_on} нет версии расписания.`,
}));
}
}
}
return {
target_date: targetDate,
window_end: windowEnd,
summary: { routes: routes.length, active, upcoming, conflicts, gaps },
routes,
issues: issues.sort((left, right) => left.severity.localeCompare(right.severity)
|| left.route_label.localeCompare(right.route_label, 'ru') || left.id.localeCompare(right.id)),
};
}
module.exports = { DAY_TYPES, addDays, buildScheduleCenter, formatMinskDate, parseDate };

View File

@@ -0,0 +1,36 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { buildScheduleCenter, parseDate } = require('../src/services/scheduleCenterReport');
test('schedule center separates active and future versions and detects gaps and overlaps', () => {
const report = buildScheduleCenter([
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 1, day_type: 'weekday', valid_from: null, valid_until: null, departure_count: 12 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 2, day_type: 'saturday', valid_from: '2026-10-01', valid_until: null, departure_count: 6 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 3, day_type: 'sunday', valid_from: null, valid_until: '2026-09-30', departure_count: 5 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 4, day_type: 'sunday', valid_from: '2026-09-25', valid_until: null, departure_count: 5 },
], '2026-09-18');
assert.deepEqual(report.summary, { routes: 1, active: 2, upcoming: 2, conflicts: 1, gaps: 1 });
assert.equal(report.window_end, '2026-12-17');
const saturday = report.routes[0].day_types.find((dayType) => dayType.value === 'saturday');
assert.equal(saturday.status, 'upcoming');
assert.deepEqual(saturday.gaps, [{ starts_on: '2026-09-18', ends_on: '2026-09-30' }]);
assert.equal(report.issues.find((issue) => issue.code === 'schedule_overlap').route_id, 7);
assert.deepEqual(report.issues.find((issue) => issue.code === 'schedule_overlap').version_ids, [3, 4]);
});
test('schedule center reports uncovered day types for routes without versions', () => {
const report = buildScheduleCenter([
{ route_id: 3, route_number: '2', route_name: 'Рынок — Больница', color: '#dc2626', schedule_id: null },
], '2026-09-18', { windowDays: 7 });
assert.equal(report.summary.gaps, 3);
assert.equal(report.issues.filter((issue) => issue.code === 'schedule_gap').length, 3);
assert.equal(report.routes[0].day_types.every((dayType) => dayType.status === 'gap'), true);
});
test('date validation accepts calendar dates only', () => {
assert.ok(parseDate('2026-02-28'));
assert.equal(parseDate('2026-02-29'), null);
assert.equal(parseDate('18.09.2026'), null);
});

View File

@@ -13,6 +13,8 @@ import RouteEditorPage from './pages/RouteEditorPage';
import StopsPage from './pages/StopsPage';
import SchedulesPage from './pages/SchedulesPage';
import ScheduleImportPage from './pages/ScheduleImportPage';
import ScheduleCenterPage from './pages/ScheduleCenterPage';
import LiveMapPage from './pages/LiveMapPage';
import QualityPage from './pages/QualityPage';
import HolidaysPage from './pages/HolidaysPage';
import AlertsPage from './pages/AlertsPage';
@@ -70,6 +72,8 @@ function AnimatedRoutes({ cityId }) {
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
<Route path="/live-map" element={<ProtectedRoute><LiveMapPage /></ProtectedRoute>} />
<Route path="/schedule-center" element={<ProtectedRoute requireAdmin><ScheduleCenterPage /></ProtectedRoute>} />
<Route path="/schedule-import" element={<ProtectedRoute requireAdmin><ScheduleImportPage /></ProtectedRoute>} />
<Route path="/quality" element={<ProtectedRoute requireAdmin><QualityPage /></ProtectedRoute>} />
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck } from 'lucide-react';
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck, CalendarRange, Radio } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import BrandLogo from './BrandLogo';
import ProfileMenu from './ProfileMenu';
@@ -12,7 +12,9 @@ const navigationGroups = [
{ label: 'Транспорт', items: [
{ to: '/routes', label: 'Маршруты', icon: Bus },
{ to: '/stops', label: 'Остановки', icon: MapPin },
{ to: '/live-map', label: 'Живая карта', icon: Radio },
{ to: '/schedules', label: 'Расписания', icon: CalendarDays },
{ to: '/schedule-center', label: 'Центр расписаний', icon: CalendarRange, adminOnly: true },
{ to: '/schedule-import', label: 'Импорт расписаний', icon: Download, adminOnly: true },
{ to: '/holidays', label: 'Праздники', icon: Gift },
] },
@@ -80,7 +82,7 @@ export default function AppLayout({ children }) {
</div>
</div>
</aside>
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules'].includes(pathname) ? 'app-main-wide' : ''}`}>
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules', '/schedule-center', '/live-map'].includes(pathname) ? 'app-main-wide' : ''}`}>
<div className="app-content">{children}</div>
</main>
</div>

View File

@@ -78,15 +78,18 @@
.city-center-map { height: 320px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; }
.city-center-coordinates { @apply text-sm text-gray-600; }
.city-center-coordinates strong { @apply text-gray-900; }
.city-avatar-editor { @apply ml-auto flex shrink-0 flex-col items-end gap-2; }
.city-avatar-editor { @apply relative flex shrink-0 flex-col items-end gap-2; }
.city-avatar-editor .btn-secondary { @apply inline-flex items-center gap-2; }
.city-avatar-editor-error { @apply max-w-[200px] text-right text-xs text-red-700; }
.city-avatar-editor-error { @apply absolute right-0 top-full z-10 mt-1 w-52 rounded-md bg-red-50 px-2 py-1 text-right text-xs text-red-700 shadow-sm; }
.cities-grid { @apply grid gap-4 md:grid-cols-2; }
.city-network-card { @apply flex flex-wrap items-center gap-4; min-height: 104px; }
.city-network-card.is-active { border-color: #0958d9; box-shadow: inset 0 0 0 1px #0958d9; }
.city-network-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 24px; row-gap: 16px; min-height: 144px; }
.city-network-card.is-active { border: 2px solid #2f72e0; box-shadow: 0 0 0 2px rgb(47 114 224 / 10%); }
.city-card-summary { @apply flex min-w-0 items-center gap-4; }
.city-card-actions { @apply flex flex-wrap items-center justify-end gap-2; }
.city-edit-button { @apply inline-flex items-center gap-2; }
.city-network-card h2 { @apply text-lg font-semibold; }
.city-network-card p { @apply mt-1 text-sm text-gray-500; }
.city-current { @apply inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; }
.city-current { @apply inline-flex shrink-0 items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; white-space: nowrap; }
.city-isolation-note { @apply flex items-start gap-3 rounded-xl bg-primary-50 p-4 text-sm text-primary-700; }
.city-isolation-note svg { @apply mt-0.5 shrink-0; }
.login-surface {
@@ -270,8 +273,14 @@
.login-card { padding: 28px 24px; }
.login-device-card { padding: 24px; }
}
@media (max-width: 1499px) {
.city-network-card { grid-template-columns: minmax(0, 1fr); }
.city-card-actions { justify-content: flex-start; }
}
@media (max-width: 767px) {
.app-main.app-main-wide { padding: 24px 16px; }
.city-network-card { grid-template-columns: minmax(0, 1fr); }
.city-card-actions { justify-content: flex-start; }
}
@media (min-width: 1024px) {
.app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; }

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { CheckCircle2, ImagePlus, Loader2, MapPinned, Plus } from 'lucide-react';
import { CheckCircle2, ImagePlus, Loader2, MapPinned, Pencil, Plus } from 'lucide-react';
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import { citiesApi } from '../services/api';
@@ -26,6 +26,16 @@ function slugFromName(name) {
.join('').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function cityToForm(city) {
return {
name: city.name || '',
slug: city.slug || '',
emblem: city.emblem || '🏙️',
center_lat: city.center_lat == null ? '' : String(city.center_lat),
center_lon: city.center_lon == null ? '' : String(city.center_lon)
};
}
function isImageValid(file) {
if (!acceptedImageTypes.has(file.type)) return 'Выберите изображение в формате JPG, PNG или WEBP.';
if (file.size > 5 * 1024 * 1024) return 'Размер изображения не должен превышать 5 МБ.';
@@ -112,8 +122,9 @@ function CityAvatarEditor({ city }) {
}
export default function CitiesPage() {
const { cities, activeCity, addCity } = useCity();
const { cities, activeCity, addCity, replaceCity } = useCity();
const [showForm, setShowForm] = useState(false);
const [editingCity, setEditingCity] = useState(null);
const [form, setForm] = useState(emptyForm);
const [slugEdited, setSlugEdited] = useState(false);
const [avatarFile, setAvatarFile] = useState(null);
@@ -121,23 +132,50 @@ export default function CitiesPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const isEditing = Boolean(editingCity);
const selectedPosition = form.center_lat !== '' && form.center_lon !== ''
? [Number(form.center_lat), Number(form.center_lon)]
: null;
const fallbackCenter = activeCity?.center_lat != null && activeCity?.center_lon != null
? [Number(activeCity.center_lat), Number(activeCity.center_lon)]
const mapCity = editingCity || activeCity;
const fallbackCenter = mapCity?.center_lat != null && mapCity?.center_lon != null
? [Number(mapCity.center_lat), Number(mapCity.center_lon)]
: BELARUS_CENTER;
useEffect(() => () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
}, [avatarPreview]);
function clearForm() {
function clearEditor() {
setForm(emptyForm);
setSlugEdited(false);
setAvatarFile(null);
setAvatarPreview('');
setEditingCity(null);
}
function closeEditor() {
clearEditor();
setShowForm(false);
setError('');
}
function openCreate() {
clearEditor();
setNotice('');
setError('');
setShowForm(true);
}
function openEdit(city) {
setEditingCity(city);
setForm(cityToForm(city));
setSlugEdited(true);
setAvatarFile(null);
setAvatarPreview('');
setNotice('');
setError('');
setShowForm(true);
}
function updateForm(field, value) {
@@ -175,24 +213,32 @@ export default function CitiesPage() {
setError('');
setNotice('');
try {
let { data: city } = await citiesApi.create(form);
let avatarWarning = '';
let city;
if (editingCity) {
const { data } = await citiesApi.update(editingCity.id, form);
city = data;
} else {
const { data } = await citiesApi.create(form);
city = data;
}
let avatarWarning = '';
if (avatarFile) {
try {
const upload = await citiesApi.uploadAvatar(city.id, avatarFile);
city = upload.data.city;
} catch (uploadError) {
avatarWarning = uploadError.response?.data?.error || 'Город создан, но изображение не удалось загрузить.';
avatarWarning = uploadError.response?.data?.error || 'Изменения города сохранены, но изображение не удалось загрузить.';
}
}
addCity(city);
clearForm();
setShowForm(false);
if (avatarWarning) setNotice(avatarWarning);
if (editingCity) replaceCity(city);
else addCity(city);
const action = editingCity ? 'Изменения города сохранены.' : 'Город добавлен.';
closeEditor();
setNotice(avatarWarning || action);
} catch (requestError) {
setError(requestError.response?.data?.error || 'Не удалось добавить город.');
setError(requestError.response?.data?.error || (editingCity ? 'Не удалось сохранить изменения города.' : 'Не удалось добавить город.'));
} finally {
setSaving(false);
}
@@ -201,7 +247,7 @@ export default function CitiesPage() {
return <div className="cities-page">
<header className="cities-heading">
<div><h1 className="page-title">Города</h1><p>У каждого города — свои маршруты, остановки, расписания и уведомления.</p></div>
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={() => { setShowForm(current => !current); setError(''); setNotice(''); }}>
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={showForm ? closeEditor : openCreate}>
<Plus size={18} aria-hidden="true" />{showForm ? 'Закрыть форму' : 'Добавить город'}
</button>
</header>
@@ -209,24 +255,29 @@ export default function CitiesPage() {
{notice && <p className="city-form-notice" role="status">{notice}</p>}
{showForm && <form className="card city-form" onSubmit={submit}>
<div><h2>Новый город</h2><p>После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.</p></div>
<div><h2>{isEditing ? `Редактирование: ${editingCity.name}` : 'Новый город'}</h2><p>{isEditing ? 'Измените данные города и сохраните их. Данные его транспортной сети останутся без изменений.' : 'После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.'}</p></div>
{error && <p className="city-form-error" role="alert">{error}</p>}
<div className="city-form-grid">
<label><span className="label">Название</span><input required className="input" value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="Например, Новополоцк" /></label>
<label><span className="label">Код города</span><input required pattern="[a-z0-9]+(-[a-z0-9]+)*" className="input" value={form.slug} onChange={event => { setSlugEdited(true); updateForm('slug', event.target.value.toLowerCase()); }} placeholder="novopolotsk" /><span className="city-form-hint">Латинские буквы, цифры и дефисы.</span></label>
<label><span className="label">Иконка без картинки</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /><span className="city-form-hint">Показывается, если кастомное изображение не задано.</span></label>
<label className="city-avatar-field"><span className="label">Картинка для герба</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
<label className="city-avatar-field"><span className="label">{isEditing ? 'Новая картинка для герба' : 'Картинка для герба'}</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
<CityCenterPicker position={selectedPosition} fallbackCenter={fallbackCenter} onSelect={selectCenter} />
</div>
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={() => { clearForm(); setShowForm(false); setError(''); }}>Отмена</button></div>
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : isEditing ? 'Сохранить изменения' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={closeEditor}>Отмена</button></div>
</form>}
<section className="cities-grid" aria-label="Доступные города">
{cities.map(city => <article key={city.id} className={`card city-network-card ${city.id === activeCity?.id ? 'is-active' : ''}`}>
<CityEmblem city={city} className="is-large" />
<div className="min-w-0 flex-1"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
<CityAvatarEditor city={city} />
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
<div className="city-card-summary">
<CityEmblem city={city} className="is-large" />
<div className="min-w-0"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
</div>
<div className="city-card-actions">
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
<button type="button" className="btn-secondary city-edit-button" onClick={() => openEdit(city)}><Pencil size={16} aria-hidden="true" />Редактировать</button>
<CityAvatarEditor city={city} />
</div>
</article>)}
</section>
<div className="city-isolation-note"><MapPinned size={20} aria-hidden="true" /><p>Переключение города в боковой панели меняет рабочий контур целиком: данные одного города не отображаются и не редактируются в другом.</p></div>

View File

@@ -0,0 +1,74 @@
.live-map-page { display: flex; flex-direction: column; gap: 20px; }
.live-map-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.live-map-heading p { max-width: 760px; margin-top: 8px; color: #737373; font-size: 14px; line-height: 21px; }
.live-map-refresh { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 8px; }
.live-map-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 18px; color: #525252; font-size: 13px; line-height: 20px; }
.live-map-status > span, .live-map-connection { display: inline-flex; align-items: center; gap: 7px; }
.live-map-connection { color: #2563eb; font-weight: 500; }
.live-map-connection.is-connected { color: #15803d; }
.live-map-connection.is-error { color: #b91c1c; }
.live-map-connection.is-reconnecting { color: #b45309; }
.live-map-error { display: flex; align-items: flex-start; gap: 10px; border-radius: 12px; padding: 13px 15px; color: #92400e; background: #fffbeb; font-size: 14px; line-height: 20px; }
.live-map-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; min-height: 660px; }
.live-map-panel, .live-map-sidebar { overflow: hidden; border-radius: 12px; background: #fff; }
.live-map-panel { display: flex; min-width: 0; flex-direction: column; }
.live-map-filters { display: flex; gap: 8px; overflow-x: auto; padding: 14px 16px; border-bottom: 1px solid #f0f0f0; }
.live-map-filters button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; flex: 0 0 auto; border-radius: 8px; padding: 7px 10px; color: #525252; background: #f5f5f5; font-size: 13px; line-height: 20px; }
.live-map-filters button[aria-pressed="true"] { color: #1d4ed8; background: #eff6ff; font-weight: 600; }
.live-map-filters span { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; border-radius: 999px; padding: 0 5px; color: inherit; background: rgb(0 0 0 / 7%); font-size: 11px; }
.live-map-canvas { position: relative; flex: 1; min-height: 560px; }
.live-map-leaflet { width: 100%; height: 100%; }
.live-map-empty { position: absolute; z-index: 500; top: 50%; left: 50%; display: flex; width: min(440px, calc(100% - 32px)); align-items: flex-start; gap: 14px; border-radius: 12px; padding: 18px; color: #737373; background: rgb(255 255 255 / 95%); box-shadow: 0 12px 30px rgb(0 0 0 / 12%); transform: translate(-50%, -50%); }
.live-map-empty svg { flex: 0 0 auto; color: #a3a3a3; }
.live-map-empty h2 { color: #262626; font-size: 16px; line-height: 22px; font-weight: 600; }
.live-map-empty p { margin-top: 4px; font-size: 13px; line-height: 19px; }
.live-map-sidebar { display: flex; flex-direction: column; min-height: 0; }
.live-map-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid #f0f0f0; }
.live-map-metrics > div { display: flex; min-width: 0; align-items: flex-start; gap: 8px; padding: 14px; }
.live-map-metrics > div + div { border-left: 1px solid #f0f0f0; }
.live-map-metrics span { display: flex; width: 28px; height: 28px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 7px; color: #15803d; background: #f0fdf4; }
.live-map-metrics > div + div span { color: #6d28d9; background: #f5f3ff; }
.live-map-metrics p { min-width: 0; color: #737373; font-size: 11px; line-height: 15px; }
.live-map-metrics strong { display: block; margin-top: 2px; color: #262626; font-size: 19px; line-height: 24px; font-weight: 600; }
.live-map-vehicles { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.live-map-sidebar-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 16px 12px; }
.live-map-sidebar-heading h2 { color: #262626; font-size: 16px; line-height: 22px; font-weight: 600; }
.live-map-sidebar-heading p { margin-top: 2px; color: #737373; font-size: 12px; line-height: 18px; }
.live-map-sidebar-heading svg { color: #2563eb; }
.live-map-vehicle-list { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow-y: auto; padding: 0 8px 12px; }
.live-map-vehicle { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; border-radius: 8px; padding: 10px 8px; text-align: left; }
.live-map-vehicle:hover, .live-map-vehicle.is-selected { background: #f5f5f5; }
.live-map-vehicle.is-selected { box-shadow: inset 0 0 0 1px #bfdbfe; background: #eff6ff; }
.live-map-vehicle .route-badge { min-width: 36px; min-height: 36px; padding: 4px; font-size: 13px; line-height: 18px; }
.live-map-vehicle-main { min-width: 0; }
.live-map-vehicle-main strong, .live-map-vehicle-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.live-map-vehicle-main strong { color: #262626; font-size: 13px; line-height: 18px; font-weight: 600; }
.live-map-vehicle-main small { margin-top: 2px; color: #737373; font-size: 11px; line-height: 16px; }
.live-map-vehicle i { width: 8px; height: 8px; border-radius: 999px; background: #16a34a; }
.live-map-vehicle i.is-crowd { background: #7c3aed; }
.live-map-sidebar-empty { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px; color: #737373; font-size: 13px; text-align: center; }
.live-map-sidebar-empty svg { color: #d4d4d4; }
.live-map-routes-link { min-height: 48px; padding: 14px 16px; border-top: 1px solid #f0f0f0; color: #2563eb; font-size: 14px; font-weight: 500; }
.live-map-routes-link:hover { background: #fafafa; }
.live-map-marker { background: none; border: 0; }
.live-map-marker-label { display: inline-flex; min-width: 38px; max-width: 116px; height: 28px; align-items: center; gap: 5px; overflow: hidden; border: 2px solid var(--route-color); border-radius: 999px; padding: 0 8px; color: #262626; background: #fff; box-shadow: 0 4px 12px rgb(15 23 42 / 20%); font-size: 11px; line-height: 16px; font-weight: 700; white-space: nowrap; text-overflow: ellipsis; }
.live-map-marker-arrow { display: inline-block; flex: 0 0 auto; color: var(--route-color); font-size: 12px; line-height: 12px; }
.live-map-popup { min-width: 190px; padding: 2px; }
.live-map-popup > div { display: flex; align-items: center; gap: 8px; }
.live-map-popup .route-badge { min-width: 30px; min-height: 30px; padding: 3px; font-size: 12px; line-height: 16px; }
.live-map-popup strong { font-size: 13px; line-height: 18px; }
.live-map-popup > p { margin-top: 8px; color: #525252; font-size: 12px; line-height: 17px; }
.live-map-popup dl { display: grid; gap: 4px; margin-top: 9px; color: #525252; font-size: 12px; line-height: 17px; }
.live-map-popup dl > div { display: flex; justify-content: space-between; gap: 12px; }
.live-map-popup dt { color: #737373; }
.live-map-popup dd { text-align: right; }
@media (max-width: 1100px) { .live-map-layout { grid-template-columns: 1fr; min-height: 0; } .live-map-sidebar { min-height: 340px; } }
@media (max-width: 639px) {
.live-map-heading { flex-direction: column; }
.live-map-refresh { width: 100%; justify-content: center; }
.live-map-status { gap: 8px 14px; }
.live-map-canvas { min-height: 500px; }
.live-map-empty { flex-direction: column; }
.live-map-sidebar { min-height: 300px; }
}

View File

@@ -0,0 +1,285 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet';
import L from 'leaflet';
import { AlertCircle, Bus, CheckCircle2, Clock3, MapPin, Radio, RefreshCw, Signal, Users } from 'lucide-react';
import { realtimeApi, routesApi } from '../services/api';
import { RouteRealtimeClient } from '../services/realtime';
import { useCity } from '../contexts/CityContext';
import { PageLoader } from '../components/AnimatedComponents';
import RouteBadge from '../components/RouteBadge';
import 'leaflet/dist/leaflet.css';
import './LiveMapPage.css';
const DEFAULT_CENTER = [55.4869, 28.7856];
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function safeColor(value) {
return /^#[0-9a-f]{3,8}$/i.test(String(value || '')) ? value : '#2563eb';
}
function vehicleLabel(vehicle) {
return vehicle.vehicle_number || vehicle.registration || `Транспорт ${vehicle.vehicle_id}`;
}
function formatAge(vehicle, now) {
const seenAt = new Date(vehicle.last_seen_at).getTime();
const seconds = Number.isFinite(seenAt)
? Math.max(0, Math.floor((now - seenAt) / 1000))
: Number(vehicle.seconds_since_update);
if (!Number.isFinite(seconds)) return 'нет данных';
if (seconds < 15) return 'только что';
if (seconds < 60) return `${seconds} сек назад`;
return `${Math.floor(seconds / 60)} мин назад`;
}
function sourceLabel(vehicle) {
return vehicle.source === 'crowdsourced' ? 'По данным пассажиров' : 'Официальный GPS';
}
function createVehicleIcon(vehicle, color) {
const heading = Number.isFinite(Number(vehicle.heading)) ? Number(vehicle.heading) : 0;
const isCrowd = vehicle.source === 'crowdsourced';
const markerColor = isCrowd ? '#7c3aed' : safeColor(color);
const label = escapeHtml(`${vehicle.route_number || '—'} · ${vehicleLabel(vehicle)}`);
return L.divIcon({
className: 'live-map-marker',
html: `<div class="live-map-marker-label" style="--route-color:${markerColor}">
<span class="live-map-marker-arrow" style="transform:rotate(${heading}deg)">▲</span>
<span>${label}</span>
</div>`,
iconSize: [116, 32],
iconAnchor: [58, 16],
popupAnchor: [0, -18],
});
}
function MapViewport({ city, selectedVehicle }) {
const map = useMap();
const cityId = city?.id;
useEffect(() => {
const latitude = Number(city?.center_lat);
const longitude = Number(city?.center_lon);
map.setView(Number.isFinite(latitude) && Number.isFinite(longitude) ? [latitude, longitude] : DEFAULT_CENTER, 13);
}, [map, cityId]);
useEffect(() => {
if (selectedVehicle && Number.isFinite(Number(selectedVehicle.latitude)) && Number.isFinite(Number(selectedVehicle.longitude))) {
map.flyTo([Number(selectedVehicle.latitude), Number(selectedVehicle.longitude)], Math.max(map.getZoom(), 14), { duration: 0.45 });
}
}, [map, selectedVehicle?.vehicle_id]);
return null;
}
function replaceRouteVehicles(current, routeId, nextVehicles) {
const withoutRoute = current.filter((vehicle) => Number(vehicle.route_id) !== Number(routeId));
return [...withoutRoute, ...(Array.isArray(nextVehicles) ? nextVehicles : [])];
}
function upsertVehicle(current, nextVehicle) {
const index = current.findIndex((vehicle) => String(vehicle.vehicle_id) === String(nextVehicle.vehicle_id));
if (index === -1) return [...current, nextVehicle];
return current.map((vehicle, currentIndex) => currentIndex === index ? nextVehicle : vehicle);
}
function isMappable(vehicle) {
return Number.isFinite(Number(vehicle.latitude)) && Number.isFinite(Number(vehicle.longitude));
}
function realtimeStatusLabel(status) {
if (status === 'connected') return 'Realtime подключён';
if (status === 'connecting' || status === 'authenticating' || status === 'authenticated') return 'Подключаем realtime…';
if (status === 'reconnecting') return 'Realtime переподключается…';
if (status === 'error') return 'Realtime недоступен';
return 'Ожидание realtime';
}
export default function LiveMapPage() {
const { activeCity } = useCity();
const [routes, setRoutes] = useState([]);
const [vehicles, setVehicles] = useState([]);
const [selectedRouteId, setSelectedRouteId] = useState('all');
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
const [connectionState, setConnectionState] = useState('idle');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const [now, setNow] = useState(Date.now());
const alive = useRef(true);
const refreshSnapshot = useCallback(async (currentRoutes, { manual = false } = {}) => {
if (!currentRoutes.length) return;
if (manual) setRefreshing(true);
try {
const results = await Promise.allSettled(currentRoutes.map((route) => realtimeApi.getRouteVehicles(route.id)));
if (!alive.current) return;
const received = results.flatMap((result) => result.status === 'fulfilled' ? [result.value.data || []] : []);
const failed = results.some((result) => result.status === 'rejected');
if (received.length) {
const nextVehicles = received.flat();
setVehicles(nextVehicles);
setLastUpdatedAt(new Date());
}
if (failed) setError('Не удалось обновить часть маршрутов. Карта показывает последний доступный снимок.');
} catch {
if (alive.current) setError('Не удалось загрузить снимок транспорта. Повторите попытку.');
} finally {
if (alive.current && manual) setRefreshing(false);
}
}, []);
useEffect(() => {
alive.current = true;
async function loadRoutes() {
setError('');
try {
const { data } = await routesApi.getAll();
if (!alive.current) return;
setRoutes(data || []);
await refreshSnapshot(data || []);
} catch (requestError) {
if (alive.current) setError(requestError.response?.data?.error || 'Не удалось загрузить маршруты города.');
} finally {
if (alive.current) setLoading(false);
}
}
loadRoutes();
return () => { alive.current = false; };
}, [refreshSnapshot]);
useEffect(() => {
if (!routes.length) return undefined;
const realtimeClient = new RouteRealtimeClient({
routeIds: routes.map((route) => route.id),
token: localStorage.getItem('accessToken'),
apiKey: import.meta.env.VITE_API_KEY || 'dev_key_12345678',
onStatusChange: (status) => {
if (!alive.current) return;
setConnectionState(status.state);
if (status.state === 'connected') setError('');
},
onSnapshot: (nextVehicles, routeId) => {
if (!alive.current) return;
setVehicles((current) => replaceRouteVehicles(current, routeId, nextVehicles));
setLastUpdatedAt(new Date());
},
onVehiclePosition: (vehicle) => {
if (!alive.current) return;
setVehicles((current) => upsertVehicle(current, vehicle));
setLastUpdatedAt(new Date());
},
onError: (message) => {
if (alive.current) setError(`${message}. Карта продолжит обновляться по снимкам.`);
},
});
realtimeClient.connect();
const snapshotTimer = window.setInterval(() => refreshSnapshot(routes), 60_000);
return () => {
window.clearInterval(snapshotTimer);
realtimeClient.disconnect();
};
}, [refreshSnapshot, routes]);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 15_000);
return () => window.clearInterval(timer);
}, []);
const routesById = useMemo(() => new Map(routes.map((route) => [Number(route.id), route])), [routes]);
const mappableVehicles = vehicles.filter(isMappable);
const visibleVehicles = mappableVehicles
.filter((vehicle) => selectedRouteId === 'all' || Number(vehicle.route_id) === Number(selectedRouteId))
.sort((left, right) => Number(right.is_online) - Number(left.is_online)
|| String(left.route_number || '').localeCompare(String(right.route_number || ''), 'ru', { numeric: true })
|| String(left.vehicle_number || left.vehicle_id).localeCompare(String(right.vehicle_number || right.vehicle_id)));
const selectedVehicle = visibleVehicles.find((vehicle) => String(vehicle.vehicle_id) === String(selectedVehicleId)) || null;
const officialCount = visibleVehicles.filter((vehicle) => vehicle.source !== 'crowdsourced').length;
const crowdCount = visibleVehicles.filter((vehicle) => vehicle.source === 'crowdsourced').length;
const cityLatitude = Number(activeCity?.center_lat);
const cityLongitude = Number(activeCity?.center_lon);
const center = Number.isFinite(cityLatitude) && Number.isFinite(cityLongitude)
? [cityLatitude, cityLongitude]
: DEFAULT_CENTER;
if (loading) return <PageLoader />;
return <div className="live-map-page">
<header className="live-map-heading">
<div><h1 className="page-title">Живая карта</h1><p>Положение транспорта обновляется через realtime. Если соединение временно пропало, остаётся последний доступный снимок.</p></div>
<button type="button" className="btn-secondary live-map-refresh" onClick={() => refreshSnapshot(routes, { manual: true })} disabled={refreshing || !routes.length}>
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />{refreshing ? 'Обновляем…' : 'Обновить'}
</button>
</header>
<section className="live-map-status" aria-label="Статус живой карты">
<div className={`live-map-connection is-${connectionState}`}><Signal size={18} aria-hidden="true" /><span>{realtimeStatusLabel(connectionState)}</span></div>
<span><Clock3 size={16} aria-hidden="true" />{lastUpdatedAt ? `Снимок: ${lastUpdatedAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}` : 'Ожидаем первый снимок'}</span>
<span><Bus size={16} aria-hidden="true" />На карте: <strong>{visibleVehicles.length}</strong></span>
</section>
{error && <div className="live-map-error" role="alert"><AlertCircle size={20} aria-hidden="true" /><span>{error}</span></div>}
<div className="live-map-layout">
<section className="live-map-panel" aria-labelledby="live-map-title">
<h2 id="live-map-title" className="sr-only">Карта живого транспорта</h2>
<div className="live-map-filters" role="group" aria-label="Фильтр маршрутов">
<button type="button" aria-pressed={selectedRouteId === 'all'} onClick={() => { setSelectedRouteId('all'); setSelectedVehicleId(null); }}>Все <span>{mappableVehicles.length}</span></button>
{routes.map((route) => <button type="button" key={route.id} aria-pressed={Number(selectedRouteId) === Number(route.id)} onClick={() => { setSelectedRouteId(route.id); setSelectedVehicleId(null); }}>
№{route.route_number}<span>{mappableVehicles.filter((vehicle) => Number(vehicle.route_id) === Number(route.id)).length}</span>
</button>)}
</div>
<div className="live-map-canvas">
<MapContainer center={center} zoom={13} className="live-map-leaflet" scrollWheelZoom>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<MapViewport city={activeCity} selectedVehicle={selectedVehicle} />
{visibleVehicles.map((vehicle) => {
const route = routesById.get(Number(vehicle.route_id));
const color = vehicle.route_color || route?.color;
return <Marker key={vehicle.vehicle_id} position={[Number(vehicle.latitude), Number(vehicle.longitude)]}
icon={createVehicleIcon(vehicle, color)} eventHandlers={{ click: () => setSelectedVehicleId(vehicle.vehicle_id) }}>
<Popup><div className="live-map-popup">
<div><RouteBadge route={{ route_number: vehicle.route_number || '—', color }} /><strong>{vehicleLabel(vehicle)}</strong></div>
<p>{route?.name || vehicle.route_name || 'Маршрут'}</p>
<dl><div><dt>Источник</dt><dd>{sourceLabel(vehicle)}</dd></div><div><dt>Обновлено</dt><dd>{formatAge(vehicle, now)}</dd></div><div><dt>Скорость</dt><dd>{vehicle.speed_kmh != null ? `${Math.round(Number(vehicle.speed_kmh))} км/ч` : 'нет данных'}</dd></div></dl>
</div></Popup>
</Marker>;
})}
</MapContainer>
{!visibleVehicles.length && <div className="live-map-empty"><MapPin size={40} aria-hidden="true" /><div><h2>Пока нет живых координат</h2><p>Когда GPS или подтверждённые агрегированные данные поступят, транспорт появится здесь автоматически.</p></div></div>}
</div>
</section>
<aside className="live-map-sidebar" aria-label="Транспорт на карте">
<section className="live-map-metrics">
<div><span><CheckCircle2 size={18} aria-hidden="true" /></span><p>Официальный GPS<strong>{officialCount}</strong></p></div>
<div><span><Users size={18} aria-hidden="true" /></span><p>По данным пассажиров<strong>{crowdCount}</strong></p></div>
</section>
<section className="live-map-vehicles">
<div className="live-map-sidebar-heading"><div><h2>Транспорт</h2><p>{selectedRouteId === 'all' ? 'Все маршруты' : `Маршрут №${routesById.get(Number(selectedRouteId))?.route_number || '—'}`}</p></div><Radio size={19} aria-hidden="true" /></div>
{visibleVehicles.length ? <div className="live-map-vehicle-list">{visibleVehicles.map((vehicle) => {
const route = routesById.get(Number(vehicle.route_id));
const color = vehicle.route_color || route?.color;
return <button type="button" key={vehicle.vehicle_id} className={`live-map-vehicle ${String(selectedVehicleId) === String(vehicle.vehicle_id) ? 'is-selected' : ''}`} onClick={() => setSelectedVehicleId(vehicle.vehicle_id)}>
<RouteBadge route={{ route_number: vehicle.route_number || '—', color }} />
<span className="live-map-vehicle-main"><strong>{vehicleLabel(vehicle)}</strong><small>{sourceLabel(vehicle)} · {formatAge(vehicle, now)}</small></span>
<i className={vehicle.source === 'crowdsourced' ? 'is-crowd' : ''} aria-label={vehicle.is_online ? 'Онлайн' : 'Не в сети'} />
</button>;
})}</div> : <div className="live-map-sidebar-empty"><Bus size={34} aria-hidden="true" /><p>Нет транспорта по выбранному фильтру.</p></div>}
</section>
<Link className="live-map-routes-link" to="/routes">Настроить маршруты</Link>
</aside>
</div>
</div>;
}

View File

@@ -0,0 +1,83 @@
.schedule-center-page { display: flex; flex-direction: column; gap: 24px; }
.schedule-center-heading, .schedule-center-section-heading, .schedule-center-route > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.schedule-center-heading p, .schedule-center-section-heading p { max-width: 760px; margin-top: 8px; color: #737373; font-size: 14px; line-height: 21px; }
.schedule-center-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
.schedule-center-actions .btn-secondary, .schedule-center-route-action, .schedule-center-issue-action { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 8px; }
.schedule-center-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px; padding: 16px 20px; }
.schedule-center-controls > svg { color: #2563eb; }
.schedule-center-controls label { display: flex; align-items: center; gap: 10px; color: #404040; font-size: 14px; font-weight: 500; }
.schedule-center-controls .input { width: 178px; padding-top: 8px; padding-bottom: 8px; }
.schedule-center-controls p { color: #737373; font-size: 13px; }
.schedule-center-message { display: flex; align-items: center; gap: 12px; border-radius: 12px; padding: 14px 16px; color: #b91c1c; background: #fef2f2; font-size: 14px; }
.schedule-center-message button { margin-left: auto; font-weight: 600; text-decoration: underline; }
.schedule-center-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.schedule-center-summary-card { display: flex; min-height: 104px; align-items: flex-start; gap: 12px; border-radius: 12px; background: #fff; padding: 16px; }
.schedule-center-summary-card > span { display: flex; width: 38px; height: 38px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 8px; color: #525252; background: #f5f5f5; }
.schedule-center-summary-card.is-active > span { color: #15803d; background: #f0fdf4; }
.schedule-center-summary-card.is-future > span { color: #2563eb; background: #eff6ff; }
.schedule-center-summary-card.is-critical > span { color: #dc2626; background: #fef2f2; }
.schedule-center-summary-card.is-warning > span { color: #b45309; background: #fffbeb; }
.schedule-center-summary-card p { color: #737373; font-size: 13px; line-height: 18px; }
.schedule-center-summary-card strong { display: block; margin-top: 5px; color: #171717; font-size: 26px; line-height: 30px; font-weight: 600; }
.schedule-center-calendar, .schedule-center-issues { border-radius: 12px; background: #fff; padding: 20px; }
.schedule-center-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; color: #525252; font-size: 12px; }
.schedule-center-legend span { display: inline-flex; align-items: center; gap: 5px; }
.schedule-center-legend span::before { width: 10px; height: 10px; border-radius: 3px; content: ''; background: #16a34a; }
.schedule-center-legend .is-upcoming::before { background: #2563eb; }
.schedule-center-legend .has-conflict::before { border: 2px solid #dc2626; background: #fecaca; }
.schedule-center-route-list { display: flex; flex-direction: column; margin-top: 20px; border-top: 1px solid #f0f0f0; }
.schedule-center-route { padding: 20px 0; border-bottom: 1px solid #f0f0f0; }
.schedule-center-route:last-child { padding-bottom: 0; border-bottom: 0; }
.schedule-center-route-title { display: flex; min-width: 0; align-items: center; gap: 12px; }
.schedule-center-route-title h3 { font-size: 16px; line-height: 22px; font-weight: 600; overflow-wrap: anywhere; }
.schedule-center-route-title p { margin-top: 2px; color: #737373; font-size: 13px; line-height: 18px; }
.schedule-center-route-action, .schedule-center-issue-action { flex: 0 0 auto; border-radius: 8px; padding: 9px 11px; color: #262626; background: #f5f5f5; font-size: 13px; font-weight: 500; white-space: nowrap; }
.schedule-center-route-action:hover, .schedule-center-issue-action:hover { background: #ebebeb; }
.schedule-center-date-scale { display: flex; justify-content: space-between; margin: 22px 0 6px 126px; color: #a3a3a3; font-size: 11px; line-height: 16px; }
.schedule-center-lanes { display: flex; flex-direction: column; gap: 12px; overflow-x: auto; }
.schedule-center-lane { display: grid; min-width: 720px; grid-template-columns: 110px minmax(0, 1fr); gap: 16px; }
.schedule-center-lane-label { padding-top: 7px; }
.schedule-center-lane-label strong { display: block; color: #262626; font-size: 13px; line-height: 18px; }
.schedule-center-lane-label span { display: block; margin-top: 2px; color: #a3a3a3; font-size: 12px; line-height: 16px; }
.schedule-center-lane-content { min-width: 0; }
.schedule-center-track { position: relative; overflow: hidden; border-radius: 8px; background-color: #fafafa; background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px); background-size: 33.333% 100%; }
.schedule-center-empty-track { position: absolute; inset: 0; display: grid; place-items: center; color: #a3a3a3; font-size: 12px; }
.schedule-center-version { position: absolute; display: flex; min-width: 28px; height: 22px; align-items: center; justify-content: center; overflow: hidden; border-radius: 5px; padding: 0 6px; color: #fff; background: #16a34a; font-size: 11px; line-height: 16px; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; }
.schedule-center-version.is-upcoming { background: #2563eb; }
.schedule-center-version.is-past { background: #a3a3a3; }
.schedule-center-version.is-invalid, .schedule-center-version.has-conflict { outline: 2px solid #dc2626; outline-offset: -2px; background: #dc2626; }
.schedule-center-version-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
.schedule-center-version-meta, .schedule-center-gap-meta { border-radius: 999px; padding: 3px 8px; color: #525252; background: #f5f5f5; font-size: 11px; line-height: 16px; }
.schedule-center-version-meta.is-upcoming { color: #1d4ed8; background: #eff6ff; }
.schedule-center-version-meta.has-conflict { color: #b91c1c; background: #fef2f2; }
.schedule-center-gap-meta { color: #92400e; background: #fffbeb; }
.schedule-center-issues { overflow: hidden; }
.schedule-center-issue-list { display: flex; flex-direction: column; margin-top: 18px; border-top: 1px solid #f0f0f0; }
.schedule-center-issue { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: 14px; padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.schedule-center-issue:last-child { padding-bottom: 0; border-bottom: 0; }
.schedule-center-issue > span { display: flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: 8px; }
.schedule-center-issue.is-critical > span { color: #dc2626; background: #fef2f2; }
.schedule-center-issue.is-warning > span { color: #b45309; background: #fffbeb; }
.schedule-center-issue-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.schedule-center-issue-heading h3 { color: #262626; font-size: 15px; line-height: 21px; font-weight: 600; }
.schedule-center-issue-heading small { border-radius: 999px; padding: 2px 8px; color: #737373; background: #f5f5f5; font-size: 12px; line-height: 18px; }
.schedule-center-issue p { margin-top: 5px; color: #525252; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; }
.schedule-center-clear { display: flex; min-height: 160px; align-items: center; justify-content: center; gap: 14px; color: #16a34a; }
.schedule-center-clear h3 { color: #262626; font-size: 16px; line-height: 23px; font-weight: 600; }
.schedule-center-clear p { max-width: 460px; margin-top: 4px; color: #737373; font-size: 14px; line-height: 21px; }
@media (max-width: 1199px) { .schedule-center-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 767px) {
.schedule-center-heading, .schedule-center-section-heading, .schedule-center-route > header { flex-direction: column; }
.schedule-center-actions, .schedule-center-actions .btn-secondary, .schedule-center-route-action { width: 100%; }
.schedule-center-controls { align-items: flex-start; flex-direction: column; }
.schedule-center-controls label, .schedule-center-controls .input { width: 100%; }
.schedule-center-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.schedule-center-calendar, .schedule-center-issues { padding: 16px; }
.schedule-center-legend { justify-content: flex-start; }
.schedule-center-date-scale { margin-left: 0; min-width: 720px; }
.schedule-center-lane { grid-template-columns: 92px minmax(0, 1fr); gap: 10px; }
.schedule-center-issue { grid-template-columns: auto minmax(0, 1fr); }
.schedule-center-issue-action { grid-column: 2; justify-self: start; }
.schedule-center-clear { flex-direction: column; padding: 24px 8px; text-align: center; }
}

View File

@@ -0,0 +1,204 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { AlertCircle, AlertTriangle, ArrowRight, CalendarClock, CheckCircle2, Clock3, Download, RefreshCw } from 'lucide-react';
import { scheduleCenterApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
import { PageLoader } from '../components/AnimatedComponents';
import RouteBadge from '../components/RouteBadge';
import './ScheduleCenterPage.css';
const DAY_STATUS = {
active: 'Есть версия на выбранную дату',
upcoming: 'Следующая версия ещё не вступила в силу',
gap: 'Нет версии в периоде обзора',
};
function minskDate() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
}
function dateValue(date) {
return date ? new Date(`${date}T12:00:00.000Z`) : null;
}
function formatDate(date) {
const value = dateValue(date);
return value && !Number.isNaN(value.getTime())
? value.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'Europe/Minsk' })
: 'без даты';
}
function formatPeriod(version) {
if (!version.valid_from && !version.valid_until) return 'Бессрочно';
return `${version.valid_from ? `с ${formatDate(version.valid_from)}` : 'без даты начала'}${version.valid_until ? ` по ${formatDate(version.valid_until)}` : ''}`;
}
function dayDistance(from, to) {
const fromValue = dateValue(from);
const toValue = dateValue(to);
return Math.round((toValue - fromValue) / 86400000);
}
function visibleVersion(version, start, end) {
return (!version.valid_until || version.valid_until >= start)
&& (!version.valid_from || version.valid_from <= end);
}
function versionStyle(version, index, start, end) {
const blockStart = version.valid_from && version.valid_from > start ? version.valid_from : start;
const blockEnd = version.valid_until && version.valid_until < end ? version.valid_until : end;
const totalDays = dayDistance(start, end) + 1;
const left = (dayDistance(start, blockStart) / totalDays) * 100;
const width = Math.max(4, ((dayDistance(blockStart, blockEnd) + 1) / totalDays) * 100);
return { left: `${left}%`, width: `${width}%`, top: `${index * 30 + 7}px` };
}
function SummaryCard({ icon: Icon, label, value, tone }) {
return <article className={`schedule-center-summary-card is-${tone}`}>
<span><Icon size={20} aria-hidden="true" /></span>
<div><p>{label}</p><strong>{value}</strong></div>
</article>;
}
function TimelineLane({ dayType, start, end }) {
const versions = dayType.versions.filter((version) => visibleVersion(version, start, end));
return <section className="schedule-center-lane" aria-label={`${dayType.label}: ${DAY_STATUS[dayType.status]}`}>
<div className="schedule-center-lane-label">
<strong>{dayType.label}</strong><span>{dayType.shortLabel}</span>
</div>
<div className="schedule-center-lane-content">
<div className="schedule-center-track" style={{ minHeight: `${Math.max(48, versions.length * 30 + 12)}px` }}>
{!versions.length && <span className="schedule-center-empty-track">Нет версии</span>}
{versions.map((version, index) => <span key={version.id}
className={`schedule-center-version is-${version.state}${version.has_conflict ? ' has-conflict' : ''}`}
style={versionStyle(version, index, start, end)}
title={`Версия №${version.id}: ${formatPeriod(version)}`}>
№{version.id}
</span>)}
</div>
<div className="schedule-center-version-list">
{versions.map((version) => <span key={version.id} className={`schedule-center-version-meta is-${version.state}${version.has_conflict ? ' has-conflict' : ''}`}>
<strong>№{version.id}</strong> · {formatPeriod(version)} · {version.departure_count} рейс{version.departure_count === 1 ? '' : version.departure_count > 1 && version.departure_count < 5 ? 'а' : 'ов'}
</span>)}
{dayType.gaps.map((gap) => <span key={`${gap.starts_on}-${gap.ends_on}`} className="schedule-center-gap-meta">
Пробел: {formatDate(gap.starts_on)}{gap.ends_on !== gap.starts_on && ` — ${formatDate(gap.ends_on)}`}
</span>)}
</div>
</div>
</section>;
}
function Issue({ issue }) {
const isCritical = issue.severity === 'critical';
const Icon = isCritical ? AlertCircle : AlertTriangle;
return <article className={`schedule-center-issue is-${issue.severity}`}>
<span><Icon size={20} aria-hidden="true" /></span>
<div>
<div className="schedule-center-issue-heading"><h3>{issue.title}</h3><small>{issue.day_type_label}</small></div>
<p>{issue.route_label}: {issue.description}</p>
</div>
<Link to={`/schedules?route=${issue.route_id}`} className="schedule-center-issue-action">
Исправить<ArrowRight size={16} aria-hidden="true" />
</Link>
</article>;
}
export default function ScheduleCenterPage() {
const { activeCity } = useCity();
const [targetDate, setTargetDate] = useState(minskDate);
const [report, setReport] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const requestId = useRef(0);
const loadReport = useCallback(async ({ refresh = false } = {}) => {
const request = ++requestId.current;
setError('');
if (refresh) setRefreshing(true);
else setLoading(true);
try {
const { data } = await scheduleCenterApi.getOverview(targetDate);
if (request === requestId.current) setReport(data);
} catch (requestError) {
if (request === requestId.current) {
setError(requestError.response?.data?.error || 'Не удалось загрузить центр расписаний. Повторите попытку.');
}
} finally {
if (request === requestId.current) {
setLoading(false);
setRefreshing(false);
}
}
}, [targetDate]);
useEffect(() => {
loadReport();
return () => { requestId.current += 1; };
}, [loadReport]);
const summary = report?.summary || { routes: 0, active: 0, upcoming: 0, conflicts: 0, gaps: 0 };
return <div className="schedule-center-page">
<header className="schedule-center-heading">
<div>
<h1 className="page-title">Центр расписаний</h1>
<p>Показывает, какие версии действуют на выбранную дату, что вступит в силу дальше и где периоды пересекаются или остаются без покрытия.</p>
</div>
<div className="schedule-center-actions">
{activeCity?.schedule_source_url && <Link to="/schedule-import" className="btn-secondary"><Download size={16} aria-hidden="true" />Импорт</Link>}
<button type="button" className="btn-secondary" onClick={() => loadReport({ refresh: true })} disabled={loading || refreshing}>
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />{refreshing ? 'Обновляем…' : 'Обновить'}
</button>
</div>
</header>
<section className="schedule-center-controls card" aria-label="Период обзора">
<CalendarClock size={20} aria-hidden="true" />
<label>Дата проверки<input className="input" type="date" value={targetDate} onChange={(event) => setTargetDate(event.target.value)} /></label>
<p>Показан период с {formatDate(targetDate)} по {report ? formatDate(report.window_end) : '…'}.</p>
</section>
{loading && !report ? <PageLoader /> : <>
{error && <div className="schedule-center-message" role="alert"><AlertCircle size={20} aria-hidden="true" /><span>{error}</span><button type="button" onClick={() => loadReport()}>Повторить</button></div>}
{report && <>
<section className="schedule-center-summary" aria-label="Сводка расписаний">
<SummaryCard icon={CalendarClock} label="Маршрутов в обзоре" value={summary.routes} tone="neutral" />
<SummaryCard icon={CheckCircle2} label="Действующих версий" value={summary.active} tone="active" />
<SummaryCard icon={Clock3} label="Вступят в силу позже" value={summary.upcoming} tone="future" />
<SummaryCard icon={AlertCircle} label="Пересечений периодов" value={summary.conflicts} tone="critical" />
<SummaryCard icon={AlertTriangle} label="Пробелов в покрытии" value={summary.gaps} tone="warning" />
</section>
<section className="schedule-center-calendar" aria-labelledby="schedule-center-calendar-title">
<div className="schedule-center-section-heading">
<div><h2 id="schedule-center-calendar-title">Календарь версий</h2><p>Каждая полоска — отдельная версия. Красная рамка означает пересечение с другой версией того же типа дня.</p></div>
<div className="schedule-center-legend" aria-label="Обозначения"><span className="is-active">Действует</span><span className="is-upcoming">Будущая</span><span className="has-conflict">Пересечение</span></div>
</div>
<div className="schedule-center-route-list">
{report.routes.map((route) => <article className="schedule-center-route" key={route.route_id}>
<header>
<div className="schedule-center-route-title"><RouteBadge route={route} /><div><h3>{route.route_name}</h3><p>Версии для Пн–Пт, Сб и Вс</p></div></div>
<Link to={`/schedules?route=${route.route_id}`} className="schedule-center-route-action">Открыть редактор<ArrowRight size={16} aria-hidden="true" /></Link>
</header>
<div className="schedule-center-date-scale" aria-hidden="true"><span>{formatDate(report.target_date)}</span><span>+ 30 дней</span><span>+ 60 дней</span><span>{formatDate(report.window_end)}</span></div>
<div className="schedule-center-lanes">
{route.day_types.map((dayType) => <TimelineLane key={dayType.value} dayType={dayType} start={report.target_date} end={report.window_end} />)}
</div>
</article>)}
</div>
</section>
<section className="schedule-center-issues" aria-labelledby="schedule-center-issues-title">
<div className="schedule-center-section-heading"><div><h2 id="schedule-center-issues-title">Требует внимания</h2><p>Центр ничего не исправляет сам: откройте редактор только для нужного маршрута.</p></div></div>
{!report.issues.length ? <div className="schedule-center-clear"><CheckCircle2 size={40} aria-hidden="true" /><div><h3>Периоды согласованы</h3><p>На следующие 90 дней нет пересечений и пробелов в версиях расписаний.</p></div></div>
: <div className="schedule-center-issue-list">{report.issues.map((issue) => <Issue key={issue.id} issue={issue} />)}</div>}
</section>
</>}
</>}
</div>;
}

View File

@@ -102,6 +102,7 @@ export const routesApi = {
export const citiesApi = {
getAll: () => api.get('/cities'),
create: (data) => api.post('/cities', data),
update: (cityId, data) => api.put(`/cities/${cityId}`, data),
uploadAvatar: (cityId, file) => {
const formData = new FormData();
formData.append('avatar', file);
@@ -149,6 +150,10 @@ export const qualityApi = {
getReport: () => api.get('/quality')
};
export const scheduleCenterApi = {
getOverview: (date) => api.get('/schedule-center', { params: { date } })
};
// Schedules API (расписания)
export const schedulesApi = {
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),

View File

@@ -31,6 +31,7 @@ function buildRealtimeUrl() {
export class RouteRealtimeClient {
constructor({
routeId,
routeIds,
token,
apiKey = API_KEY,
onStatusChange,
@@ -38,7 +39,10 @@ export class RouteRealtimeClient {
onVehiclePosition,
onError
}) {
this.routeId = routeId;
this.routeIds = [...new Set([...(Array.isArray(routeIds) ? routeIds : []), routeId]
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value) && value > 0))];
this.routeId = this.routeIds[0] || null;
this.token = token;
this.apiKey = apiKey;
this.onStatusChange = onStatusChange;
@@ -52,7 +56,7 @@ export class RouteRealtimeClient {
}
connect() {
if (!this.routeId || this.socket) {
if (!this.routeIds.length || this.socket) {
return;
}
@@ -112,15 +116,14 @@ export class RouteRealtimeClient {
}));
}
subscribeToRoute() {
subscribeToRoutes() {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return;
}
this.socket.send(JSON.stringify({
type: 'subscribe:route',
routeId: this.routeId
}));
this.routeIds.forEach((routeId) => {
this.socket.send(JSON.stringify({ type: 'subscribe:route', routeId }));
});
}
handleMessage(rawMessage) {
@@ -132,7 +135,7 @@ export class RouteRealtimeClient {
break;
case 'auth:ok':
this.updateStatus('authenticated', payload);
this.subscribeToRoute();
this.subscribeToRoutes();
break;
case 'subscription:ok':
this.updateStatus('connected', payload);
@@ -141,7 +144,7 @@ export class RouteRealtimeClient {
this.updateStatus('disconnected', payload);
break;
case 'snapshot:route':
this.onSnapshot?.(payload.vehicles || []);
this.onSnapshot?.(payload.vehicles || [], payload.routeId);
break;
case 'vehicle:position':
if (payload.vehicle) {