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