GitHub Actions commited on
Commit
cdf3344
·
0 Parent(s):

Deploy from GitHub Actions

Browse files
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ pulsetransit-worker/
4
+ __pycache__/
5
+ *.pyc
.github/workflows/collect.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: TUS Collector
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ permissions:
7
+ contents: write
8
+
9
+ jobs:
10
+ collect:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.11"
18
+
19
+ - name: Collect estimaciones
20
+ run: PYTHONPATH=src python src/pulsetransit/collector.py estimaciones
21
+
22
+ - name: Collect posiciones every 20 min
23
+ run: |
24
+ MINUTE=$(date -u +%M)
25
+ if [ $((MINUTE % 20)) -eq 0 ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
26
+ PYTHONPATH=src python src/pulsetransit/collector.py posiciones
27
+ else
28
+ echo "Skipping posiciones this run (minute=$MINUTE)"
29
+ fi
30
+
31
+ - name: Validate
32
+ run: PYTHONPATH=src python src/pulsetransit/validate.py
33
+
34
+ - name: Commit DB update
35
+ run: |
36
+ git config user.name "tus-bot"
37
+ git config user.email "bot@tus"
38
+ git add -f data/tus.db
39
+ git diff --cached --quiet || (
40
+ git commit -m "data: collect $(date -u +%H:%M)" &&
41
+ git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/pmatorras/pulsetransit.git
42
+ )
.github/workflows/deploy-hf.yml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy Dashboard to HF Space
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ sync-to-hub:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+
12
+ - name: Deploy to Hugging Face
13
+ env:
14
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
15
+ run: |
16
+ # 1. Create a clean temporary directory
17
+ mkdir /tmp/hf_deploy
18
+
19
+ # 2. Copy all files EXCEPT the .git folder and the DB files
20
+ rsync -av --exclude='.git' --exclude='data/*.db' ./ /tmp/hf_deploy/
21
+
22
+ # 3. Go into the clean directory
23
+ cd /tmp/hf_deploy
24
+
25
+ # 4. Merge the README with the HF metadata
26
+ cat hf_space_metadata.yml > hf_readme.md
27
+ echo "" >> hf_readme.md
28
+ cat README.md >> hf_readme.md
29
+ mv hf_readme.md README.md
30
+
31
+ # 5. Initialize a brand new git repo (NO HISTORY)
32
+ git init
33
+ git config user.email "github-actions@github.com"
34
+ git config user.name "GitHub Actions"
35
+
36
+ # 6. Commit the current state
37
+ git add .
38
+ git commit -m "Deploy from GitHub Actions"
39
+
40
+ # 7. Force push to the 'main' branch on Hugging Face
41
+ git push -f https://pmatorras:$HF_TOKEN@huggingface.co/spaces/pmatorras/pulsetransit HEAD:main
.github/workflows/monitor.yml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Monitor Worker
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 8 * * *'
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ health-check:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Check worker health
13
+ run: |
14
+ RESPONSE=$(curl -s https://pulsetransit-worker.pablo-matorras.workers.dev/health)
15
+ echo "Worker response: $RESPONSE"
16
+
17
+ LAST_EST=$(echo $RESPONSE | jq -r '.last_estimaciones')
18
+ LAST_POS=$(echo $RESPONSE | jq -r '.last_posiciones')
19
+
20
+ # Calculate age
21
+ EST_AGE=$(($(date +%s) - $(date -d "$LAST_EST" +%s)))
22
+ POS_AGE=$(($(date +%s) - $(date -d "$LAST_POS" +%s)))
23
+
24
+ # Check current hour (UTC - adjust if needed)
25
+ CURRENT_HOUR=$(date +%H)
26
+
27
+ # Service hours: 5am-11pm UTC (6am-midnight CET)
28
+ if [ $CURRENT_HOUR -ge 5 ] && [ $CURRENT_HOUR -lt 23 ]; then
29
+ # During service hours - strict checks
30
+ if [ $EST_AGE -gt 1800 ]; then # 30 min
31
+ echo "⛔ ERROR: Estimaciones stale (${EST_AGE}s) during service hours"
32
+ exit 1
33
+ elif [ $EST_AGE -gt 600 ]; then # 10 min
34
+ echo "⚠️ WARNING: Estimaciones possibly stale (${EST_AGE}s old)"
35
+ # Don't exit - just warn
36
+ fi
37
+
38
+ if [ $POS_AGE -gt 7200 ]; then
39
+ echo "⛔ ERROR: Posiciones stale (${POS_AGE}s) during service hours"
40
+ exit 1
41
+ fi
42
+ else
43
+ # Outside service hours - lenient
44
+ echo "💤 Outside service hours - data age: est=${EST_AGE}s, pos=${POS_AGE}s"
45
+ if [ $EST_AGE -gt 43200 ]; then # 12 hours
46
+ echo "⚠️ WARNING: Data very stale, but outside service hours"
47
+ fi
48
+ # Don't fail outside service hours
49
+ fi
50
+
51
+ echo "✅ Worker is healthy"
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data — only commit static info
2
+ *.db
3
+ *.csv
4
+ !data/gtfs-static/
5
+ !data/gtfs-static/*.txt
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.pyc
10
+ *.pyo
11
+ .env
12
+ .venv/
13
+ *.egg-info/
14
+ dist/
15
+ build/
16
+
17
+ # Notebooks
18
+ .ipynb_checkpoints/
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ .wrangler
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use a lightweight Python base image
2
+ FROM python:3.11-slim
3
+
4
+ # Hugging Face Spaces strongly recommends running as a non-root user (User ID 1000)
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+
8
+ # Set environment variables for the user and Streamlit
9
+ ENV HOME=/home/user \
10
+ PATH=/home/user/.local/bin:$PATH \
11
+ STREAMLIT_SERVER_PORT=7860 \
12
+ STREAMLIT_SERVER_ADDRESS=0.0.0.0
13
+
14
+ # Set the working directory
15
+ WORKDIR $HOME/app
16
+
17
+ # Copy all project files into the container with the correct permissions
18
+ COPY --chown=user:user . $HOME/app
19
+
20
+ # Install your package using your existing pyproject.toml
21
+ RUN pip install --no-cache-dir --upgrade pip && \
22
+ pip install --no-cache-dir .
23
+
24
+ # Expose the default Hugging Face Spaces port
25
+ EXPOSE 7860
26
+
27
+ # Run the Streamlit dashboard
28
+ CMD ["streamlit", "run", "src/pulsetransit/dashboard/app.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pablo Matorras
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PulseTransit
3
+ emoji: 🚌
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ ---
8
+
9
+ # PulseTransit
10
+ ![Worker Status](https://img.shields.io/endpoint?url=https://pulsetransit-worker.pablo-matorras.workers.dev/badge&cacheSeconds=60)
11
+
12
+
13
+ Real-time data pipeline for TUS (Transportes Urbanos de Santander) bus network.
14
+ Collects live vehicle positions and stop-level ETA predictions to build a
15
+ historical dataset for delay analysis and ML-based prediction.
16
+
17
+ ## Data Sources
18
+
19
+ ### Real-time Data (datos.santander.es API)
20
+
21
+ - **`posiciones`**: GPS positions of buses (lat/lon, timestamp, line, vehicle ID)
22
+ - **`estimaciones_parada`**: Real-time ETAs for each bus-stop pair
23
+ - ~~**`pasos_parada`**: Historical passages (stale since June 2025, not used)~~
24
+
25
+ ### Static Data (NAP - National Access Point)
26
+
27
+ GTFS static files from [nap.transportes.gob.es](https://nap.transportes.gob.es/Files/Detail/1391):
28
+
29
+ - **`stops.txt`**: Stop coordinates and metadata (for proximity calculation)
30
+ - **`shapes.txt`**: Detailed route geometries (for GPS map-matching and visualization)
31
+ - **`routes.txt`**: Route names, colors, and metadata
32
+ - **`trips.txt`**: Trip patterns and service IDs
33
+ - **`stop_times.txt`**: Stop sequences and route structure
34
+ - **`calendar_dates.txt`**: Service exceptions (holidays, special schedules)
35
+
36
+ **Note**: GTFS files are stored in `data/gtfs-static/` (not tracked in git due to size).
37
+
38
+
39
+ Source: [datos.santander.es](http://datos.santander.es)
40
+
41
+ ## Architecture
42
+
43
+ **Data Collection:**
44
+ - **Cloudflare Worker** (`pulsetransit-worker/`): Scheduled collection every 2 minutes (estimaciones) and hourly (posiciones), storing in Cloudflare D1 database
45
+ - **GitHub Actions (Legacy)** (`.github/workflows/collect.yml`): Legacy collector, writes to `data/tus.db` for development/testing
46
+
47
+ **Database Schema:**
48
+ - `estimaciones`: Predictions with `UNIQUE(parada_id, linea, fech_actual)` to deduplicate
49
+ - `posiciones`: GPS breadcrumbs with `UNIQUE(vehiculo, instante)` to deduplicate overlapping route histories
50
+
51
+ ## Project Structure
52
+
53
+ ```
54
+ src/pulsetransit/ # Legacy Python collector (backup/testing)
55
+ ├── collector.py # API fetching and DB insertion
56
+ └── db.py # Schema and connection management
57
+
58
+ pulsetransit-worker/ # Cloudflare Worker (production collector)
59
+ ├── src/index.js # Scheduled tasks, API fetching, health endpoint
60
+ ├── schema.sql # D1 database schema
61
+ └── wrangler.jsonc # Cloudflare config and cron triggers
62
+
63
+ .github/workflows/
64
+ ├── collect.yml # Manual backup collector
65
+ └── monitor.yml # Hourly worker health check
66
+
67
+ data/
68
+ └── tus.db # SQLite database (GitHub Actions/local dev)
69
+ ```
70
+
71
+
72
+ ## Roadmap
73
+
74
+ - [x] Data collection pipeline (GPS + ETA)
75
+ - [ ] GTFS static feed integration (stop geometries, scheduled timetables)
76
+ - [ ] Delay computation (predicted vs actual arrival)
77
+ - [ ] Weather feature enrichment (via meteomat)
78
+ - [ ] ML delay prediction model
79
+ - [ ] Live dashboard
80
+
81
+ ## Setup
82
+
83
+ ```bash
84
+ pip install -e .
85
+ python src/pulsetransit/collector.py both
86
+ ```
data/gtfs-static/agency.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ agency_id,agency_name,agency_url,agency_timezone,agency_lang,agency_phone,agency_fare_url
2
+ 1,S.M.T.U. SANTANDER (TUS),https://tus.santander.es,Europe/Madrid,es,,https://tus.santander.es
data/gtfs-static/calendar.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date
data/gtfs-static/calendar_dates.txt ADDED
The diff for this file is too large to render. See raw diff
 
data/gtfs-static/routes.txt ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ route_id,agency_id,route_short_name,route_long_name,route_desc,route_type,route_url,route_color,route_text_color
2
+ 1,1,1,G.TREVILLA 14-PCTCAN/ADARZO,,3,,FF0000,FFFFFF
3
+ 2,1,2,CORBAN-CONSUELO BERGES,,3,,EBBCC2,000000
4
+ 3,1,3,OJAIZ-SARDINERO/UNI/P.PEREDA,,3,,FFD014,000000
5
+ 4,1,4,B.PESQUERO-INT.SARDINERO/UNI,,3,,3AF2E5,000000
6
+ 11,1,11,AVDA.VALDECILLA-PZA.ITALIA,,3,,BA3309,FFFFFF
7
+ 12,1,12,CARREFOUR,,3,,B2DB74,000000
8
+ 13,1,13,LLUJA-CUETO,,3,,D4B0D3,000000
9
+ 14,1,14,ESTACIONES,,3,,3EB9E6,000000
10
+ 15,1,15,ESTACIONES-CAMPING,,3,,F5F53D,000000
11
+ 16,1,16,PLAZA DE LOS REMEDIOS,,3,,D444B2,000000
12
+ 17,1,17,PZA.ESTACIONES-CORBAN/CIRIEGO,,3,,FFD2C7,000000
13
+ 18,1,18,PUERTOCHICO-CORBANERA/CASTILLO,,3,,C9F3FF,000000
14
+ 31,1,E31,ALISAL-INT.SARDINERO,,3,,BED171,000000
15
+ 41,1,E1,SE INT.VALDECILLA- INT.SARDINERO,,3,,2A99B5,FFFFFF
16
+ 42,1,E2,S.E. INTERMODAL,,3,,C9CCC0,000000
17
+ 43,1,E3,SE MIRANDA-INSTITUTOS,,3,,D692D4,000000
18
+ 44,1,E4,SE SAN MARTIN-INSTITUTOS,,3,,E8B780,000000
19
+ 51,1,5C1,MIRANDA/PLZ. ITALIA C1,,3,,CFCFCF,000000
20
+ 52,1,5C2,MIRANDA/PLZ. ITALIA C2,,3,,ADADAD,000000
21
+ 61,1,6C1,COMPLEJO C1,,3,,0EC758,FFFFFF
22
+ 62,1,6C2,COMPLEJO RUTH BEITIA C2,,3,,00C200,FFFFFF
23
+ 71,1,7C1,LUIS QUINTANILLA C1,,3,,FF9D1C,000000
24
+ 72,1,7C2,LUIS QUINTANILLA C2,,3,,FF9D1C,000000
25
+ 99,1,99,LANZADERA,,3,,FF9D1C,000000
26
+ 100,1,LC,INT.VALDECILLA-INT SARDINERO,,3,,1717FF,000000
27
+ 101,1,N1,CORBAN-G. ATECA por Valdenoja,,3,,ADADAD,000000
28
+ 102,1,N2,CORBAN-COMPLEJO por Paseo Altamira,,3,,696969,FFFFFF
29
+ 103,1,N3,PEÑACASTILLO-PLAZA DE ITALIA ,,3,,ABABAB,000000
30
+ 241,1,24C1,PCTCAN-SAN MARTIN-PCTCAN,,3,,FF6622,000000
31
+ 242,1,24C2,PCTCAN-SAN MARTIN-PCTCAN,,3,,FF6622,000000
data/gtfs-static/shapes.txt ADDED
The diff for this file is too large to render. See raw diff
 
data/gtfs-static/stop_times.txt ADDED
The diff for this file is too large to render. See raw diff
 
data/gtfs-static/stops.txt ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ stop_id,stop_code,stop_name,stop_desc,stop_lat,stop_lon,zone_id,stop_url,location_type,parent_station,stop_timezone
2
+ 1,PA1,LOS CIRUELOS 47,Parada,43.4576488249693,-3.85555763126247,,,,,Europe/Madrid
3
+ 10,PA10,CUATRO CAMINOS,Parada,43.4587638498046,-3.82538532259084,,,,,Europe/Madrid
4
+ 99,PA100,LOS CASTROS 39,Parada,43.4724960314191,-3.79209293900641,,,,,Europe/Madrid
5
+ 100,PA101,LOS CASTROS 23,Parada,43.4732880183678,-3.78791271217829,,,,,Europe/Madrid
6
+ 101,PA103,LAS ESTACIONES,Parada,43.4591167695794,-3.81075395497855,,,,,Europe/Madrid
7
+ 102,PA104,CALLE CASTILLA 27,Parada,43.4575008706594,-3.81285327700227,,,,,Europe/Madrid
8
+ 103,PA105,CALLE CASTILLA 51,Parada,43.4557605431402,-3.817533946692,,,,,Europe/Madrid
9
+ 104,PA106,CALLE CASTILLA 71,Parada,43.4545723535417,-3.82078517573242,,,,,Europe/Madrid
10
+ 105,PA107,CALLE CASTILLA 64,Parada,43.453146350609,-3.82493768892471,,,,,Europe/Madrid
11
+ 106,PA108,PUENTE LA MARGA,Parada,43.452694764383,-3.82774505856727,,,,,Europe/Madrid
12
+ 107,PA109,JERONIMO SAINZ DE LA MAZA 9,Parada,43.4560471857845,-3.82704379820745,,,,,Europe/Madrid
13
+ 11,PA11,SAN FERNANDO,Parada,43.4602549664282,-3.8199534076077,,,,,Europe/Madrid
14
+ 109,PA111,MENENDEZ PELAYO 97,Parada,43.4673013619228,-3.79216289556649,,,,,Europe/Madrid
15
+ 110,PA112,MENENDEZ PELAYO 61,Parada,43.4667016896395,-3.79595983788604,,,,,Europe/Madrid
16
+ 111,PA113,VALLICIERGO 7,Parada,43.4647215973614,-3.80033541399032,,,,,Europe/Madrid
17
+ 112,PA114,SANTA LUCIA 1,Parada,43.4639724054467,-3.80384447007856,,,,,Europe/Madrid
18
+ 113,PA115,GUEVARA 21,Parada,43.4637344370763,-3.80742315320999,,,,,Europe/Madrid
19
+ 114,PA116,PLAZA DE LOS REMEDIOS,Parada,43.4627399847095,-3.80867161085099,,,,,Europe/Madrid
20
+ 115,PA118,JOSE HIERRO 22,Parada,43.4606156156359,-3.82542496460283,,,,,Europe/Madrid
21
+ 116,PA119,JOSE HIERRO 32,Parada,43.4624208941179,-3.82369373013492,,,,,Europe/Madrid
22
+ 12,PA12,JESUS DE MONASTERIO 21,Parada,43.4617290137147,-3.81243950472629,,,,,Europe/Madrid
23
+ 117,PA120,LAS MERCEDARIAS,Parada,43.4644229373817,-3.81974712118845,,,,,Europe/Madrid
24
+ 118,PA121,PASEO DE ALTAMIRA 89,Parada,43.4652773202967,-3.81775393456929,,,,,Europe/Madrid
25
+ 119,PA122,PASEO DE ALTAMIRA 87,Parada,43.4671905802941,-3.81475214229526,,,,,Europe/Madrid
26
+ 120,PA123,LOS SALESIANOS,Parada,43.4673992465001,-3.80870448748227,,,,,Europe/Madrid
27
+ 121,PA124,PRADO SAN ROQUE,Parada,43.4672659976073,-3.80625175486188,,,,,Europe/Madrid
28
+ 122,PA125,PASEO DE ALTAMIRA 41,Parada,43.4677650748204,-3.80111657271067,,,,,Europe/Madrid
29
+ 123,PA126,SANTA CLOTILDE,Parada,43.4685213555613,-3.79765062764561,,,,,Europe/Madrid
30
+ 124,PA127,BAJADA DE LA ENCINA 1,Parada,43.468870608137,-3.7916241731006,,,,,Europe/Madrid
31
+ 125,PA128,FERNANDO CALDERÓN RUEDA 6,Parada,43.470468715894,-3.78918220049349,,,,,Europe/Madrid
32
+ 126,PA129,PLAZA DE ITALIA CASINO,Parada,43.4720669820009,-3.78226709203205,,,,,Europe/Madrid
33
+ 13,PA13,AYUNTAMIENTO,Parada,43.4614716076811,-3.81004714351273,,,,,Europe/Madrid
34
+ 127,PA130,REINA VICTORIA 117,Parada,43.4698843300937,-3.77750007123078,,,,,Europe/Madrid
35
+ 128,PA131,LAS ESCLAVAS,Parada,43.4675248095255,-3.7794741268824,,,,,Europe/Madrid
36
+ 129,PA132,PEREZ GALDÓS 17,Parada,43.4664497093828,-3.78491352890353,,,,,Europe/Madrid
37
+ 130,PA133,JOSE HIERRO 10,Parada,43.4594847354251,-3.82642371027838,,,,,Europe/Madrid
38
+ 131,PA134,LOS PINARES,Parada,43.471390090196,-3.7877614643787,,,,,Europe/Madrid
39
+ 133,PA136,PASEO DE ALTAMIRA 28,Parada,43.4686154673231,-3.79287101284989,,,,,Europe/Madrid
40
+ 134,PA137,PASEO DE ALTAMIRA 58,Parada,43.4685837762071,-3.7977379893668,,,,,Europe/Madrid
41
+ 135,PA138,PASEO DE ALTAMIRA 84,Parada,43.4676743729047,-3.8019555747684,,,,,Europe/Madrid
42
+ 136,PA139,PASEO DE ALTAMIRA 122,Parada,43.4674532440932,-3.80559355392471,,,,,Europe/Madrid
43
+ 14,PA14,CORREOS,Parada,43.4614823957,-3.80653011145617,,,,,Europe/Madrid
44
+ 137,PA140,LOS SALESIANOS,Parada,43.4675874471235,-3.80932910373885,,,,,Europe/Madrid
45
+ 138,PA141,PASEO DE ALTAMIRA 224,Parada,43.4673384590671,-3.81473682082279,,,,,Europe/Madrid
46
+ 139,PA142,PASEO DE ALTAMIRA 256,Parada,43.4654361606394,-3.81772813699138,,,,,Europe/Madrid
47
+ 140,PA143,PASEO DE ALTAMIRA 266,Parada,43.4643419018453,-3.82032421432836,,,,,Europe/Madrid
48
+ 141,PA144,COLEGIO LA SALLE,Parada,43.4624561585847,-3.8238619575228,,,,,Europe/Madrid
49
+ 142,PA145,JOSE HIERRO 19,Parada,43.4604061435979,-3.82581594595342,,,,,Europe/Madrid
50
+ 143,PA146,CATEDRAL,Parada,43.4614250526855,-3.80724262100708,,,,,Europe/Madrid
51
+ 146,PA149,MENENDEZ PELAYO 14,Parada,43.4657017223253,-3.79808139223909,,,,,Europe/Madrid
52
+ 15,PA15,JARDINES DE PEREDA,Parada,43.4617267613344,-3.80353646845979,,,,,Europe/Madrid
53
+ 147,PA150,MENENDEZ PELAYO 46,Parada,43.4667049715542,-3.79555165572053,,,,,Europe/Madrid
54
+ 148,PA151,MENENDEZ PELAYO 76,Parada,43.4671475465413,-3.79249220675055,,,,,Europe/Madrid
55
+ 149,PA152,BAJADA DE LA ENCINA S/N,Parada,43.4707630715103,-3.78912928257658,,,,,Europe/Madrid
56
+ 150,PA153,COMPLEJO DEPORTIVO,Parada,43.4602087594312,-3.85267179932964,,,,,Europe/Madrid
57
+ 151,PA154,AVENIDA DEL DEPORTE 11,Parada,43.4613667190941,-3.84717137239985,,,,,Europe/Madrid
58
+ 152,PA155,LAVAPIES 1,Parada,43.4626994625506,-3.83889751016233,,,,,Europe/Madrid
59
+ 153,PA156,CALLE REPUENTE 43,Parada,43.4640869279286,-3.83493860027926,,,,,Europe/Madrid
60
+ 154,PA157,CALLE REPUENTE 26,Parada,43.4671122353559,-3.83196315715773,,,,,Europe/Madrid
61
+ 155,PA158,CALLE REPUENTE 15,Parada,43.46918793866,-3.8282166937927,,,,,Europe/Madrid
62
+ 156,PA159,BARRIO LA TORRE 95,Parada,43.4714991606583,-3.82114062648601,,,,,Europe/Madrid
63
+ 16,PA16,PUERTO CHICO,Parada,43.4621794921801,-3.79741106347385,,,,,Europe/Madrid
64
+ 157,PA160,BARRIO LA TORRE 1,Parada,43.4738398841223,-3.81528526705468,,,,,Europe/Madrid
65
+ 158,PA161,JORGE SEPULVEDA 11,Parada,43.4742986025426,-3.81156862881098,,,,,Europe/Madrid
66
+ 159,PA162,AVENIDA CANTABRIA 43,Parada,43.4751237424502,-3.80905605470708,,,,,Europe/Madrid
67
+ 160,PA163,PADRE MENNI,Parada,43.4763196471126,-3.80303363302839,,,,,Europe/Madrid
68
+ 161,PA164,AVENIDA CANTABRIA 11,Parada,43.4775564670184,-3.79755458610791,,,,,Europe/Madrid
69
+ 163,PA167,MANUEL GONZALEZ HOYOS 39,Parada,43.4807082871033,-3.79577564210222,,,,,Europe/Madrid
70
+ 164,PA168,VALDENOJA 25,Parada,43.4808930736711,-3.79274105426077,,,,,Europe/Madrid
71
+ 165,PA169,VALDENOJA 3,Parada,43.4811720017571,-3.78993570707354,,,,,Europe/Madrid
72
+ 17,PA17,CASTELAR,Parada,43.4627488279462,-3.79292037646895,,,,,Europe/Madrid
73
+ 167,PA171,ALCALDE VEGA LAMERA 1,Parada,43.4731789406836,-3.79343630829962,,,,,Europe/Madrid
74
+ 168,PA172,JOSE HIERRO 9,Parada,43.4595818856838,-3.82654915173831,,,,,Europe/Madrid
75
+ 169,PA173,GRUPO SAN FRANCISCO,Parada,43.4630464130455,-3.82487302573295,,,,,Europe/Madrid
76
+ 170,PA174,PASEO DE ALTAMIRA 314,Parada,43.4617601311828,-3.82944341397276,,,,,Europe/Madrid
77
+ 171,PA175,FACULTAD DE MEDICINA,Parada,43.4596742222992,-3.83457235466751,,,,,Europe/Madrid
78
+ 172,PA176,CARDENAL HERRERA ORIA 26,Parada,43.4588974095354,-3.83894435969918,,,,,Europe/Madrid
79
+ 173,PA177,CARDENAL HERRERA ORIA 42,Parada,43.4586036798906,-3.84340454913237,,,,,Europe/Madrid
80
+ 174,PA178,CARDENAL HERRERA ORIA 76,Parada,43.4577348267658,-3.84651538042628,,,,,Europe/Madrid
81
+ 175,PA179,RESIDENCIA SANTA LUCIA,Parada,43.4562303034807,-3.85151063298984,,,,,Europe/Madrid
82
+ 18,PA18,REINA VICTORIA 18,Parada,43.4634398241484,-3.78832006473095,,,,,Europe/Madrid
83
+ 176,PA180,CARDENAL HERRERA ORIA 130,Parada,43.4550472743475,-3.85543210604103,,,,,Europe/Madrid
84
+ 177,PA181,AVENIDA DEL DEPORTE 15,Parada,43.4595958490693,-3.85577727708927,,,,,Europe/Madrid
85
+ 178,PA182,COMPLEJO RUTH BEITIA,Parada,43.4602479564986,-3.85357312820778,,,,,Europe/Madrid
86
+ 179,PA183,POLIDEPORTIVO,Parada,43.4596711662686,-3.85604844292869,,,,,Europe/Madrid
87
+ 180,PA184,CRUCE VICENTE TRUEBA,Parada,43.4558879811215,-3.85638830302217,,,,,Europe/Madrid
88
+ 181,PA185,CARDENAL HERRERA ORIA 95,Parada,43.4562809722381,-3.85070356319127,,,,,Europe/Madrid
89
+ 182,PA186,CARDENAL HERRERA ORIA 51,Parada,43.4578134690333,-3.84598754771547,,,,,Europe/Madrid
90
+ 183,PA187,CARDENAL HERRERA ORIA 31,Parada,43.4585234411853,-3.8432704302976,,,,,Europe/Madrid
91
+ 184,PA188,CARDENAL HERRERA ORIA 23,Parada,43.4588157358959,-3.83880874294457,,,,,Europe/Madrid
92
+ 185,PA189,CARDENAL HERRERA ORIA 17,Parada,43.4590374667873,-3.83607204901029,,,,,Europe/Madrid
93
+ 19,PA19,AVENIDA REINA VICTORIA,Parada,43.4645272222598,-3.78281785697395,,,,,Europe/Madrid
94
+ 186,PA190,CARDENAL HERRERA ORIA,Parada,43.4597797627929,-3.83399096458054,,,,,Europe/Madrid
95
+ 187,PA191,PASEO DE ALTAMIRA 125,Parada,43.4618085537798,-3.82889990640637,,,,,Europe/Madrid
96
+ 188,PA192,PASEO DE ALTAMIRA 117,Parada,43.4629113926715,-3.82495426126731,,,,,Europe/Madrid
97
+ 190,PA194,INSTITUTO LAS LLAMAS,Parada,43.4732789396835,-3.79329914055971,,,,,Europe/Madrid
98
+ 191,PA195,PALACIO DE EXPOSICIONES,Parada,43.4757106160868,-3.79166813367462,,,,,Europe/Madrid
99
+ 193,PA197,VALDENOJA 32,Parada,43.4813288301357,-3.78974398185534,,,,,Europe/Madrid
100
+ 194,PA198,VALDENOJA 50,Parada,43.4809760396086,-3.79319151389859,,,,,Europe/Madrid
101
+ 195,PA199,MANUEL GONZALEZ HOYOS 7,Parada,43.4807313562557,-3.79588945976864,,,,,Europe/Madrid
102
+ 2,PA2,LOS CIRUELOS 27,Parada,43.4582276304313,-3.8524726489669,,,,,Europe/Madrid
103
+ 20,PA20,AVENIDA REINA VICTORIA 38,Parada,43.4663164713884,-3.77941016312866,,,,,Europe/Madrid
104
+ 196,PA200,CONSUELO BERGÉS 16,Parada,43.4795447472921,-3.79817673088218,,,,,Europe/Madrid
105
+ 197,PA201,CONCHA ESPINA 18,Parada,43.479168866962,-3.79721209973858,,,,,Europe/Madrid
106
+ 198,PA203,PADRE MENNI,Parada,43.4764827807206,-3.80260104220408,,,,,Europe/Madrid
107
+ 199,PA205,AVENIDA CANTABRIA 100,Parada,43.4753447240692,-3.80883865789799,,,,,Europe/Madrid
108
+ 200,PA206,BARRIO LA TORRE 2,Parada,43.4738108897242,-3.8156514833982,,,,,Europe/Madrid
109
+ 201,PA207,BARRIO LA TORRE 60,Parada,43.4714960901312,-3.82138007956667,,,,,Europe/Madrid
110
+ 202,PA208,CALLE REPUENTE 16,Parada,43.4691434023217,-3.82844802950164,,,,,Europe/Madrid
111
+ 203,PA209,CALLE REPUENTE 19,Parada,43.4673029484697,-3.83216209280771,,,,,Europe/Madrid
112
+ 21,PA21,LA MAGDALENA,Parada,43.4687262484862,-3.77644957676557,,,,,Europe/Madrid
113
+ 204,PA210,CALLE REPUENTE 36,Parada,43.4642951675794,-3.83473304144922,,,,,Europe/Madrid
114
+ 205,PA211,LAVAPIES 34,Parada,43.4627196233941,-3.84043265161535,,,,,Europe/Madrid
115
+ 206,PA212,AVENIDA DEL DEPORTE 6,Parada,43.4612905163792,-3.84794142993128,,,,,Europe/Madrid
116
+ 207,PA213,AVENIDA CANTABRIA 10,Parada,43.4790364609833,-3.79315281013775,,,,,Europe/Madrid
117
+ 208,PA214,GUTIERREZ SOLANA 13,Parada,43.4598357026417,-3.83870762546173,,,,,Europe/Madrid
118
+ 209,PA215,AVENIDA LOS CASTROS,Parada,43.4620337879232,-3.83406333648598,,,,,Europe/Madrid
119
+ 210,PA216,AVENIDA LOS CASTROS 155,Parada,43.4644267251748,-3.82914605910189,,,,,Europe/Madrid
120
+ 211,PA217,AVENIDA LOS CASTROS 139,Parada,43.46599281024,-3.82355773446237,,,,,Europe/Madrid
121
+ 212,PA218,AVENIDA LOS CASTROS 119,Parada,43.4679615896012,-3.81738668620196,,,,,Europe/Madrid
122
+ 213,PA219,AVENIDA LOS CASTROS 83,Parada,43.4697964813046,-3.80767872749989,,,,,Europe/Madrid
123
+ 22,PA22,LUIS MARTINEZ,Parada,43.4710929658344,-3.77858290562545,,,,,Europe/Madrid
124
+ 214,PA220,TORRES QUEVEDO 12,Parada,43.4569237399302,-3.83547954047646,,,,,Europe/Madrid
125
+ 215,PA221,AVENIDA CANTABRIA 12,Parada,43.4790276429021,-3.79372545892564,,,,,Europe/Madrid
126
+ 216,PA223,GERARDO DIEGO,Parada,43.4558164385288,-3.84591447880765,,,,,Europe/Madrid
127
+ 217,PA224,JOAQUIN BUSTAMANTE 10,Parada,43.4565499751617,-3.83979696615997,,,,,Europe/Madrid
128
+ 218,PA225,JOAQUIN BUSTAMANTE 5,Parada,43.4565424914924,-3.83584993960173,,,,,Europe/Madrid
129
+ 219,PA226,LOS CASTROS 62,Parada,43.4698129315964,-3.8087666879077,,,,,Europe/Madrid
130
+ 220,PA227,AVENIDA LOS CASTROS 115,Parada,43.4680764595134,-3.81746574938437,,,,,Europe/Madrid
131
+ 221,PA228,BAJADA DE SAN JUAN,Parada,43.4660004819531,-3.82395518000018,,,,,Europe/Madrid
132
+ 222,PA229,BAJADA DEL CALERUCO,Parada,43.464276875972,-3.83010529972449,,,,,Europe/Madrid
133
+ 23,PA23,PLAZA DE ITALIA 3,Parada,43.4721308522484,-3.78214427885009,,,,,Europe/Madrid
134
+ 223,PA230,LOS CASTROS 136,Parada,43.4621227824407,-3.83419596437341,,,,,Europe/Madrid
135
+ 224,PA231,GUTIERREZ SOLANA 34,Parada,43.4599601458889,-3.83891701408624,,,,,Europe/Madrid
136
+ 226,PA234,BARRIO HOSPITALILLO 40,Parada,43.4540841536187,-3.86025586724053,,,,,Europe/Madrid
137
+ 227,PA235,ADARZO,Parada,43.4531679620636,-3.86556096605062,,,,,Europe/Madrid
138
+ 228,PA236,RUCANDIAL 28,Parada,43.4575465905675,-3.86911004913654,,,,,Europe/Madrid
139
+ 231,PA239,PLAZA AMADOR TOCA,Parada,43.4529252290187,-3.86560545992211,,,,,Europe/Madrid
140
+ 24,PA24,PARQUE PIQUIO,Parada,43.4742918403958,-3.78511458124276,,,,,Europe/Madrid
141
+ 232,PA240,BARRIO HOSPITALILLO 97,Parada,43.4540015223164,-3.86018028870079,,,,,Europe/Madrid
142
+ 233,PA241,CARDENAL HERRERA ORIA 119,Parada,43.4549907793605,-3.85502786044527,,,,,Europe/Madrid
143
+ 234,PA244,INÉS DIEGO DEL NOVAL 50,Parada,43.4841265599562,-3.79889327741975,,,,,Europe/Madrid
144
+ 235,PA245,INÉS DIEGO DEL NOVAL 108,Parada,43.4821203689433,-3.80267337271866,,,,,Europe/Madrid
145
+ 236,PA246,INÉS DIEGO DEL NOVAL 152,Parada,43.4808946645051,-3.80542969729909,,,,,Europe/Madrid
146
+ 237,PA247,HERMANOS TONETTI 8,Parada,43.4828555112216,-3.80637340814649,,,,,Europe/Madrid
147
+ 238,PA248,HERMANOS TONETTI 16,Parada,43.4850837454485,-3.8071174393841,,,,,Europe/Madrid
148
+ 239,PA249,CALLE ARRIBA 85,Parada,43.4789257492991,-3.80939695722868,,,,,Europe/Madrid
149
+ 25,PA25,DOCTOR FLEMING,Parada,43.4756430358505,-3.78820689598774,,,,,Europe/Madrid
150
+ 240,PA250,CALLE ARRIBA 31,Parada,43.478070806657,-3.81221260798722,,,,,Europe/Madrid
151
+ 241,PA251,RESIDENCIA MAYORES DE CUETO,Parada,43.476856643692,-3.8120462496972,,,,,Europe/Madrid
152
+ 242,PA252,CRUCE DE POLIO,Parada,43.4750953688282,-3.8105081478848,,,,,Europe/Madrid
153
+ 244,PA254,INÉS DIEGO DEL NOVAL 61,Parada,43.4808973644216,-3.80518698629473,,,,,Europe/Madrid
154
+ 245,PA255,INÉS DIEGO DEL NOVAL 55,Parada,43.4820632064633,-3.80263279825868,,,,,Europe/Madrid
155
+ 246,PA256,INÉS DIEGO DEL NOVAL 25,Parada,43.484130977366,-3.79869918711736,,,,,Europe/Madrid
156
+ 26,PA26,LOS CASTROS 95,Parada,43.4690556461798,-3.8129867136293,,,,,Europe/Madrid
157
+ 248,PA261,CASIMIRO SAINZ 6,Parada,43.4641854337078,-3.7966350946193,,,,,Europe/Madrid
158
+ 249,PA262,ESCOLAPIOS,Parada,43.4644887369753,-3.79485444163084,,,,,Europe/Madrid
159
+ 250,PA263,CANALEJAS 26,Parada,43.4648568628653,-3.79125400966089,,,,,Europe/Madrid
160
+ 251,PA264,CANALEJAS 42,Parada,43.4650664069891,-3.78965642343758,,,,,Europe/Madrid
161
+ 252,PA265,PEREZ GALDÓS 4,Parada,43.4667426293496,-3.7863863798326,,,,,Europe/Madrid
162
+ 253,PA266,PEREZ GALDÓS 18,Parada,43.4664414104567,-3.78392737275812,,,,,Europe/Madrid
163
+ 254,PA267,PEREZ GALDÓS 36,Parada,43.4673104323219,-3.78005416302407,,,,,Europe/Madrid
164
+ 255,PA268,MERCADO MEJICO,Parada,43.4581230263184,-3.82521876975588,,,,,Europe/Madrid
165
+ 256,PA269,CALLE ALTA 109,Parada,43.45839110343,-3.82175040089812,,,,,Europe/Madrid
166
+ 257,PA270,CALLE ALTA 81,Parada,43.4588215638876,-3.81779667398707,,,,,Europe/Madrid
167
+ 258,PA271,CALLE ALTA 45,Parada,43.4596593548805,-3.81524542469063,,,,,Europe/Madrid
168
+ 259,PA272,MONTE CALOCA,Parada,43.4608342730004,-3.81235843529524,,,,,Europe/Madrid
169
+ 260,PA273,ESTACIONES,Parada,43.4593350508244,-3.81063857613249,,,,,Europe/Madrid
170
+ 261,PA274,AVENIDA EL FARO 20,Parada,43.4812610782138,-3.78896455104923,,,,,Europe/Madrid
171
+ 265,PA280,MUTUA MONTAÑESA,Parada,43.4817699900265,-3.78850918008642,,,,,Europe/Madrid
172
+ 266,PA281,PASEO DE ALTAMIRA 15,Parada,43.468463995661,-3.79163272509664,,,,,Europe/Madrid
173
+ 29,PA29,PLAZA DOCTOR FLEMING,Parada,43.4757194052145,-3.78891266137255,,,,,Europe/Madrid
174
+ 268,PA290,AVENIDA CANTABRIA 28,Parada,43.4779835779547,-3.7965817329675,,,,,Europe/Madrid
175
+ 270,PA293,AVENIDA PARAYAS 14,Parada,43.4492551181044,-3.83090134980203,,,,,Europe/Madrid
176
+ 271,PA294,AVENIDA PARAYAS 28,Parada,43.4469699003364,-3.83259229565344,,,,,Europe/Madrid
177
+ 272,PA295,AVENIDA PARAYAS 32,Parada,43.4449163393099,-3.83409919683719,,,,,Europe/Madrid
178
+ 273,PA296,ABILIO GARCIA BARON 1,Parada,43.4428706674346,-3.83752925302995,,,,,Europe/Madrid
179
+ 274,PA297,AUTONOMIA 8,Parada,43.4814493565375,-3.79541216296859,,,,,Europe/Madrid
180
+ 275,PA298,FRANCISCO TOMAS Y VALIENTE 7,Parada,43.4394001454439,-3.84098609006235,,,,,Europe/Madrid
181
+ 276,PA299,BARTOLOMÉ DARNIS,Parada,43.4400181110233,-3.84272801196556,,,,,Europe/Madrid
182
+ 3,PA3,JOSE MARIA COSSIO 54,Parada,43.4589971199811,-3.84849236072325,,,,,Europe/Madrid
183
+ 30,PA30,PIQUIO,Parada,43.4736352493327,-3.78459742536888,,,,,Europe/Madrid
184
+ 277,PA300,COLEGIO NUEVA MONTAÑA,Parada,43.4393427548805,-3.84475785406979,,,,,Europe/Madrid
185
+ 278,PA301,SANTIAGO EL MAYOR 10,Parada,43.4397253940847,-3.84727590566982,,,,,Europe/Madrid
186
+ 279,PA302,SAN MARTÍN DEL PINO 24,Parada,43.4445708027919,-3.85205100694995,,,,,Europe/Madrid
187
+ 280,PA303,SAN MARTÍN DEL PINO 23,Parada,43.4456647117685,-3.84832589998866,,,,,Europe/Madrid
188
+ 281,PA304,INSTITUTOS,Parada,43.4468305849545,-3.85142223753078,,,,,Europe/Madrid
189
+ 282,PA305,CARREFOUR PEÑACASTILLO,Parada,43.4452004003529,-3.85354853804297,,,,,Europe/Madrid
190
+ 283,PA306,FRANCISCO RIVAS MORENO,Parada,43.4468083944068,-3.85104054199511,,,,,Europe/Madrid
191
+ 284,PA307,NUEVO PARQUE,Parada,43.4458709785244,-3.8483774744796,,,,,Europe/Madrid
192
+ 285,PA308,SAN MARTÍN DEL PINO 13,Parada,43.4437590986191,-3.85249069018376,,,,,Europe/Madrid
193
+ 286,PA309,SANTIAGO EL MAYOR,Parada,43.439806514729,-3.8476670403417,,,,,Europe/Madrid
194
+ 31,PA31,PLAZA DE ITALIA,Parada,43.4718887035753,-3.78200021029465,,,,,Europe/Madrid
195
+ 287,PA310,FRANCISCO TOMÁS Y VALIENTE 23,Parada,43.439095278854,-3.84422514984006,,,,,Europe/Madrid
196
+ 288,PA311,FRANCISCO TOMÁS Y VALIENTE 11,Parada,43.4392458250639,-3.84223326928483,,,,,Europe/Madrid
197
+ 289,PA312,HERMANOS TONETTI 6,Parada,43.4827260399488,-3.80647408770083,,,,,Europe/Madrid
198
+ 32,PA32,REINA VICTORIA 129,Parada,43.4709936475781,-3.77866236409666,,,,,Europe/Madrid
199
+ 292,PA321,MARQUES DE LA HERMIDA 15,Parada,43.4548331609319,-3.81547606679072,,,,,Europe/Madrid
200
+ 295,PA324,PLAZA DE SAN MARTIN,Parada,43.4637532111919,-3.78780516823318,,,,,Europe/Madrid
201
+ 296,PA326,JOSE MARIA GONZALEZ TREVILLA 4,Parada,43.481871974146,-3.79617264526541,,,,,Europe/Madrid
202
+ 298,PA328,ARSENIO ODRIOZOLA 16,Parada,43.483701326269,-3.79289597102978,,,,,Europe/Madrid
203
+ 33,PA33,LA MAGDALENA,Parada,43.4690235105157,-3.77701911622299,,,,,Europe/Madrid
204
+ 299,PA330,DOCTOR DIEGO MADRAZO,Parada,43.4825944704407,-3.79591760373235,,,,,Europe/Madrid
205
+ 300,PA331,CORBAN,Parada,43.4648061891114,-3.86742808576968,,,,,Europe/Madrid
206
+ 301,PA332,CRUCE CON RUCANDIAL,Parada,43.4628644592016,-3.86597969104836,,,,,Europe/Madrid
207
+ 302,PA333,CONSUELO BERGÉS 22,Parada,43.4785422440693,-3.80141450437114,,,,,Europe/Madrid
208
+ 303,PA334,JULIO JAURENA ,Parada,43.4629858608501,-3.86592900988451,,,,,Europe/Madrid
209
+ 304,PA335,AUTONOMIA 9,Parada,43.4825958600313,-3.79209727138814,,,,,Europe/Madrid
210
+ 305,PA336,AUTONOMIA,Parada,43.4825520302991,-3.79264253867287,,,,,Europe/Madrid
211
+ 306,PA337,JOSE MARIA GONZALEZ TREVILLA,Parada,43.4838186253368,-3.79679857207913,,,,,Europe/Madrid
212
+ 307,PA338,GLORIETA DE ADARZO,Parada,43.4545356646387,-3.86554894351014,,,,,Europe/Madrid
213
+ 308,PA339,BARRIO LA TORRE 123,Parada,43.4699971631456,-3.82427795435541,,,,,Europe/Madrid
214
+ 34,PA34,GONZALEZ DE RIANCHO,Parada,43.4659393691515,-3.78028442332358,,,,,Europe/Madrid
215
+ 309,PA340,BARRIO LA TORRE 76,Parada,43.4698401969132,-3.82475416679008,,,,,Europe/Madrid
216
+ 310,PA341,DECATHLON,Parada,43.455250403206,-3.86404248638163,,,,,Europe/Madrid
217
+ 311,PA342,CARREFOUR ALISAL,Parada,43.4562769744994,-3.8613263575244,,,,,Europe/Madrid
218
+ 312,PA343,JOAQUIN RODRIGO,Parada,43.4562109963297,-3.86113196767049,,,,,Europe/Madrid
219
+ 313,PA345,CUESTA LA ATALAYA 32,Parada,43.4654420929829,-3.80766184020256,,,,,Europe/Madrid
220
+ 314,PA346,MARIA CRISTINA,Parada,43.4662176026056,-3.80853644011791,,,,,Europe/Madrid
221
+ 315,PA347,VIA CORNELIA,Parada,43.4663555156704,-3.81299073912499,,,,,Europe/Madrid
222
+ 316,PA348,JUAN 23 NUMERO 2,Parada,43.4660025858023,-3.81198165891434,,,,,Europe/Madrid
223
+ 35,PA35,REINA VICTORIA 79,Parada,43.4643871707779,-3.78374694236764,,,,,Europe/Madrid
224
+ 318,PA350,CALLE EL MONTE 30,Parada,43.4647453372469,-3.81656702685798,,,,,Europe/Madrid
225
+ 319,PA351,FRANCISCO PALAZUELOS 13,Parada,43.4661862753964,-3.79935691278352,,,,,Europe/Madrid
226
+ 320,PA352,CAMARREAL 135,Parada,43.4444367277555,-3.8752822022717,,,,,Europe/Madrid
227
+ 321,PA353,CAMARREAL 136,Parada,43.4442654651638,-3.87562329791023,,,,,Europe/Madrid
228
+ 322,PA354,FRANCISCO CACERES,Parada,43.477861272355,-3.79717997070447,,,,,Europe/Madrid
229
+ 323,PA355,OJAIZ,Parada,43.4417726780897,-3.88047216501227,,,,,Europe/Madrid
230
+ 324,PA356,PLAZA DE LAS ESTACIONES,Parada,43.4591675760181,-3.81090321376792,,,,,Europe/Madrid
231
+ 327,PA359,PRIMERO DE MAYO 34,Parada,43.4434358462366,-3.85727049009976,,,,,Europe/Madrid
232
+ 36,PA36,SAN MARTIN,Parada,43.4635786630664,-3.78825585603695,,,,,Europe/Madrid
233
+ 329,PA361,RICARDO LOPEZ ARANDA 22,Parada,43.4410256857036,-3.86279867455001,,,,,Europe/Madrid
234
+ 330,PA362,PRIMERO DE MAYO 9,Parada,43.4434422791554,-3.85695284839424,,,,,Europe/Madrid
235
+ 332,PA364,PEDRO SAN MARTIN 12,Parada,43.4605170909995,-3.82926399210909,,,,,Europe/Madrid
236
+ 333,PA365,CALERUCO,Parada,43.462094425948,-3.8305051649941,,,,,Europe/Madrid
237
+ 335,PA367,GRUPO ATECA,Parada,43.4697654062749,-3.8266346330138,,,,,Europe/Madrid
238
+ 336,PA368,IGLESIA,Parada,43.4716736099347,-3.83046992930636,,,,,Europe/Madrid
239
+ 337,PA369,SAN PEDRO DEL MAR 64,Parada,43.4735647658029,-3.83112390520699,,,,,Europe/Madrid
240
+ 37,PA37,CASTELAR 29,Parada,43.4629523625136,-3.79305914045617,,,,,Europe/Madrid
241
+ 338,PA370,CORBANERA 95,Parada,43.4764066075481,-3.83160461715894,,,,,Europe/Madrid
242
+ 339,PA371,EL CASTILLO,Parada,43.477406477731,-3.83465839437778,,,,,Europe/Madrid
243
+ 340,PA372,CORBANERA 162,Parada,43.4766034771749,-3.83604841166022,,,,,Europe/Madrid
244
+ 341,PA373,EL PARQUE,Parada,43.473335289669,-3.83320883186837,,,,,Europe/Madrid
245
+ 342,PA374,SAN PEDRO DEL MAR 91,Parada,43.4735245497666,-3.83136706042645,,,,,Europe/Madrid
246
+ 343,PA375,BARRIO BOLADO 23,Parada,43.4734575677743,-3.8287088169145,,,,,Europe/Madrid
247
+ 344,PA376,BARRIO BOLADO 37,Parada,43.4739377046032,-3.82673967501052,,,,,Europe/Madrid
248
+ 345,PA377,BARRIO BOLADO 58,Parada,43.4745673217105,-3.82546457119698,,,,,Europe/Madrid
249
+ 346,PA378,CRUCE AVICHE,Parada,43.4755034754695,-3.82214329576031,,,,,Europe/Madrid
250
+ 347,PA379,AVICHE 37,Parada,43.47282372707,-3.82307065328326,,,,,Europe/Madrid
251
+ 38,PA38,CASTELAR 1,Parada,43.4631695135272,-3.79625114489291,,,,,Europe/Madrid
252
+ 348,PA380,CANTEROS DE TRASMIERA 2,Parada,43.471165809806,-3.82504740500348,,,,,Europe/Madrid
253
+ 349,PA381,CALLE REPUENTE,Parada,43.4679107059954,-3.83038308652781,,,,,Europe/Madrid
254
+ 350,PA382,CALERUCO 3,Parada,43.4623709961287,-3.83082207310368,,,,,Europe/Madrid
255
+ 351,PA383,CORBANERA 53,Parada,43.4783137279772,-3.82950047716986,,,,,Europe/Madrid
256
+ 352,PA384,CORBANERA,Parada,43.479492303054,-3.82701628444093,,,,,Europe/Madrid
257
+ 353,PA385,CORBANERA 57,Parada,43.478437110576,-3.82941029332153,,,,,Europe/Madrid
258
+ 354,PA386,CORBANERA 93,Parada,43.4763120050059,-3.83178583282105,,,,,Europe/Madrid
259
+ 355,PA387,SAN PEDRO DEL MAR 51,Parada,43.4713845481523,-3.83031080862172,,,,,Europe/Madrid
260
+ 356,PA388,VIRGEN DEL MAR 18,Parada,43.4683072184823,-3.8702605865064,,,,,Europe/Madrid
261
+ 357,PA389,VIRGEN DEL MAR 37,Parada,43.4691711239441,-3.87058972428273,,,,,Europe/Madrid
262
+ 39,PA39,PASEO DE PEREDA,Parada,43.4620618834338,-3.80162765789247,,,,,Europe/Madrid
263
+ 358,PA390,LA ALBERICIA 18,Parada,43.4619663482906,-3.83549489880529,,,,,Europe/Madrid
264
+ 360,PA393,AVENIDA DEL DEPORTE 2,Parada,43.4620263487846,-3.84447257017362,,,,,Europe/Madrid
265
+ 361,PA394,INSTITUTO ALBERICIA,Parada,43.4611095835658,-3.84878370701347,,,,,Europe/Madrid
266
+ 362,PA395,CASA DEL DEPORTE,Parada,43.4606634368548,-3.8509015662423,,,,,Europe/Madrid
267
+ 363,PA396,CORCEÑO,Parada,43.4626014783748,-3.85690491208748,,,,,Europe/Madrid
268
+ 364,PA397,BARRIO EL SOMO,Parada,43.4656044028786,-3.85401659911379,,,,,Europe/Madrid
269
+ 365,PA398,AMBULATORIO,Parada,43.4661101291899,-3.85659863480881,,,,,Europe/Madrid
270
+ 366,PA399,BARRIO EL SOMO 82,Parada,43.4665377009843,-3.85906587107027,,,,,Europe/Madrid
271
+ 4,PA4,JOSE MARIA COSSIO 33,Parada,43.4594039079168,-3.84621045531486,,,,,Europe/Madrid
272
+ 40,PA40,CORREOS,Parada,43.461586284304,-3.8070180554901,,,,,Europe/Madrid
273
+ 367,PA400,BARRIO EL SOMO 118,Parada,43.4659477306817,-3.86386786040264,,,,,Europe/Madrid
274
+ 368,PA401,CORBAN 2,Parada,43.4658147043635,-3.86766453612096,,,,,Europe/Madrid
275
+ 369,PA402,CIRIEGO,Parada,43.4717418305777,-3.8697485132745,,,,,Europe/Madrid
276
+ 370,PA403,CORBAN,Parada,43.4651662033829,-3.86768740207848,,,,,Europe/Madrid
277
+ 371,PA404,EL MAZO 30,Parada,43.4679707941334,-3.86551981516366,,,,,Europe/Madrid
278
+ 372,PA405,EL MAZO 2,Parada,43.4692314976257,-3.85997387982827,,,,,Europe/Madrid
279
+ 373,PA406,BARRIO EL SOMO 55,Parada,43.4664769308924,-3.85913658950547,,,,,Europe/Madrid
280
+ 374,PA407,BARRIO EL SOMO 37,Parada,43.4660687872555,-3.85666035776502,,,,,Europe/Madrid
281
+ 375,PA408,EL SOMO,Parada,43.4655283284939,-3.85390649220254,,,,,Europe/Madrid
282
+ 376,PA409,CORCEÑO 69,Parada,43.4622106975426,-3.85707110832907,,,,,Europe/Madrid
283
+ 41,PA41,PLAZA AYUNTAMIENTO,Parada,43.461676344705,-3.81017720129389,,,,,Europe/Madrid
284
+ 377,PA410,AVENIDA DEL DEPORTE 9,Parada,43.4607778179876,-3.85013538058959,,,,,Europe/Madrid
285
+ 378,PA411,AVENIDA DEL DEPORTE 3,Parada,43.4619169934569,-3.84464906753743,,,,,Europe/Madrid
286
+ 379,PA412,LA CAVADUCA,Parada,43.462543661705,-3.8421984864839,,,,,Europe/Madrid
287
+ 380,PA413,LA ALBERICIA 1,Parada,43.4618212447229,-3.83546397437353,,,,,Europe/Madrid
288
+ 381,PA414,LA GLORIA 8,Parada,43.4630810515708,-3.83928217198746,,,,,Europe/Madrid
289
+ 382,PA415,LA GLORIA 60,Parada,43.4643542452569,-3.84129975321356,,,,,Europe/Madrid
290
+ 383,PA416,LA GLORIA 120,Parada,43.4655500824443,-3.84407034857136,,,,,Europe/Madrid
291
+ 384,PA417,LA GLORIA 144,Parada,43.4654106599076,-3.84709725163737,,,,,Europe/Madrid
292
+ 385,PA418,LA GLORIA 234,Parada,43.4654611121416,-3.8499836334989,,,,,Europe/Madrid
293
+ 42,PA42,JESUS DE MONASTERIO 12,Parada,43.4618409362494,-3.81282101148866,,,,,Europe/Madrid
294
+ 386,PA420,LA GLORIA 179,Parada,43.4654139429291,-3.85108460378396,,,,,Europe/Madrid
295
+ 387,PA421,LA GLORIA 157,Parada,43.4653296295831,-3.84709612008427,,,,,Europe/Madrid
296
+ 388,PA422,LA GLORIA 123,Parada,43.4654687783052,-3.84410630424926,,,,,Europe/Madrid
297
+ 389,PA423,LA GLORIA 43,Parada,43.4638673779052,-3.8408205252234,,,,,Europe/Madrid
298
+ 390,PA424,LA GLORIA 1,Parada,43.4630739670259,-3.83949902438931,,,,,Europe/Madrid
299
+ 394,PA428,INSTITUTOS PEÑACASTILLO,Parada,43.4465876612644,-3.85242358740531,,,,,Europe/Madrid
300
+ 395,PA429,CALLE ALTA 46,Parada,43.4596932782695,-3.81546456183005,,,,,Europe/Madrid
301
+ 43,PA43,SAN FERNANDO 22,Parada,43.4604212625301,-3.81986739529346,,,,,Europe/Madrid
302
+ 396,PA430,CALLE ALTA 56,Parada,43.4590074277368,-3.81751758270069,,,,,Europe/Madrid
303
+ 397,PA431,CALLE ALTA 80,Parada,43.4584920717696,-3.82167105156775,,,,,Europe/Madrid
304
+ 398,PA432,CALLE ARGENTINA 7,Parada,43.4572892645479,-3.82355996322701,,,,,Europe/Madrid
305
+ 399,PA433,PLAZA DE TOROS,Parada,43.4567534235799,-3.82669235968599,,,,,Europe/Madrid
306
+ 400,PA434,CALLE ALTA 28,Parada,43.460311331633,-3.81240249785165,,,,,Europe/Madrid
307
+ 402,PA436,PEDRO SAN MARTIN 8,Parada,43.4588514581089,-3.82799251980776,,,,,Europe/Madrid
308
+ 403,PA437,EMILIO DIAZ CANEJA 2,Parada,43.4608502825807,-3.83293143783027,,,,,Europe/Madrid
309
+ 404,PA438,CUESTA LA ATALAYA 2,Parada,43.4639046627338,-3.80780061859014,,,,,Europe/Madrid
310
+ 44,PA44,SAN FERNANDO 66,Parada,43.4592421279873,-3.82426744137884,,,,,Europe/Madrid
311
+ 406,PA440,AVENIDA VICENTE TRUEBA 8,Parada,43.4558475256162,-3.85622346273512,,,,,Europe/Madrid
312
+ 407,PA441,EMILIO DIAZ CANEJA,Parada,43.460868133141,-3.83323861642279,,,,,Europe/Madrid
313
+ 408,PA442,PASEO DE ALTAMIRA 208,Parada,43.467652445223,-3.81271027786179,,,,,Europe/Madrid
314
+ 409,PA443,EL MAZO,Parada,43.4666841504528,-3.86817815177243,,,,,Europe/Madrid
315
+ 410,PA444,PASEO DE PEREDA 35,Parada,43.4624092988485,-3.79809399093298,,,,,Europe/Madrid
316
+ 411,PA445,CEMENTERIO DE LLUJA,Parada,43.4506305958069,-3.87271290141561,,,,,Europe/Madrid
317
+ 412,PA446,OJAIZ 89,Parada,43.4407974733323,-3.88554429982084,,,,,Europe/Madrid
318
+ 413,PA447,OJAIZ 166,Parada,43.4408128886208,-3.88578612232193,,,,,Europe/Madrid
319
+ 414,PA448,VALDECILLA SUR,Parada,43.4547201945284,-3.82769058964239,,,,,Europe/Madrid
320
+ 415,PA449,VALDECILLA SUR,Parada,43.4546510006132,-3.82748347885879,,,,,Europe/Madrid
321
+ 45,PA45,AVENIDA DE VALDECILLA,Parada,43.4575563357455,-3.83041565699705,,,,,Europe/Madrid
322
+ 420,PA454,PCTCAN 1,Parada,43.4527992548983,-3.87004191278329,,,,,Europe/Madrid
323
+ 421,PA455,JOAQUIN RODRIGO 10,Parada,43.4546095856968,-3.86575171499463,,,,,Europe/Madrid
324
+ 422,PA456,CALLE CERVANTES 29,Parada,43.4638600403187,-3.81115675576739,,,,,Europe/Madrid
325
+ 423,PA457,CALLE DEL MONTE 12,Parada,43.463922606637,-3.81426497264765,,,,,Europe/Madrid
326
+ 425,PA459,VERIDIANO ROJO,Parada,43.4778276729954,-3.81022004304701,,,,,Europe/Madrid
327
+ 46,PA46,TORRES QUEVEDO 22,Parada,43.4573284352537,-3.83720105898256,,,,,Europe/Madrid
328
+ 427,PA461,MENENDEZ PELAYO 25,Parada,43.4658259655421,-3.79808500484664,,,,,Europe/Madrid
329
+ 428,PA462,JORGE SEPULVEDA 2,Parada,43.4744784946856,-3.81127998359722,,,,,Europe/Madrid
330
+ 429,PA463,CALLE LA PEREDA 14,Parada,43.4780858614423,-3.8046645991396,,,,,Europe/Madrid
331
+ 430,PA464,CALLE LA PEREDA,Parada,43.4780475234356,-3.80448257354547,,,,,Europe/Madrid
332
+ 431,PA465,AVENIDA DEL DEPORTE 9,Parada,43.4610186418197,-3.8488441182331,,,,,Europe/Madrid
333
+ 432,PA466,BARRIO LA SIERRA,Parada,43.4609242100335,-3.85865737041405,,,,,Europe/Madrid
334
+ 433,PA467,VICENTE TRUEBA 19,Parada,43.4600710070074,-3.85682444881087,,,,,Europe/Madrid
335
+ 434,PA468,VICENTE TRUEBA,Parada,43.459986994748,-3.85666849995585,,,,,Europe/Madrid
336
+ 435,PA469,BARRIO LA SIERRA 10,Parada,43.4613344431689,-3.85796324425597,,,,,Europe/Madrid
337
+ 47,PA47,PLAZA MANUEL LLANO,Parada,43.4582239387637,-3.84071370115077,,,,,Europe/Madrid
338
+ 436,PA470,PASEO DE ALTAMIRA 77,Parada,43.467533179491,-3.8119300608435,,,,,Europe/Madrid
339
+ 437,PA471,CALLE ARRIBA 64,Parada,43.4790204226531,-3.80818096318293,,,,,Europe/Madrid
340
+ 439,PA473,RICARDO LEON,Parada,43.4557152673055,-3.84884130859439,,,,,Europe/Madrid
341
+ 443,PA477,JOSE ORTEGA Y GASSET 2,Parada,43.4462019313202,-3.86296969164083,,,,,Europe/Madrid
342
+ 444,PA478,CAMARREAL,Parada,43.4471914170464,-3.86634191402969,,,,,Europe/Madrid
343
+ 445,PA479,MARQUES DE HAZAS 5,Parada,43.4767660359588,-3.80712441511871,,,,,Europe/Madrid
344
+ 48,PA48,JOSE MARIA COSSIO 12,Parada,43.4598511204018,-3.84150152934565,,,,,Europe/Madrid
345
+ 446,PA480,MARQUES DE HAZAS,Parada,43.4768960647198,-3.80711069007873,,,,,Europe/Madrid
346
+ 447,PA481,PLAZA DE MEJICO,Parada,43.457936768905,-3.82631142841492,,,,,Europe/Madrid
347
+ 448,PA482,LUIS QUINTANILLA ISASI,Parada,43.4546966693559,-3.84986113410367,,,,,Europe/Madrid
348
+ 449,PA483,GERARDO DIEGO 1,Parada,43.4557216939061,-3.84560056484003,,,,,Europe/Madrid
349
+ 452,PA486,PCTCAN 2,Parada,43.4529102297186,-3.87144353206348,,,,,Europe/Madrid
350
+ 453,PA487,PCTCAN 3,Parada,43.4503880060865,-3.87446954423473,,,,,Europe/Madrid
351
+ 454,PA488,PCTCAN-UNEATLANTICO,Parada,43.451337425151,-3.8768700067606,,,,,Europe/Madrid
352
+ 455,PA489,ALBERT EINSTEIN 14,Parada,43.4530227581068,-3.87003980235665,,,,,Europe/Madrid
353
+ 49,PA49,JOSE MARIA COSSIO 24,Parada,43.4598340579658,-3.8435263392934,,,,,Europe/Madrid
354
+ 456,PA490,CALLE ADARZO 48,Parada,43.4532986472827,-3.86303956647519,,,,,Europe/Madrid
355
+ 457,PA491,CALLE ADARZO 117,Parada,43.4532805807024,-3.86288498448966,,,,,Europe/Madrid
356
+ 458,PA492,CENTRO DE SALUD NUEVA MONTAÑA,Parada,43.4398902843286,-3.8554051774051,,,,,Europe/Madrid
357
+ 459,PA493,GERTRUDIS GOMEZ DE AVELLANEDA,Parada,43.4386652911293,-3.85355042737659,,,,,Europe/Madrid
358
+ 460,PA494,EUSEBIO SANTAMARIA 1,Parada,43.4412335639345,-3.85132058303889,,,,,Europe/Madrid
359
+ 461,PA495,EUSEBIO SANTAMARIA 2,Parada,43.4412772511232,-3.85152399301006,,,,,Europe/Madrid
360
+ 462,PA496,EUSEBIO SANTAMARIA,Parada,43.4388248168292,-3.85324212857421,,,,,Europe/Madrid
361
+ 463,PA497,CARMEN BRAVO VILLASANTE,Parada,43.4396761585398,-3.85536941435556,,,,,Europe/Madrid
362
+ 464,PA498,CARMEN BRAVO VILLASANTE 1,Parada,43.4425574491711,-3.8533336995566,,,,,Europe/Madrid
363
+ 465,PA499,CAMARREAL 45,Parada,43.4471534447214,-3.86651674057648,,,,,Europe/Madrid
364
+ 5,PA5,JOSE MARIA COSSIO 17,Parada,43.4596836342094,-3.84310483395947,,,,,Europe/Madrid
365
+ 50,PA50,JOSE MARIA COSSIO 44,Parada,43.4594752137699,-3.84663852625277,,,,,Europe/Madrid
366
+ 466,PA500,ORTEGA Y GASSET 28,Parada,43.4438028762276,-3.85718583107456,,,,,Europe/Madrid
367
+ 467,PA502,BARRIO CAMINO 27,Parada,43.4664401514431,-3.7883590187461,,,,,Europe/Madrid
368
+ 468,PA503,CENTRO DE SALUD,Parada,43.4663203052662,-3.79058631172042,,,,,Europe/Madrid
369
+ 469,PA504,TETUAN 41,Parada,43.4660219475815,-3.79356555793385,,,,,Europe/Madrid
370
+ 470,PA505,AVENIDA CANTABRIA 35,Parada,43.4759532537233,-3.80622222893083,,,,,Europe/Madrid
371
+ 471,PA506,AVENIDA CANTABRIA 76,Parada,43.4759360310165,-3.80663424389743,,,,,Europe/Madrid
372
+ 607,PA508,CALLE RUCANDIAL,Parada,43.454691161505,-3.86660797761512,,,,,Europe/Madrid
373
+ 622,PA509,INTERCAMBIADOR DE VALDECILLA,Parada,43.4567167237491,-3.83137413617495,,,,,Europe/Madrid
374
+ 51,PA51,JOSE MARIA COSSIO 52,Parada,43.4591619177878,-3.84858687875844,,,,,Europe/Madrid
375
+ 623,PA510,JESÚS DE MONASTERIO 7,Parada,43.4616528357724,-3.81075121789887,,,,,Europe/Madrid
376
+ 624,PA511,INTERCAMBIADOR DEL SARDINERO 1,Parada,43.4775020790382,-3.79085673855073,,,,,Europe/Madrid
377
+ 625,PA512,INTERCAMBIADOR AVENIDA VALDECILLA,Parada,43.4568340994357,-3.83165336292031,,,,,Europe/Madrid
378
+ 626,PA513,ESTACION DE AUTOBUSES 1,Parada,43.4596150793176,-3.81014389134529,,,,,Europe/Madrid
379
+ 628,PA515,INTERCAMBIADOR DEL SARDINERO 2,Parada,43.4776577444221,-3.79095187603918,,,,,Europe/Madrid
380
+ 629,PA516,INTERCAMBIADOR SARDINERO,Parada,43.477729285163,-3.79134984436265,,,,,Europe/Madrid
381
+ 630,PA517,LOS AGUSTINOS,Parada,43.4775628908506,-3.7906697102616,,,,,Europe/Madrid
382
+ 680,PA518,CANALEJAS 90,Parada,43.467740734326,-3.78749776759895,,,,,Europe/Madrid
383
+ 681,PA519,CANALEJAS 93,Parada,43.4674317296456,-3.78742773876638,,,,,Europe/Madrid
384
+ 52,PA52,LOS CIRUELOS 26,Parada,43.4584374654372,-3.85244506567506,,,,,Europe/Madrid
385
+ 697,PA521,ESTACION,Parada,43.4588100795261,-3.81090549402236,,,,,Europe/Madrid
386
+ 703,PA522,MIRANDA,Parada,43.4681888025525,-3.78879552016277,,,,,Europe/Madrid
387
+ 704,PA523,MIRANDA,Parada,43.4687332287924,-3.78783702457795,,,,,Europe/Madrid
388
+ 705,PA524,AVENIDA LOS INFANTES,Parada,43.46841880731,-3.78770374779538,,,,,Europe/Madrid
389
+ 1021,PA525,CAMARREAL 40,Parada,43.447348842043,-3.86734881936896,,,,,Europe/Madrid
390
+ 1022,PA526,CAMARREAL 51,Parada,43.4472039357739,-3.86745797622715,,,,,Europe/Madrid
391
+ 1082,PA528,JULIO JAURENA 3,Parada,43.4573438255219,-3.85783097073306,,,,,Europe/Madrid
392
+ 1083,PA529,ERNEST LLUCH 25,Parada,43.4729155192166,-3.81388770481565,,,,,Europe/Madrid
393
+ 53,PA53,INSTITUTO ALISAL,Parada,43.4578006520384,-3.85572417030395,,,,,Europe/Madrid
394
+ 1084,PA530,ERNEST LLUCH 17,Parada,43.4738693062942,-3.8104068323338,,,,,Europe/Madrid
395
+ 1085,PA531,ERNEST LLUCH 9,Parada,43.4751160991395,-3.80624758578527,,,,,Europe/Madrid
396
+ 1086,PA532,ERNEST LLUCH 3,Parada,43.4757722764766,-3.80368592827066,,,,,Europe/Madrid
397
+ 1087,PA533,PRIMERO MAYO 64,Parada,43.4408314170724,-3.85974308912486,,,,,Europe/Madrid
398
+ 1088,PA534,RICARDO LOPEZ ARANDA 23,Parada,43.4409313492861,-3.86275902617791,,,,,Europe/Madrid
399
+ 1089,PA535,RICARDO LOPEZ ARANDA 17,Parada,43.4428265595752,-3.86069013994948,,,,,Europe/Madrid
400
+ 1090,PA536,JOSE ORTEGA Y GASSET 32,Parada,43.4449743486346,-3.85974288868983,,,,,Europe/Madrid
401
+ 1091,PA537,JOSE ORTEGA Y GASSET 39,Parada,43.4458527491669,-3.86250350205888,,,,,Europe/Madrid
402
+ 1092,PA538,JOSE ORTEGA Y GASSET 21,Parada,43.4448639008703,-3.85984476200943,,,,,Europe/Madrid
403
+ 1093,PA539,RICARDO LOPEZ ARANDA 18,Parada,43.4428687603334,-3.86079095941926,,,,,Europe/Madrid
404
+ 54,PA54,BARRIO DE OJAIZ 7,Parada,43.4414597543452,-3.88173603114804,,,,,Europe/Madrid
405
+ 1094,PA540,ERNEST LLUCH 2,Parada,43.4760319870018,-3.80287464911648,,,,,Europe/Madrid
406
+ 1095,PA541,ERNEST LLUCH 10,Parada,43.4751750356589,-3.80633009753582,,,,,Europe/Madrid
407
+ 1096,PA542,ERNEST LLUCH 18,Parada,43.4738339428105,-3.81069567841201,,,,,Europe/Madrid
408
+ 1097,PA543,ERNEST LLUCH 26,Parada,43.47299145066,-3.813922106319,,,,,Europe/Madrid
409
+ 1098,PA544,JULIO JAURENA 4,Parada,43.4574681640917,-3.85788044089065,,,,,Europe/Madrid
410
+ 1113,PA545,JOSE MARIA GONZALEZ TREVILLA 14,Parada,43.483344837503,-3.79644584982088,,,,,Europe/Madrid
411
+ 55,PA55,LOS CASTROS 76,Parada,43.4692541279202,-3.8126789239879,,,,,Europe/Madrid
412
+ 1152,PA551,MARIA GUERRERO,Parada,43.4426417985347,-3.85508428813021,,,,,Europe/Madrid
413
+ 2123,PA552,PIQUIO-PZA ITALIA,Parada,43.4735840958775,-3.78450163494693,,,,,Europe/Madrid
414
+ 56,PA56,CAMARREAL 109,Parada,43.4463066400746,-3.87060899664979,,,,,Europe/Madrid
415
+ 57,PA57,IGLESIA LA PEÑA,Parada,43.4478230465854,-3.86418178584658,,,,,Europe/Madrid
416
+ 58,PA58,PEÑACASTILLO ESCUELAS,Parada,43.4475649145202,-3.86058523138619,,,,,Europe/Madrid
417
+ 59,PA59,LOS LLANOS,Parada,43.4480318230699,-3.85798296254346,,,,,Europe/Madrid
418
+ 6,PA6,JOSE MARIA COSSIO 1,Parada,43.4597071637227,-3.84088653041633,,,,,Europe/Madrid
419
+ 60,PA60,EL EMPALME,Parada,43.4488092590174,-3.85344896511681,,,,,Europe/Madrid
420
+ 1023,PA600,CAMARREAL 40 (Cocheras),Parada,43.4480891030846,-3.86709983819633,,,,,Europe/Madrid
421
+ 61,PA61,CAMPOGIRO 23,Parada,43.4510329397632,-3.84918942439161,,,,,Europe/Madrid
422
+ 62,PA62,CAMPOGIRO,Parada,43.4536539776165,-3.84617396507445,,,,,Europe/Madrid
423
+ 63,PA63,CAMPOGIRO 5,Parada,43.4545529680249,-3.84246577375812,,,,,Europe/Madrid
424
+ 64,PA64,CAJO 17,Parada,43.4541833616203,-3.83835269834877,,,,,Europe/Madrid
425
+ 65,PA65,CAJO 5,Parada,43.4549304071569,-3.83384909059824,,,,,Europe/Madrid
426
+ 66,PA66,PLAZA BRISAS,Parada,43.4738646764307,-3.7853099792147,,,,,Europe/Madrid
427
+ 67,PA67,LOS CASTROS 20,Parada,43.4733741380285,-3.78803907794824,,,,,Europe/Madrid
428
+ 68,PA68,LOS CASTROS 38,Parada,43.4726961366342,-3.79163228038217,,,,,Europe/Madrid
429
+ 69,PA69,UIMP,Parada,43.4720195637191,-3.79571836097396,,,,,Europe/Madrid
430
+ 7,PA7,MANUEL LLANO,Parada,43.4579542340806,-3.84117016926005,,,,,Europe/Madrid
431
+ 70,PA70,ESCUELA DE CAMINOS,Parada,43.4712521263596,-3.79950189008281,,,,,Europe/Madrid
432
+ 71,PA71,INTERFACULTATIVO,Parada,43.4704921227331,-3.80354109622813,,,,,Europe/Madrid
433
+ 72,PA72,RECTORADO,Parada,43.4702037397336,-3.80582911104401,,,,,Europe/Madrid
434
+ 73,PA73,LOS CASTROS 63,Parada,43.4703601042066,-3.80354330341222,,,,,Europe/Madrid
435
+ 74,PA74,PARQUE LA TEJA,Parada,43.4712010216925,-3.79906984115093,,,,,Europe/Madrid
436
+ 75,PA75,LOS CASTROS 53,Parada,43.4718671838563,-3.79561745250289,,,,,Europe/Madrid
437
+ 76,PA76,CASIMIRO SAINZ 9,Parada,43.4642164612191,-3.79682266407217,,,,,Europe/Madrid
438
+ 77,PA77,CALVO SOTELO 1,Parada,43.4614927538632,-3.80933576022168,,,,,Europe/Madrid
439
+ 78,PA78,CAJO 2,Parada,43.4553595638565,-3.83289753604669,,,,,Europe/Madrid
440
+ 79,PA79,CAJO 10,Parada,43.4546850231196,-3.83501241696265,,,,,Europe/Madrid
441
+ 8,PA8,INSTITUTO TORRES QUEVEDO,Parada,43.4570117954802,-3.8364805039506,,,,,Europe/Madrid
442
+ 80,PA80,PARQUE DOCTOR MORALES,Parada,43.4542903979677,-3.83840646010009,,,,,Europe/Madrid
443
+ 81,PA81,LAS CALIFORNAS,Parada,43.4545546310208,-3.84297317938012,,,,,Europe/Madrid
444
+ 82,PA82,CAMPOGIRO 90,Parada,43.4531283081487,-3.84702369002419,,,,,Europe/Madrid
445
+ 83,PA83,ALTO DE LA PEÑA,Parada,43.4511947070305,-3.84923123801319,,,,,Europe/Madrid
446
+ 84,PA84,EL EMPALME 6,Parada,43.449023828337,-3.85296797976471,,,,,Europe/Madrid
447
+ 85,PA85,LOS LLANOS,Parada,43.4481216795872,-3.85799584997868,,,,,Europe/Madrid
448
+ 86,PA86,ESCUELAS PEÑACASTILLO,Parada,43.4476401391254,-3.8606997507865,,,,,Europe/Madrid
449
+ 87,PA87,CALLE LA PEÑA,Parada,43.4478805623503,-3.86376758563544,,,,,Europe/Madrid
450
+ 88,PA88,CAMARREAL 68,Parada,43.4465008165512,-3.87026029455905,,,,,Europe/Madrid
451
+ 89,PA89,DEPOSITO MUNICIPAL,Parada,43.441454158967,-3.87791097747283,,,,,Europe/Madrid
452
+ 9,PA9,VALDECILLA,Parada,43.4573874930483,-3.83008121452806,,,,,Europe/Madrid
453
+ 90,PA90,BARRIO LAS TEJERAS,Parada,43.4389535091726,-3.87833096279877,,,,,Europe/Madrid
454
+ 92,PA92,JERONIMO SAINZ DE LA MAZA,Parada,43.4563261084019,-3.82709914298288,,,,,Europe/Madrid
455
+ 93,PA93,CANDINA,Parada,43.4531465599797,-3.82832522356584,,,,,Europe/Madrid
456
+ 94,PA94,LA LONJA,Parada,43.4532909066148,-3.82111332664809,,,,,Europe/Madrid
457
+ 95,PA95,MARQUES DE LA HERMIDA 36,Parada,43.4540308940062,-3.81842261568624,,,,,Europe/Madrid
458
+ 96,PA96,BARRIO PESQUERO,Parada,43.4521640063865,-3.81877146629328,,,,,Europe/Madrid
459
+ 97,PA97,PARQUE VARADERO,Parada,43.4540444185371,-3.81811811145799,,,,,Europe/Madrid
460
+ 98,PA98,MARQUES DE LA HERMIDA 1,Parada,43.4556979608165,-3.81231000356183,,,,,Europe/Madrid
data/gtfs-static/trips.txt ADDED
The diff for this file is too large to render. See raw diff
 
hf_space_metadata.yml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PulseTransit
3
+ emoji: 🚌
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ ---
pulsetransit-worker/.editorconfig ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # http://editorconfig.org
2
+ root = true
3
+
4
+ [*]
5
+ indent_style = tab
6
+ end_of_line = lf
7
+ charset = utf-8
8
+ trim_trailing_whitespace = true
9
+ insert_final_newline = true
10
+
11
+ [*.yml]
12
+ indent_style = space
pulsetransit-worker/.gitignore ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+
3
+ logs
4
+ _.log
5
+ npm-debug.log_
6
+ yarn-debug.log*
7
+ yarn-error.log*
8
+ lerna-debug.log*
9
+ .pnpm-debug.log*
10
+
11
+ # Diagnostic reports (https://nodejs.org/api/report.html)
12
+
13
+ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
14
+
15
+ # Runtime data
16
+
17
+ pids
18
+ _.pid
19
+ _.seed
20
+ \*.pid.lock
21
+
22
+ # Directory for instrumented libs generated by jscoverage/JSCover
23
+
24
+ lib-cov
25
+
26
+ # Coverage directory used by tools like istanbul
27
+
28
+ coverage
29
+ \*.lcov
30
+
31
+ # nyc test coverage
32
+
33
+ .nyc_output
34
+
35
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36
+
37
+ .grunt
38
+
39
+ # Bower dependency directory (https://bower.io/)
40
+
41
+ bower_components
42
+
43
+ # node-waf configuration
44
+
45
+ .lock-wscript
46
+
47
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
48
+
49
+ build/Release
50
+
51
+ # Dependency directories
52
+
53
+ node_modules/
54
+ jspm_packages/
55
+
56
+ # Snowpack dependency directory (https://snowpack.dev/)
57
+
58
+ web_modules/
59
+
60
+ # TypeScript cache
61
+
62
+ \*.tsbuildinfo
63
+
64
+ # Optional npm cache directory
65
+
66
+ .npm
67
+
68
+ # Optional eslint cache
69
+
70
+ .eslintcache
71
+
72
+ # Optional stylelint cache
73
+
74
+ .stylelintcache
75
+
76
+ # Microbundle cache
77
+
78
+ .rpt2_cache/
79
+ .rts2_cache_cjs/
80
+ .rts2_cache_es/
81
+ .rts2_cache_umd/
82
+
83
+ # Optional REPL history
84
+
85
+ .node_repl_history
86
+
87
+ # Output of 'npm pack'
88
+
89
+ \*.tgz
90
+
91
+ # Yarn Integrity file
92
+
93
+ .yarn-integrity
94
+
95
+ # parcel-bundler cache (https://parceljs.org/)
96
+
97
+ .cache
98
+ .parcel-cache
99
+
100
+ # Next.js build output
101
+
102
+ .next
103
+ out
104
+
105
+ # Nuxt.js build / generate output
106
+
107
+ .nuxt
108
+ dist
109
+
110
+ # Gatsby files
111
+
112
+ .cache/
113
+
114
+ # Comment in the public line in if your project uses Gatsby and not Next.js
115
+
116
+ # https://nextjs.org/blog/next-9-1#public-directory-support
117
+
118
+ # public
119
+
120
+ # vuepress build output
121
+
122
+ .vuepress/dist
123
+
124
+ # vuepress v2.x temp and cache directory
125
+
126
+ .temp
127
+ .cache
128
+
129
+ # Docusaurus cache and generated files
130
+
131
+ .docusaurus
132
+
133
+ # Serverless directories
134
+
135
+ .serverless/
136
+
137
+ # FuseBox cache
138
+
139
+ .fusebox/
140
+
141
+ # DynamoDB Local files
142
+
143
+ .dynamodb/
144
+
145
+ # TernJS port file
146
+
147
+ .tern-port
148
+
149
+ # Stores VSCode versions used for testing VSCode extensions
150
+
151
+ .vscode-test
152
+
153
+ # yarn v2
154
+
155
+ .yarn/cache
156
+ .yarn/unplugged
157
+ .yarn/build-state.yml
158
+ .yarn/install-state.gz
159
+ .pnp.\*
160
+
161
+ # wrangler project
162
+
163
+ .dev.vars*
164
+ !.dev.vars.example
165
+ .env*
166
+ !.env.example
167
+ .wrangler/
168
+
169
+ #vscode
170
+ .vscode
pulsetransit-worker/.prettierrc ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "printWidth": 140,
3
+ "singleQuote": true,
4
+ "semi": true,
5
+ "useTabs": true
6
+ }
pulsetransit-worker/AGENTS.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cloudflare Workers
2
+
3
+ STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task.
4
+
5
+ ## Docs
6
+
7
+ - https://developers.cloudflare.com/workers/
8
+ - MCP: `https://docs.mcp.cloudflare.com/mcp`
9
+
10
+ For all limits and quotas, retrieve from the product's `/platform/limits/` page. eg. `/workers/platform/limits`
11
+
12
+ ## Commands
13
+
14
+ | Command | Purpose |
15
+ |---------|---------|
16
+ | `npx wrangler dev` | Local development |
17
+ | `npx wrangler deploy` | Deploy to Cloudflare |
18
+ | `npx wrangler types` | Generate TypeScript types |
19
+
20
+ Run `wrangler types` after changing bindings in wrangler.jsonc.
21
+
22
+ ## Node.js Compatibility
23
+
24
+ https://developers.cloudflare.com/workers/runtime-apis/nodejs/
25
+
26
+ ## Errors
27
+
28
+ - **Error 1102** (CPU/Memory exceeded): Retrieve limits from `/workers/platform/limits/`
29
+ - **All errors**: https://developers.cloudflare.com/workers/observability/errors/
30
+
31
+ ## Product Docs
32
+
33
+ Retrieve API references and limits from:
34
+ `/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/`
pulsetransit-worker/package-lock.json ADDED
@@ -0,0 +1,1504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "pulsetransit-worker",
3
+ "version": "0.2.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "pulsetransit-worker",
9
+ "version": "0.2.0",
10
+ "devDependencies": {
11
+ "wrangler": "^4.68.0"
12
+ }
13
+ },
14
+ "node_modules/@cloudflare/kv-asset-handler": {
15
+ "version": "0.4.2",
16
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz",
17
+ "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==",
18
+ "dev": true,
19
+ "license": "MIT OR Apache-2.0",
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ }
23
+ },
24
+ "node_modules/@cloudflare/unenv-preset": {
25
+ "version": "2.14.0",
26
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.14.0.tgz",
27
+ "integrity": "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==",
28
+ "dev": true,
29
+ "license": "MIT OR Apache-2.0",
30
+ "peerDependencies": {
31
+ "unenv": "2.0.0-rc.24",
32
+ "workerd": "^1.20260218.0"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "workerd": {
36
+ "optional": true
37
+ }
38
+ }
39
+ },
40
+ "node_modules/@cloudflare/workerd-darwin-64": {
41
+ "version": "1.20260302.0",
42
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260302.0.tgz",
43
+ "integrity": "sha512-cGtxPByeVrgoqxbmd8qs631wuGwf8yTm/FY44dEW4HdoXrb5jhlE4oWYHFafedkQCvGjY1Vbs3puAiKnuMxTXQ==",
44
+ "cpu": [
45
+ "x64"
46
+ ],
47
+ "dev": true,
48
+ "license": "Apache-2.0",
49
+ "optional": true,
50
+ "os": [
51
+ "darwin"
52
+ ],
53
+ "engines": {
54
+ "node": ">=16"
55
+ }
56
+ },
57
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
58
+ "version": "1.20260302.0",
59
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260302.0.tgz",
60
+ "integrity": "sha512-WRGqV6RNXM3xoQblJJw1EHKwx9exyhB18cdnToSCUFPObFhk3fzMLoQh7S+nUHUpto6aUrXPVj6R/4G3UPjCxw==",
61
+ "cpu": [
62
+ "arm64"
63
+ ],
64
+ "dev": true,
65
+ "license": "Apache-2.0",
66
+ "optional": true,
67
+ "os": [
68
+ "darwin"
69
+ ],
70
+ "engines": {
71
+ "node": ">=16"
72
+ }
73
+ },
74
+ "node_modules/@cloudflare/workerd-linux-64": {
75
+ "version": "1.20260302.0",
76
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260302.0.tgz",
77
+ "integrity": "sha512-gG423mtUjrmlQT+W2+KisLc6qcGcBLR+QcK5x1gje3bu/dF3oNiYuqY7o58A+sQk6IB849UC4UyNclo1RhP2xw==",
78
+ "cpu": [
79
+ "x64"
80
+ ],
81
+ "dev": true,
82
+ "license": "Apache-2.0",
83
+ "optional": true,
84
+ "os": [
85
+ "linux"
86
+ ],
87
+ "engines": {
88
+ "node": ">=16"
89
+ }
90
+ },
91
+ "node_modules/@cloudflare/workerd-linux-arm64": {
92
+ "version": "1.20260302.0",
93
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260302.0.tgz",
94
+ "integrity": "sha512-7M25noGI4WlSBOhrIaY8xZrnn87OQKtJg9YWAO2EFqGjF1Su5QXGaLlQVF4fAKbqTywbHnI8BAuIsIlUSNkhCg==",
95
+ "cpu": [
96
+ "arm64"
97
+ ],
98
+ "dev": true,
99
+ "license": "Apache-2.0",
100
+ "optional": true,
101
+ "os": [
102
+ "linux"
103
+ ],
104
+ "engines": {
105
+ "node": ">=16"
106
+ }
107
+ },
108
+ "node_modules/@cloudflare/workerd-windows-64": {
109
+ "version": "1.20260302.0",
110
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260302.0.tgz",
111
+ "integrity": "sha512-jK1L3ADkiWxFzlqZTq2iHW1Bd2Nzu1fmMWCGZw4sMZ2W1B2WCm2wHwO2SX/py4BgylyEN3wuF+5zagbkNKht9A==",
112
+ "cpu": [
113
+ "x64"
114
+ ],
115
+ "dev": true,
116
+ "license": "Apache-2.0",
117
+ "optional": true,
118
+ "os": [
119
+ "win32"
120
+ ],
121
+ "engines": {
122
+ "node": ">=16"
123
+ }
124
+ },
125
+ "node_modules/@cspotcode/source-map-support": {
126
+ "version": "0.8.1",
127
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
128
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
129
+ "dev": true,
130
+ "license": "MIT",
131
+ "dependencies": {
132
+ "@jridgewell/trace-mapping": "0.3.9"
133
+ },
134
+ "engines": {
135
+ "node": ">=12"
136
+ }
137
+ },
138
+ "node_modules/@emnapi/runtime": {
139
+ "version": "1.8.1",
140
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
141
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
142
+ "dev": true,
143
+ "license": "MIT",
144
+ "optional": true,
145
+ "dependencies": {
146
+ "tslib": "^2.4.0"
147
+ }
148
+ },
149
+ "node_modules/@esbuild/aix-ppc64": {
150
+ "version": "0.27.3",
151
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
152
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
153
+ "cpu": [
154
+ "ppc64"
155
+ ],
156
+ "dev": true,
157
+ "license": "MIT",
158
+ "optional": true,
159
+ "os": [
160
+ "aix"
161
+ ],
162
+ "engines": {
163
+ "node": ">=18"
164
+ }
165
+ },
166
+ "node_modules/@esbuild/android-arm": {
167
+ "version": "0.27.3",
168
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
169
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
170
+ "cpu": [
171
+ "arm"
172
+ ],
173
+ "dev": true,
174
+ "license": "MIT",
175
+ "optional": true,
176
+ "os": [
177
+ "android"
178
+ ],
179
+ "engines": {
180
+ "node": ">=18"
181
+ }
182
+ },
183
+ "node_modules/@esbuild/android-arm64": {
184
+ "version": "0.27.3",
185
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
186
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
187
+ "cpu": [
188
+ "arm64"
189
+ ],
190
+ "dev": true,
191
+ "license": "MIT",
192
+ "optional": true,
193
+ "os": [
194
+ "android"
195
+ ],
196
+ "engines": {
197
+ "node": ">=18"
198
+ }
199
+ },
200
+ "node_modules/@esbuild/android-x64": {
201
+ "version": "0.27.3",
202
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
203
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
204
+ "cpu": [
205
+ "x64"
206
+ ],
207
+ "dev": true,
208
+ "license": "MIT",
209
+ "optional": true,
210
+ "os": [
211
+ "android"
212
+ ],
213
+ "engines": {
214
+ "node": ">=18"
215
+ }
216
+ },
217
+ "node_modules/@esbuild/darwin-arm64": {
218
+ "version": "0.27.3",
219
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
220
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
221
+ "cpu": [
222
+ "arm64"
223
+ ],
224
+ "dev": true,
225
+ "license": "MIT",
226
+ "optional": true,
227
+ "os": [
228
+ "darwin"
229
+ ],
230
+ "engines": {
231
+ "node": ">=18"
232
+ }
233
+ },
234
+ "node_modules/@esbuild/darwin-x64": {
235
+ "version": "0.27.3",
236
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
237
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
238
+ "cpu": [
239
+ "x64"
240
+ ],
241
+ "dev": true,
242
+ "license": "MIT",
243
+ "optional": true,
244
+ "os": [
245
+ "darwin"
246
+ ],
247
+ "engines": {
248
+ "node": ">=18"
249
+ }
250
+ },
251
+ "node_modules/@esbuild/freebsd-arm64": {
252
+ "version": "0.27.3",
253
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
254
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
255
+ "cpu": [
256
+ "arm64"
257
+ ],
258
+ "dev": true,
259
+ "license": "MIT",
260
+ "optional": true,
261
+ "os": [
262
+ "freebsd"
263
+ ],
264
+ "engines": {
265
+ "node": ">=18"
266
+ }
267
+ },
268
+ "node_modules/@esbuild/freebsd-x64": {
269
+ "version": "0.27.3",
270
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
271
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
272
+ "cpu": [
273
+ "x64"
274
+ ],
275
+ "dev": true,
276
+ "license": "MIT",
277
+ "optional": true,
278
+ "os": [
279
+ "freebsd"
280
+ ],
281
+ "engines": {
282
+ "node": ">=18"
283
+ }
284
+ },
285
+ "node_modules/@esbuild/linux-arm": {
286
+ "version": "0.27.3",
287
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
288
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
289
+ "cpu": [
290
+ "arm"
291
+ ],
292
+ "dev": true,
293
+ "license": "MIT",
294
+ "optional": true,
295
+ "os": [
296
+ "linux"
297
+ ],
298
+ "engines": {
299
+ "node": ">=18"
300
+ }
301
+ },
302
+ "node_modules/@esbuild/linux-arm64": {
303
+ "version": "0.27.3",
304
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
305
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
306
+ "cpu": [
307
+ "arm64"
308
+ ],
309
+ "dev": true,
310
+ "license": "MIT",
311
+ "optional": true,
312
+ "os": [
313
+ "linux"
314
+ ],
315
+ "engines": {
316
+ "node": ">=18"
317
+ }
318
+ },
319
+ "node_modules/@esbuild/linux-ia32": {
320
+ "version": "0.27.3",
321
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
322
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
323
+ "cpu": [
324
+ "ia32"
325
+ ],
326
+ "dev": true,
327
+ "license": "MIT",
328
+ "optional": true,
329
+ "os": [
330
+ "linux"
331
+ ],
332
+ "engines": {
333
+ "node": ">=18"
334
+ }
335
+ },
336
+ "node_modules/@esbuild/linux-loong64": {
337
+ "version": "0.27.3",
338
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
339
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
340
+ "cpu": [
341
+ "loong64"
342
+ ],
343
+ "dev": true,
344
+ "license": "MIT",
345
+ "optional": true,
346
+ "os": [
347
+ "linux"
348
+ ],
349
+ "engines": {
350
+ "node": ">=18"
351
+ }
352
+ },
353
+ "node_modules/@esbuild/linux-mips64el": {
354
+ "version": "0.27.3",
355
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
356
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
357
+ "cpu": [
358
+ "mips64el"
359
+ ],
360
+ "dev": true,
361
+ "license": "MIT",
362
+ "optional": true,
363
+ "os": [
364
+ "linux"
365
+ ],
366
+ "engines": {
367
+ "node": ">=18"
368
+ }
369
+ },
370
+ "node_modules/@esbuild/linux-ppc64": {
371
+ "version": "0.27.3",
372
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
373
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
374
+ "cpu": [
375
+ "ppc64"
376
+ ],
377
+ "dev": true,
378
+ "license": "MIT",
379
+ "optional": true,
380
+ "os": [
381
+ "linux"
382
+ ],
383
+ "engines": {
384
+ "node": ">=18"
385
+ }
386
+ },
387
+ "node_modules/@esbuild/linux-riscv64": {
388
+ "version": "0.27.3",
389
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
390
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
391
+ "cpu": [
392
+ "riscv64"
393
+ ],
394
+ "dev": true,
395
+ "license": "MIT",
396
+ "optional": true,
397
+ "os": [
398
+ "linux"
399
+ ],
400
+ "engines": {
401
+ "node": ">=18"
402
+ }
403
+ },
404
+ "node_modules/@esbuild/linux-s390x": {
405
+ "version": "0.27.3",
406
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
407
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
408
+ "cpu": [
409
+ "s390x"
410
+ ],
411
+ "dev": true,
412
+ "license": "MIT",
413
+ "optional": true,
414
+ "os": [
415
+ "linux"
416
+ ],
417
+ "engines": {
418
+ "node": ">=18"
419
+ }
420
+ },
421
+ "node_modules/@esbuild/linux-x64": {
422
+ "version": "0.27.3",
423
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
424
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
425
+ "cpu": [
426
+ "x64"
427
+ ],
428
+ "dev": true,
429
+ "license": "MIT",
430
+ "optional": true,
431
+ "os": [
432
+ "linux"
433
+ ],
434
+ "engines": {
435
+ "node": ">=18"
436
+ }
437
+ },
438
+ "node_modules/@esbuild/netbsd-arm64": {
439
+ "version": "0.27.3",
440
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
441
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
442
+ "cpu": [
443
+ "arm64"
444
+ ],
445
+ "dev": true,
446
+ "license": "MIT",
447
+ "optional": true,
448
+ "os": [
449
+ "netbsd"
450
+ ],
451
+ "engines": {
452
+ "node": ">=18"
453
+ }
454
+ },
455
+ "node_modules/@esbuild/netbsd-x64": {
456
+ "version": "0.27.3",
457
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
458
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
459
+ "cpu": [
460
+ "x64"
461
+ ],
462
+ "dev": true,
463
+ "license": "MIT",
464
+ "optional": true,
465
+ "os": [
466
+ "netbsd"
467
+ ],
468
+ "engines": {
469
+ "node": ">=18"
470
+ }
471
+ },
472
+ "node_modules/@esbuild/openbsd-arm64": {
473
+ "version": "0.27.3",
474
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
475
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
476
+ "cpu": [
477
+ "arm64"
478
+ ],
479
+ "dev": true,
480
+ "license": "MIT",
481
+ "optional": true,
482
+ "os": [
483
+ "openbsd"
484
+ ],
485
+ "engines": {
486
+ "node": ">=18"
487
+ }
488
+ },
489
+ "node_modules/@esbuild/openbsd-x64": {
490
+ "version": "0.27.3",
491
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
492
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
493
+ "cpu": [
494
+ "x64"
495
+ ],
496
+ "dev": true,
497
+ "license": "MIT",
498
+ "optional": true,
499
+ "os": [
500
+ "openbsd"
501
+ ],
502
+ "engines": {
503
+ "node": ">=18"
504
+ }
505
+ },
506
+ "node_modules/@esbuild/openharmony-arm64": {
507
+ "version": "0.27.3",
508
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
509
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
510
+ "cpu": [
511
+ "arm64"
512
+ ],
513
+ "dev": true,
514
+ "license": "MIT",
515
+ "optional": true,
516
+ "os": [
517
+ "openharmony"
518
+ ],
519
+ "engines": {
520
+ "node": ">=18"
521
+ }
522
+ },
523
+ "node_modules/@esbuild/sunos-x64": {
524
+ "version": "0.27.3",
525
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
526
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
527
+ "cpu": [
528
+ "x64"
529
+ ],
530
+ "dev": true,
531
+ "license": "MIT",
532
+ "optional": true,
533
+ "os": [
534
+ "sunos"
535
+ ],
536
+ "engines": {
537
+ "node": ">=18"
538
+ }
539
+ },
540
+ "node_modules/@esbuild/win32-arm64": {
541
+ "version": "0.27.3",
542
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
543
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
544
+ "cpu": [
545
+ "arm64"
546
+ ],
547
+ "dev": true,
548
+ "license": "MIT",
549
+ "optional": true,
550
+ "os": [
551
+ "win32"
552
+ ],
553
+ "engines": {
554
+ "node": ">=18"
555
+ }
556
+ },
557
+ "node_modules/@esbuild/win32-ia32": {
558
+ "version": "0.27.3",
559
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
560
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
561
+ "cpu": [
562
+ "ia32"
563
+ ],
564
+ "dev": true,
565
+ "license": "MIT",
566
+ "optional": true,
567
+ "os": [
568
+ "win32"
569
+ ],
570
+ "engines": {
571
+ "node": ">=18"
572
+ }
573
+ },
574
+ "node_modules/@esbuild/win32-x64": {
575
+ "version": "0.27.3",
576
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
577
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
578
+ "cpu": [
579
+ "x64"
580
+ ],
581
+ "dev": true,
582
+ "license": "MIT",
583
+ "optional": true,
584
+ "os": [
585
+ "win32"
586
+ ],
587
+ "engines": {
588
+ "node": ">=18"
589
+ }
590
+ },
591
+ "node_modules/@img/colour": {
592
+ "version": "1.0.0",
593
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
594
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
595
+ "dev": true,
596
+ "license": "MIT",
597
+ "engines": {
598
+ "node": ">=18"
599
+ }
600
+ },
601
+ "node_modules/@img/sharp-darwin-arm64": {
602
+ "version": "0.34.5",
603
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
604
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
605
+ "cpu": [
606
+ "arm64"
607
+ ],
608
+ "dev": true,
609
+ "license": "Apache-2.0",
610
+ "optional": true,
611
+ "os": [
612
+ "darwin"
613
+ ],
614
+ "engines": {
615
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
616
+ },
617
+ "funding": {
618
+ "url": "https://opencollective.com/libvips"
619
+ },
620
+ "optionalDependencies": {
621
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
622
+ }
623
+ },
624
+ "node_modules/@img/sharp-darwin-x64": {
625
+ "version": "0.34.5",
626
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
627
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
628
+ "cpu": [
629
+ "x64"
630
+ ],
631
+ "dev": true,
632
+ "license": "Apache-2.0",
633
+ "optional": true,
634
+ "os": [
635
+ "darwin"
636
+ ],
637
+ "engines": {
638
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
639
+ },
640
+ "funding": {
641
+ "url": "https://opencollective.com/libvips"
642
+ },
643
+ "optionalDependencies": {
644
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
645
+ }
646
+ },
647
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
648
+ "version": "1.2.4",
649
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
650
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
651
+ "cpu": [
652
+ "arm64"
653
+ ],
654
+ "dev": true,
655
+ "license": "LGPL-3.0-or-later",
656
+ "optional": true,
657
+ "os": [
658
+ "darwin"
659
+ ],
660
+ "funding": {
661
+ "url": "https://opencollective.com/libvips"
662
+ }
663
+ },
664
+ "node_modules/@img/sharp-libvips-darwin-x64": {
665
+ "version": "1.2.4",
666
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
667
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
668
+ "cpu": [
669
+ "x64"
670
+ ],
671
+ "dev": true,
672
+ "license": "LGPL-3.0-or-later",
673
+ "optional": true,
674
+ "os": [
675
+ "darwin"
676
+ ],
677
+ "funding": {
678
+ "url": "https://opencollective.com/libvips"
679
+ }
680
+ },
681
+ "node_modules/@img/sharp-libvips-linux-arm": {
682
+ "version": "1.2.4",
683
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
684
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
685
+ "cpu": [
686
+ "arm"
687
+ ],
688
+ "dev": true,
689
+ "license": "LGPL-3.0-or-later",
690
+ "optional": true,
691
+ "os": [
692
+ "linux"
693
+ ],
694
+ "funding": {
695
+ "url": "https://opencollective.com/libvips"
696
+ }
697
+ },
698
+ "node_modules/@img/sharp-libvips-linux-arm64": {
699
+ "version": "1.2.4",
700
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
701
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
702
+ "cpu": [
703
+ "arm64"
704
+ ],
705
+ "dev": true,
706
+ "license": "LGPL-3.0-or-later",
707
+ "optional": true,
708
+ "os": [
709
+ "linux"
710
+ ],
711
+ "funding": {
712
+ "url": "https://opencollective.com/libvips"
713
+ }
714
+ },
715
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
716
+ "version": "1.2.4",
717
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
718
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
719
+ "cpu": [
720
+ "ppc64"
721
+ ],
722
+ "dev": true,
723
+ "license": "LGPL-3.0-or-later",
724
+ "optional": true,
725
+ "os": [
726
+ "linux"
727
+ ],
728
+ "funding": {
729
+ "url": "https://opencollective.com/libvips"
730
+ }
731
+ },
732
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
733
+ "version": "1.2.4",
734
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
735
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
736
+ "cpu": [
737
+ "riscv64"
738
+ ],
739
+ "dev": true,
740
+ "license": "LGPL-3.0-or-later",
741
+ "optional": true,
742
+ "os": [
743
+ "linux"
744
+ ],
745
+ "funding": {
746
+ "url": "https://opencollective.com/libvips"
747
+ }
748
+ },
749
+ "node_modules/@img/sharp-libvips-linux-s390x": {
750
+ "version": "1.2.4",
751
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
752
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
753
+ "cpu": [
754
+ "s390x"
755
+ ],
756
+ "dev": true,
757
+ "license": "LGPL-3.0-or-later",
758
+ "optional": true,
759
+ "os": [
760
+ "linux"
761
+ ],
762
+ "funding": {
763
+ "url": "https://opencollective.com/libvips"
764
+ }
765
+ },
766
+ "node_modules/@img/sharp-libvips-linux-x64": {
767
+ "version": "1.2.4",
768
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
769
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
770
+ "cpu": [
771
+ "x64"
772
+ ],
773
+ "dev": true,
774
+ "license": "LGPL-3.0-or-later",
775
+ "optional": true,
776
+ "os": [
777
+ "linux"
778
+ ],
779
+ "funding": {
780
+ "url": "https://opencollective.com/libvips"
781
+ }
782
+ },
783
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
784
+ "version": "1.2.4",
785
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
786
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
787
+ "cpu": [
788
+ "arm64"
789
+ ],
790
+ "dev": true,
791
+ "license": "LGPL-3.0-or-later",
792
+ "optional": true,
793
+ "os": [
794
+ "linux"
795
+ ],
796
+ "funding": {
797
+ "url": "https://opencollective.com/libvips"
798
+ }
799
+ },
800
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
801
+ "version": "1.2.4",
802
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
803
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
804
+ "cpu": [
805
+ "x64"
806
+ ],
807
+ "dev": true,
808
+ "license": "LGPL-3.0-or-later",
809
+ "optional": true,
810
+ "os": [
811
+ "linux"
812
+ ],
813
+ "funding": {
814
+ "url": "https://opencollective.com/libvips"
815
+ }
816
+ },
817
+ "node_modules/@img/sharp-linux-arm": {
818
+ "version": "0.34.5",
819
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
820
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
821
+ "cpu": [
822
+ "arm"
823
+ ],
824
+ "dev": true,
825
+ "license": "Apache-2.0",
826
+ "optional": true,
827
+ "os": [
828
+ "linux"
829
+ ],
830
+ "engines": {
831
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
832
+ },
833
+ "funding": {
834
+ "url": "https://opencollective.com/libvips"
835
+ },
836
+ "optionalDependencies": {
837
+ "@img/sharp-libvips-linux-arm": "1.2.4"
838
+ }
839
+ },
840
+ "node_modules/@img/sharp-linux-arm64": {
841
+ "version": "0.34.5",
842
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
843
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
844
+ "cpu": [
845
+ "arm64"
846
+ ],
847
+ "dev": true,
848
+ "license": "Apache-2.0",
849
+ "optional": true,
850
+ "os": [
851
+ "linux"
852
+ ],
853
+ "engines": {
854
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
855
+ },
856
+ "funding": {
857
+ "url": "https://opencollective.com/libvips"
858
+ },
859
+ "optionalDependencies": {
860
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
861
+ }
862
+ },
863
+ "node_modules/@img/sharp-linux-ppc64": {
864
+ "version": "0.34.5",
865
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
866
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
867
+ "cpu": [
868
+ "ppc64"
869
+ ],
870
+ "dev": true,
871
+ "license": "Apache-2.0",
872
+ "optional": true,
873
+ "os": [
874
+ "linux"
875
+ ],
876
+ "engines": {
877
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
878
+ },
879
+ "funding": {
880
+ "url": "https://opencollective.com/libvips"
881
+ },
882
+ "optionalDependencies": {
883
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
884
+ }
885
+ },
886
+ "node_modules/@img/sharp-linux-riscv64": {
887
+ "version": "0.34.5",
888
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
889
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
890
+ "cpu": [
891
+ "riscv64"
892
+ ],
893
+ "dev": true,
894
+ "license": "Apache-2.0",
895
+ "optional": true,
896
+ "os": [
897
+ "linux"
898
+ ],
899
+ "engines": {
900
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
901
+ },
902
+ "funding": {
903
+ "url": "https://opencollective.com/libvips"
904
+ },
905
+ "optionalDependencies": {
906
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
907
+ }
908
+ },
909
+ "node_modules/@img/sharp-linux-s390x": {
910
+ "version": "0.34.5",
911
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
912
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
913
+ "cpu": [
914
+ "s390x"
915
+ ],
916
+ "dev": true,
917
+ "license": "Apache-2.0",
918
+ "optional": true,
919
+ "os": [
920
+ "linux"
921
+ ],
922
+ "engines": {
923
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
924
+ },
925
+ "funding": {
926
+ "url": "https://opencollective.com/libvips"
927
+ },
928
+ "optionalDependencies": {
929
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
930
+ }
931
+ },
932
+ "node_modules/@img/sharp-linux-x64": {
933
+ "version": "0.34.5",
934
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
935
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
936
+ "cpu": [
937
+ "x64"
938
+ ],
939
+ "dev": true,
940
+ "license": "Apache-2.0",
941
+ "optional": true,
942
+ "os": [
943
+ "linux"
944
+ ],
945
+ "engines": {
946
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
947
+ },
948
+ "funding": {
949
+ "url": "https://opencollective.com/libvips"
950
+ },
951
+ "optionalDependencies": {
952
+ "@img/sharp-libvips-linux-x64": "1.2.4"
953
+ }
954
+ },
955
+ "node_modules/@img/sharp-linuxmusl-arm64": {
956
+ "version": "0.34.5",
957
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
958
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
959
+ "cpu": [
960
+ "arm64"
961
+ ],
962
+ "dev": true,
963
+ "license": "Apache-2.0",
964
+ "optional": true,
965
+ "os": [
966
+ "linux"
967
+ ],
968
+ "engines": {
969
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
970
+ },
971
+ "funding": {
972
+ "url": "https://opencollective.com/libvips"
973
+ },
974
+ "optionalDependencies": {
975
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
976
+ }
977
+ },
978
+ "node_modules/@img/sharp-linuxmusl-x64": {
979
+ "version": "0.34.5",
980
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
981
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
982
+ "cpu": [
983
+ "x64"
984
+ ],
985
+ "dev": true,
986
+ "license": "Apache-2.0",
987
+ "optional": true,
988
+ "os": [
989
+ "linux"
990
+ ],
991
+ "engines": {
992
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
993
+ },
994
+ "funding": {
995
+ "url": "https://opencollective.com/libvips"
996
+ },
997
+ "optionalDependencies": {
998
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
999
+ }
1000
+ },
1001
+ "node_modules/@img/sharp-wasm32": {
1002
+ "version": "0.34.5",
1003
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
1004
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
1005
+ "cpu": [
1006
+ "wasm32"
1007
+ ],
1008
+ "dev": true,
1009
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
1010
+ "optional": true,
1011
+ "dependencies": {
1012
+ "@emnapi/runtime": "^1.7.0"
1013
+ },
1014
+ "engines": {
1015
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1016
+ },
1017
+ "funding": {
1018
+ "url": "https://opencollective.com/libvips"
1019
+ }
1020
+ },
1021
+ "node_modules/@img/sharp-win32-arm64": {
1022
+ "version": "0.34.5",
1023
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
1024
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
1025
+ "cpu": [
1026
+ "arm64"
1027
+ ],
1028
+ "dev": true,
1029
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1030
+ "optional": true,
1031
+ "os": [
1032
+ "win32"
1033
+ ],
1034
+ "engines": {
1035
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1036
+ },
1037
+ "funding": {
1038
+ "url": "https://opencollective.com/libvips"
1039
+ }
1040
+ },
1041
+ "node_modules/@img/sharp-win32-ia32": {
1042
+ "version": "0.34.5",
1043
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
1044
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
1045
+ "cpu": [
1046
+ "ia32"
1047
+ ],
1048
+ "dev": true,
1049
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1050
+ "optional": true,
1051
+ "os": [
1052
+ "win32"
1053
+ ],
1054
+ "engines": {
1055
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1056
+ },
1057
+ "funding": {
1058
+ "url": "https://opencollective.com/libvips"
1059
+ }
1060
+ },
1061
+ "node_modules/@img/sharp-win32-x64": {
1062
+ "version": "0.34.5",
1063
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
1064
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
1065
+ "cpu": [
1066
+ "x64"
1067
+ ],
1068
+ "dev": true,
1069
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1070
+ "optional": true,
1071
+ "os": [
1072
+ "win32"
1073
+ ],
1074
+ "engines": {
1075
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1076
+ },
1077
+ "funding": {
1078
+ "url": "https://opencollective.com/libvips"
1079
+ }
1080
+ },
1081
+ "node_modules/@jridgewell/resolve-uri": {
1082
+ "version": "3.1.2",
1083
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
1084
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
1085
+ "dev": true,
1086
+ "license": "MIT",
1087
+ "engines": {
1088
+ "node": ">=6.0.0"
1089
+ }
1090
+ },
1091
+ "node_modules/@jridgewell/sourcemap-codec": {
1092
+ "version": "1.5.5",
1093
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
1094
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
1095
+ "dev": true,
1096
+ "license": "MIT"
1097
+ },
1098
+ "node_modules/@jridgewell/trace-mapping": {
1099
+ "version": "0.3.9",
1100
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
1101
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
1102
+ "dev": true,
1103
+ "license": "MIT",
1104
+ "dependencies": {
1105
+ "@jridgewell/resolve-uri": "^3.0.3",
1106
+ "@jridgewell/sourcemap-codec": "^1.4.10"
1107
+ }
1108
+ },
1109
+ "node_modules/@poppinss/colors": {
1110
+ "version": "4.1.6",
1111
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
1112
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
1113
+ "dev": true,
1114
+ "license": "MIT",
1115
+ "dependencies": {
1116
+ "kleur": "^4.1.5"
1117
+ }
1118
+ },
1119
+ "node_modules/@poppinss/dumper": {
1120
+ "version": "0.6.5",
1121
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
1122
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
1123
+ "dev": true,
1124
+ "license": "MIT",
1125
+ "dependencies": {
1126
+ "@poppinss/colors": "^4.1.5",
1127
+ "@sindresorhus/is": "^7.0.2",
1128
+ "supports-color": "^10.0.0"
1129
+ }
1130
+ },
1131
+ "node_modules/@poppinss/exception": {
1132
+ "version": "1.2.3",
1133
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
1134
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
1135
+ "dev": true,
1136
+ "license": "MIT"
1137
+ },
1138
+ "node_modules/@sindresorhus/is": {
1139
+ "version": "7.2.0",
1140
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
1141
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
1142
+ "dev": true,
1143
+ "license": "MIT",
1144
+ "engines": {
1145
+ "node": ">=18"
1146
+ },
1147
+ "funding": {
1148
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
1149
+ }
1150
+ },
1151
+ "node_modules/@speed-highlight/core": {
1152
+ "version": "1.2.14",
1153
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
1154
+ "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
1155
+ "dev": true,
1156
+ "license": "CC0-1.0"
1157
+ },
1158
+ "node_modules/blake3-wasm": {
1159
+ "version": "2.1.5",
1160
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
1161
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
1162
+ "dev": true,
1163
+ "license": "MIT"
1164
+ },
1165
+ "node_modules/cookie": {
1166
+ "version": "1.1.1",
1167
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
1168
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
1169
+ "dev": true,
1170
+ "license": "MIT",
1171
+ "engines": {
1172
+ "node": ">=18"
1173
+ },
1174
+ "funding": {
1175
+ "type": "opencollective",
1176
+ "url": "https://opencollective.com/express"
1177
+ }
1178
+ },
1179
+ "node_modules/detect-libc": {
1180
+ "version": "2.1.2",
1181
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1182
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1183
+ "dev": true,
1184
+ "license": "Apache-2.0",
1185
+ "engines": {
1186
+ "node": ">=8"
1187
+ }
1188
+ },
1189
+ "node_modules/error-stack-parser-es": {
1190
+ "version": "1.0.5",
1191
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
1192
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
1193
+ "dev": true,
1194
+ "license": "MIT",
1195
+ "funding": {
1196
+ "url": "https://github.com/sponsors/antfu"
1197
+ }
1198
+ },
1199
+ "node_modules/esbuild": {
1200
+ "version": "0.27.3",
1201
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
1202
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
1203
+ "dev": true,
1204
+ "hasInstallScript": true,
1205
+ "license": "MIT",
1206
+ "bin": {
1207
+ "esbuild": "bin/esbuild"
1208
+ },
1209
+ "engines": {
1210
+ "node": ">=18"
1211
+ },
1212
+ "optionalDependencies": {
1213
+ "@esbuild/aix-ppc64": "0.27.3",
1214
+ "@esbuild/android-arm": "0.27.3",
1215
+ "@esbuild/android-arm64": "0.27.3",
1216
+ "@esbuild/android-x64": "0.27.3",
1217
+ "@esbuild/darwin-arm64": "0.27.3",
1218
+ "@esbuild/darwin-x64": "0.27.3",
1219
+ "@esbuild/freebsd-arm64": "0.27.3",
1220
+ "@esbuild/freebsd-x64": "0.27.3",
1221
+ "@esbuild/linux-arm": "0.27.3",
1222
+ "@esbuild/linux-arm64": "0.27.3",
1223
+ "@esbuild/linux-ia32": "0.27.3",
1224
+ "@esbuild/linux-loong64": "0.27.3",
1225
+ "@esbuild/linux-mips64el": "0.27.3",
1226
+ "@esbuild/linux-ppc64": "0.27.3",
1227
+ "@esbuild/linux-riscv64": "0.27.3",
1228
+ "@esbuild/linux-s390x": "0.27.3",
1229
+ "@esbuild/linux-x64": "0.27.3",
1230
+ "@esbuild/netbsd-arm64": "0.27.3",
1231
+ "@esbuild/netbsd-x64": "0.27.3",
1232
+ "@esbuild/openbsd-arm64": "0.27.3",
1233
+ "@esbuild/openbsd-x64": "0.27.3",
1234
+ "@esbuild/openharmony-arm64": "0.27.3",
1235
+ "@esbuild/sunos-x64": "0.27.3",
1236
+ "@esbuild/win32-arm64": "0.27.3",
1237
+ "@esbuild/win32-ia32": "0.27.3",
1238
+ "@esbuild/win32-x64": "0.27.3"
1239
+ }
1240
+ },
1241
+ "node_modules/fsevents": {
1242
+ "version": "2.3.3",
1243
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1244
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1245
+ "dev": true,
1246
+ "hasInstallScript": true,
1247
+ "license": "MIT",
1248
+ "optional": true,
1249
+ "os": [
1250
+ "darwin"
1251
+ ],
1252
+ "engines": {
1253
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1254
+ }
1255
+ },
1256
+ "node_modules/kleur": {
1257
+ "version": "4.1.5",
1258
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
1259
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
1260
+ "dev": true,
1261
+ "license": "MIT",
1262
+ "engines": {
1263
+ "node": ">=6"
1264
+ }
1265
+ },
1266
+ "node_modules/miniflare": {
1267
+ "version": "4.20260302.0",
1268
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260302.0.tgz",
1269
+ "integrity": "sha512-joGFywlo7HdfHXXGOkc6tDCVkwjEncM0mwEsMOLWcl+vDVJPj9HRV7JtEa0+lCpNOLdYw7mZNHYe12xz9KtJOw==",
1270
+ "dev": true,
1271
+ "license": "MIT",
1272
+ "dependencies": {
1273
+ "@cspotcode/source-map-support": "0.8.1",
1274
+ "sharp": "^0.34.5",
1275
+ "undici": "7.18.2",
1276
+ "workerd": "1.20260302.0",
1277
+ "ws": "8.18.0",
1278
+ "youch": "4.1.0-beta.10"
1279
+ },
1280
+ "bin": {
1281
+ "miniflare": "bootstrap.js"
1282
+ },
1283
+ "engines": {
1284
+ "node": ">=18.0.0"
1285
+ }
1286
+ },
1287
+ "node_modules/path-to-regexp": {
1288
+ "version": "6.3.0",
1289
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
1290
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
1291
+ "dev": true,
1292
+ "license": "MIT"
1293
+ },
1294
+ "node_modules/pathe": {
1295
+ "version": "2.0.3",
1296
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1297
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1298
+ "dev": true,
1299
+ "license": "MIT"
1300
+ },
1301
+ "node_modules/semver": {
1302
+ "version": "7.7.4",
1303
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
1304
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
1305
+ "dev": true,
1306
+ "license": "ISC",
1307
+ "bin": {
1308
+ "semver": "bin/semver.js"
1309
+ },
1310
+ "engines": {
1311
+ "node": ">=10"
1312
+ }
1313
+ },
1314
+ "node_modules/sharp": {
1315
+ "version": "0.34.5",
1316
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
1317
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
1318
+ "dev": true,
1319
+ "hasInstallScript": true,
1320
+ "license": "Apache-2.0",
1321
+ "dependencies": {
1322
+ "@img/colour": "^1.0.0",
1323
+ "detect-libc": "^2.1.2",
1324
+ "semver": "^7.7.3"
1325
+ },
1326
+ "engines": {
1327
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1328
+ },
1329
+ "funding": {
1330
+ "url": "https://opencollective.com/libvips"
1331
+ },
1332
+ "optionalDependencies": {
1333
+ "@img/sharp-darwin-arm64": "0.34.5",
1334
+ "@img/sharp-darwin-x64": "0.34.5",
1335
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
1336
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
1337
+ "@img/sharp-libvips-linux-arm": "1.2.4",
1338
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
1339
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
1340
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
1341
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
1342
+ "@img/sharp-libvips-linux-x64": "1.2.4",
1343
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
1344
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
1345
+ "@img/sharp-linux-arm": "0.34.5",
1346
+ "@img/sharp-linux-arm64": "0.34.5",
1347
+ "@img/sharp-linux-ppc64": "0.34.5",
1348
+ "@img/sharp-linux-riscv64": "0.34.5",
1349
+ "@img/sharp-linux-s390x": "0.34.5",
1350
+ "@img/sharp-linux-x64": "0.34.5",
1351
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
1352
+ "@img/sharp-linuxmusl-x64": "0.34.5",
1353
+ "@img/sharp-wasm32": "0.34.5",
1354
+ "@img/sharp-win32-arm64": "0.34.5",
1355
+ "@img/sharp-win32-ia32": "0.34.5",
1356
+ "@img/sharp-win32-x64": "0.34.5"
1357
+ }
1358
+ },
1359
+ "node_modules/supports-color": {
1360
+ "version": "10.2.2",
1361
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
1362
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
1363
+ "dev": true,
1364
+ "license": "MIT",
1365
+ "engines": {
1366
+ "node": ">=18"
1367
+ },
1368
+ "funding": {
1369
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
1370
+ }
1371
+ },
1372
+ "node_modules/tslib": {
1373
+ "version": "2.8.1",
1374
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1375
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1376
+ "dev": true,
1377
+ "license": "0BSD",
1378
+ "optional": true
1379
+ },
1380
+ "node_modules/undici": {
1381
+ "version": "7.18.2",
1382
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
1383
+ "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
1384
+ "dev": true,
1385
+ "license": "MIT",
1386
+ "engines": {
1387
+ "node": ">=20.18.1"
1388
+ }
1389
+ },
1390
+ "node_modules/unenv": {
1391
+ "version": "2.0.0-rc.24",
1392
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
1393
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
1394
+ "dev": true,
1395
+ "license": "MIT",
1396
+ "dependencies": {
1397
+ "pathe": "^2.0.3"
1398
+ }
1399
+ },
1400
+ "node_modules/workerd": {
1401
+ "version": "1.20260302.0",
1402
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260302.0.tgz",
1403
+ "integrity": "sha512-FhNdC8cenMDllI6bTktFgxP5Bn5ZEnGtofgKipY6pW9jtq708D1DeGI6vGad78KQLBGaDwFy1eThjCoLYgFfog==",
1404
+ "dev": true,
1405
+ "hasInstallScript": true,
1406
+ "license": "Apache-2.0",
1407
+ "bin": {
1408
+ "workerd": "bin/workerd"
1409
+ },
1410
+ "engines": {
1411
+ "node": ">=16"
1412
+ },
1413
+ "optionalDependencies": {
1414
+ "@cloudflare/workerd-darwin-64": "1.20260302.0",
1415
+ "@cloudflare/workerd-darwin-arm64": "1.20260302.0",
1416
+ "@cloudflare/workerd-linux-64": "1.20260302.0",
1417
+ "@cloudflare/workerd-linux-arm64": "1.20260302.0",
1418
+ "@cloudflare/workerd-windows-64": "1.20260302.0"
1419
+ }
1420
+ },
1421
+ "node_modules/wrangler": {
1422
+ "version": "4.68.0",
1423
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.68.0.tgz",
1424
+ "integrity": "sha512-DCjl2ZfjwWV10iH4Zn+97isitPkb7BYxpbt4E/Okd/QKLFTp9xdwoa999UN9lugToqPm5Zz/UsRu6hpKZuT8BA==",
1425
+ "dev": true,
1426
+ "license": "MIT OR Apache-2.0",
1427
+ "dependencies": {
1428
+ "@cloudflare/kv-asset-handler": "0.4.2",
1429
+ "@cloudflare/unenv-preset": "2.14.0",
1430
+ "blake3-wasm": "2.1.5",
1431
+ "esbuild": "0.27.3",
1432
+ "miniflare": "4.20260302.0",
1433
+ "path-to-regexp": "6.3.0",
1434
+ "unenv": "2.0.0-rc.24",
1435
+ "workerd": "1.20260302.0"
1436
+ },
1437
+ "bin": {
1438
+ "wrangler": "bin/wrangler.js",
1439
+ "wrangler2": "bin/wrangler.js"
1440
+ },
1441
+ "engines": {
1442
+ "node": ">=20.0.0"
1443
+ },
1444
+ "optionalDependencies": {
1445
+ "fsevents": "~2.3.2"
1446
+ },
1447
+ "peerDependencies": {
1448
+ "@cloudflare/workers-types": "^4.20260302.0"
1449
+ },
1450
+ "peerDependenciesMeta": {
1451
+ "@cloudflare/workers-types": {
1452
+ "optional": true
1453
+ }
1454
+ }
1455
+ },
1456
+ "node_modules/ws": {
1457
+ "version": "8.18.0",
1458
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
1459
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
1460
+ "dev": true,
1461
+ "license": "MIT",
1462
+ "engines": {
1463
+ "node": ">=10.0.0"
1464
+ },
1465
+ "peerDependencies": {
1466
+ "bufferutil": "^4.0.1",
1467
+ "utf-8-validate": ">=5.0.2"
1468
+ },
1469
+ "peerDependenciesMeta": {
1470
+ "bufferutil": {
1471
+ "optional": true
1472
+ },
1473
+ "utf-8-validate": {
1474
+ "optional": true
1475
+ }
1476
+ }
1477
+ },
1478
+ "node_modules/youch": {
1479
+ "version": "4.1.0-beta.10",
1480
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
1481
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
1482
+ "dev": true,
1483
+ "license": "MIT",
1484
+ "dependencies": {
1485
+ "@poppinss/colors": "^4.1.5",
1486
+ "@poppinss/dumper": "^0.6.4",
1487
+ "@speed-highlight/core": "^1.2.7",
1488
+ "cookie": "^1.0.2",
1489
+ "youch-core": "^0.3.3"
1490
+ }
1491
+ },
1492
+ "node_modules/youch-core": {
1493
+ "version": "0.3.3",
1494
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
1495
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
1496
+ "dev": true,
1497
+ "license": "MIT",
1498
+ "dependencies": {
1499
+ "@poppinss/exception": "^1.2.2",
1500
+ "error-stack-parser-es": "^1.0.5"
1501
+ }
1502
+ }
1503
+ }
1504
+ }
pulsetransit-worker/package.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "pulsetransit-worker",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "deploy": "wrangler deploy",
7
+ "dev": "wrangler dev --test-scheduled",
8
+ "start": "wrangler dev --test-scheduled"
9
+ },
10
+ "devDependencies": {
11
+ "wrangler": "^4.68.0"
12
+ }
13
+ }
pulsetransit-worker/schema.sql ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS estimaciones (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ collected_at TEXT NOT NULL,
4
+ parada_id INTEGER,
5
+ linea TEXT,
6
+ fech_actual TEXT,
7
+ tiempo1 INTEGER,
8
+ tiempo2 INTEGER,
9
+ distancia1 INTEGER,
10
+ distancia2 INTEGER,
11
+ destino1 TEXT,
12
+ destino2 TEXT,
13
+ predicted_arrival TEXT,
14
+ UNIQUE(parada_id, linea, fech_actual)
15
+ );
16
+
17
+ CREATE INDEX IF NOT EXISTS idx_est_parada ON estimaciones(parada_id);
18
+ CREATE INDEX IF NOT EXISTS idx_est_linea ON estimaciones(linea);
19
+ CREATE INDEX IF NOT EXISTS idx_est_arrival ON estimaciones(predicted_arrival);
20
+
21
+ CREATE TABLE IF NOT EXISTS posiciones (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ collected_at TEXT NOT NULL,
24
+ instante TEXT NOT NULL,
25
+ vehiculo INTEGER,
26
+ linea INTEGER,
27
+ lat REAL,
28
+ lon REAL,
29
+ velocidad INTEGER,
30
+ estado INTEGER,
31
+ UNIQUE(vehiculo, instante)
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_pos_instant ON posiciones(instante);
35
+ CREATE INDEX IF NOT EXISTS idx_pos_linea ON posiciones(linea);
36
+ CREATE INDEX IF NOT EXISTS idx_pos_vehiculo ON posiciones(vehiculo);
pulsetransit-worker/src/index.js ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ async fetch(req, env, ctx) {
3
+ const url = new URL(req.url);
4
+
5
+ if (url.pathname === "/trigger") {
6
+ await collectEstimaciones(env);
7
+ await new Promise(r => setTimeout(r, 1000));
8
+ await collectPosiciones(env);
9
+ return new Response("Triggered estimaciones+posiciones");
10
+ }
11
+
12
+ if (url.pathname === "/health") {
13
+ const lastEst = await env.DB.prepare(
14
+ "SELECT MAX(collected_at) as last FROM estimaciones"
15
+ ).first();
16
+ const lastPos = await env.DB.prepare(
17
+ "SELECT MAX(collected_at) as last FROM posiciones"
18
+ ).first();
19
+
20
+ return Response.json({
21
+ status: "ok",
22
+ last_estimaciones: lastEst?.last,
23
+ last_posiciones: lastPos?.last
24
+ });
25
+ }
26
+ if (url.pathname === "/badge") {
27
+ const lastEst = await env.DB.prepare(
28
+ "SELECT MAX(collected_at) as last FROM estimaciones"
29
+ ).first();
30
+
31
+ const now = new Date();
32
+ const lastTime = lastEst?.last ? new Date(lastEst.last) : null;
33
+ const ageSeconds = lastTime ? Math.floor((now - lastTime) / 1000) : Infinity;
34
+ const hourUTC = now.getUTCHours();
35
+ const inServiceHours = hourUTC >= 5 && hourUTC < 23; // 6am-midnight CET
36
+
37
+ let message, color;
38
+
39
+ if (!lastTime) {
40
+ message = "no data";
41
+ color = "lightgrey";
42
+ } else if (inServiceHours && ageSeconds > 1800) {
43
+ message = `stale ${Math.floor(ageSeconds / 60)}m`;
44
+ color = "red";
45
+ } else if (inServiceHours && ageSeconds > 600) {
46
+ message = `stale ${Math.floor(ageSeconds / 60)}m`;
47
+ color = "yellow";
48
+ } else if (!inServiceHours) {
49
+ message = "off hours";
50
+ color = "blue";
51
+ } else {
52
+ message = "live";
53
+ color = "brightgreen";
54
+ }
55
+
56
+ return Response.json({
57
+ schemaVersion: 1,
58
+ label: "TUS worker",
59
+ message,
60
+ color
61
+ }, {
62
+ headers: { "Cache-Control": "no-cache, max-age=0" }
63
+ });
64
+ }
65
+
66
+
67
+ return new Response("pulsetransit-worker running");
68
+ },
69
+
70
+ async scheduled(event, env, ctx) {
71
+ if (event.cron === "0 * * * *") {
72
+ await collectPosiciones(env);
73
+ } else if (event.cron === "*/2 * * * *") {
74
+ await collectEstimaciones(env);
75
+ }
76
+ },
77
+ };
78
+
79
+
80
+ async function collectEstimaciones(env) {
81
+ const url = "https://datos.santander.es/api/rest/datasets/control_flotas_estimaciones.json?rows=5000";
82
+ const resp = await fetch(url, { signal: AbortSignal.timeout(25000) });
83
+ if (!resp.ok) throw new Error(`API fetch failed: ${resp.status}`);
84
+
85
+ const json = await resp.json();
86
+ const rows = json.resources ?? [];
87
+ const collectedAt = new Date().toISOString();
88
+
89
+ let inserted = 0;
90
+ for (const item of rows) {
91
+ const fechActual = item["ayto:fechActual"] ?? null;
92
+ const tiempo1 = item["ayto:tiempo1"] ?? null;
93
+
94
+ let predictedArrival = null;
95
+ if (fechActual && tiempo1 !== null) {
96
+ try {
97
+ const t = new Date(fechActual);
98
+ t.setSeconds(t.getSeconds() + Number(tiempo1));
99
+ predictedArrival = t.toISOString();
100
+ } catch (_) { }
101
+ }
102
+
103
+ const result = await env.DB.prepare(`
104
+ INSERT OR IGNORE INTO estimaciones
105
+ (collected_at, parada_id, linea, fech_actual, tiempo1, tiempo2,
106
+ distancia1, distancia2, destino1, destino2, predicted_arrival)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
108
+ `).bind(
109
+ collectedAt,
110
+ item["ayto:paradaId"] ?? null,
111
+ item["ayto:etiqLinea"] ?? null,
112
+ fechActual,
113
+ tiempo1,
114
+ item["ayto:tiempo2"] ?? null,
115
+ item["ayto:distancia1"] ?? null,
116
+ item["ayto:distancia2"] ?? null,
117
+ item["ayto:destino1"] ?? null,
118
+ item["ayto:destino2"] ?? null,
119
+ predictedArrival,
120
+ ).run();
121
+
122
+ if (result.meta.changes > 0) inserted++;
123
+ }
124
+
125
+ console.log(`[${collectedAt}] estimaciones: ${inserted} new rows from ${rows.length} fetched`);
126
+ }
127
+
128
+ async function collectPosiciones(env) {
129
+ const url = "https://datos.santander.es/api/rest/datasets/control_flotas_posiciones.json?rows=5000";
130
+ const resp = await fetch(url, { signal: AbortSignal.timeout(25000) });
131
+ if (!resp.ok) throw new Error(`API fetch failed: ${resp.status}`);
132
+
133
+ const json = await resp.json();
134
+ const rows = json.resources ?? [];
135
+ const collectedAt = new Date().toISOString();
136
+
137
+ let inserted = 0;
138
+ for (const item of rows) {
139
+ const result = await env.DB.prepare(`
140
+ INSERT OR IGNORE INTO posiciones
141
+ (collected_at, instante, vehiculo, linea, lat, lon, velocidad, estado)
142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
143
+ `).bind(
144
+ collectedAt,
145
+ item["ayto:instante"] ?? null,
146
+ item["ayto:vehiculo"] ?? null,
147
+ item["ayto:linea"] ?? null,
148
+ item["wgs84_pos:lat"] ?? null,
149
+ item["wgs84_pos:long"] ?? null,
150
+ item["ayto:velocidad"] ?? null,
151
+ item["ayto:estado"] ?? null,
152
+ ).run();
153
+
154
+ if (result.meta.changes > 0) inserted++;
155
+ }
156
+
157
+ console.log(`[${collectedAt}] posiciones: ${inserted} new rows from ${rows.length} fetched`);
158
+ }
pulsetransit-worker/wrangler.jsonc ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * For more details on how to configure Wrangler, refer to:
3
+ * https://developers.cloudflare.com/workers/wrangler/configuration/
4
+ */
5
+ {
6
+ "$schema": "node_modules/wrangler/config-schema.json",
7
+ "name": "pulsetransit-worker",
8
+ "main": "src/index.js",
9
+ "compatibility_date": "2026-02-24",
10
+ "observability": { "enabled": true },
11
+ "compatibility_flags": ["nodejs_compat"],
12
+ "triggers": {
13
+ "crons": ["*/2 * * * *", "0 * * * *"] // estimaciones every 5min, posiciones every hour
14
+ },
15
+ "d1_databases": [
16
+ {
17
+ "binding": "DB",
18
+ "database_name": "pulsetransit-db",
19
+ "database_id": "14eda04e-aa18-4fb4-a6cb-b3ca5c333cb9"
20
+ }
21
+ ]
22
+ }
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pulsetransit"
7
+ version = "0.4.0"
8
+ description = "TUS Santander bus data pipeline and delay prediction"
9
+ readme = "README.md"
10
+ dependencies = [
11
+ "pandas",
12
+ "plotly",
13
+ "streamlit",
14
+ "streamlit_js_eval",
15
+ ]
16
+ requires-python = ">=3.10"
17
+ license = { text = "MIT" }
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
src/pulsetransit/__init__.py ADDED
File without changes
src/pulsetransit/cfg/config.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LANG = {
2
+ "en": {
3
+ "title": "TUS Santander Tracker",
4
+ "subtitle": "Public transport network visualization",
5
+ "browse_tab": "Browse",
6
+ "plan_tab": "Plan",
7
+ "search_stop": "Search stop",
8
+ "search_placeholder": "Type stop ID or name to search...",
9
+ "click_info": "Click a stop on the map or use the search bar",
10
+ "scheduled_departures": "Scheduled Departures",
11
+ "no_departures": "No upcoming departures found for this stop.",
12
+ "line": "Line",
13
+ "destination": "Destination",
14
+ "time": "Time",
15
+ "in": "In",
16
+ "min": "min",
17
+ "now": "Now",
18
+ "plan_trip": "Plan Your Trip",
19
+ "query_time": "Query time",
20
+ "query_time_help": "Show schedules for this time of day",
21
+ "coming_soon": " COMING SOON: \n - 📍 Live bus positions, \n - ⏱️ Real-time arrival predictions (estimaciones), and \n3) 💻 ML-enhanced predictions (currently collecting training data)",
22
+ "stops":"Stops",
23
+ "selected_stop": "Selected Stop"
24
+ },
25
+ "es": {
26
+ "title": "TUS Santander Tracker",
27
+ "subtitle": "Visualización de la red de transporte público",
28
+ "browse_tab": "Explorar",
29
+ "plan_tab": "Planificar",
30
+ "search_stop": "Buscar parada",
31
+ "search_placeholder": "Escribe el nombre o el número de parada...",
32
+ "click_info": "Pulsa en una parada del mapa o usa el buscador",
33
+ "scheduled_departures": "Próximas Salidas",
34
+ "no_departures": "No se encontraron salidas próximas para esta parada.",
35
+ "line": "Línea",
36
+ "destination": "Destino",
37
+ "time": "Hora",
38
+ "in": "En",
39
+ "min": "min",
40
+ "now": "Ahora",
41
+ "plan_trip": "Planifica tu Viaje",
42
+ "query_time": "Hora de consulta",
43
+ "query_time_help": "Mostrar horarios para esta hora del día",
44
+ "coming_soon": " PRÓXIMAMENTE:\n - 📍 Posiciones en vivo de autobuses, \n - ⏱️ Predicciones de llegada en tiempo real (estimaciones), \n - 💻 Predicciones mejoradas con ML (actualmente recopilando datos de entrenamiento)",
45
+ "stops":"Paradas",
46
+ "selected_stop": "Parada Seleccionada"
47
+
48
+ }
49
+ }
src/pulsetransit/collector.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # collector.py
2
+ import urllib.request
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pulsetransit.db import get_connection, init_db
6
+
7
+
8
+ def fetch_json(dataset, rows=5000):
9
+ url = f"http://datos.santander.es/api/rest/datasets/{dataset}.json?rows={rows}"
10
+ with urllib.request.urlopen(url, timeout=30) as r:
11
+ return json.loads(r.read()).get("resources", [])
12
+
13
+ def collect_estimaciones(conn):
14
+ collected_at = datetime.now(timezone.utc).isoformat()
15
+ rows = fetch_json("control_flotas_estimaciones")
16
+ inserted = 0
17
+ for item in rows:
18
+ fech_actual = item.get("ayto:fechActual")
19
+ tiempo1 = item.get("ayto:tiempo1")
20
+ # Compute predicted arrival = fechActual + tiempo1 seconds
21
+ predicted_arrival = None
22
+ if fech_actual and tiempo1 is not None:
23
+ try:
24
+ from datetime import timedelta
25
+ t = datetime.fromisoformat(fech_actual.replace("Z", "+00:00"))
26
+ predicted_arrival = (t + timedelta(seconds=int(tiempo1))).isoformat()
27
+ except Exception:
28
+ pass
29
+ try:
30
+ conn.execute("""
31
+ INSERT OR IGNORE INTO estimaciones
32
+ (collected_at, parada_id, linea, fech_actual, tiempo1, tiempo2,
33
+ distancia1, distancia2, destino1, destino2, predicted_arrival)
34
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)
35
+ """, (
36
+ collected_at,
37
+ item.get("ayto:paradaId"),
38
+ item.get("ayto:etiqLinea"),
39
+ fech_actual,
40
+ tiempo1,
41
+ item.get("ayto:tiempo2"),
42
+ item.get("ayto:distancia1"),
43
+ item.get("ayto:distancia2"),
44
+ item.get("ayto:destino1"),
45
+ item.get("ayto:destino2"),
46
+ predicted_arrival
47
+ ))
48
+ inserted += conn.execute("SELECT changes()").fetchone()[0]
49
+ except Exception as e:
50
+ print(f" estimaciones insert error: {e}")
51
+ conn.commit()
52
+ print(f"[{collected_at}] estimaciones: {inserted} new rows from {len(rows)} fetched")
53
+
54
+ def collect_posiciones(conn):
55
+ collected_at = datetime.now(timezone.utc).isoformat()
56
+ rows = fetch_json("control_flotas_posiciones")
57
+ inserted = 0
58
+ for item in rows:
59
+ try:
60
+ conn.execute("""
61
+ INSERT OR IGNORE INTO posiciones
62
+ (collected_at, instante, vehiculo, linea, lat, lon, velocidad, estado)
63
+ VALUES (?,?,?,?,?,?,?,?)
64
+ """, (
65
+ collected_at,
66
+ item.get("ayto:instante"),
67
+ item.get("ayto:vehiculo"),
68
+ item.get("ayto:linea"),
69
+ item.get("wgs84_pos:lat"),
70
+ item.get("wgs84_pos:long"),
71
+ item.get("ayto:velocidad"),
72
+ item.get("ayto:estado"),
73
+ ))
74
+ inserted += conn.execute("SELECT changes()").fetchone()[0]
75
+ except Exception as e:
76
+ print(f" posiciones insert error: {e}")
77
+ conn.commit()
78
+ print(f"[{collected_at}] posiciones: {inserted} new rows from {len(rows)} fetched")
79
+
80
+ if __name__ == "__main__":
81
+ import sys
82
+ conn = get_connection()
83
+ init_db(conn)
84
+ mode = sys.argv[1] if len(sys.argv) > 1 else "both"
85
+ if mode in ("estimaciones", "both"):
86
+ collect_estimaciones(conn)
87
+ if mode in ("posiciones", "both"):
88
+ collect_posiciones(conn)
89
+ conn.close()
src/pulsetransit/dashboard/app.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import streamlit as st
3
+ import pandas as pd
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from pulsetransit.dashboard.map import (
8
+ build_map,
9
+ load_stops,
10
+ load_shapes,
11
+ load_trips,
12
+ load_routes,
13
+ )
14
+ from pulsetransit.dashboard.schedules import get_next_departures
15
+ from pulsetransit.cfg.config import LANG
16
+
17
+ def render_interactive_map(stops, shapes, trips, routes, highlight_stop_id=None, lang_code='es'):
18
+ """Render map and handle click interactions"""
19
+ fig = build_map(
20
+ stops=stops,
21
+ shapes=shapes,
22
+ trips=trips,
23
+ routes=routes,
24
+ highlight_stop_id=highlight_stop_id,
25
+ lang_code=lang_code
26
+ )
27
+
28
+ selected_point = st.plotly_chart(
29
+ fig,
30
+ width='stretch',
31
+ on_select="rerun",
32
+ selection_mode="points",
33
+ key="transit_map",
34
+ )
35
+
36
+ if selected_point and "selection" in selected_point:
37
+ points = selected_point["selection"].get("points", [])
38
+ if points:
39
+ point = points[0]
40
+ clicked_lat = point["lat"]
41
+ clicked_lon = point["lon"]
42
+
43
+ stops["dist"] = (
44
+ (stops["stop_lat"] - clicked_lat) ** 2 +
45
+ (stops["stop_lon"] - clicked_lon) ** 2
46
+ )
47
+ nearest_stop = stops.loc[stops["dist"].idxmin()]
48
+ new_stop_id = int(nearest_stop["stop_id"])
49
+
50
+ if new_stop_id != st.session_state.clicked_stop_id:
51
+ st.session_state.clicked_stop_id = new_stop_id
52
+ st.rerun()
53
+
54
+ def display_stop_schedule(active_stop_id, stops, t):
55
+ """Display schedule for a given stop"""
56
+ st.subheader(t["scheduled_departures"])
57
+ stop_info = stops[stops["stop_id"] == active_stop_id].iloc[0]
58
+ stop_name = stop_info["stop_name"]
59
+
60
+ st.markdown(f"**{active_stop_id} - {stop_name}**")
61
+
62
+ reference_dt = datetime.now()
63
+ departures = get_next_departures(active_stop_id, reference_dt, limit=10)
64
+
65
+ if not departures.empty:
66
+ departures["In"] = departures["minutes_until"].apply(
67
+ lambda m: f"{m} min" if m > 0 else "Now"
68
+ )
69
+ display = departures[[
70
+ "route_short_name",
71
+ "trip_headsign",
72
+ "departure_time",
73
+ "In"
74
+ ]]
75
+ display.columns = [t["line"], t["destination"], t["time"], t["in"]]
76
+
77
+ st.dataframe(display, width='stretch', hide_index=True)
78
+ else:
79
+ st.info("No upcoming departures found for this stop.")
80
+
81
+ # Get language from query params
82
+ query_params = st.query_params
83
+ default_lang = query_params.get("lang", "es") # Default to Spanish
84
+ if default_lang not in ["en", "es"]:
85
+ default_lang = "es"
86
+
87
+ #Page config and language selector
88
+ st.set_page_config(page_title="PulseTransit - Santander TUS", layout="wide", page_icon="🚌")
89
+
90
+ st.markdown("""
91
+ <style>
92
+
93
+ /* Ensure the markdown (subtitle) doesn't have its own bottom margin */
94
+ .stMarkdown div p { margin-bottom: 0.5rem !important; }
95
+
96
+ /* Optional: fine-tune tab list position */
97
+ .stTabs [data-baseweb="tab-list"] { margin-top: -2.0rem !important; }
98
+ </style>
99
+ """, unsafe_allow_html=True)
100
+
101
+ # Language selector in header (Meteomat style)
102
+ col1, col2 = st.columns([6, 1], vertical_alignment="top")
103
+ with col2:
104
+ default_idx = 0 if default_lang == "en" else 1
105
+ lang = st.selectbox("🌐", ["🇬🇧 EN", "🇪🇸 ES"], index=default_idx, label_visibility="collapsed", key="lang_selector")
106
+ lang_code = "en" if "EN" in lang else "es"
107
+
108
+ # Update URL when language changes
109
+ if lang_code != default_lang:
110
+ st.query_params["lang"] = lang_code
111
+
112
+ # Get translations for current language
113
+ t = LANG[lang_code]
114
+
115
+ with col1:
116
+ st.title(f"🚌 {t['title']}")
117
+ st.markdown(f"**{t['subtitle']}** · Santander, España", unsafe_allow_html=True)
118
+
119
+
120
+ # Load GTFS data
121
+ stops = load_stops()
122
+ shapes = load_shapes()
123
+ trips = load_trips()
124
+ routes = load_routes()
125
+
126
+ # Initialize session state
127
+ if "clicked_stop_id" not in st.session_state:
128
+ st.session_state.clicked_stop_id = None
129
+
130
+ # MOBILE DETECTION
131
+ try:
132
+ from streamlit_js_eval import streamlit_js_eval
133
+ screen_width = streamlit_js_eval(js_expressions='window.innerWidth', key='WIDTH')
134
+ is_mobile = screen_width and screen_width < 768
135
+ except:
136
+ is_mobile = False
137
+
138
+
139
+
140
+ # TABS: Browse vs Plan
141
+ tab_browse, tab_plan = st.tabs([f"📅 {t['browse_tab']}", f"🚏 {t['plan_tab']}"])
142
+
143
+ with tab_browse:
144
+ # SEARCH ABOVE MAP
145
+ stops["search_label"] = stops["stop_id"].astype(str) + " - " + stops["stop_name"]
146
+ stop_options = [""] + stops["search_label"].tolist()
147
+ st.info(f"👆 {t['click_info']}")
148
+
149
+ selected_stop_label = st.selectbox(
150
+ t["search_stop"],
151
+ options=stop_options,
152
+ index=None,
153
+ placeholder=t["search_placeholder"],
154
+ label_visibility='collapsed'
155
+ )
156
+
157
+ # Parse selected stop
158
+ selected_stop_id = None
159
+ if selected_stop_label:
160
+ selected_stop_id = int(selected_stop_label.split(" - ")[0])
161
+
162
+ # Determine active stop: search bar > map click
163
+ if selected_stop_id:
164
+ active_stop_id = selected_stop_id
165
+ st.session_state.clicked_stop_id = None
166
+ else:
167
+ active_stop_id = st.session_state.clicked_stop_id
168
+
169
+ # RESPONSIVE LAYOUT
170
+ if is_mobile:
171
+ # Mobile: Schedules FIRST, then map
172
+ if active_stop_id: display_stop_schedule(active_stop_id, stops, t)
173
+
174
+ # Map schedules on mobile
175
+ render_interactive_map(stops, shapes, trips, routes, highlight_stop_id=active_stop_id, lang_code=lang_code)
176
+
177
+ else:
178
+ # Desktop: Full-width map until stop is selected
179
+ if active_stop_id:
180
+ # Stop selected: 2-column layout
181
+ col1, col2 = st.columns([2, 1])
182
+
183
+ with col1:
184
+ render_interactive_map(stops, shapes, trips, routes, highlight_stop_id=active_stop_id, lang_code=lang_code)
185
+
186
+ with col2:
187
+ display_stop_schedule(active_stop_id, stops, t)
188
+
189
+ else:
190
+ render_interactive_map(stops, shapes, trips, routes, highlight_stop_id=None , lang_code=lang_code)
191
+
192
+ with tab_plan:
193
+ st.subheader(t["plan_trip"])
194
+
195
+ # Query time
196
+ query_time = st.time_input(
197
+ t["query_time"],
198
+ value=datetime.now().time(),
199
+ help="Show schedules for this time of day"
200
+ )
201
+ st.info(t["coming_soon"])
src/pulsetransit/dashboard/map.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import plotly.graph_objects as go
3
+ from pathlib import Path
4
+ from pulsetransit.cfg.config import LANG
5
+ SANTANDER = dict(lat=43.4623, lon=-3.8099)
6
+ GTFS_DIR = Path("data/gtfs-static")
7
+
8
+ def load_stops() -> pd.DataFrame:
9
+ return pd.read_csv(GTFS_DIR / "stops.txt")
10
+
11
+ def load_shapes() -> pd.DataFrame:
12
+ return pd.read_csv(GTFS_DIR / "shapes.txt")
13
+
14
+ def load_routes() -> pd.DataFrame:
15
+ return pd.read_csv(GTFS_DIR / "routes.txt")
16
+
17
+ def load_trips() -> pd.DataFrame:
18
+ return pd.read_csv(GTFS_DIR / "trips.txt")
19
+
20
+ def _build_shape_colors(trips: pd.DataFrame, routes: pd.DataFrame) -> dict:
21
+ """Map shape_id → (route_short_name, #rrggbb color)."""
22
+ shape_route = trips[["shape_id", "route_id"]].drop_duplicates("shape_id")
23
+ merged = shape_route.merge(
24
+ routes[["route_id", "route_short_name", "route_color"]],
25
+ on="route_id"
26
+ )
27
+ merged["color"] = merged["route_color"].apply(
28
+ lambda c: f"#{c}" if pd.notna(c) and str(c).strip() else "#888888"
29
+ )
30
+ return merged.set_index("shape_id")[["route_short_name", "color"]].to_dict("index")
31
+
32
+ def _shapes_to_lines_colored(
33
+ shapes: pd.DataFrame,
34
+ shape_colors: dict,
35
+ lang_code: str,
36
+ ) -> list[dict]:
37
+ """One trace per route, with None separators within each."""
38
+ route_groups: dict[str, dict] = {}
39
+ t = LANG[lang_code]
40
+
41
+ for shape_id, group in shapes.groupby("shape_id", sort=False):
42
+ info = shape_colors.get(shape_id)
43
+ if info is None:
44
+ continue
45
+
46
+ color = info["color"]
47
+ name = info["route_short_name"]
48
+ if name == "99": continue # remove 99 lanzadera from list
49
+ # Group by route NAME (not color)
50
+ if name not in route_groups:
51
+ route_groups[name] = {
52
+ "name": f"{t['line']} {name}",
53
+ "color": color,
54
+ "lats": [],
55
+ "lons": []
56
+ }
57
+
58
+ pts = group.sort_values("shape_pt_sequence")
59
+ route_groups[name]["lats"].extend(pts["shape_pt_lat"].tolist() + [None])
60
+ route_groups[name]["lons"].extend(pts["shape_pt_lon"].tolist() + [None])
61
+
62
+ # Sort: LC first, then by route number
63
+ def sort_key(item):
64
+ name_key, data = item
65
+
66
+ if name_key == "LC":
67
+ return (0, 0, "")
68
+
69
+ if name_key[0].isdigit():
70
+ num_part = ""
71
+ for char in name_key:
72
+ if char.isdigit():
73
+ num_part += char
74
+ else:
75
+ break
76
+
77
+ if num_part == name_key:
78
+ return (1, int(num_part), "")
79
+ else:
80
+ suffix = name_key[len(num_part):]
81
+ return (1, int(num_part), suffix)
82
+
83
+ return (2, 0, name_key)
84
+
85
+ sorted_groups = sorted(route_groups.items(), key=sort_key)
86
+
87
+ return [{"color": v["color"], "name": v["name"], "lats": v["lats"], "lons": v["lons"]} for k, v in sorted_groups]
88
+
89
+ def _extract_arrow_points(shapes: pd.DataFrame, shape_colors: dict, interval: int = 15) -> list[dict]:
90
+ """Extract evenly-spaced arrow markers along each shape."""
91
+ arrows = []
92
+ for shape_id, group in shapes.groupby("shape_id", sort=False):
93
+ info = shape_colors.get(shape_id, {"route_short_name": "?", "color": "#888888"})
94
+ pts = group.sort_values("shape_pt_sequence")
95
+
96
+ # Sample every Nth point for arrows
97
+ sampled = pts.iloc[::interval]
98
+ if len(sampled) < 2:
99
+ continue
100
+
101
+ # Calculate bearing from current point to next point
102
+ lats, lons, angles = [], [], []
103
+ for i in range(len(sampled) - 1):
104
+ lat1, lon1 = sampled.iloc[i][["shape_pt_lat", "shape_pt_lon"]]
105
+ lat2, lon2 = sampled.iloc[i+1][["shape_pt_lat", "shape_pt_lon"]]
106
+
107
+ # Simple angle calculation (good enough for small distances)
108
+ import math
109
+ dlat = lat2 - lat1
110
+ dlon = lon2 - lon1
111
+ angle = math.degrees(math.atan2(dlon, dlat))
112
+
113
+ lats.append(lat1)
114
+ lons.append(lon1)
115
+ angles.append(angle)
116
+
117
+ arrows.append({
118
+ "lats": lats,
119
+ "lons": lons,
120
+ "angles": angles,
121
+ "color": info["color"],
122
+ "name": info["route_short_name"],
123
+ })
124
+ return arrows
125
+
126
+ def build_map(
127
+ stops: pd.DataFrame | None = None,
128
+ shapes: pd.DataFrame | None = None,
129
+ trips: pd.DataFrame | None = None,
130
+ routes: pd.DataFrame | None = None,
131
+ highlight_stop_id: int | None = None,
132
+ lang_code: str = 'es',
133
+ ) -> go.Figure:
134
+ fig = go.Figure()
135
+
136
+ if shapes is not None and trips is not None and routes is not None:
137
+ shape_colors = _build_shape_colors(trips, routes)
138
+
139
+ # Route lines
140
+ for trace in _shapes_to_lines_colored(shapes, shape_colors, lang_code):
141
+ fig.add_trace(go.Scattermap(
142
+ lat=trace["lats"],
143
+ lon=trace["lons"],
144
+ mode="lines",
145
+ line=dict(width=3, color=trace["color"]),
146
+ hoverinfo="skip",
147
+ name=trace["name"],
148
+ showlegend=True,
149
+ ))
150
+
151
+ # Direction arrows
152
+ for arrow in _extract_arrow_points(shapes, shape_colors):
153
+ fig.add_trace(go.Scattermap(
154
+ lat=arrow["lats"],
155
+ lon=arrow["lons"],
156
+ mode="markers",
157
+ marker=dict(
158
+ size=8,
159
+ color=arrow["color"],
160
+ symbol="arrow",
161
+ angle=arrow["angles"],
162
+ ),
163
+ hoverinfo="skip",
164
+ showlegend=False,
165
+ ))
166
+
167
+ elif shapes is not None:
168
+ # Fallback: no color info
169
+ lats, lons = [], []
170
+ for _, group in shapes.groupby("shape_id", sort=False):
171
+ pts = group.sort_values("shape_pt_sequence")
172
+ lats.extend(pts["shape_pt_lat"].tolist() + [None])
173
+ lons.extend(pts["shape_pt_lon"].tolist() + [None])
174
+ fig.add_trace(go.Scattermap(
175
+ lat=lats, lon=lons, mode="lines",
176
+ line=dict(width=3, color="#888888"),
177
+ hoverinfo="skip", name="Routes",
178
+ ))
179
+ t= LANG["es"]
180
+ if stops is not None:
181
+ # Separate highlighted stop from others
182
+ if highlight_stop_id is not None:
183
+ regular_stops = stops[stops["stop_id"] != highlight_stop_id]
184
+ highlighted = stops[stops["stop_id"] == highlight_stop_id]
185
+ else:
186
+ regular_stops = stops
187
+ highlighted = pd.DataFrame()
188
+
189
+ if not regular_stops.empty:
190
+ # Dark border layer
191
+ fig.add_trace(go.Scattermap(
192
+ lat=regular_stops["stop_lat"],
193
+ lon=regular_stops["stop_lon"],
194
+ mode="markers",
195
+ marker=dict(size=10, color="#333333", opacity=0.8),
196
+ hoverinfo="skip",
197
+ showlegend=False,
198
+ ))
199
+ # Visible inner circle
200
+ fig.add_trace(go.Scattermap(
201
+ lat=regular_stops["stop_lat"],
202
+ lon=regular_stops["stop_lon"],
203
+ mode="markers",
204
+ marker=dict(size=7, color="#B8B6B6", opacity=1.0),
205
+ text=regular_stops["stop_id"].astype(str) + " - " + regular_stops["stop_name"],
206
+ hovertemplate="<b>%{text}</b><extra></extra>", # ← simplified
207
+ name=t["stops"],
208
+ ))
209
+ # Highlighted stop (larger, bright color)
210
+ if not highlighted.empty:
211
+ fig.add_trace(go.Scattermap(
212
+ lat=highlighted["stop_lat"],
213
+ lon=highlighted["stop_lon"],
214
+ mode="markers",
215
+ marker=dict(size=16, color="#FF4444", opacity=0.9),
216
+ hoverinfo="skip",
217
+ showlegend=False,
218
+ ))
219
+ fig.add_trace(go.Scattermap(
220
+ lat=highlighted["stop_lat"],
221
+ lon=highlighted["stop_lon"],
222
+ mode="markers",
223
+ marker=dict(size=12, color="white", opacity=1.0),
224
+ text=highlighted["stop_id"].astype(str) + " - " + highlighted["stop_name"],
225
+ hovertemplate="<b>%{text}</b><extra></extra>",
226
+ name=t["selected_stop"],
227
+ ))
228
+
229
+ # Zoom to highlighted stop
230
+ center = dict(
231
+ lat=highlighted["stop_lat"].iloc[0],
232
+ lon=highlighted["stop_lon"].iloc[0]
233
+ )
234
+ zoom = 16 # Closer zoom
235
+ else:
236
+ center = SANTANDER
237
+ zoom = 13
238
+ else:
239
+ center = SANTANDER
240
+ zoom = 13
241
+
242
+
243
+ fig.update_layout(
244
+ map=dict(
245
+ style="open-street-map",
246
+ center=center,
247
+ zoom=zoom,
248
+ ),
249
+ legend=dict(bgcolor="rgba(255,255,255,0.8)", borderwidth=1),
250
+ margin=dict(l=0, r=0, t=0, b=0),
251
+ height=600,
252
+ )
253
+ return fig
254
+
255
+ if __name__ == "__main__":
256
+ build_map(
257
+ stops=load_stops(),
258
+ shapes=load_shapes(),
259
+ trips=load_trips(),
260
+ routes=load_routes(),
261
+ ).show()
src/pulsetransit/dashboard/schedules.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ from pathlib import Path
3
+ from datetime import datetime, time, timedelta
4
+
5
+ GTFS_DIR = Path("data/gtfs-static")
6
+
7
+ def load_stop_times() -> pd.DataFrame:
8
+ return pd.read_csv(GTFS_DIR / "stop_times.txt")
9
+
10
+ def load_trips() -> pd.DataFrame:
11
+ return pd.read_csv(GTFS_DIR / "trips.txt")
12
+
13
+ def load_routes() -> pd.DataFrame:
14
+ return pd.read_csv(GTFS_DIR / "routes.txt")
15
+
16
+ def load_calendar_dates() -> pd.DataFrame:
17
+ return pd.read_csv(GTFS_DIR / "calendar_dates.txt")
18
+
19
+
20
+ def _parse_gtfs_time(time_str: str) -> int:
21
+ """Convert GTFS time string (HH:MM:SS, possibly >24h) to minutes since midnight."""
22
+ h, m, s = map(int, time_str.split(":"))
23
+ return h * 60 + m
24
+
25
+
26
+ def get_next_departures(
27
+ stop_id: int,
28
+ reference_datetime: datetime,
29
+ limit: int = 10
30
+ ) -> pd.DataFrame:
31
+ """
32
+ Get next N scheduled departures for a stop after reference_datetime.
33
+
34
+ Returns DataFrame with columns:
35
+ - route_short_name: Line number/name
36
+ - trip_headsign: Destination
37
+ - departure_time: Original GTFS time string
38
+ - minutes_until: Minutes from now until departure
39
+ """
40
+ # Load data
41
+ stop_times = load_stop_times()
42
+ trips = load_trips()
43
+ routes = load_routes()
44
+ calendar = load_calendar_dates()
45
+
46
+ # Filter by stop
47
+ stop_schedule = stop_times[stop_times["stop_id"] == stop_id].copy()
48
+
49
+ # Join to get route and service info
50
+ stop_schedule = stop_schedule.merge(trips, on="trip_id")
51
+ stop_schedule = stop_schedule.merge(
52
+ routes[["route_id", "route_short_name", "route_color"]],
53
+ on="route_id"
54
+ )
55
+
56
+ # Filter by service active on reference date
57
+ date_str = reference_datetime.strftime("%Y%m%d")
58
+ active_services = calendar[
59
+ (calendar["date"] == int(date_str)) & (calendar["exception_type"] == 1)
60
+ ]["service_id"].unique()
61
+
62
+ stop_schedule = stop_schedule[stop_schedule["service_id"].isin(active_services)]
63
+
64
+ # Convert times to minutes since midnight
65
+ stop_schedule["departure_minutes"] = stop_schedule["departure_time"].apply(_parse_gtfs_time)
66
+ reference_minutes = reference_datetime.hour * 60 + reference_datetime.minute
67
+
68
+ # Handle overnight times: if departure > 24h, it's for "tomorrow" in GTFS terms
69
+ # but we're querying for "today", so skip those unless we're past midnight
70
+ stop_schedule["minutes_until"] = stop_schedule["departure_minutes"] - reference_minutes
71
+
72
+ # Filter future departures only
73
+ upcoming = stop_schedule[stop_schedule["minutes_until"] >= 0].copy()
74
+
75
+ # Sort and limit
76
+ upcoming = upcoming.sort_values("minutes_until").head(limit)
77
+
78
+ return upcoming[[
79
+ "route_short_name",
80
+ "trip_headsign",
81
+ "departure_time",
82
+ "minutes_until"
83
+ ]]
84
+
85
+
86
+ if __name__ == "__main__":
87
+ # Test with a real stop
88
+ stop_times = load_stop_times()
89
+ sample_stop = stop_times["stop_id"].iloc[0]
90
+
91
+ print(f"Testing stop_id: {sample_stop}")
92
+
93
+ # Test at 10:00 AM today
94
+ test_time = datetime.now().replace(hour=10, minute=0, second=0, microsecond=0)
95
+
96
+ departures = get_next_departures(sample_stop, test_time, limit=5)
97
+ print(f"\nNext 5 departures from stop {sample_stop} after {test_time.strftime('%H:%M')}:")
98
+ print(departures.to_string(index=False))
src/pulsetransit/db.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/pulsetransit/db.py
2
+ import sqlite3
3
+ from pathlib import Path
4
+
5
+ DB_PATH = Path(__file__).parent.parent.parent / "data" / "tus.db"
6
+
7
+ def get_connection():
8
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
9
+ conn = sqlite3.connect(DB_PATH)
10
+ conn.row_factory = sqlite3.Row
11
+ return conn
12
+
13
+ def init_db(conn):
14
+ conn.execute("""
15
+ CREATE TABLE IF NOT EXISTS estimaciones (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ collected_at TEXT NOT NULL,
18
+ parada_id INTEGER,
19
+ linea TEXT,
20
+ fech_actual TEXT,
21
+ tiempo1 INTEGER,
22
+ tiempo2 INTEGER,
23
+ distancia1 INTEGER,
24
+ distancia2 INTEGER,
25
+ destino1 TEXT,
26
+ destino2 TEXT,
27
+ predicted_arrival TEXT,
28
+ UNIQUE(parada_id, linea, fech_actual)
29
+ )
30
+ """)
31
+ conn.execute("""
32
+ CREATE TABLE IF NOT EXISTS posiciones (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ collected_at TEXT NOT NULL,
35
+ instante TEXT NOT NULL,
36
+ vehiculo INTEGER,
37
+ linea INTEGER,
38
+ lat REAL,
39
+ lon REAL,
40
+ velocidad INTEGER,
41
+ estado INTEGER,
42
+ UNIQUE(vehiculo, instante)
43
+ )
44
+ """)
45
+ conn.commit()
src/pulsetransit/validate.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import sys
3
+ from datetime import datetime, timezone, timedelta
4
+ from pathlib import Path
5
+
6
+ DB_PATH = Path(__file__).parent.parent.parent / "data" / "tus.db"
7
+ MAX_AGE_HOURS = 2 # fail if no data collected in last 2 hours
8
+
9
+ def check_table(conn, table, time_col):
10
+ row = conn.execute(f"SELECT COUNT(*), MAX({time_col}) FROM {table}").fetchone()
11
+ count, latest = row[0], row[1]
12
+ if not latest:
13
+ print(f" FAIL — {table}: no data at all")
14
+ return False
15
+ # Normalise timestamp
16
+ latest_dt = datetime.fromisoformat(latest.replace("Z", "+00:00"))
17
+ age = datetime.now(timezone.utc) - latest_dt
18
+ ok = age < timedelta(hours=MAX_AGE_HOURS)
19
+ status = "OK" if ok else "FAIL"
20
+ print(f" {status} — {table}: {count} rows, latest {latest_dt.strftime('%H:%M UTC')} ({age.seconds//60} min ago)")
21
+ return ok
22
+
23
+ conn = sqlite3.connect(DB_PATH)
24
+ print(f"Validating at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
25
+ results = [
26
+ check_table(conn, "estimaciones", "collected_at"),
27
+ check_table(conn, "posiciones", "instante"),
28
+ ]
29
+ conn.close()
30
+
31
+ if not all(results):
32
+ print("Validation FAILED")
33
+ sys.exit(1)
34
+
35
+ print("Validation PASSED")