initial commit
This commit is contained in:
commit
91c81b0175
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# uv
|
||||||
|
.uv/
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# GTFS data files (large)
|
||||||
|
data/*.zip
|
||||||
|
data/*.txt
|
||||||
|
data/gtfs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
3.9
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
# 🚀 Quick Start Guide
|
||||||
|
|
||||||
|
## Wymagania
|
||||||
|
|
||||||
|
Upewnij się, że masz zainstalowane [uv](https://docs.astral.sh/uv/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalacja uv
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Sprawdź instalację
|
||||||
|
uv --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uruchomienie w 3 krokach
|
||||||
|
|
||||||
|
### 1. Instalacja zależności i pobranie danych
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Lub ręcznie:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv venv
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
uv run download_gtfs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Uruchom backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Lub ręcznie:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Serwer uruchomi się na `http://localhost:5000`
|
||||||
|
|
||||||
|
### 3. Otwórz frontend
|
||||||
|
|
||||||
|
**Opcja A** - Bezpośrednio w przeglądarce:
|
||||||
|
```bash
|
||||||
|
firefox frontend/index.html
|
||||||
|
# lub
|
||||||
|
open frontend/index.html # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opcja B** - Przez serwer HTTP (zalecane):
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
python3 -m http.server 8000
|
||||||
|
```
|
||||||
|
Następnie otwórz: `http://localhost:8000`
|
||||||
|
|
||||||
|
## Pierwsze kroki
|
||||||
|
|
||||||
|
1. W polu wyszukiwania wpisz nazwę stacji, np.:
|
||||||
|
- "Wrocław Główny"
|
||||||
|
- "Warszawa Centralna"
|
||||||
|
- "Kraków Główny"
|
||||||
|
- "Gdańsk Główny"
|
||||||
|
|
||||||
|
2. Wybierz stację z listy wyników
|
||||||
|
|
||||||
|
3. Zaznacz przedziały czasowe (np. 30, 60, 90 minut)
|
||||||
|
|
||||||
|
4. Kliknij "Oblicz izochrony"
|
||||||
|
|
||||||
|
5. Zobacz kolorowe obszary na mapie pokazujące gdzie możesz dojechać!
|
||||||
|
|
||||||
|
## Przykładowe testy
|
||||||
|
|
||||||
|
### Test 1: Wrocław Główny
|
||||||
|
```
|
||||||
|
Stacja: Wrocław Główny
|
||||||
|
Przedziały: 30, 60, 90, 120 min
|
||||||
|
Oczekiwany wynik: ~15-50 stacji w zależności od czasu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Warszawa Centralna
|
||||||
|
```
|
||||||
|
Stacja: Warszawa Centralna
|
||||||
|
Przedziały: 60, 120, 180 min
|
||||||
|
Oczekiwany wynik: Duży obszar pokrycia centralnej Polski
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sprawdzanie API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Status serwera
|
||||||
|
curl http://localhost:5000/api/health
|
||||||
|
|
||||||
|
# Wyszukaj stacje
|
||||||
|
curl "http://localhost:5000/api/stops/search?q=Wrocław"
|
||||||
|
|
||||||
|
# Oblicz izochrony (wymaga ID stacji)
|
||||||
|
curl -X POST http://localhost:5000/api/isochrones \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"origin_stop_id": "5100069", "time_intervals": [30, 60]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Lub użyj przykładowego skryptu:
|
||||||
|
```bash
|
||||||
|
python examples/api_examples.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rozwiązywanie problemów
|
||||||
|
|
||||||
|
### Błąd: "Nie znaleziono pliku GTFS"
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv run download_gtfs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Błąd: "ModuleNotFoundError"
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Błąd: "uv: command not found"
|
||||||
|
```bash
|
||||||
|
# Zainstaluj uv
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
# Lub
|
||||||
|
pip install uv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend nie łączy się z API
|
||||||
|
- Sprawdź czy backend działa: `curl http://localhost:5000/api/health`
|
||||||
|
- Sprawdź w konsoli przeglądarki (F12) czy nie ma błędów CORS
|
||||||
|
|
||||||
|
### Mapa się nie ładuje
|
||||||
|
- Sprawdź połączenie z internetem (Leaflet pobiera kafelki z OSM)
|
||||||
|
- Otwórz konsolę przeglądarki (F12) i sprawdź błędy
|
||||||
|
|
||||||
|
## Następne kroki
|
||||||
|
|
||||||
|
- Przeczytaj `TECHNICAL.md` dla szczegółów technicznych
|
||||||
|
- Eksperymentuj z różnymi stacjami i przedziałami czasowymi
|
||||||
|
- Modyfikuj kod backendu aby dodać nowe funkcje
|
||||||
|
- Customizuj frontend (kolory, style, kontrolki)
|
||||||
|
|
||||||
|
## Dodatkowe możliwości
|
||||||
|
|
||||||
|
### Zmiana zakresu danych GTFS
|
||||||
|
|
||||||
|
Edytuj `backend/download_gtfs.py` aby dodać więcej źródeł:
|
||||||
|
|
||||||
|
```python
|
||||||
|
GTFS_SOURCES = {
|
||||||
|
'polish_trains': 'https://mkuran.pl/gtfs/polish_trains.zip',
|
||||||
|
'koleje_dolnoslaskie': 'URL_DO_KD_GTFS', # dodaj tutaj
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zmiana przedziałów czasowych
|
||||||
|
|
||||||
|
W `frontend/index.html` dodaj nowe checkboxy lub edytuj istniejące.
|
||||||
|
|
||||||
|
### Zmiana kolorów izochron
|
||||||
|
|
||||||
|
W `frontend/map.js` edytuj obiekt `ISOCHRONE_COLORS`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ISOCHRONE_COLORS = {
|
||||||
|
30: '#0080ff', // niebieski
|
||||||
|
60: '#00c864', // zielony
|
||||||
|
90: '#ffc800', // żółty
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pomoc
|
||||||
|
|
||||||
|
- Issues: Stwórz issue w repozytorium
|
||||||
|
- Dokumentacja: Zobacz `README.md` i `TECHNICAL.md`
|
||||||
|
- Źródła danych: [mkuran.pl/gtfs](https://mkuran.pl/gtfs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Miłej zabawy z izochronami! 🚂🗺️**
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
# Izochrona - Mapa izochron dla połączeń kolejowych w Polsce
|
||||||
|
|
||||||
|
Interaktywna mapa pokazująca izochrony (obszary dostępne w określonym czasie) dla połączeń kolejowych PKP Intercity i Kolei Dolnośląskich.
|
||||||
|
|
||||||
|
## Funkcjonalności
|
||||||
|
|
||||||
|
- Wyświetlanie izochron dla wybranej stacji początkowej
|
||||||
|
- Obliczanie czasów podróży na podstawie rzeczywistych rozkładów jazdy
|
||||||
|
- Interaktywna mapa z Leaflet
|
||||||
|
- Obsługa danych GTFS od polskich przewoźników
|
||||||
|
|
||||||
|
## Struktura projektu
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ - Serwer Python (Flask) i logika obliczania izochron
|
||||||
|
frontend/ - Mapa interaktywna (HTML + Leaflet)
|
||||||
|
data/ - Dane GTFS (nie wrzucane do repozytorium)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Źródła danych
|
||||||
|
|
||||||
|
- [mkuran.pl/gtfs](https://mkuran.pl/gtfs/) - polish_trains.zip (PKP IC, PolRegio, etc.)
|
||||||
|
- [Koleje Dolnośląskie GTFS](https://kolejedolnoslaskie.pl/rozklady-gtfs/) - oficjalne dane KD
|
||||||
|
|
||||||
|
## 📚 Dokumentacja
|
||||||
|
|
||||||
|
- **[README.md](README.md)** - Ten plik, podstawowe informacje
|
||||||
|
- **[QUICKSTART.md](QUICKSTART.md)** - Szybki start w 3 krokach
|
||||||
|
- **[TECHNICAL.md](TECHNICAL.md)** - Dokumentacja techniczna i API
|
||||||
|
- **[UV_GUIDE.md](UV_GUIDE.md)** - Przewodnik po uv (menedżer pakietów)
|
||||||
|
- **[UV_CHEATSHEET.md](UV_CHEATSHEET.md)** - Ściągawka z komend uv
|
||||||
|
|
||||||
|
## Instalacja
|
||||||
|
|
||||||
|
### Wymagania:
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
- [uv](https://docs.astral.sh/uv/) - szybki menedżer pakietów Pythona
|
||||||
|
- Przeglądarka internetowa (Chrome, Firefox, Safari)
|
||||||
|
- ~500MB wolnego miejsca na dysku (dane GTFS)
|
||||||
|
|
||||||
|
### Instalacja uv (jeśli nie masz):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Lub przez pip
|
||||||
|
pip install uv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Szybka instalacja (Linux/macOS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ręczna instalacja:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backend
|
||||||
|
cd backend
|
||||||
|
uv venv
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. Pobierz dane GTFS
|
||||||
|
uv run download_gtfs.py
|
||||||
|
|
||||||
|
# 3. Uruchom serwer
|
||||||
|
uv run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternatywnie z pyproject.toml:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run backend/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Użycie
|
||||||
|
|
||||||
|
### Uruchomienie:
|
||||||
|
|
||||||
|
**Szybki start:**
|
||||||
|
```bash
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lub ręcznie:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Następnie otwórz `frontend/index.html` w przeglądarce lub użyj prostego serwera HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
python3 -m http.server 8000
|
||||||
|
# Otworz http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Korzystanie z aplikacji:
|
||||||
|
|
||||||
|
1. **Wyszukaj stację** - wpisz nazwę stacji w polu wyszukiwania (np. "Wrocław Główny")
|
||||||
|
2. **Wybierz przedziały czasowe** - zaznacz checkboxy (30, 60, 90, 120 lub 180 minut)
|
||||||
|
3. **Kliknij "Oblicz izochrony"** - poczekaj na wygenerowanie mapy
|
||||||
|
4. **Eksploruj wyniki** - zobacz obszary dostępne w wybranym czasie!
|
||||||
|
|
||||||
|
### API Endpoints:
|
||||||
|
|
||||||
|
Backend udostępnia REST API:
|
||||||
|
|
||||||
|
- `GET /api/health` - status serwera
|
||||||
|
- `GET /api/stops` - lista wszystkich przystanków
|
||||||
|
- `GET /api/stops/search?q=nazwa` - wyszukiwanie przystanków
|
||||||
|
- `POST /api/isochrones` - obliczanie izochron
|
||||||
|
- `POST /api/reachable` - lista osiągalnych przystanków
|
||||||
|
|
||||||
|
## Technologie
|
||||||
|
|
||||||
|
- **Backend**: Python 3.9+, Flask, gtfs-kit, NetworkX, GeoPandas
|
||||||
|
- **Frontend**: Leaflet, OpenStreetMap, vanilla JavaScript
|
||||||
|
- **Dane**: GTFS (General Transit Feed Specification)
|
||||||
|
- **Narzędzia**: [uv](https://docs.astral.sh/uv/) - szybki menedżer pakietów (10-100x szybszy niż pip!)
|
||||||
|
|
||||||
|
> 💡 **Dlaczego uv?** Zobacz [UV_GUIDE.md](UV_GUIDE.md) aby dowiedzieć się więcej o uv i jak go używać.
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
# Dokumentacja techniczna
|
||||||
|
|
||||||
|
## Stack technologiczny
|
||||||
|
|
||||||
|
- **Backend**: Python 3.9+, Flask, gtfs-kit, NetworkX, GeoPandas
|
||||||
|
- **Frontend**: Leaflet, OpenStreetMap, vanilla JavaScript
|
||||||
|
- **Dane**: GTFS (General Transit Feed Specification)
|
||||||
|
- **Zarządzanie zależnościami**: [uv](https://docs.astral.sh/uv/) - szybki menedżer pakietów
|
||||||
|
|
||||||
|
## Architektura
|
||||||
|
|
||||||
|
### Backend (Python)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── app.py # Flask API server
|
||||||
|
├── gtfs_loader.py # Ładowanie i parsowanie GTFS
|
||||||
|
├── isochrone_calculator.py # Algorytm obliczania izochron
|
||||||
|
└── download_gtfs.py # Pobieranie danych GTFS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (JavaScript)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── index.html # Struktura strony
|
||||||
|
├── map.js # Logika mapy Leaflet i API
|
||||||
|
└── style.css # Stylowanie
|
||||||
|
```
|
||||||
|
|
||||||
|
## Algorytm obliczania izochron
|
||||||
|
|
||||||
|
### 1. Budowanie grafu połączeń
|
||||||
|
|
||||||
|
- **Węzły**: przystanki kolejowe (stop_id)
|
||||||
|
- **Krawędzie**: bezpośrednie połączenia między przystankami
|
||||||
|
- **Wagi**: czas podróży w minutach
|
||||||
|
|
||||||
|
```python
|
||||||
|
G = nx.DiGraph()
|
||||||
|
G.add_node(stop_id, name=..., lat=..., lon=...)
|
||||||
|
G.add_edge(from_stop, to_stop, weight=travel_time_minutes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Algorytm Dijkstry
|
||||||
|
|
||||||
|
Obliczamy najkrótsze ścieżki z punktu startowego do wszystkich osiągalnych przystanków:
|
||||||
|
|
||||||
|
```python
|
||||||
|
lengths = nx.single_source_dijkstra_path_length(
|
||||||
|
graph, origin_stop_id, cutoff=max_time, weight='weight'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Generowanie wielokątów izochron
|
||||||
|
|
||||||
|
Dla każdego przedziału czasowego (30, 60, 90, 120 min):
|
||||||
|
|
||||||
|
1. **Filtruj przystanki** - wybierz te osiągalne w danym czasie
|
||||||
|
2. **Zbierz punkty** - współrzędne geograficzne przystanków
|
||||||
|
3. **Generuj wielokąt**:
|
||||||
|
- Jeśli < 3 punkty: bufor wokół punktów
|
||||||
|
- Jeśli ≥ 3 punkty: convex hull z buforem
|
||||||
|
|
||||||
|
```python
|
||||||
|
points = [(lon, lat) for each reachable stop]
|
||||||
|
polygon = MultiPoint(points).convex_hull.buffer(0.05)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format danych GTFS
|
||||||
|
|
||||||
|
### Używane pliki:
|
||||||
|
|
||||||
|
- **stops.txt** - przystanki (stop_id, stop_name, stop_lat, stop_lon)
|
||||||
|
- **routes.txt** - linie (route_id, route_short_name, route_type)
|
||||||
|
- **trips.txt** - kursy (trip_id, route_id, service_id)
|
||||||
|
- **stop_times.txt** - rozkład jazdy (trip_id, stop_id, arrival_time, departure_time)
|
||||||
|
|
||||||
|
### Przykład struktury:
|
||||||
|
|
||||||
|
```
|
||||||
|
stops.txt:
|
||||||
|
stop_id,stop_name,stop_lat,stop_lon
|
||||||
|
5100069,Wrocław Główny,51.0989,17.0368
|
||||||
|
|
||||||
|
stop_times.txt:
|
||||||
|
trip_id,arrival_time,departure_time,stop_id,stop_sequence
|
||||||
|
trip_1,08:00:00,08:00:00,5100069,1
|
||||||
|
trip_1,08:45:00,08:46:00,5100011,2
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### POST /api/isochrones
|
||||||
|
|
||||||
|
Oblicza izochrony dla wybranej stacji.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"origin_stop_id": "5100069",
|
||||||
|
"time_intervals": [30, 60, 90, 120]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"origin_stop_id": "5100069",
|
||||||
|
"isochrones": [
|
||||||
|
{
|
||||||
|
"time": 30,
|
||||||
|
"geometry": { "type": "Polygon", "coordinates": [...] },
|
||||||
|
"stops": ["id1", "id2", ...],
|
||||||
|
"stop_count": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reachable_stops": [
|
||||||
|
{
|
||||||
|
"stop_id": "...",
|
||||||
|
"name": "...",
|
||||||
|
"lat": 51.0,
|
||||||
|
"lon": 17.0,
|
||||||
|
"time": 25.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optymalizacja
|
||||||
|
|
||||||
|
### Wydajność:
|
||||||
|
|
||||||
|
- **Cache grafu** - graf budowany raz przy starcie
|
||||||
|
- **Cutoff w Dijkstra** - tylko obliczamy ścieżki do max_time
|
||||||
|
- **Buforowanie wyników** - możliwość dodania Redis dla cache'owania
|
||||||
|
|
||||||
|
### Możliwe ulepszenia:
|
||||||
|
|
||||||
|
1. **GTFS Realtime** - aktualne opóźnienia pociągów
|
||||||
|
2. **Różne godziny wyjazdu** - zmienny graf w ciągu dnia
|
||||||
|
3. **Dni tygodnia** - uwzględnienie calendar.txt
|
||||||
|
4. **Przesiadki** - dodanie czasu na przesiadkę (np. 5 min)
|
||||||
|
5. **Concave hull** - bardziej precyzyjne wielokąty (alpha shapes)
|
||||||
|
6. **Filtrowanie przewoźników** - tylko PKP IC lub tylko KD
|
||||||
|
|
||||||
|
## Znane limity
|
||||||
|
|
||||||
|
- **Uproszczony model**: jeden "reprezentatywny" dzień, bez uwzględnienia różnic w rozkładzie
|
||||||
|
- **Convex hull**: wielokąty mogą obejmować obszary bez realnych połączeń
|
||||||
|
- **Brak czasu przesiadki**: zakłada natychmiastową przesiadkę
|
||||||
|
- **Brak uwzględnienia opóźnień**: tylko statyczne rozkłady
|
||||||
|
|
||||||
|
## Testy
|
||||||
|
|
||||||
|
Przykładowe testy dla backendu:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Testowanie ładowania GTFS
|
||||||
|
loader = GTFSLoader('data/polish_trains.zip')
|
||||||
|
assert len(loader.get_stops()) > 0
|
||||||
|
|
||||||
|
# Testowanie budowania grafu
|
||||||
|
graph = loader.build_route_graph()
|
||||||
|
assert graph.number_of_nodes() > 0
|
||||||
|
|
||||||
|
# Testowanie obliczeń izochron
|
||||||
|
calc = IsochroneCalculator(graph)
|
||||||
|
isochrones = calc.create_isochrones('5100069', [30, 60])
|
||||||
|
assert len(isochrones) == 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Lokalne:
|
||||||
|
- Backend: Flask development server
|
||||||
|
- Frontend: Otwarcie pliku HTML lub `python -m http.server`
|
||||||
|
|
||||||
|
### Produkcja:
|
||||||
|
- Backend: Gunicorn + Nginx
|
||||||
|
- Frontend: Nginx / GitHub Pages / Netlify
|
||||||
|
- Dane: CDN dla GTFS lub okresowe aktualizacje
|
||||||
|
|
||||||
|
### Docker (przyszłe):
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.9
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/ .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
CMD ["gunicorn", "app:app"]
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
# uv - Ściągawka (Cheatsheet)
|
||||||
|
|
||||||
|
## 🚀 Szybki start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalacja uv
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Projekt Izochrona
|
||||||
|
./setup.sh # Konfiguracja
|
||||||
|
./run.sh # Uruchomienie serwera
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Zarządzanie pakietami
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalacja z requirements.txt
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Instalacja pojedynczego pakietu
|
||||||
|
uv pip install flask
|
||||||
|
|
||||||
|
# Instalacja z pyproject.toml
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Aktualizacja pakietów
|
||||||
|
uv pip install --upgrade flask
|
||||||
|
uv pip install --upgrade -r requirements.txt
|
||||||
|
|
||||||
|
# Lista pakietów
|
||||||
|
uv pip list
|
||||||
|
uv pip freeze
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Środowiska wirtualne
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stwórz venv
|
||||||
|
uv venv
|
||||||
|
|
||||||
|
# Z konkretną wersją Pythona
|
||||||
|
uv venv --python 3.11
|
||||||
|
|
||||||
|
# Usuń venv
|
||||||
|
rm -rf .venv/
|
||||||
|
```
|
||||||
|
|
||||||
|
## ▶️ Uruchamianie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Uruchom skrypt (auto-używa venv!)
|
||||||
|
uv run --no-project app.py
|
||||||
|
uv run --no-project path/to/script.py
|
||||||
|
|
||||||
|
# Uruchom moduł
|
||||||
|
uv run --no-project -m flask run
|
||||||
|
|
||||||
|
# Z argumentami
|
||||||
|
uv run --no-project app.py --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Projekt Izochrona - Komendy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalacja projektu
|
||||||
|
./setup.sh
|
||||||
|
|
||||||
|
# Uruchomienie backendu
|
||||||
|
./run.sh
|
||||||
|
# lub
|
||||||
|
cd backend && uv run --no-project app.py
|
||||||
|
|
||||||
|
# Pobranie danych GTFS
|
||||||
|
cd backend && uv run --no-project download_gtfs.py
|
||||||
|
|
||||||
|
# Przykłady API
|
||||||
|
uv run --no-project examples/api_examples.py
|
||||||
|
|
||||||
|
# Dodaj nowy pakiet
|
||||||
|
cd backend
|
||||||
|
echo "matplotlib>=3.8.0" >> requirements.txt
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ Dlaczego uv?
|
||||||
|
|
||||||
|
| Operacja | pip | uv |
|
||||||
|
|----------|-----|-----|
|
||||||
|
| Instalacja pakietów | ~30-60s | ~2-5s |
|
||||||
|
| Rozwiązywanie zależności | ~10-20s | ~1-2s |
|
||||||
|
| Cache | Lokalne | Globalne |
|
||||||
|
| Aktywacja venv | Ręczna | Automatyczna |
|
||||||
|
|
||||||
|
## 💡 Porady
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nie musisz aktywować venv!
|
||||||
|
uv run --no-project python script.py
|
||||||
|
|
||||||
|
# Cache jest globalny - oszczędność miejsca
|
||||||
|
~/.cache/uv/
|
||||||
|
|
||||||
|
# Kompilacja requirements
|
||||||
|
uv pip compile requirements.txt -o requirements.lock
|
||||||
|
|
||||||
|
# Sync z lock file
|
||||||
|
uv pip sync requirements.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 Linki
|
||||||
|
|
||||||
|
- Docs: https://docs.astral.sh/uv/
|
||||||
|
- GitHub: https://github.com/astral-sh/uv
|
||||||
|
- Porównanie z pip: https://astral.sh/blog/uv
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
# Przewodnik po uv
|
||||||
|
|
||||||
|
## Czym jest uv?
|
||||||
|
|
||||||
|
[uv](https://docs.astral.sh/uv/) to niezwykle szybki menedżer pakietów i środowisk Pythona, napisany w Rust przez Astral (twórców Ruff). Jest on **10-100x szybszy** od pip i pip-tools.
|
||||||
|
|
||||||
|
## Instalacja uv
|
||||||
|
|
||||||
|
### Linux/macOS:
|
||||||
|
```bash
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Przez pip:
|
||||||
|
```bash
|
||||||
|
pip install uv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sprawdź instalację:
|
||||||
|
```bash
|
||||||
|
uv --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Ważne: Flaga --no-project w projekcie Izochrona
|
||||||
|
|
||||||
|
W tym projekcie używamy `uv run --no-project` zamiast `uv run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zamiast: uv run app.py
|
||||||
|
uv run --no-project app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dlaczego?**
|
||||||
|
- Projekt Izochrona nie jest pakietem Pythona przeznaczonym do instalacji
|
||||||
|
- `--no-project` mówi uv aby **nie próbował instalować** projektu jako editable package
|
||||||
|
- Tylko uruchamiamy skrypty, korzystając ze środowiska wirtualnego i zainstalowanych zależności
|
||||||
|
|
||||||
|
**Bez `--no-project`:** uv próbuje zbudować i zainstalować cały projekt używając `pyproject.toml`, co może powodować błędy.
|
||||||
|
|
||||||
|
## Podstawowe komendy
|
||||||
|
|
||||||
|
### Tworzenie środowiska wirtualnego:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv venv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instalacja zależności:
|
||||||
|
```bash
|
||||||
|
# Z requirements.txt
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Z pyproject.toml (w katalogu głównym projektu)
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Pojedynczy pakiet
|
||||||
|
uv pip install flask
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uruchamianie skryptów:
|
||||||
|
```bash
|
||||||
|
# uv automatycznie używa środowiska wirtualnego
|
||||||
|
uv run --no-project app.py
|
||||||
|
uv run --no-project download_gtfs.py
|
||||||
|
|
||||||
|
# Lub z katalogu głównego
|
||||||
|
uv run --no-project backend/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aktualizacja pakietów:
|
||||||
|
```bash
|
||||||
|
uv pip install --upgrade flask
|
||||||
|
uv pip install --upgrade -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lista zainstalowanych pakietów:
|
||||||
|
```bash
|
||||||
|
uv pip list
|
||||||
|
uv pip freeze
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dlaczego uv?
|
||||||
|
|
||||||
|
### Szybkość:
|
||||||
|
- **Instalacja pakietów**: 10-100x szybsza niż pip
|
||||||
|
- **Rozwiązywanie zależności**: bardzo szybkie
|
||||||
|
- **Cache**: globalny cache pakietów (oszczędność miejsca)
|
||||||
|
|
||||||
|
### Wygoda:
|
||||||
|
- **Automatyczna aktywacja venv**: `uv run` automatycznie używa środowiska
|
||||||
|
- **Brak `source venv/bin/activate`**: nie musisz ręcznie aktywować środowiska
|
||||||
|
- **Kompatybilność z pip**: używa tych samych formatów (requirements.txt, pyproject.toml)
|
||||||
|
|
||||||
|
### Przykład - porównanie:
|
||||||
|
|
||||||
|
**Tradycyjnie (pip):**
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt # ~30-60s
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Z uv:**
|
||||||
|
```bash
|
||||||
|
uv venv
|
||||||
|
uv pip install -r requirements.txt # ~2-5s
|
||||||
|
uv run --no-project app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Użycie w projekcie Izochrona
|
||||||
|
|
||||||
|
### Pierwsze uruchomienie:
|
||||||
|
```bash
|
||||||
|
./setup.sh # Używa uv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codzienne użycie:
|
||||||
|
```bash
|
||||||
|
# Uruchomienie serwera
|
||||||
|
./run.sh # lub: uv run --no-project backend/app.py
|
||||||
|
|
||||||
|
# Pobranie danych
|
||||||
|
uv run --no-project backend/download_gtfs.py
|
||||||
|
|
||||||
|
# Przykłady API
|
||||||
|
uv run --no-project examples/api_examples.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dodawanie nowych pakietów:
|
||||||
|
|
||||||
|
**Opcja A** - requirements.txt:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
echo "matplotlib>=3.8.0" >> requirements.txt
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opcja B** - pyproject.toml (zalecane):
|
||||||
|
```bash
|
||||||
|
# Edytuj pyproject.toml, dodaj do dependencies:
|
||||||
|
# "matplotlib>=3.8.0",
|
||||||
|
|
||||||
|
# Następnie:
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Porady
|
||||||
|
|
||||||
|
### 1. Nie musisz aktywować środowiska:
|
||||||
|
```bash
|
||||||
|
# Zamiast:
|
||||||
|
source venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
|
||||||
|
# Użyj:
|
||||||
|
uv run --no-project app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Global cache:
|
||||||
|
uv przechowuje pakiety globalnie, więc instalacja w nowych projektach jest ultra-szybka.
|
||||||
|
|
||||||
|
### 3. Lock file (opcjonalnie):
|
||||||
|
```bash
|
||||||
|
uv pip compile requirements.txt -o requirements.lock
|
||||||
|
uv pip sync requirements.lock # Deterministyczna instalacja
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Różne wersje Pythona:
|
||||||
|
```bash
|
||||||
|
# uv może zarządzać wersjami Pythona
|
||||||
|
uv python install 3.11
|
||||||
|
uv venv --python 3.11
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migracja z pip do uv
|
||||||
|
|
||||||
|
Jeśli masz istniejący projekt z pip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Usuń stare venv (opcjonalnie)
|
||||||
|
rm -rf venv/
|
||||||
|
|
||||||
|
# 2. Stwórz nowe z uv
|
||||||
|
uv venv
|
||||||
|
|
||||||
|
# 3. Zainstaluj zależności
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 4. Gotowe! Używaj uv run zamiast python
|
||||||
|
uv run --no-project app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zasoby
|
||||||
|
|
||||||
|
- Dokumentacja: https://docs.astral.sh/uv/
|
||||||
|
- GitHub: https://github.com/astral-sh/uv
|
||||||
|
- Blog: https://astral.sh/blog
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Czy uv jest kompatybilne z pip?**
|
||||||
|
A: Tak! uv używa tych samych formatów (requirements.txt, pyproject.toml, wheel).
|
||||||
|
|
||||||
|
**Q: Czy mogę używać uv i pip jednocześnie?**
|
||||||
|
A: Technicznie tak, ale nie jest to zalecane. Wybierz jedno.
|
||||||
|
|
||||||
|
**Q: Co jeśli pakiet nie działa z uv?**
|
||||||
|
A: Bardzo rzadki przypadek. Zawsze możesz wrócić do pip w konkretnym środowisku.
|
||||||
|
|
||||||
|
**Q: Czy uv obsługuje editable installs?**
|
||||||
|
A: Tak! `uv pip install -e .`
|
||||||
|
|
||||||
|
**Q: Gdzie jest cache pakietów?**
|
||||||
|
A: `~/.cache/uv/` (Linux/macOS) lub `%LOCALAPPDATA%\uv\cache` (Windows)
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install gunicorn
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY *.py .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:app"]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# Backend package
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Flask API do obliczania izochron dla połączeń kolejowych
|
||||||
|
"""
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
from pathlib import Path
|
||||||
|
from gtfs_loader import GTFSLoader
|
||||||
|
from isochrone_calculator import IsochroneCalculator
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app) # Pozwala na żądania z frontendu
|
||||||
|
|
||||||
|
# Globalne zmienne dla danych
|
||||||
|
gtfs_loader = None
|
||||||
|
calculator = None
|
||||||
|
route_graph = None
|
||||||
|
|
||||||
|
def init_data():
|
||||||
|
"""Inicjalizuje dane GTFS i graf"""
|
||||||
|
global gtfs_loader, calculator, route_graph
|
||||||
|
|
||||||
|
print('=' * 60)
|
||||||
|
print('🚂 INICJALIZACJA BACKENDU IZOCHRONA')
|
||||||
|
print('=' * 60)
|
||||||
|
|
||||||
|
data_dir = Path(__file__).parent.parent / 'data'
|
||||||
|
gtfs_file = data_dir / 'polish_trains.zip'
|
||||||
|
|
||||||
|
if not gtfs_file.exists():
|
||||||
|
print(f'❌ BŁĄD: Nie znaleziono pliku GTFS: {gtfs_file}')
|
||||||
|
print('Uruchom: python backend/download_gtfs.py')
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f'\n📍 Plik GTFS: {gtfs_file}')
|
||||||
|
print(f'📏 Rozmiar: {gtfs_file.stat().st_size / 1024 / 1024:.2f} MB\n')
|
||||||
|
|
||||||
|
gtfs_loader = GTFSLoader(str(gtfs_file))
|
||||||
|
print()
|
||||||
|
route_graph = gtfs_loader.build_route_graph()
|
||||||
|
print()
|
||||||
|
calculator = IsochroneCalculator(route_graph)
|
||||||
|
|
||||||
|
print('=' * 60)
|
||||||
|
print('✅ DANE ZAŁADOWANE POMYŚLNIE!')
|
||||||
|
print('=' * 60)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f'\n❌ Błąd podczas ładowania danych: {e}')
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/stops', methods=['GET'])
|
||||||
|
def get_stops():
|
||||||
|
"""Zwraca listę wszystkich przystanków"""
|
||||||
|
if gtfs_loader is None:
|
||||||
|
return jsonify({'error': 'Dane nie zostały załadowane'}), 500
|
||||||
|
|
||||||
|
stops = gtfs_loader.get_stops()
|
||||||
|
stops_list = stops.to_dict('records')
|
||||||
|
|
||||||
|
return jsonify({'stops': stops_list, 'count': len(stops_list)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/stops/search', methods=['GET'])
|
||||||
|
def search_stops():
|
||||||
|
"""Wyszukuje przystanki po nazwie"""
|
||||||
|
query = request.args.get('q', '')
|
||||||
|
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return jsonify({'error': 'Zapytanie musi mieć min. 2 znaki'}), 400
|
||||||
|
|
||||||
|
if gtfs_loader is None:
|
||||||
|
return jsonify({'error': 'Dane nie zostały załadowane'}), 500
|
||||||
|
|
||||||
|
stops = gtfs_loader.find_stops_by_name(query)
|
||||||
|
stops_list = stops.to_dict('records')
|
||||||
|
|
||||||
|
return jsonify({'stops': stops_list, 'count': len(stops_list)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/isochrones', methods=['POST'])
|
||||||
|
def calculate_isochrones():
|
||||||
|
"""
|
||||||
|
Oblicza izochrony dla wybranego przystanku
|
||||||
|
|
||||||
|
Body (JSON):
|
||||||
|
{
|
||||||
|
"origin_stop_id": "...",
|
||||||
|
"time_intervals": [30, 60, 90, 120] # opcjonalne
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if calculator is None:
|
||||||
|
return jsonify({'error': 'Dane nie zostały załadowane'}), 500
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
origin_stop_id = data.get('origin_stop_id')
|
||||||
|
|
||||||
|
if not origin_stop_id:
|
||||||
|
return jsonify({'error': 'Brak origin_stop_id'}), 400
|
||||||
|
|
||||||
|
time_intervals = data.get('time_intervals', [30, 60, 90, 120])
|
||||||
|
|
||||||
|
try:
|
||||||
|
isochrones = calculator.create_isochrones(origin_stop_id, time_intervals)
|
||||||
|
reachable_stops = calculator.get_reachable_stops(
|
||||||
|
origin_stop_id,
|
||||||
|
max_time=max(time_intervals)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'origin_stop_id': origin_stop_id,
|
||||||
|
'isochrones': isochrones,
|
||||||
|
'reachable_stops': reachable_stops
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'Błąd obliczeń: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/reachable', methods=['POST'])
|
||||||
|
def get_reachable():
|
||||||
|
"""
|
||||||
|
Zwraca listę osiągalnych przystanków
|
||||||
|
|
||||||
|
Body (JSON):
|
||||||
|
{
|
||||||
|
"origin_stop_id": "...",
|
||||||
|
"max_time": 120 # w minutach
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if calculator is None:
|
||||||
|
return jsonify({'error': 'Dane nie zostały załadowane'}), 500
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
origin_stop_id = data.get('origin_stop_id')
|
||||||
|
max_time = data.get('max_time', 120)
|
||||||
|
|
||||||
|
if not origin_stop_id:
|
||||||
|
return jsonify({'error': 'Brak origin_stop_id'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
reachable = calculator.get_reachable_stops(origin_stop_id, max_time)
|
||||||
|
return jsonify({'reachable_stops': reachable, 'count': len(reachable)})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/health', methods=['GET'])
|
||||||
|
def health():
|
||||||
|
"""Sprawdza status serwera"""
|
||||||
|
status = {
|
||||||
|
'status': 'ok' if gtfs_loader is not None else 'error',
|
||||||
|
'data_loaded': gtfs_loader is not None,
|
||||||
|
'stops_count': len(gtfs_loader.get_stops()) if gtfs_loader else 0,
|
||||||
|
'graph_nodes': route_graph.number_of_nodes() if route_graph else 0,
|
||||||
|
'graph_edges': route_graph.number_of_edges() if route_graph else 0,
|
||||||
|
}
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
|
# Inicjalizacja danych przy starcie (działa zarówno z gunicornem jak i bezpośrednio)
|
||||||
|
init_data()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print('=== Izochrona API Server ===\n')
|
||||||
|
|
||||||
|
if gtfs_loader is not None:
|
||||||
|
print('\n🚀 Serwer uruchomiony na http://localhost:5000')
|
||||||
|
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||||
|
else:
|
||||||
|
print('\n❌ Nie udało się załadować danych. Sprawdź logi powyżej.')
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Skrypt do pobierania danych GTFS dla polskich przewoźników kolejowych
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# URL do danych GTFS
|
||||||
|
GTFS_SOURCES = {
|
||||||
|
'polish_trains': 'https://mkuran.pl/gtfs/polish_trains.zip',
|
||||||
|
# Możesz dodać więcej źródeł:
|
||||||
|
# 'koleje_dolnoslaskie': 'https://kolejedolnoslaskie.pl/...',
|
||||||
|
}
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent.parent / 'data'
|
||||||
|
|
||||||
|
def download_gtfs():
|
||||||
|
"""Pobiera pliki GTFS z określonych źródeł"""
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
for name, url in GTFS_SOURCES.items():
|
||||||
|
output_file = DATA_DIR / f'{name}.zip'
|
||||||
|
print(f'Pobieranie {name} z {url}...')
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, stream=True, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(output_file, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
print(f'✓ Zapisano do {output_file}')
|
||||||
|
print(f' Rozmiar: {output_file.stat().st_size / 1024 / 1024:.2f} MB')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'✗ Błąd podczas pobierania {name}: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print('=== Pobieranie danych GTFS ===\n')
|
||||||
|
download_gtfs()
|
||||||
|
print('\n=== Zakończono ===')
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""
|
||||||
|
Moduł do ładowania i przetwarzania danych GTFS
|
||||||
|
"""
|
||||||
|
import gtfs_kit as gk
|
||||||
|
import pandas as pd
|
||||||
|
import networkx as nx
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
class GTFSLoader:
|
||||||
|
"""Klasa do ładowania i parsowania danych GTFS"""
|
||||||
|
|
||||||
|
def __init__(self, gtfs_path):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
gtfs_path: ścieżka do pliku ZIP z danymi GTFS
|
||||||
|
"""
|
||||||
|
print(f'📦 Ładowanie danych GTFS z {gtfs_path}...')
|
||||||
|
print(' To może potrwać 1-2 minuty dla dużego pliku...')
|
||||||
|
self.feed = gk.read_feed(gtfs_path, dist_units='km')
|
||||||
|
print(f'✓ Załadowano {len(self.feed.stops)} przystanków')
|
||||||
|
print(f'✓ Znaleziono {len(self.feed.routes)} linii')
|
||||||
|
print(f'✓ Znaleziono {len(self.feed.trips)} kursów')
|
||||||
|
|
||||||
|
def get_stops(self):
|
||||||
|
"""Zwraca DataFrame ze wszystkimi przystankami"""
|
||||||
|
return self.feed.stops[['stop_id', 'stop_name', 'stop_lat', 'stop_lon']]
|
||||||
|
|
||||||
|
def build_route_graph(self, date=None, start_time='06:00:00', end_time='22:00:00'):
|
||||||
|
"""
|
||||||
|
Buduje graf połączeń kolejowych dla określonej daty i przedziału czasowego
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: data w formacie YYYYMMDD (domyślnie: dzisiaj)
|
||||||
|
start_time: początek przedziału czasowego
|
||||||
|
end_time: koniec przedziału czasowego
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NetworkX DiGraph z wagami jako czasy podróży w minutach
|
||||||
|
"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now().strftime('%Y%m%d')
|
||||||
|
|
||||||
|
print(f'🔨 Budowanie grafu dla daty {date}, godz. {start_time}-{end_time}...')
|
||||||
|
print(' Może to potrwać 2-3 minuty...')
|
||||||
|
|
||||||
|
# Filtruj trips dla wybranej daty
|
||||||
|
print(' 1/5 Kopiowanie danych trips...')
|
||||||
|
trips = self.feed.trips.copy()
|
||||||
|
stop_times = self.feed.stop_times.copy()
|
||||||
|
|
||||||
|
# Połącz trips ze stop_times
|
||||||
|
print(' 2/5 Łączenie trips ze stop_times...')
|
||||||
|
stop_times = stop_times.merge(trips[['trip_id', 'route_id']], on='trip_id')
|
||||||
|
|
||||||
|
# Filtruj po czasie
|
||||||
|
print(' 3/5 Filtrowanie po czasie...')
|
||||||
|
stop_times = stop_times[
|
||||||
|
(stop_times['arrival_time'] >= start_time) &
|
||||||
|
(stop_times['departure_time'] <= end_time)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sortuj po trip_id i stop_sequence
|
||||||
|
print(' 4/5 Sortowanie...')
|
||||||
|
stop_times = stop_times.sort_values(['trip_id', 'stop_sequence'])
|
||||||
|
|
||||||
|
# Buduj graf
|
||||||
|
print(' 5/5 Budowanie grafu połączeń...')
|
||||||
|
G = nx.DiGraph()
|
||||||
|
|
||||||
|
# Dodaj wszystkie przystanki jako węzły
|
||||||
|
total_stops = len(self.feed.stops)
|
||||||
|
print(f' Dodawanie {total_stops} przystanków...')
|
||||||
|
for _, stop in self.feed.stops.iterrows():
|
||||||
|
G.add_node(stop['stop_id'],
|
||||||
|
name=stop['stop_name'],
|
||||||
|
lat=stop['stop_lat'],
|
||||||
|
lon=stop['stop_lon'])
|
||||||
|
|
||||||
|
# Dodaj krawędzie między kolejnymi przystankami w trasie
|
||||||
|
grouped = list(stop_times.groupby('trip_id'))
|
||||||
|
total_trips = len(grouped)
|
||||||
|
print(f' Przetwarzanie {total_trips} kursów...')
|
||||||
|
for i, (trip_id, group) in enumerate(grouped):
|
||||||
|
stops = group.sort_values('stop_sequence')
|
||||||
|
for i in range(len(stops) - 1):
|
||||||
|
from_stop = stops.iloc[i]
|
||||||
|
to_stop = stops.iloc[i + 1]
|
||||||
|
|
||||||
|
# Oblicz czas podróży w minutach
|
||||||
|
from_time = pd.to_datetime(from_stop['departure_time'])
|
||||||
|
to_time = pd.to_datetime(to_stop['arrival_time'])
|
||||||
|
travel_time = (to_time - from_time).total_seconds() / 60
|
||||||
|
|
||||||
|
if travel_time < 0:
|
||||||
|
travel_time += 24 * 60 # Następny dzień
|
||||||
|
|
||||||
|
# Dodaj lub zaktualizuj krawędź (wybierz najszybsze połączenie)
|
||||||
|
if G.has_edge(from_stop['stop_id'], to_stop['stop_id']):
|
||||||
|
current_time = G[from_stop['stop_id']][to_stop['stop_id']]['weight']
|
||||||
|
if travel_time < current_time:
|
||||||
|
G[from_stop['stop_id']][to_stop['stop_id']]['weight'] = travel_time
|
||||||
|
else:
|
||||||
|
G.add_edge(from_stop['stop_id'], to_stop['stop_id'],
|
||||||
|
weight=travel_time)
|
||||||
|
|
||||||
|
# Progress indicator co 100 kursów
|
||||||
|
if (i + 1) % 100 == 0:
|
||||||
|
print(f' Przetworzono {i + 1}/{total_trips} kursów...')
|
||||||
|
|
||||||
|
print(f'✓ Graf zbudowany: {G.number_of_nodes()} węzłów, {G.number_of_edges()} krawędzi')
|
||||||
|
return G
|
||||||
|
|
||||||
|
def find_stops_by_name(self, name_fragment):
|
||||||
|
"""Znajduje przystanki po fragmencie nazwy"""
|
||||||
|
stops = self.get_stops()
|
||||||
|
mask = stops['stop_name'].str.contains(name_fragment, case=False, na=False)
|
||||||
|
return stops[mask]
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
"""
|
||||||
|
Moduł do obliczania izochron dla połączeń kolejowych
|
||||||
|
"""
|
||||||
|
import networkx as nx
|
||||||
|
from shapely.geometry import Point, MultiPoint
|
||||||
|
|
||||||
|
|
||||||
|
class IsochroneCalculator:
|
||||||
|
"""Klasa do obliczania i generowania izochron"""
|
||||||
|
|
||||||
|
def __init__(self, graph):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
graph: NetworkX DiGraph z wagami jako czasy podróży
|
||||||
|
"""
|
||||||
|
self.graph = graph
|
||||||
|
|
||||||
|
def calculate_travel_times(self, origin_stop_id, max_time=120):
|
||||||
|
"""
|
||||||
|
Oblicza czasy podróży z punktu startowego do wszystkich osiągalnych przystanków
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin_stop_id: ID przystanku początkowego
|
||||||
|
max_time: maksymalny czas podróży w minutach
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {stop_id: travel_time_in_minutes}
|
||||||
|
"""
|
||||||
|
if origin_stop_id not in self.graph:
|
||||||
|
raise ValueError(f'Przystanek {origin_stop_id} nie istnieje w grafie')
|
||||||
|
|
||||||
|
# Dijkstra - najkrótsze ścieżki
|
||||||
|
try:
|
||||||
|
lengths = nx.single_source_dijkstra_path_length(
|
||||||
|
self.graph,
|
||||||
|
origin_stop_id,
|
||||||
|
cutoff=max_time,
|
||||||
|
weight='weight'
|
||||||
|
)
|
||||||
|
return lengths
|
||||||
|
except nx.NodeNotFound:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def create_isochrones(self, origin_stop_id, time_intervals=[30, 60, 90, 120]):
|
||||||
|
"""
|
||||||
|
Tworzy wielokąty izochron dla różnych przedziałów czasowych
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin_stop_id: ID przystanku początkowego
|
||||||
|
time_intervals: lista czasów w minutach dla których generować izochrony
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of dict: [{'time': 30, 'geometry': {...}, 'stops': [...]}, ...]
|
||||||
|
"""
|
||||||
|
max_time = max(time_intervals)
|
||||||
|
travel_times = self.calculate_travel_times(origin_stop_id, max_time)
|
||||||
|
|
||||||
|
if not travel_times:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Grupuj przystanki według przedziałów czasowych
|
||||||
|
isochrones = []
|
||||||
|
for time_limit in sorted(time_intervals):
|
||||||
|
stops_in_range = {
|
||||||
|
stop_id: time
|
||||||
|
for stop_id, time in travel_times.items()
|
||||||
|
if time <= time_limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if not stops_in_range:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pobierz współrzędne przystanków
|
||||||
|
points = []
|
||||||
|
stop_ids = []
|
||||||
|
for stop_id in stops_in_range.keys():
|
||||||
|
node_data = self.graph.nodes[stop_id]
|
||||||
|
points.append([node_data['lon'], node_data['lat']])
|
||||||
|
stop_ids.append(stop_id)
|
||||||
|
|
||||||
|
if len(points) < 3:
|
||||||
|
# Za mało punktów dla wielokąta - użyj bufora
|
||||||
|
geometry = self._create_buffer_polygon(points, buffer_km=10)
|
||||||
|
else:
|
||||||
|
# Stwórz wypukły wielokąt (convex hull) lub użyj Voronoi
|
||||||
|
geometry = self._create_concave_hull(points)
|
||||||
|
|
||||||
|
isochrones.append({
|
||||||
|
'time': time_limit,
|
||||||
|
'geometry': geometry,
|
||||||
|
'stops': stop_ids,
|
||||||
|
'stop_count': len(stop_ids)
|
||||||
|
})
|
||||||
|
|
||||||
|
return isochrones
|
||||||
|
|
||||||
|
def _create_buffer_polygon(self, points, buffer_km=10):
|
||||||
|
"""Tworzy wielokąt z buforem wokół punktów"""
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Konwersja km na stopnie (przybliżenie)
|
||||||
|
buffer_degrees = buffer_km / 111.0
|
||||||
|
|
||||||
|
multi_point = MultiPoint([Point(p) for p in points])
|
||||||
|
buffered = multi_point.buffer(buffer_degrees)
|
||||||
|
|
||||||
|
return self._geometry_to_geojson(buffered)
|
||||||
|
|
||||||
|
def _create_concave_hull(self, points, alpha=0.3):
|
||||||
|
"""
|
||||||
|
Tworzy concave hull (wielokąt wklęsły) dla punktów
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: lista [lon, lat]
|
||||||
|
alpha: parametr kształtu (0-1, mniejszy = bardziej wklęsły)
|
||||||
|
"""
|
||||||
|
if len(points) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prosty convex hull jako punkt startowy
|
||||||
|
multi_point = MultiPoint([Point(p) for p in points])
|
||||||
|
hull = multi_point.convex_hull
|
||||||
|
|
||||||
|
# Dodaj lekki bufor dla lepszej wizualizacji
|
||||||
|
buffered = hull.buffer(0.05) # ~5.5 km
|
||||||
|
|
||||||
|
return self._geometry_to_geojson(buffered)
|
||||||
|
|
||||||
|
def _geometry_to_geojson(self, geometry):
|
||||||
|
"""Konwertuje Shapely geometry do GeoJSON"""
|
||||||
|
if geometry is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return geometry.__geo_interface__
|
||||||
|
|
||||||
|
def get_reachable_stops(self, origin_stop_id, max_time=120):
|
||||||
|
"""
|
||||||
|
Zwraca listę wszystkich osiągalnych przystanków z czasami podróży
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin_stop_id: ID przystanku początkowego
|
||||||
|
max_time: maksymalny czas podróży w minutach
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of dict: [{'stop_id': ..., 'name': ..., 'lat': ..., 'lon': ..., 'time': ...}]
|
||||||
|
"""
|
||||||
|
travel_times = self.calculate_travel_times(origin_stop_id, max_time)
|
||||||
|
|
||||||
|
reachable = []
|
||||||
|
for stop_id, time in travel_times.items():
|
||||||
|
node_data = self.graph.nodes[stop_id]
|
||||||
|
reachable.append({
|
||||||
|
'stop_id': stop_id,
|
||||||
|
'name': node_data.get('name', 'Unknown'),
|
||||||
|
'lat': node_data['lat'],
|
||||||
|
'lon': node_data['lon'],
|
||||||
|
'time': round(time, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted(reachable, key=lambda x: x['time'])
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
flask>=3.0.0
|
||||||
|
flask-cors>=4.0.0
|
||||||
|
gtfs-kit>=5.2.0
|
||||||
|
pandas>=2.1.0
|
||||||
|
shapely>=1.8.0,<2.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
networkx>=3.2.0
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Przykłady użycia API backendu Izochrona
|
||||||
|
|
||||||
|
Uruchom: uv run examples/api_examples.py
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
API_URL = 'http://localhost:5000/api'
|
||||||
|
|
||||||
|
def check_health():
|
||||||
|
"""Sprawdź status serwera"""
|
||||||
|
response = requests.get(f'{API_URL}/health')
|
||||||
|
data = response.json()
|
||||||
|
print('=== Status serwera ===')
|
||||||
|
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
return data
|
||||||
|
|
||||||
|
def search_stops(query):
|
||||||
|
"""Wyszukaj przystanki po nazwie"""
|
||||||
|
response = requests.get(f'{API_URL}/stops/search', params={'q': query})
|
||||||
|
data = response.json()
|
||||||
|
print(f'\n=== Wyniki wyszukiwania: "{query}" ===')
|
||||||
|
print(f'Znaleziono: {data["count"]} przystanków')
|
||||||
|
for stop in data['stops'][:5]:
|
||||||
|
print(f' - {stop["stop_name"]} (ID: {stop["stop_id"]})')
|
||||||
|
return data['stops']
|
||||||
|
|
||||||
|
def calculate_isochrones(stop_id, time_intervals=[30, 60, 90, 120]):
|
||||||
|
"""Oblicz izochrony dla przystanku"""
|
||||||
|
response = requests.post(
|
||||||
|
f'{API_URL}/isochrones',
|
||||||
|
json={
|
||||||
|
'origin_stop_id': stop_id,
|
||||||
|
'time_intervals': time_intervals
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
print(f'\n=== Izochrony dla {stop_id} ===')
|
||||||
|
for iso in data['isochrones']:
|
||||||
|
print(f' {iso["time"]} min: {iso["stop_count"]} stacji')
|
||||||
|
|
||||||
|
print(f'\nCałkowita liczba osiągalnych stacji: {len(data["reachable_stops"])}')
|
||||||
|
|
||||||
|
# Najdalsze 5 stacji
|
||||||
|
print('\nNajdalsze stacje:')
|
||||||
|
for stop in sorted(data['reachable_stops'], key=lambda x: x['time'], reverse=True)[:5]:
|
||||||
|
print(f' - {stop["name"]}: {stop["time"]:.1f} min')
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_reachable_stops(stop_id, max_time=90):
|
||||||
|
"""Pobierz listę osiągalnych przystanków"""
|
||||||
|
response = requests.post(
|
||||||
|
f'{API_URL}/reachable',
|
||||||
|
json={
|
||||||
|
'origin_stop_id': stop_id,
|
||||||
|
'max_time': max_time
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
print(f'\n=== Przystanki osiągalne w {max_time} min z {stop_id} ===')
|
||||||
|
print(f'Liczba: {data["count"]}')
|
||||||
|
|
||||||
|
for stop in data['reachable_stops'][:10]:
|
||||||
|
print(f' {stop["time"]:5.1f} min - {stop["name"]}')
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print('=== Przykłady użycia API Izochrona ===\n')
|
||||||
|
|
||||||
|
# 1. Sprawdź status
|
||||||
|
health = check_health()
|
||||||
|
|
||||||
|
if health['status'] != 'ok':
|
||||||
|
print('\n❌ Serwer nie jest gotowy. Uruchom: python backend/app.py')
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# 2. Wyszukaj stacje
|
||||||
|
stops = search_stops('Wrocław')
|
||||||
|
|
||||||
|
if not stops:
|
||||||
|
print('\n❌ Nie znaleziono stacji. Spróbuj innego zapytania.')
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# 3. Oblicz izochrony dla pierwszej znalezionej stacji
|
||||||
|
first_stop = stops[0]
|
||||||
|
isochrones = calculate_isochrones(first_stop['stop_id'])
|
||||||
|
|
||||||
|
# 4. Pobierz osiągalne przystanki w 60 min
|
||||||
|
reachable = get_reachable_stops(first_stop['stop_id'], max_time=60)
|
||||||
|
|
||||||
|
print('\n✅ Przykłady zakończone!')
|
||||||
|
print('\nKolej na Ciebie - otwórz frontend/index.html i eksploruj interaktywnie!')
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Izochrona - Mapa połączeń kolejowych</title>
|
||||||
|
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<h1>🚂 Izochrona</h1>
|
||||||
|
<p class="subtitle">Mapa dostępności połączeń kolejowych</p>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<details>
|
||||||
|
<summary style="cursor: pointer; font-size: 13px; margin-bottom: 10px;">⚙️ Konfiguracja API</summary>
|
||||||
|
<div style="font-size: 12px; margin-top: 8px;">
|
||||||
|
<label style="display: block; margin-bottom: 4px;">Adres backendu:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="api-url-input"
|
||||||
|
placeholder="http://hostname:5000/api"
|
||||||
|
style="width: 100%; padding: 6px; font-size: 11px; margin-bottom: 6px;"
|
||||||
|
/>
|
||||||
|
<div style="display: flex; gap: 4px;">
|
||||||
|
<button id="api-url-save" style="flex: 1; padding: 6px; font-size: 11px;">Zapisz</button>
|
||||||
|
<button id="api-url-reset" style="flex: 1; padding: 6px; font-size: 11px;">Reset</button>
|
||||||
|
</div>
|
||||||
|
<p id="api-url-status" style="margin-top: 6px; color: #666;"></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<h3>Wyszukaj stację początkową</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="station-search"
|
||||||
|
placeholder="np. Wrocław Główny"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selected-station" id="selected-station" style="display: none;">
|
||||||
|
<h3>Wybrana stacja</h3>
|
||||||
|
<p id="station-name"></p>
|
||||||
|
<button id="clear-selection">Wyczyść wybór</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="time-controls" id="time-controls" style="display: none;">
|
||||||
|
<h3>Przedziały czasowe (minuty)</h3>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" value="30" checked> 30 min
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" value="60" checked> 60 min (1h)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" value="90" checked> 90 min (1.5h)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" value="120" checked> 120 min (2h)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" value="180"> 180 min (3h)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
<label style="font-size: 13px; display: block; margin-bottom: 4px;">
|
||||||
|
Własne wartości (oddziel przecinkami):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="custom-times"
|
||||||
|
placeholder="np. 45, 75, 150"
|
||||||
|
style="width: 100%; padding: 8px; border: 1px solid #ced4da; border-radius: 4px; font-size: 13px;"
|
||||||
|
/>
|
||||||
|
<p style="font-size: 11px; color: #6c757d; margin-top: 4px;">
|
||||||
|
💡 Możesz wpisać dowolne wartości w minutach
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="calculate-btn" class="btn-primary">Oblicz izochrony</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend" id="legend" style="display: none;">
|
||||||
|
<h3>Legenda</h3>
|
||||||
|
<!-- Dynamicznie generowane w JS -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats" id="stats" style="display: none;">
|
||||||
|
<h3>Statystyki</h3>
|
||||||
|
<p id="stats-content"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<h4>Jak używać?</h4>
|
||||||
|
<ol>
|
||||||
|
<li>Wyszukaj stację początkową w polu powyżej</li>
|
||||||
|
<li>Wybierz przedziały czasowe</li>
|
||||||
|
<li>Kliknij "Oblicz izochrony"</li>
|
||||||
|
<li>Zobacz obszary dostępne w wybranym czasie!</li>
|
||||||
|
</ol>
|
||||||
|
<p class="data-source">
|
||||||
|
Dane: <a href="https://mkuran.pl/gtfs/" target="_blank">mkuran.pl/gtfs</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="map-container">
|
||||||
|
<div id="map"></div>
|
||||||
|
<div id="loading" class="loading" style="display: none;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Obliczanie izochron...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaflet JS -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script src="map.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,383 @@
|
||||||
|
// Konfiguracja API - sparametryzowana
|
||||||
|
function getDefaultApiUrl() {
|
||||||
|
// Auto-detect: użyj hostname z którego serwowany jest frontend
|
||||||
|
const protocol = window.location.protocol; // http: lub https:
|
||||||
|
const hostname = window.location.hostname; // np. 192.168.1.100 lub nazwa hosta
|
||||||
|
|
||||||
|
// Jeśli otwarte jako file://, użyj localhost
|
||||||
|
if (protocol === 'file:') {
|
||||||
|
return 'http://localhost:5000/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// W przeciwnym razie użyj tego samego hosta (przez reverse proxy)
|
||||||
|
return `${window.location.origin}/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiUrl() {
|
||||||
|
// Sprawdź localStorage
|
||||||
|
const saved = localStorage.getItem('izochrona_api_url');
|
||||||
|
if (saved) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Użyj auto-detected
|
||||||
|
return getDefaultApiUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApiUrl(url) {
|
||||||
|
localStorage.setItem('izochrona_api_url', url);
|
||||||
|
location.reload(); // Przeładuj stronę
|
||||||
|
}
|
||||||
|
|
||||||
|
let API_URL = getApiUrl();
|
||||||
|
|
||||||
|
// Stan aplikacji
|
||||||
|
let map;
|
||||||
|
let selectedStop = null;
|
||||||
|
let isochroneLayers = [];
|
||||||
|
let stopsLayer = null;
|
||||||
|
|
||||||
|
// Kolory dla różnych przedziałów czasowych
|
||||||
|
const ISOCHRONE_COLORS = {
|
||||||
|
30: '#0080ff',
|
||||||
|
60: '#00c864',
|
||||||
|
90: '#ffc800',
|
||||||
|
120: '#ff6400',
|
||||||
|
180: '#ff0064'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funkcja do generowania koloru dla dowolnego czasu
|
||||||
|
function getColorForTime(time) {
|
||||||
|
// Jeśli jest w predefiniowanych, użyj go
|
||||||
|
if (ISOCHRONE_COLORS[time]) {
|
||||||
|
return ISOCHRONE_COLORS[time];
|
||||||
|
}
|
||||||
|
|
||||||
|
// W przeciwnym razie generuj kolor na podstawie czasu
|
||||||
|
// Im dłuższy czas, tym bardziej czerwony
|
||||||
|
const hue = Math.max(0, 220 - (time * 1.2)); // Od niebieskiego (220) do czerwonego (0)
|
||||||
|
return `hsl(${hue}, 80%, 50%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicjalizacja mapy
|
||||||
|
function initMap() {
|
||||||
|
map = L.map('map').setView([52.0, 19.0], 7); // Polska
|
||||||
|
|
||||||
|
// Warstwa OSM
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 18
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
console.log('Mapa zainicjalizowana');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wyszukiwanie stacji
|
||||||
|
let searchTimeout;
|
||||||
|
document.getElementById('station-search').addEventListener('input', (e) => {
|
||||||
|
const query = e.target.value.trim();
|
||||||
|
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
document.getElementById('search-results').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
searchStations(query);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function searchStations(query) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/stops/search?q=${encodeURIComponent(query)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
displaySearchResults(data.stops);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Błąd wyszukiwania:', error);
|
||||||
|
document.getElementById('search-results').innerHTML =
|
||||||
|
'<p style="color: red; padding: 8px;">Błąd połączenia z serwerem</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySearchResults(stops) {
|
||||||
|
const resultsDiv = document.getElementById('search-results');
|
||||||
|
|
||||||
|
if (!stops || stops.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<p style="padding: 8px; color: #6c757d;">Brak wyników</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = stops.slice(0, 10).map(stop => `
|
||||||
|
<div class="search-result-item" data-stop-id="${stop.stop_id}">
|
||||||
|
<strong>${stop.stop_name}</strong>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Dodaj event listenery
|
||||||
|
resultsDiv.querySelectorAll('.search-result-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const stopId = item.dataset.stopId;
|
||||||
|
const stopName = item.querySelector('strong').textContent;
|
||||||
|
const stop = stops.find(s => s.stop_id === stopId);
|
||||||
|
selectStation(stop);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStation(stop) {
|
||||||
|
selectedStop = stop;
|
||||||
|
|
||||||
|
// Ukryj wyniki wyszukiwania
|
||||||
|
document.getElementById('search-results').innerHTML = '';
|
||||||
|
document.getElementById('station-search').value = '';
|
||||||
|
|
||||||
|
// Pokaż wybraną stację
|
||||||
|
document.getElementById('selected-station').style.display = 'block';
|
||||||
|
document.getElementById('station-name').textContent = stop.stop_name;
|
||||||
|
|
||||||
|
// Pokaż kontrolki
|
||||||
|
document.getElementById('time-controls').style.display = 'block';
|
||||||
|
|
||||||
|
// Wycentruj mapę na stacji
|
||||||
|
map.setView([stop.stop_lat, stop.stop_lon], 10);
|
||||||
|
|
||||||
|
// Dodaj marker
|
||||||
|
if (stopsLayer) {
|
||||||
|
map.removeLayer(stopsLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopsLayer = L.marker([stop.stop_lat, stop.stop_lon], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: 'custom-marker',
|
||||||
|
html: '📍',
|
||||||
|
iconSize: [30, 30]
|
||||||
|
})
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
stopsLayer.bindPopup(`<strong>${stop.stop_name}</strong>`).openPopup();
|
||||||
|
|
||||||
|
console.log('Wybrano stację:', stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wyczyść wybór
|
||||||
|
document.getElementById('clear-selection').addEventListener('click', () => {
|
||||||
|
selectedStop = null;
|
||||||
|
document.getElementById('selected-station').style.display = 'none';
|
||||||
|
document.getElementById('time-controls').style.display = 'none';
|
||||||
|
document.getElementById('legend').style.display = 'none';
|
||||||
|
document.getElementById('stats').style.display = 'none';
|
||||||
|
|
||||||
|
clearIsochrones();
|
||||||
|
|
||||||
|
if (stopsLayer) {
|
||||||
|
map.removeLayer(stopsLayer);
|
||||||
|
stopsLayer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Oblicz izochrony
|
||||||
|
document.getElementById('calculate-btn').addEventListener('click', async () => {
|
||||||
|
if (!selectedStop) return;
|
||||||
|
|
||||||
|
// Pobierz wartości z checkboxów
|
||||||
|
const checkboxes = document.querySelectorAll('.checkbox-group input[type="checkbox"]:checked');
|
||||||
|
const timeIntervals = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
// Pobierz własne wartości
|
||||||
|
const customInput = document.getElementById('custom-times').value.trim();
|
||||||
|
if (customInput) {
|
||||||
|
const customTimes = customInput
|
||||||
|
.split(',')
|
||||||
|
.map(t => parseInt(t.trim()))
|
||||||
|
.filter(t => !isNaN(t) && t > 0); // Filtruj poprawne liczby
|
||||||
|
|
||||||
|
timeIntervals.push(...customTimes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usuń duplikaty i posortuj
|
||||||
|
const uniqueIntervals = [...new Set(timeIntervals)].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (uniqueIntervals.length === 0) {
|
||||||
|
alert('Wybierz przynajmniej jeden przedział czasowy lub wpisz własną wartość');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await calculateIsochrones(selectedStop.stop_id, uniqueIntervals);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function calculateIsochrones(stopId, timeIntervals) {
|
||||||
|
// Pokaż loading
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
document.getElementById('calculate-btn').disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/isochrones`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin_stop_id: stopId,
|
||||||
|
time_intervals: timeIntervals
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
displayIsochrones(data.isochrones, data.reachable_stops);
|
||||||
|
} else {
|
||||||
|
alert(`Błąd: ${data.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Błąd:', error);
|
||||||
|
alert('Błąd połączenia z serwerem');
|
||||||
|
} finally {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('calculate-btn').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayIsochrones(isochrones, reachableStops) {
|
||||||
|
// Usuń poprzednie warstwy
|
||||||
|
clearIsochrones();
|
||||||
|
|
||||||
|
// Sortuj od największego do najmniejszego (żeby mniejsze były na wierzchu)
|
||||||
|
isochrones.sort((a, b) => b.time - a.time);
|
||||||
|
|
||||||
|
// Dodaj wielokąty izochron
|
||||||
|
isochrones.forEach(iso => {
|
||||||
|
if (!iso.geometry) return;
|
||||||
|
|
||||||
|
const color = getColorForTime(iso.time);
|
||||||
|
|
||||||
|
const layer = L.geoJSON(iso.geometry, {
|
||||||
|
style: {
|
||||||
|
color: color,
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.8,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.2
|
||||||
|
}
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
layer.bindPopup(`<strong>${iso.time} min</strong><br>${iso.stop_count} stacji`);
|
||||||
|
|
||||||
|
isochroneLayers.push(layer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dodaj markery dla osiągalnych stacji
|
||||||
|
reachableStops.forEach(stop => {
|
||||||
|
const marker = L.circleMarker([stop.lat, stop.lon], {
|
||||||
|
radius: 4,
|
||||||
|
fillColor: '#0080ff',
|
||||||
|
color: 'white',
|
||||||
|
weight: 1,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<strong>${stop.name}</strong><br>
|
||||||
|
Czas: ${stop.time} min
|
||||||
|
`);
|
||||||
|
|
||||||
|
isochroneLayers.push(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aktualizuj legendę dynamicznie
|
||||||
|
updateLegend(isochrones);
|
||||||
|
|
||||||
|
// Pokaż statystyki
|
||||||
|
const statsContent = `
|
||||||
|
<strong>Osiągalne stacje:</strong> ${reachableStops.length}<br>
|
||||||
|
<strong>Izochrony:</strong> ${isochrones.length}<br>
|
||||||
|
<strong>Najdalsza stacja:</strong> ${reachableStops[reachableStops.length - 1]?.name || 'brak'}
|
||||||
|
(${reachableStops[reachableStops.length - 1]?.time || 0} min)
|
||||||
|
`;
|
||||||
|
document.getElementById('stats-content').innerHTML = statsContent;
|
||||||
|
document.getElementById('stats').style.display = 'block';
|
||||||
|
|
||||||
|
console.log('Wyświetlono izochrony:', isochrones.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIsochrones() {
|
||||||
|
isochroneLayers.forEach(layer => map.removeLayer(layer));
|
||||||
|
isochroneLayers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLegend(isochrones) {
|
||||||
|
const legend = document.getElementById('legend');
|
||||||
|
const legendContainer = legend.querySelector('.legend-items') || createLegendContainer(legend);
|
||||||
|
|
||||||
|
// Wyczyść starą legendę
|
||||||
|
legendContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Dodaj wpisy dla każdej izochrony
|
||||||
|
isochrones.forEach(iso => {
|
||||||
|
const color = getColorForTime(iso.time);
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'legend-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="legend-color" style="background: ${color}; opacity: 0.6;"></span>
|
||||||
|
<span>${iso.time} min</span>
|
||||||
|
`;
|
||||||
|
legendContainer.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
legend.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLegendContainer(legend) {
|
||||||
|
// Znajdź h3 i dodaj kontener po nim
|
||||||
|
const h3 = legend.querySelector('h3');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'legend-items';
|
||||||
|
|
||||||
|
// Usuń stare statyczne wpisy jeśli istnieją
|
||||||
|
const oldItems = legend.querySelectorAll('.legend-item');
|
||||||
|
oldItems.forEach(item => item.remove());
|
||||||
|
|
||||||
|
legend.appendChild(container);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konfiguracja API URL
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const input = document.getElementById('api-url-input');
|
||||||
|
const saveBtn = document.getElementById('api-url-save');
|
||||||
|
const resetBtn = document.getElementById('api-url-reset');
|
||||||
|
const status = document.getElementById('api-url-status');
|
||||||
|
|
||||||
|
// Pokaż aktualny URL
|
||||||
|
input.value = API_URL;
|
||||||
|
status.textContent = `Aktualny: ${API_URL}`;
|
||||||
|
|
||||||
|
// Zapisz nowy URL
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const newUrl = input.value.trim();
|
||||||
|
if (!newUrl) {
|
||||||
|
status.textContent = 'Błąd: Adres nie może być pusty';
|
||||||
|
status.style.color = 'red';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApiUrl(newUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset do auto-detect
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
localStorage.removeItem('izochrona_api_url');
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inicjalizacja przy starcie
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initMap();
|
||||||
|
console.log('Aplikacja gotowa');
|
||||||
|
console.log('API URL:', API_URL);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 350px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #dee2e6;
|
||||||
|
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 15px 0 8px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config */
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section details summary {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section details[open] {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#station-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#station-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0080ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover {
|
||||||
|
background: #e7f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected station */
|
||||||
|
.selected-station {
|
||||||
|
background: #e7f3ff;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#station-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0080ff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear-selection {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear-selection:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time controls */
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: #0080ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #ced4da;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.legend {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats-content {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info */
|
||||||
|
.info {
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info ol {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-source {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-source a {
|
||||||
|
color: #0080ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map */
|
||||||
|
.map-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
text-align: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #0080ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:5000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Konfiguracja projektu Izochrona
|
||||||
|
# Ten plik służy głównie do dokumentacji zależności
|
||||||
|
# Używamy requirements.txt dla rzeczywistej instalacji
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "izochrona"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Mapa izochron dla połączeń kolejowych w Polsce"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
|
# Zależności są w backend/requirements.txt
|
||||||
|
# Ten plik służy głównie do konfiguracji narzędzi
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py39']
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Szybkie uruchomienie backendu
|
||||||
|
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Uruchom z uv (bezpośrednio, bez instalowania pakietu)
|
||||||
|
uv run --no-project app.py
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Skrypt do pierwszego uruchomienia projektu
|
||||||
|
|
||||||
|
echo "=== Konfiguracja projektu Izochrona ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Sprawdź czy uv jest zainstalowane
|
||||||
|
if ! command -v uv &> /dev/null; then
|
||||||
|
echo "❌ uv nie jest zainstalowane!"
|
||||||
|
echo ""
|
||||||
|
echo "Zainstaluj uv:"
|
||||||
|
echo " curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||||
|
echo " lub:"
|
||||||
|
echo " pip install uv"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stwórz wirtualne środowisko Python
|
||||||
|
echo "1. Tworzenie wirtualnego środowiska Python z uv..."
|
||||||
|
cd backend
|
||||||
|
uv venv
|
||||||
|
|
||||||
|
# Zainstaluj zależności
|
||||||
|
echo ""
|
||||||
|
echo "2. Instalacja zależności Python..."
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Pobierz dane GTFS
|
||||||
|
echo ""
|
||||||
|
echo "3. Pobieranie danych GTFS..."
|
||||||
|
uv run --no-project download_gtfs.py
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Konfiguracja zakończona ==="
|
||||||
|
echo ""
|
||||||
|
echo "Aby uruchomić projekt:"
|
||||||
|
echo " 1. Backend: ./run.sh"
|
||||||
|
echo " 2. Frontend: cd frontend && python3 -m http.server 8000"
|
||||||
|
echo ""
|
||||||
Loading…
Reference in New Issue