commit 91c81b01757cb0a1444f77a022dffee034f86cf7 Author: Maciej Bator Date: Tue Feb 24 17:40:04 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5aa195 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..7a42fb8 --- /dev/null +++ b/QUICKSTART.md @@ -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! 🚂🗺️** diff --git a/README.md b/README.md new file mode 100644 index 0000000..0767b2a --- /dev/null +++ b/README.md @@ -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ć. diff --git a/TECHNICAL.md b/TECHNICAL.md new file mode 100644 index 0000000..a55a399 --- /dev/null +++ b/TECHNICAL.md @@ -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"] +``` diff --git a/UV_CHEATSHEET.md b/UV_CHEATSHEET.md new file mode 100644 index 0000000..596fd2c --- /dev/null +++ b/UV_CHEATSHEET.md @@ -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 diff --git a/UV_GUIDE.md b/UV_GUIDE.md new file mode 100644 index 0000000..5bd4193 --- /dev/null +++ b/UV_GUIDE.md @@ -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) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f4159aa --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..7f83169 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Backend package diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..b630749 --- /dev/null +++ b/backend/app.py @@ -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.') diff --git a/backend/download_gtfs.py b/backend/download_gtfs.py new file mode 100644 index 0000000..7a39729 --- /dev/null +++ b/backend/download_gtfs.py @@ -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 ===') diff --git a/backend/gtfs_loader.py b/backend/gtfs_loader.py new file mode 100644 index 0000000..0793bd5 --- /dev/null +++ b/backend/gtfs_loader.py @@ -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] diff --git a/backend/isochrone_calculator.py b/backend/isochrone_calculator.py new file mode 100644 index 0000000..0ba4dc2 --- /dev/null +++ b/backend/isochrone_calculator.py @@ -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']) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e33b63c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc62ef4 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/examples/api_examples.py b/examples/api_examples.py new file mode 100755 index 0000000..0d0d69f --- /dev/null +++ b/examples/api_examples.py @@ -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!') diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7015101 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,134 @@ + + + + + + Izochrona - Mapa połączeń kolejowych + + + + + + + + +
+ + +
+
+ +
+
+ + + + + + + + diff --git a/frontend/map.js b/frontend/map.js new file mode 100644 index 0000000..419ee40 --- /dev/null +++ b/frontend/map.js @@ -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 = + '

Błąd połączenia z serwerem

'; + } +} + +function displaySearchResults(stops) { + const resultsDiv = document.getElementById('search-results'); + + if (!stops || stops.length === 0) { + resultsDiv.innerHTML = '

Brak wyników

'; + return; + } + + resultsDiv.innerHTML = stops.slice(0, 10).map(stop => ` +
+ ${stop.stop_name} +
+ `).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(`${stop.stop_name}`).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(`${iso.time} min
${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(` + ${stop.name}
+ Czas: ${stop.time} min + `); + + isochroneLayers.push(marker); + }); + + // Aktualizuj legendę dynamicznie + updateLegend(isochrones); + + // Pokaż statystyki + const statsContent = ` + Osiągalne stacje: ${reachableStops.length}
+ Izochrony: ${isochrones.length}
+ Najdalsza stacja: ${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 = ` + + ${iso.time} min + `; + 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); +}); diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..b00e801 --- /dev/null +++ b/frontend/style.css @@ -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; +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..61d5a4d --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..332405f --- /dev/null +++ b/pyproject.toml @@ -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'] diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..665b9c4 --- /dev/null +++ b/run.sh @@ -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 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..6d71a75 --- /dev/null +++ b/setup.sh @@ -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 ""