diff --git a/electric_train/.env b/electric_train/.env
new file mode 100644
index 000000000..983181cfd
--- /dev/null
+++ b/electric_train/.env
@@ -0,0 +1,2 @@
+REACT_APP_API_KEY=5c35214b-1ca9-4216-a86e-d852deeecfaf
+REACT_APP_PROXY_URL=http://localhost:3001/api/rasp
diff --git a/electric_train/.gitignore b/electric_train/.gitignore
new file mode 100644
index 000000000..4d29575de
--- /dev/null
+++ b/electric_train/.gitignore
@@ -0,0 +1,23 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/electric_train/package.json b/electric_train/package.json
new file mode 100644
index 000000000..62c08f8ec
--- /dev/null
+++ b/electric_train/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "electric_train",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^13.5.0",
+ "bootstrap": "^5.3.0",
+ "cors": "^2.8.6",
+ "dotenv": "^17.4.0",
+ "express": "^5.2.1",
+ "leaflet": "^1.9.4",
+ "node-fetch": "^2.7.0",
+ "ol": "^10.8.0",
+ "react": "^19.2.4",
+ "react-bootstrap": "^2.10.0",
+ "react-dom": "^19.2.4",
+ "react-scripts": "5.0.1",
+ "web-vitals": "^2.1.4"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "start:all": "concurrently \"npm run server\" \"npm run start\"",
+ "server": "node server.js",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "concurrently": "^9.1.2"
+ }
+}
diff --git a/electric_train/public/favicon.ico b/electric_train/public/favicon.ico
new file mode 100644
index 000000000..a11777cc4
Binary files /dev/null and b/electric_train/public/favicon.ico differ
diff --git a/electric_train/public/index.html b/electric_train/public/index.html
new file mode 100644
index 000000000..aa069f27c
--- /dev/null
+++ b/electric_train/public/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
diff --git a/electric_train/public/logo192.png b/electric_train/public/logo192.png
new file mode 100644
index 000000000..fc44b0a37
Binary files /dev/null and b/electric_train/public/logo192.png differ
diff --git a/electric_train/public/logo512.png b/electric_train/public/logo512.png
new file mode 100644
index 000000000..a4e47a654
Binary files /dev/null and b/electric_train/public/logo512.png differ
diff --git a/electric_train/public/manifest.json b/electric_train/public/manifest.json
new file mode 100644
index 000000000..080d6c77a
--- /dev/null
+++ b/electric_train/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/electric_train/public/robots.txt b/electric_train/public/robots.txt
new file mode 100644
index 000000000..e9e57dc4d
--- /dev/null
+++ b/electric_train/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/electric_train/server.js b/electric_train/server.js
new file mode 100644
index 000000000..d15c5536d
--- /dev/null
+++ b/electric_train/server.js
@@ -0,0 +1,47 @@
+const express = require('express');
+const cors = require('cors');
+const fetch = require('node-fetch');
+require('dotenv').config();
+
+const app = express();
+const port = 3001;
+
+const API_KEY = process.env.REACT_APP_API_KEY || '5c35214b-1ca9-4216-a86e-d852deeecfaf';
+
+app.use(cors());
+app.use(express.json());
+
+app.use('/api/rasp', async (req, res) => {
+ try {
+ const apiPath = req.originalUrl.replace('/api/rasp', '');
+ const separator = apiPath.includes('?') ? '&' : '?';
+ const targetUrl = `https://api.rasp.yandex.net/v3.0${apiPath}${separator}apikey=${API_KEY}`;
+
+ console.log('Прокси запрос к:', targetUrl.replace(API_KEY, '***HIDDEN***'));
+
+ const response = await fetch(targetUrl, {
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Mozilla/5.0'
+ }
+ });
+
+ const text = await response.text();
+
+ try {
+ const data = JSON.parse(text);
+ res.json(data);
+ } catch (e) {
+ console.error('Ошибка парсинга JSON');
+ res.status(500).json({ error: 'API вернул невалидный JSON' });
+ }
+
+ } catch (error) {
+ console.error('Ошибка прокси:', error.message);
+ res.status(500).json({ error: 'Ошибка при выполнении запроса к API' });
+ }
+});
+
+app.listen(port, () => {
+ console.log(`✅ Прокси сервер запущен на http://localhost:${port}`);
+});
diff --git a/electric_train/src/App.css b/electric_train/src/App.css
new file mode 100644
index 000000000..be03f5aac
--- /dev/null
+++ b/electric_train/src/App.css
@@ -0,0 +1,144 @@
+.App {
+ min-height: 100vh;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.app-header {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 1rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ text-align: center;
+}
+
+.app-header h1 {
+ margin: 0;
+ font-size: 1.5rem;
+ color: #333;
+}
+
+.app-header p {
+ margin: 0.5rem 0 0;
+ font-size: 0.9rem;
+ color: #666;
+}
+
+.map-container {
+ height: 100vh;
+ padding: 0;
+ position: relative;
+}
+
+.map-container > div {
+ height: 100%;
+ width: 100%;
+}
+
+.ol-map {
+ width: 100%;
+ height: 100%;
+ min-height: 500px;
+}
+
+.ol-viewport {
+ border-radius: 0;
+}
+
+.sidebar {
+ background: white;
+ height: 100vh;
+ overflow-y: auto;
+ padding: 1rem;
+ box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
+}
+
+.nav-tabs {
+ margin-bottom: 1rem;
+}
+
+.nav-tabs .nav-link {
+ color: #667eea;
+ font-weight: 500;
+}
+
+.nav-tabs .nav-link.active {
+ background-color: #667eea;
+ color: white;
+ border-color: #667eea;
+}
+
+.tab-content {
+ padding: 1rem 0;
+}
+
+@media (max-width: 768px) {
+ .map-container {
+ height: 50vh;
+ }
+
+ .sidebar {
+ height: 50vh;
+ }
+
+ .app-header h1 {
+ font-size: 1.2rem;
+ }
+}
+
+.ol-popup {
+ position: absolute;
+ background-color: white;
+ padding: 10px;
+ border-radius: 8px;
+ border: 1px solid #ccc;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
+ font-size: 14px;
+ min-width: 150px;
+ z-index: 1000;
+}
+
+.ol-popup:before {
+ content: '';
+ position: absolute;
+ bottom: -10px;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 10px 10px 0;
+ border-style: solid;
+ border-color: white transparent transparent;
+}
+
+.ol-popup button {
+ margin-top: 8px;
+ padding: 5px 10px;
+ background-color: #667eea;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ width: 100%;
+}
+
+.ol-popup button:hover {
+ background-color: #5a67d8;
+}
+
+.station-search .station-item {
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.station-search .station-item:hover {
+ background-color: #f8f9fa;
+}
+
+.schedule-item {
+ transition: background-color 0.2s;
+}
+
+.schedule-item:hover {
+ background-color: #f8f9fa;
+}
+
+.route-results {
+ margin-top: 1rem;
+}
diff --git a/electric_train/src/app/App.js b/electric_train/src/app/App.js
new file mode 100644
index 000000000..7a11dbe9a
--- /dev/null
+++ b/electric_train/src/app/App.js
@@ -0,0 +1,129 @@
+import React, { useState, useRef } from 'react';
+import 'bootstrap/dist/css/bootstrap.min.css';
+import '../App.css';
+import MapComponent from '../components/MapComponent';
+import StationSearch from '../components/StationSearch';
+import ScheduleDisplay from '../components/ScheduleDisplay';
+import RouteSearch from '../components/RouteSearch';
+import { fetchStationSchedule, fetchRouteSchedule } from '../services/YandexApi';
+
+function App() {
+ const [selectedStation, setSelectedStation] = useState(null);
+ const [schedule, setSchedule] = useState(null);
+ const [routeSchedule, setRouteSchedule] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [activeTab, setActiveTab] = useState('station');
+ const mapRef = useRef(null);
+
+ const loadStationSchedule = async (station) => {
+ setLoading(true);
+ setSelectedStation(station);
+ setRouteSchedule(null);
+ try {
+ const data = await fetchStationSchedule(station.code);
+ setSchedule(data);
+ } catch (error) {
+ console.error('Ошибка загрузки расписания:', error);
+ alert('Не удалось загрузить расписание. Проверьте подключение.');
+ }
+ setLoading(false);
+ };
+
+ const loadRouteSchedule = async (fromStation, toStation, date) => {
+ setLoading(true);
+ setSchedule(null);
+ try {
+ const data = await fetchRouteSchedule(fromStation.code, toStation.code, date);
+ setRouteSchedule(data);
+ } catch (error) {
+ console.error('Ошибка загрузки маршрута:', error);
+ alert('Не удалось загрузить расписание маршрута.');
+ }
+ setLoading(false);
+ };
+
+ const handleStationSelect = (station) => {
+ loadStationSchedule(station);
+ setActiveTab('station');
+ };
+
+ return (
+
+
+ 🚂 Прибывалка для электричек
+ Расписание электричек
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
+ {activeTab === 'station' && (
+
+
+
+
+ )}
+
+ {activeTab === 'route' && (
+
+
+ {routeSchedule && (
+
+
Результаты поиска
+
+
+ )}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/electric_train/src/components/MapComponent.js b/electric_train/src/components/MapComponent.js
new file mode 100644
index 000000000..761d75a1a
--- /dev/null
+++ b/electric_train/src/components/MapComponent.js
@@ -0,0 +1,135 @@
+import React, { useEffect, useRef, useState } from 'react';
+import 'ol/ol.css';
+import Map from 'ol/Map';
+import View from 'ol/View';
+import TileLayer from 'ol/layer/Tile';
+import OSM from 'ol/source/OSM';
+import VectorLayer from 'ol/layer/Vector';
+import VectorSource from 'ol/source/Vector';
+import Feature from 'ol/Feature';
+import Point from 'ol/geom/Point';
+import { fromLonLat } from 'ol/proj';
+import { Style, Icon, Text, Fill, Stroke } from 'ol/style';
+import Overlay from 'ol/Overlay';
+import { stationsData } from '../data/stations';
+
+const MapComponent = ({ onStationSelect, selectedStation }) => {
+ const mapRef = useRef(null);
+ const mapInstanceRef = useRef(null);
+ const popupRef = useRef(null);
+ const [popupContent, setPopupContent] = useState('');
+
+ const samaraCenter = fromLonLat([50.15, 53.2]);
+
+ useEffect(() => {
+ if (!mapRef.current) return;
+
+ const popupOverlay = new Overlay({
+ element: popupRef.current,
+ positioning: 'bottom-center',
+ offset: [0, -10],
+ });
+
+ const map = new Map({
+ target: mapRef.current,
+ layers: [
+ new TileLayer({
+ source: new OSM(),
+ }),
+ ],
+ view: new View({
+ center: samaraCenter,
+ zoom: 9,
+ }),
+ controls: [],
+ });
+
+ map.addOverlay(popupOverlay);
+ mapInstanceRef.current = map;
+
+ const addStationsToMap = () => {
+ const features = [];
+
+ stationsData.forEach(station => {
+ if (station.coordinates && station.coordinates.lon && station.coordinates.lat) {
+ const feature = new Feature({
+ geometry: new Point(fromLonLat([station.coordinates.lon, station.coordinates.lat])),
+ stationData: station,
+ });
+
+ feature.setStyle(new Style({
+ image: new Icon({
+ src: 'https://cdn.rawgit.com/openlayers/openlayers.github.io/master/en/v5.3.0/examples/data/icon.png',
+ scale: 0.5,
+ anchor: [0.5, 1],
+ }),
+ text: new Text({
+ text: station.title,
+ font: '12px Arial',
+ fill: new Fill({ color: '#333' }),
+ stroke: new Stroke({ color: 'white', width: 2 }),
+ offsetY: -20,
+ }),
+ }));
+
+ features.push(feature);
+ }
+ });
+
+ const vectorSource = new VectorSource({
+ features: features,
+ });
+
+ const vectorLayer = new VectorLayer({
+ source: vectorSource,
+ });
+
+ map.addLayer(vectorLayer);
+ };
+
+ addStationsToMap();
+
+ map.on('click', (event) => {
+ const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature);
+ if (feature && feature.get('stationData')) {
+ const stationData = feature.get('stationData');
+ const coordinate = event.coordinate;
+
+ setPopupContent(`
+ ${stationData.title}
+ ${stationData.type || 'ЖД станция'}
+
+ `);
+ popupOverlay.setPosition(coordinate);
+ } else {
+ popupOverlay.setPosition(undefined);
+ }
+ });
+
+ window.selectStationFromMap = (code) => {
+ const station = stationsData.find(s => s.code === code);
+ if (station) {
+ onStationSelect(station);
+ popupOverlay.setPosition(undefined);
+ }
+ };
+
+ return () => {
+ if (mapInstanceRef.current) {
+ mapInstanceRef.current.setTarget(null);
+ }
+ delete window.selectStationFromMap;
+ };
+ }, [onStationSelect, samaraCenter]);
+
+ return (
+
+ );
+};
+
+export default MapComponent;
diff --git a/electric_train/src/components/RouteSearch.js b/electric_train/src/components/RouteSearch.js
new file mode 100644
index 000000000..fada00c63
--- /dev/null
+++ b/electric_train/src/components/RouteSearch.js
@@ -0,0 +1,163 @@
+import React, { useState } from 'react';
+import { Form, Button, Spinner } from 'react-bootstrap';
+import { searchStations } from '../services/YandexApi';
+
+const RouteSearch = ({ onRouteSearch, loading }) => {
+ const [fromQuery, setFromQuery] = useState('');
+ const [toQuery, setToQuery] = useState('');
+ const [date, setDate] = useState('');
+ const [fromResults, setFromResults] = useState([]);
+ const [toResults, setToResults] = useState([]);
+ const [selectedFrom, setSelectedFrom] = useState(null);
+ const [selectedTo, setSelectedTo] = useState(null);
+ const [searchingFrom, setSearchingFrom] = useState(false);
+ const [searchingTo, setSearchingTo] = useState(false);
+
+ const searchStation = async (query, setResults, setSearching) => {
+ if (!query.trim()) return;
+ setSearching(true);
+ try {
+ const stations = await searchStations(query);
+ setResults(stations);
+ } catch (error) {
+ console.error('Ошибка поиска:', error);
+ }
+ setSearching(false);
+ };
+
+ const handleFromSearch = () => {
+ searchStation(fromQuery, setFromResults, setSearchingFrom);
+ };
+
+ const handleToSearch = () => {
+ searchStation(toQuery, setToResults, setSearchingTo);
+ };
+
+ const selectFromStation = (station) => {
+ setSelectedFrom(station);
+ setFromQuery(station.title);
+ setFromResults([]);
+ };
+
+ const selectToStation = (station) => {
+ setSelectedTo(station);
+ setToQuery(station.title);
+ setToResults([]);
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (selectedFrom && selectedTo) {
+ onRouteSearch(selectedFrom, selectedTo, date || undefined);
+ } else {
+ alert('Пожалуйста, выберите станции отправления и назначения');
+ }
+ };
+
+ const defaultDate = new Date().toISOString().split('T')[0];
+
+ return (
+
+
+ Станция отправления
+
+
setFromQuery(e.target.value)}
+ />
+
+
+ {fromResults.length > 0 && (
+
+ {fromResults.map((station, idx) => (
+
selectFromStation(station)}
+ style={{ cursor: 'pointer', backgroundColor: '#f8f9fa' }}
+ >
+ {station.title}
+ {station.type && {station.type}}
+
+ ))}
+
+ )}
+
+
+
+ Станция назначения
+
+
setToQuery(e.target.value)}
+ />
+
+
+ {toResults.length > 0 && (
+
+ {toResults.map((station, idx) => (
+
selectToStation(station)}
+ style={{ cursor: 'pointer', backgroundColor: '#f8f9fa' }}
+ >
+ {station.title}
+ {station.type && {station.type}}
+
+ ))}
+
+ )}
+
+
+
+ Дата поездки (опционально)
+ setDate(e.target.value)}
+ min={defaultDate}
+ />
+
+
+
+
+
+ {selectedFrom && selectedTo && (
+
+ Маршрут: {selectedFrom.title} → {selectedTo.title}
+
+ )}
+
+ );
+};
+
+export default RouteSearch;
diff --git a/electric_train/src/components/ScheduleDisplay.js b/electric_train/src/components/ScheduleDisplay.js
new file mode 100644
index 000000000..f2d77d3dc
--- /dev/null
+++ b/electric_train/src/components/ScheduleDisplay.js
@@ -0,0 +1,108 @@
+import React from 'react';
+import { Card, ListGroup, Badge, Spinner, Alert } from 'react-bootstrap';
+
+const ScheduleDisplay = ({ schedule, station, loading, isRoute = false }) => {
+ if (loading) {
+ return (
+
+
+
Загрузка расписания...
+
+ );
+ }
+
+ if (!schedule || schedule.length === 0) {
+ return (
+
+ {station ? 'Выберите станцию для просмотра расписания' : 'Расписание не найдено'}
+
+ );
+ }
+
+ const formatTime = (date) => {
+ if (!date) return 'Время уточняется';
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
+ if (isNaN(dateObj.getTime())) return 'Время уточняется';
+ return dateObj.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+ } catch {
+ return 'Время уточняется';
+ }
+ };
+
+ const formatFullDate = (date) => {
+ if (!date) return null;
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
+ if (isNaN(dateObj.getTime())) return null;
+ return dateObj.toLocaleString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ } catch {
+ return null;
+ }
+ };
+
+ return (
+
+ {station && !isRoute && (
+
+
+ {station.title}
+
+ {station.type || 'Железнодорожная станция'}
+
+
+
+ )}
+
+
+ {isRoute ? 'Доступные рейсы:' : 'Ближайшие отправления:'}
+
+
+
+ {schedule.map((item, index) => (
+
+
+
+
Поезд №{item.number || '—'}
+
+ {item.title || 'Пригородный поезд'}
+
+
+
+ {formatTime(item.departure)}
+
+
+
+
+ {item.departure && (
+
🕐 Отправление: {formatFullDate(item.departure) || 'Время уточняется'}
+ )}
+ {item.arrival && (
+
🏁 Прибытие: {formatFullDate(item.arrival) || 'Время уточняется'}
+ )}
+ {item.duration && (
+
⏱️ В пути: {typeof item.duration === 'number' ? Math.floor(item.duration / 60) + ' мин' : item.duration}
+ )}
+ {item.platform && (
+
🚉 Платформа: {item.platform}
+ )}
+ {item.carrier?.title && (
+
🚂 Перевозчик: {item.carrier.title}
+ )}
+ {isRoute && item.from && item.to && (
+
📌 {item.from} → {item.to}
+ )}
+
+
+ ))}
+
+
+ );
+};
+
+export default ScheduleDisplay;
diff --git a/electric_train/src/components/StationSearch.js b/electric_train/src/components/StationSearch.js
new file mode 100644
index 000000000..a6d3ab5ad
--- /dev/null
+++ b/electric_train/src/components/StationSearch.js
@@ -0,0 +1,115 @@
+import React, { useState } from 'react';
+import { Form, Button, ListGroup, Spinner, Alert } from 'react-bootstrap';
+import { searchStations } from '../services/YandexApi';
+
+const StationSearch = ({ onStationSelect, loading }) => {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [searching, setSearching] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleSearch = async () => {
+ if (!query.trim()) {
+ setError('Введите название станции');
+ return;
+ }
+
+ setSearching(true);
+ setError('');
+ setResults([]);
+
+ try {
+ const stations = await searchStations(query.trim());
+ if (stations.length === 0) {
+ setError('Станции не найдены. Попробуйте другой запрос');
+ } else {
+ setResults(stations);
+ }
+ } catch (error) {
+ console.error('Ошибка поиска:', error);
+ setError('Ошибка при поиске станций');
+ } finally {
+ setSearching(false);
+ }
+ };
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter') {
+ handleSearch();
+ }
+ };
+
+ const handleSelectStation = (station) => {
+ console.log('Выбрана станция:', station);
+ onStationSelect(station);
+ setResults([]);
+ setQuery('');
+ };
+
+ return (
+
+
+ Название станции
+
+
setQuery(e.target.value)}
+ onKeyPress={handleKeyPress}
+ disabled={searching || loading}
+ />
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {searching && (
+
+
+ Поиск станций...
+
+ )}
+
+ {results.length > 0 && (
+
+ {results.map((station, index) => (
+ handleSelectStation(station)}
+ className="station-item"
+ style={{ cursor: 'pointer' }}
+ >
+
+ {station.title}
+
+ {station.type && (
+ {station.type}
+ )}
+ {station.region && (
+ {station.region}
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default StationSearch;
diff --git a/electric_train/src/index.css b/electric_train/src/index.css
new file mode 100644
index 000000000..2b12aaaa4
--- /dev/null
+++ b/electric_train/src/index.css
@@ -0,0 +1,60 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #555;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.schedule-item {
+ animation: fadeIn 0.3s ease-out;
+}
+
+.cursor-pointer {
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.cursor-pointer:hover {
+ background-color: #f8f9fa;
+}
diff --git a/electric_train/src/index.js b/electric_train/src/index.js
new file mode 100644
index 000000000..a3d7d3912
--- /dev/null
+++ b/electric_train/src/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import './index.css';
+import App from './app/App';
+
+const root = ReactDOM.createRoot(document.getElementById('root'));
+root.render(
+
+
+
+);
diff --git a/electric_train/src/logo.svg b/electric_train/src/logo.svg
new file mode 100644
index 000000000..9dfc1c058
--- /dev/null
+++ b/electric_train/src/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/electric_train/src/services/YandexApi.js b/electric_train/src/services/YandexApi.js
new file mode 100644
index 000000000..c475e013d
--- /dev/null
+++ b/electric_train/src/services/YandexApi.js
@@ -0,0 +1,152 @@
+const PROXY_URL = process.env.REACT_APP_PROXY_URL || 'http://localhost:3001/api/rasp';
+
+let stationsCache = null;
+
+export const fetchAllStations = async () => {
+ try {
+ const url = `${PROXY_URL}/stations_list/?lang=ru_RU&format=json`;
+ console.log('Загрузка списка станций...');
+
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ошибка: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const stations = [];
+
+ if (data.countries) {
+ for (const country of data.countries) {
+ if (country.regions) {
+ for (const region of country.regions) {
+ if (region.settlements) {
+ for (const settlement of region.settlements) {
+ if (settlement.stations) {
+ for (const station of settlement.stations) {
+ if (station.transport_type === 'train') {
+ stations.push({
+ code: station.codes?.yandex_code,
+ title: station.title,
+ type: station.station_type,
+ lat: station.latitude,
+ lon: station.longitude,
+ region: region.title,
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ const validStations = stations.filter(s => s.code);
+ console.log(`Загружено станций: ${validStations.length}`);
+
+ return validStations;
+ } catch (error) {
+ console.error('Ошибка загрузки станций:', error);
+ throw error;
+ }
+};
+
+export const searchStations = async (query) => {
+ try {
+ if (!stationsCache) {
+ stationsCache = await fetchAllStations();
+ }
+
+ const lowerQuery = query.toLowerCase();
+ const results = stationsCache.filter(station =>
+ station.title.toLowerCase().includes(lowerQuery)
+ );
+
+ return results.slice(0, 15);
+ } catch (error) {
+ console.error('Ошибка поиска станций:', error);
+ return [];
+ }
+};
+
+export const fetchStationSchedule = async (stationCode) => {
+ try {
+ console.log('Запрос расписания для станции:', stationCode);
+
+ const url = `${PROXY_URL}/schedule/?station=${stationCode}&transport_types=suburban&event=departure&limit=50&lang=ru_RU`;
+
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ошибка: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ if (data.error) {
+ throw new Error(data.error.text || data.error);
+ }
+
+ if (data.schedule && data.schedule.length > 0) {
+ return data.schedule.map(item => ({
+ number: item.thread?.number || '—',
+ title: item.thread?.title || 'Пригородный поезд',
+ departure: item.departure,
+ arrival: item.arrival,
+ platform: item.platform,
+ carrier: item.thread?.carrier,
+ duration: item.duration,
+ }));
+ }
+
+ return [];
+ } catch (error) {
+ console.error('Ошибка при получении расписания станции:', error);
+ throw error;
+ }
+};
+
+export const fetchRouteSchedule = async (fromCode, toCode, date = null) => {
+ try {
+ let url = `${PROXY_URL}/search/?from=${fromCode}&to=${toCode}&transport_types=suburban&limit=50&lang=ru_RU`;
+ if (date) {
+ url += `&date=${date}`;
+ }
+
+ console.log('Поиск маршрута...');
+
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ошибка: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ if (data.error) {
+ throw new Error(data.error.text || data.error);
+ }
+
+ if (data.segments && data.segments.length > 0) {
+ return data.segments.map(segment => ({
+ number: segment.thread?.number || '—',
+ title: segment.thread?.title || 'Пригородный поезд',
+ departure: segment.departure,
+ arrival: segment.arrival,
+ duration: segment.duration,
+ platform: segment.platform,
+ carrier: segment.thread?.carrier,
+ from: segment.from?.title,
+ to: segment.to?.title,
+ }));
+ }
+
+ return [];
+ } catch (error) {
+ console.error('Ошибка при получении расписания маршрута:', error);
+ throw error;
+ }
+};