initial commit

This commit is contained in:
Maciej Bator 2026-02-24 17:40:04 +01:00
commit 91c81b0175
23 changed files with 2399 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@ -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

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.9

188
QUICKSTART.md Normal file
View File

@ -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! 🚂🗺️**

128
README.md Normal file
View File

@ -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ć.

191
TECHNICAL.md Normal file
View File

@ -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"]
```

114
UV_CHEATSHEET.md Normal file
View File

@ -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

215
UV_GUIDE.md Normal file
View File

@ -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)

14
backend/Dockerfile Normal file
View File

@ -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"]

1
backend/__init__.py Normal file
View File

@ -0,0 +1 @@
# Backend package

176
backend/app.py Normal file
View File

@ -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.')

42
backend/download_gtfs.py Normal file
View File

@ -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 ===')

118
backend/gtfs_loader.py Normal file
View File

@ -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]

View File

@ -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'])

7
backend/requirements.txt Normal file
View File

@ -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

17
docker-compose.yml Normal file
View File

@ -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

98
examples/api_examples.py Executable file
View File

@ -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!')

134
frontend/index.html Normal file
View File

@ -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>

383
frontend/map.js Normal file
View File

@ -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);
});

295
frontend/style.css Normal file
View File

@ -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;
}

17
nginx.conf Normal file
View File

@ -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;
}
}

21
pyproject.toml Normal file
View File

@ -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']

7
run.sh Executable file
View File

@ -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

39
setup.sh Executable file
View File

@ -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 ""