From 12ecc97b07093f3cac6567c70ff75d57b429c674 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Tue, 21 Oct 2025 17:38:01 +0200 Subject: Implement new Santiago region (WIP) --- .github/workflows/update-stops-data.yml | 14 +- data/download-stops.py | 126 - data/overrides/amenities.yaml | 23 - data/overrides/fix-gregorio-espino.yaml | 20 - data/overrides/hide-virtual-stops.yaml | 17 - data/overrides/improve-coordinates-misc.yaml | 35 - data/santiago/download-stops.py | 132 + data/santiago/overrides/.gitkeep | 0 data/stop-overrides.yaml | 30 - data/vigo/download-stops.py | 126 + data/vigo/overrides/amenities.yaml | 23 + data/vigo/overrides/fix-gregorio-espino.yaml | 20 + data/vigo/overrides/hide-virtual-stops.yaml | 17 + data/vigo/overrides/improve-coordinates-misc.yaml | 35 + .../GetStopEstimates.cs | 127 - .../SantiagoController.cs | 81 + src/Costasdev.Busurbano.Backend/VigoController.cs | 127 + src/frontend/app/AppContext.tsx | 29 + src/frontend/app/components/GroupedTable.tsx | 24 +- src/frontend/app/components/LineIcon.css | 295 +- src/frontend/app/components/LineIcon.tsx | 17 +- src/frontend/app/components/RegionSelector.tsx | 33 + src/frontend/app/components/RegularTable.tsx | 23 +- src/frontend/app/components/StopItem.tsx | 7 +- src/frontend/app/components/StopSheet.tsx | 17 +- src/frontend/app/components/TimetableTable.tsx | 4 +- src/frontend/app/data/RegionConfig.ts | 49 + src/frontend/app/data/StopDataProvider.ts | 113 +- src/frontend/app/routes/estimates-$id.tsx | 54 +- src/frontend/app/routes/map.tsx | 8 +- src/frontend/app/routes/settings.tsx | 22 + src/frontend/app/routes/stoplist.tsx | 16 +- src/frontend/app/routes/timetable-$id.tsx | 35 +- src/frontend/public/pwa-worker.js | 5 +- src/frontend/public/stops.json | 15138 ------------------- src/frontend/public/stops/santiago.json | 8585 +++++++++++ src/frontend/public/stops/vigo.json | 15138 +++++++++++++++++++ 37 files changed, 24701 insertions(+), 15864 deletions(-) delete mode 100644 data/download-stops.py delete mode 100644 data/overrides/amenities.yaml delete mode 100644 data/overrides/fix-gregorio-espino.yaml delete mode 100644 data/overrides/hide-virtual-stops.yaml delete mode 100644 data/overrides/improve-coordinates-misc.yaml create mode 100644 data/santiago/download-stops.py create mode 100644 data/santiago/overrides/.gitkeep delete mode 100644 data/stop-overrides.yaml create mode 100644 data/vigo/download-stops.py create mode 100644 data/vigo/overrides/amenities.yaml create mode 100644 data/vigo/overrides/fix-gregorio-espino.yaml create mode 100644 data/vigo/overrides/hide-virtual-stops.yaml create mode 100644 data/vigo/overrides/improve-coordinates-misc.yaml delete mode 100644 src/Costasdev.Busurbano.Backend/GetStopEstimates.cs create mode 100644 src/Costasdev.Busurbano.Backend/SantiagoController.cs create mode 100644 src/Costasdev.Busurbano.Backend/VigoController.cs create mode 100644 src/frontend/app/components/RegionSelector.tsx create mode 100644 src/frontend/app/data/RegionConfig.ts delete mode 100644 src/frontend/public/stops.json create mode 100644 src/frontend/public/stops/santiago.json create mode 100644 src/frontend/public/stops/vigo.json diff --git a/.github/workflows/update-stops-data.yml b/.github/workflows/update-stops-data.yml index 6268b5f..d22040d 100644 --- a/.github/workflows/update-stops-data.yml +++ b/.github/workflows/update-stops-data.yml @@ -13,7 +13,7 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 0 # Fetch all branches - + - name: Check for existing branch and PR id: check_existing env: @@ -21,7 +21,7 @@ jobs: run: | # Look for existing open PR with our title existing_pr=$(gh pr list --json number,headRefName --search "Update stops data in:title author:app/github-actions is:open" --limit 1) - + if [[ $(echo "$existing_pr" | jq length) -gt 0 ]]; then branch_name=$(echo "$existing_pr" | jq -r '.[0].headRefName') pr_number=$(echo "$existing_pr" | jq -r '.[0].number') @@ -43,7 +43,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - + if [[ "$HAS_EXISTING" == "true" ]]; then # Checkout existing branch git fetch origin $BRANCH_NAME @@ -59,7 +59,9 @@ jobs: uses: astral-sh/setup-uv@v7 - name: Run download script - run: uv run data/download-stops.py + run: | + uv run data/vigo/download-stops.py + uv run data/santiago/download-stops.py - name: Commit and push changes if any id: commit @@ -68,7 +70,7 @@ jobs: HAS_EXISTING: ${{ steps.check_existing.outputs.has_existing }} run: | git add src/frontend/public/stops.json - + if git diff --staged --exit-code; then echo "No changes to commit" echo "changes_made=false" >> $GITHUB_OUTPUT @@ -78,7 +80,7 @@ jobs: echo "changes_made=true" >> $GITHUB_OUTPUT echo "Committed and pushed changes to $BRANCH_NAME" fi - + - name: Create or update Pull Request if: steps.commit.outputs.changes_made == 'true' env: diff --git a/data/download-stops.py b/data/download-stops.py deleted file mode 100644 index b77eae5..0000000 --- a/data/download-stops.py +++ /dev/null @@ -1,126 +0,0 @@ -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "PyYAML>=6.0.2", # For YAML support -# ] -# /// -import json -import os -import sys -import urllib.request -import yaml # Add YAML support for overrides - -OVERRIDES_DIR = "overrides" -OUTPUT_FILE = "../src/frontend/public/stops.json" - -def load_stop_overrides(file_path): - """Load stop overrides from a YAML file""" - if not os.path.exists(file_path): - print(f"Warning: Overrides file {file_path} not found") - return {} - - try: - with open(file_path, 'r', encoding='utf-8') as f: - overrides = yaml.safe_load(f) - print(f"Loaded {len(overrides) if overrides else 0} stop overrides") - return overrides or {} - except Exception as e: - print(f"Error loading overrides: {e}", file=sys.stderr) - return {} - -def apply_overrides(stops, overrides): - """Apply overrides to the stop data""" - for stop in stops: - stop_id = stop.get("stopId") - if stop_id in overrides: - override = overrides[stop_id] - - # Apply or add alternate names - if "alternateNames" in override: - for key, value in override["alternateNames"].items(): - stop["name"][key] = value - - # Apply location override - if "location" in override: - if "latitude" in override["location"]: - stop["latitude"] = override["location"]["latitude"] - if "longitude" in override["location"]: - stop["longitude"] = override["location"]["longitude"] - - # Add amenities - if "amenities" in override: - stop["amenities"] = override["amenities"] - - # Mark stop as hidden if needed - if "hide" in override: - stop["hide"] = override["hide"] - - return stops - -def main(): - print("Fetching stop list data...") - - # Download stop list data - url = "https://datos.vigo.org/vci_api_app/api2.jsp?tipo=TRANSPORTE_PARADAS" - req = urllib.request.Request(url) - - try: - with urllib.request.urlopen(req) as response: - # Read the response and decode from ISO-8859-1 to UTF-8 - content = response.read().decode('iso-8859-1') - data = json.loads(content) - - print(f"Downloaded {len(data)} stops") - - # Process the data - processed_stops = [] - for stop in data: - name = stop.get("nombre", "").strip() - # Fix double space equals comma-space: "Castrelos 202" -> "Castrelos, 202"; and remove quotes - name = name.replace(" ", ", ").replace('"', '').replace("'", "") - - processed_stop = { - "stopId": stop.get("id"), - "name": { - "original": name - }, - "latitude": stop.get("lat"), - "longitude": stop.get("lon"), - "lines": [line.strip() for line in stop.get("lineas", "").split(",")] if stop.get("lineas") else [] - } - processed_stops.append(processed_stop) - - # Load and apply overrides - script_dir = os.path.dirname(os.path.abspath(__file__)) - overrides_dir = os.path.join(script_dir, OVERRIDES_DIR) - # For each YML/YAML file in the overrides directory, load and apply the overrides - for filename in os.listdir(overrides_dir): - if not filename.endswith(".yml") and not filename.endswith(".yaml"): - continue - - print(f"Loading overrides from {filename}") - overrides_file = os.path.join(overrides_dir, filename) - overrides = load_stop_overrides(overrides_file) - processed_stops = apply_overrides(processed_stops, overrides) - - # Filter out hidden stops - visible_stops = [stop for stop in processed_stops if not stop.get("hide")] - print(f"Removed {len(processed_stops) - len(visible_stops)} hidden stops") - - # Sort stops by ID ascending - visible_stops.sort(key=lambda x: x["stopId"]) - - output_file = os.path.join(script_dir, OUTPUT_FILE) - - with open(output_file, 'w', encoding='utf-8') as f: - json.dump(visible_stops, f, ensure_ascii=False, indent=2) - - print(f"Saved processed stops data to {output_file}") - return 0 - - except Exception as e: - print(f"Error processing stops data: {e}", file=sys.stderr) - return 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/data/overrides/amenities.yaml b/data/overrides/amenities.yaml deleted file mode 100644 index fa2067a..0000000 --- a/data/overrides/amenities.yaml +++ /dev/null @@ -1,23 +0,0 @@ -5520: # García Barbón, 7 - amenities: - - shelter - - display -5530: # García Barbón, 18 - amenities: - - shelter - - display -6620: #Policarpo Sanz, 40 - amenities: - - shelter - - display -14264: # Urzáiz - Príncipe - amenities: - - shelter - - display -20193: # Policarpo Sanz, 25 - amenities: - - shelter - - display -20198: # Policarpo Sanz, 26 - amenities: - - shelter diff --git a/data/overrides/fix-gregorio-espino.yaml b/data/overrides/fix-gregorio-espino.yaml deleted file mode 100644 index 2e035a2..0000000 --- a/data/overrides/fix-gregorio-espino.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Fix the position of the stops in Gregorio Espino, which are "opposite" to the actual location of the bus stops. -5720: # Gregorio Espino, 33 - location: - latitude: 42.23004933454558 - longitude: -8.706947409683313 - -5710: # Gregorio Espino, 22 - location: - latitude: 42.23003666347398 - longitude: -8.707266671978003 - -5730: # Gregorio Espino, 44 - location: - latitude: 42.227850036119314 - longitude: -8.708105429626789 - -5740: # Gregorio Espino, 57 - location: - latitude: 42.22783722597372 - longitude: -8.707849091551859 \ No newline at end of file diff --git a/data/overrides/hide-virtual-stops.yaml b/data/overrides/hide-virtual-stops.yaml deleted file mode 100644 index a2bf0b1..0000000 --- a/data/overrides/hide-virtual-stops.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# The Vitrasa network has several virtual stops created for internal purposes, like -# end of certain lines with a "nice" name. - -20223: # Castrelos (Pavillón) - Final U1 - hide: true -20146: # García Barbón 7 - final líneas A y 18A - hide: true -20220: # (Samil) COIA-SAMIL - Final L15A - hide: true -20001: # (Samil) Samil por Beiramar - Final L15B - hide: true -20002: # (Samil) Samil por Torrecedeira - Final L15C - hide: true -20144: # (Samil) Samil por Coia - Final C3D+C3i - hide: true -20145: # (Samil) Samil por Bouzas - Final C3D+C3i - hide: true \ No newline at end of file diff --git a/data/overrides/improve-coordinates-misc.yaml b/data/overrides/improve-coordinates-misc.yaml deleted file mode 100644 index 922f103..0000000 --- a/data/overrides/improve-coordinates-misc.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Improves coordinates for some locations in the dataset to be more accurate, and avoid clustering -6620: # Policarpo Sanz, 40 - location: - latitude: 42.23757846151978 - longitude: -8.721031378896738 - -20193: # Policarpo Sanz, 25 - location: - latitude: 42.23767601188501 - longitude: -8.721582630122455 - -3130: #Avda. de Cesáreo Vázquez 169 - location: - latitude: 42.191024803868736 - longitude: -8.799397387002196 - -3090: # Avda. de Cesáreo Vázquez 182 - location: - latitude: 42.191019711713736 - longitude: -8.799628565094565 - -14294: # Avda. de Ricardo Mella 406 - location: - latitude: 42.190684424876565 - longitude: -8.799308812770041 - -3120: # Cesáreo Vázquez 141 - location: - latitude: 42.187488521491225 - longitude: -8.801226626055183 - -3080: # Cesáreo Vázquez 136 - location: - latitude: 42.1873653089623 - longitude: -8.800886236766305 diff --git a/data/santiago/download-stops.py b/data/santiago/download-stops.py new file mode 100644 index 0000000..f673be5 --- /dev/null +++ b/data/santiago/download-stops.py @@ -0,0 +1,132 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "PyYAML>=6.0.2", # For YAML support +# ] +# /// +import json +import os +import sys +import urllib.request +import yaml # Add YAML support for overrides + +OVERRIDES_DIR = "overrides" +OUTPUT_FILE = "../../src/frontend/public/stops/santiago.json" + +def load_stop_overrides(file_path): + """Load stop overrides from a YAML file""" + if not os.path.exists(file_path): + print(f"Warning: Overrides file {file_path} not found") + return {} + + try: + with open(file_path, 'r', encoding='utf-8') as f: + overrides = yaml.safe_load(f) + print(f"Loaded {len(overrides) if overrides else 0} stop overrides") + return overrides or {} + except Exception as e: + print(f"Error loading overrides: {e}", file=sys.stderr) + return {} + +def apply_overrides(stops, overrides): + """Apply overrides to the stop data""" + for stop in stops: + stop_id = stop.get("stopId") + if stop_id in overrides: + override = overrides[stop_id] + + # Apply or add alternate names + if "alternateNames" in override: + for key, value in override["alternateNames"].items(): + stop["name"][key] = value + + # Apply location override + if "location" in override: + if "latitude" in override["location"]: + stop["latitude"] = override["location"]["latitude"] + if "longitude" in override["location"]: + stop["longitude"] = override["location"]["longitude"] + + # Add amenities + if "amenities" in override: + stop["amenities"] = override["amenities"] + + # Mark stop as hidden if needed + if "hide" in override: + stop["hide"] = override["hide"] + + return stops + +def main(): + print("Fetching stop list data...") + + # Download stop list data + url = "https://app.tussa.org/tussa/api/paradas" + body = json.dumps({ "nombre": "" }).encode('utf-8') + req = urllib.request.Request(url, data=body, headers={'Content-Type': 'application/json'}, method='POST') + + try: + with urllib.request.urlopen(req) as response: + content = response.read().decode('utf-8') + data = json.loads(content) + + print(f"Downloaded {len(data)} stops") + + # Process the data + processed_stops = [] + for stop in data: + name = stop.get("nombre", "").strip() + + lines_sinoptic: list[str] = [line.get("sinoptico").strip() for line in stop.get("lineas", [])] if stop.get("lineas") else [] + lines_id: list[str] = [] + + for line in lines_sinoptic: + line_code = line.lstrip('L') + lines_id.append(line_code) + + processed_stop = { + "stopId": stop.get("id"), + "name": { + "original": name + }, + "latitude": stop.get("coordenadas").get("latitud"), + "longitude": stop.get("coordenadas").get("longitud"), + "lines": lines_id, + "hide": len(lines_id) == 0 + } + processed_stops.append(processed_stop) + + # Load and apply overrides + script_dir = os.path.dirname(os.path.abspath(__file__)) + overrides_dir = os.path.join(script_dir, OVERRIDES_DIR) + # For each YML/YAML file in the overrides directory, load and apply the overrides + for filename in os.listdir(overrides_dir): + if not filename.endswith(".yml") and not filename.endswith(".yaml"): + continue + + print(f"Loading overrides from {filename}") + overrides_file = os.path.join(overrides_dir, filename) + overrides = load_stop_overrides(overrides_file) + processed_stops = apply_overrides(processed_stops, overrides) + + # Filter out hidden stops + visible_stops = [stop for stop in processed_stops if not stop.get("hide")] + print(f"Removed {len(processed_stops) - len(visible_stops)} hidden stops") + + # Sort stops by ID ascending + visible_stops.sort(key=lambda x: x["stopId"]) + + output_file = os.path.join(script_dir, OUTPUT_FILE) + + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(visible_stops, f, ensure_ascii=False, indent=2) + + print(f"Saved processed stops data to {output_file}") + return 0 + + except Exception as e: + print(f"Error processing stops data: {e}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/data/santiago/overrides/.gitkeep b/data/santiago/overrides/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/stop-overrides.yaml b/data/stop-overrides.yaml deleted file mode 100644 index 1a5674c..0000000 --- a/data/stop-overrides.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# This file contains overrides for specific bus stops. -# Format: -# stopId: # Numeric ID of the stop to override -# name: # Override the name -# alternateNames: # Additional names for the stop -# metro: # Stop name in -# location: # Override location coordinates -# latitude: # New latitude value -# longitude: # New longitude value -# hide: # Hide the stop from the map and list -# amenities: # List of amenities available at this stop -# - shelter # Marquesina -# - real-time display # Display with real-time information - -#6930: # Praza de América 1 -# alternateNames: - -# intersect: "Praza América - Camelias" -# amenities: -# - shelter -# - real-time display - -#14264: # Urzáiz - Príncipe -# alternateNames: -# intersect: "Urzáiz - Príncipe" -# amenities: -# - shelter -# - real-time display - - diff --git a/data/vigo/download-stops.py b/data/vigo/download-stops.py new file mode 100644 index 0000000..a57d30f --- /dev/null +++ b/data/vigo/download-stops.py @@ -0,0 +1,126 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "PyYAML>=6.0.2", # For YAML support +# ] +# /// +import json +import os +import sys +import urllib.request +import yaml # Add YAML support for overrides + +OVERRIDES_DIR = "overrides" +OUTPUT_FILE = "../../src/frontend/public/stops/vigo.json" + +def load_stop_overrides(file_path): + """Load stop overrides from a YAML file""" + if not os.path.exists(file_path): + print(f"Warning: Overrides file {file_path} not found") + return {} + + try: + with open(file_path, 'r', encoding='utf-8') as f: + overrides = yaml.safe_load(f) + print(f"Loaded {len(overrides) if overrides else 0} stop overrides") + return overrides or {} + except Exception as e: + print(f"Error loading overrides: {e}", file=sys.stderr) + return {} + +def apply_overrides(stops, overrides): + """Apply overrides to the stop data""" + for stop in stops: + stop_id = stop.get("stopId") + if stop_id in overrides: + override = overrides[stop_id] + + # Apply or add alternate names + if "alternateNames" in override: + for key, value in override["alternateNames"].items(): + stop["name"][key] = value + + # Apply location override + if "location" in override: + if "latitude" in override["location"]: + stop["latitude"] = override["location"]["latitude"] + if "longitude" in override["location"]: + stop["longitude"] = override["location"]["longitude"] + + # Add amenities + if "amenities" in override: + stop["amenities"] = override["amenities"] + + # Mark stop as hidden if needed + if "hide" in override: + stop["hide"] = override["hide"] + + return stops + +def main(): + print("Fetching stop list data...") + + # Download stop list data + url = "https://datos.vigo.org/vci_api_app/api2.jsp?tipo=TRANSPORTE_PARADAS" + req = urllib.request.Request(url) + + try: + with urllib.request.urlopen(req) as response: + # Read the response and decode from ISO-8859-1 to UTF-8 + content = response.read().decode('iso-8859-1') + data = json.loads(content) + + print(f"Downloaded {len(data)} stops") + + # Process the data + processed_stops = [] + for stop in data: + name = stop.get("nombre", "").strip() + # Fix double space equals comma-space: "Castrelos 202" -> "Castrelos, 202"; and remove quotes + name = name.replace(" ", ", ").replace('"', '').replace("'", "") + + processed_stop = { + "stopId": stop.get("id"), + "name": { + "original": name + }, + "latitude": stop.get("lat"), + "longitude": stop.get("lon"), + "lines": [line.strip() for line in stop.get("lineas", "").split(",")] if stop.get("lineas") else [] + } + processed_stops.append(processed_stop) + + # Load and apply overrides + script_dir = os.path.dirname(os.path.abspath(__file__)) + overrides_dir = os.path.join(script_dir, OVERRIDES_DIR) + # For each YML/YAML file in the overrides directory, load and apply the overrides + for filename in os.listdir(overrides_dir): + if not filename.endswith(".yml") and not filename.endswith(".yaml"): + continue + + print(f"Loading overrides from {filename}") + overrides_file = os.path.join(overrides_dir, filename) + overrides = load_stop_overrides(overrides_file) + processed_stops = apply_overrides(processed_stops, overrides) + + # Filter out hidden stops + visible_stops = [stop for stop in processed_stops if not stop.get("hide")] + print(f"Removed {len(processed_stops) - len(visible_stops)} hidden stops") + + # Sort stops by ID ascending + visible_stops.sort(key=lambda x: x["stopId"]) + + output_file = os.path.join(script_dir, OUTPUT_FILE) + + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(visible_stops, f, ensure_ascii=False, indent=2) + + print(f"Saved processed stops data to {output_file}") + return 0 + + except Exception as e: + print(f"Error processing stops data: {e}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/data/vigo/overrides/amenities.yaml b/data/vigo/overrides/amenities.yaml new file mode 100644 index 0000000..fa2067a --- /dev/null +++ b/data/vigo/overrides/amenities.yaml @@ -0,0 +1,23 @@ +5520: # García Barbón, 7 + amenities: + - shelter + - display +5530: # García Barbón, 18 + amenities: + - shelter + - display +6620: #Policarpo Sanz, 40 + amenities: + - shelter + - display +14264: # Urzáiz - Príncipe + amenities: + - shelter + - display +20193: # Policarpo Sanz, 25 + amenities: + - shelter + - display +20198: # Policarpo Sanz, 26 + amenities: + - shelter diff --git a/data/vigo/overrides/fix-gregorio-espino.yaml b/data/vigo/overrides/fix-gregorio-espino.yaml new file mode 100644 index 0000000..2e035a2 --- /dev/null +++ b/data/vigo/overrides/fix-gregorio-espino.yaml @@ -0,0 +1,20 @@ +# Fix the position of the stops in Gregorio Espino, which are "opposite" to the actual location of the bus stops. +5720: # Gregorio Espino, 33 + location: + latitude: 42.23004933454558 + longitude: -8.706947409683313 + +5710: # Gregorio Espino, 22 + location: + latitude: 42.23003666347398 + longitude: -8.707266671978003 + +5730: # Gregorio Espino, 44 + location: + latitude: 42.227850036119314 + longitude: -8.708105429626789 + +5740: # Gregorio Espino, 57 + location: + latitude: 42.22783722597372 + longitude: -8.707849091551859 \ No newline at end of file diff --git a/data/vigo/overrides/hide-virtual-stops.yaml b/data/vigo/overrides/hide-virtual-stops.yaml new file mode 100644 index 0000000..a2bf0b1 --- /dev/null +++ b/data/vigo/overrides/hide-virtual-stops.yaml @@ -0,0 +1,17 @@ +# The Vitrasa network has several virtual stops created for internal purposes, like +# end of certain lines with a "nice" name. + +20223: # Castrelos (Pavillón) - Final U1 + hide: true +20146: # García Barbón 7 - final líneas A y 18A + hide: true +20220: # (Samil) COIA-SAMIL - Final L15A + hide: true +20001: # (Samil) Samil por Beiramar - Final L15B + hide: true +20002: # (Samil) Samil por Torrecedeira - Final L15C + hide: true +20144: # (Samil) Samil por Coia - Final C3D+C3i + hide: true +20145: # (Samil) Samil por Bouzas - Final C3D+C3i + hide: true \ No newline at end of file diff --git a/data/vigo/overrides/improve-coordinates-misc.yaml b/data/vigo/overrides/improve-coordinates-misc.yaml new file mode 100644 index 0000000..922f103 --- /dev/null +++ b/data/vigo/overrides/improve-coordinates-misc.yaml @@ -0,0 +1,35 @@ +# Improves coordinates for some locations in the dataset to be more accurate, and avoid clustering +6620: # Policarpo Sanz, 40 + location: + latitude: 42.23757846151978 + longitude: -8.721031378896738 + +20193: # Policarpo Sanz, 25 + location: + latitude: 42.23767601188501 + longitude: -8.721582630122455 + +3130: #Avda. de Cesáreo Vázquez 169 + location: + latitude: 42.191024803868736 + longitude: -8.799397387002196 + +3090: # Avda. de Cesáreo Vázquez 182 + location: + latitude: 42.191019711713736 + longitude: -8.799628565094565 + +14294: # Avda. de Ricardo Mella 406 + location: + latitude: 42.190684424876565 + longitude: -8.799308812770041 + +3120: # Cesáreo Vázquez 141 + location: + latitude: 42.187488521491225 + longitude: -8.801226626055183 + +3080: # Cesáreo Vázquez 136 + location: + latitude: 42.1873653089623 + longitude: -8.800886236766305 diff --git a/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs b/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs deleted file mode 100644 index 8fcdd8e..0000000 --- a/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs +++ /dev/null @@ -1,127 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; -using Costasdev.VigoTransitApi; -using System.Text.Json; - -namespace Costasdev.Busurbano.Backend; - -[ApiController] -[Route("api")] -public class ApiController : ControllerBase -{ - private readonly VigoTransitApiClient _api; - private readonly IMemoryCache _cache; - private readonly HttpClient _httpClient; - - public ApiController(HttpClient http, IMemoryCache cache) - { - _api = new VigoTransitApiClient(http); - _cache = cache; - _httpClient = http; - } - - [HttpGet("GetStopEstimates")] - public async Task Run() - { - var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); - if (!argumentAvailable) - { - return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); - } - - var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); - if (!argumentNumber) - { - return BadRequest("The provided stop id is not a valid number."); - } - - try - { - var estimates = await _api.GetStopEstimates(requestedStopId); - return new OkObjectResult(estimates); - } - catch (InvalidOperationException) - { - return new BadRequestObjectResult("Stop not found"); - } - } - - [HttpGet("GetStopTimetable")] - public async Task GetStopTimetable() - { - // Get date parameter (default to today if not provided) - var dateString = Request.Query.TryGetValue("date", out var requestedDate) - ? requestedDate.ToString() - : DateTime.Today.ToString("yyyy-MM-dd"); - - // Validate date format - if (!DateTime.TryParseExact(dateString, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var parsedDate)) - { - return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); - } - - // Get stopId parameter - if (!Request.Query.TryGetValue("stopId", out var requestedStopIdString)) - { - return BadRequest("Please provide a stop id as a query parameter with the name 'stopId'."); - } - - if (!int.TryParse(requestedStopIdString, out var requestedStopId)) - { - return BadRequest("The provided stop id is not a valid number."); - } - - // Create cache key - var cacheKey = $"timetable_{dateString}_{requestedStopId}"; - - // Try to get from cache first - if (_cache.TryGetValue(cacheKey, out var cachedData)) - { - return new OkObjectResult(cachedData); - } - - try - { - // Fetch data from external API - var url = $"https://costas.dev/static-storage/vitrasa_svc/stops/{dateString}/{requestedStopId}.json"; - var response = await _httpClient.GetAsync(url); - - if (!response.IsSuccessStatusCode) - { - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return NotFound($"Timetable data not found for stop {requestedStopId} on {dateString}"); - } - return StatusCode((int)response.StatusCode, "Error fetching timetable data"); - } - - var jsonContent = await response.Content.ReadAsStringAsync(); - var timetableData = JsonSerializer.Deserialize(jsonContent); - - // Cache the data for 12 hours - var cacheOptions = new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12), - SlidingExpiration = TimeSpan.FromHours(6), // Refresh cache if accessed within 6 hours of expiry - Priority = CacheItemPriority.Normal - }; - - _cache.Set(cacheKey, timetableData, cacheOptions); - - return new OkObjectResult(timetableData); - } - catch (HttpRequestException ex) - { - return StatusCode(500, $"Error fetching timetable data: {ex.Message}"); - } - catch (JsonException ex) - { - return StatusCode(500, $"Error parsing timetable data: {ex.Message}"); - } - catch (Exception ex) - { - return StatusCode(500, $"Unexpected error: {ex.Message}"); - } - } -} - diff --git a/src/Costasdev.Busurbano.Backend/SantiagoController.cs b/src/Costasdev.Busurbano.Backend/SantiagoController.cs new file mode 100644 index 0000000..51a76c6 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/SantiagoController.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using Costasdev.VigoTransitApi.Types; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Costasdev.Busurbano.Backend; + +[ApiController] +[Route("api/santiago")] +public class SantiagoController : ControllerBase +{ + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public SantiagoController(HttpClient http, IMemoryCache cache) + { + _cache = cache; + _httpClient = http; + } + + [HttpGet("GetStopEstimates")] + public async Task Run() + { + var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); + if (!argumentAvailable) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); + } + + var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); + if (!argumentNumber) + { + return BadRequest("The provided stop id is not a valid number."); + } + + try + { + var obj = await _httpClient.GetFromJsonAsync( + $"https://app.tussa.org/tussa/api/paradas/{requestedStopId}"); + + if (obj is null) + { + return BadRequest("No response returned from the API, or whatever"); + } + + var root = obj.RootElement; + + List estimates = root + .GetProperty("lineas") + .EnumerateArray() + .Select(el => new StopEstimate( + el.GetProperty("sinoptico").GetString() ?? string.Empty, + el.GetProperty("nombre").GetString() ?? string.Empty, + el.GetProperty("minutosProximoPaso").GetInt32(), + 0 + )).ToList(); + + return new OkObjectResult(new StopEstimateResponse + { + Stop = new StopEstimateResponse.StopInfo + { + Name = root.GetProperty("nombre").GetString() ?? string.Empty, + Id = root.GetProperty("id").GetInt32(), + Latitude = root.GetProperty("coordenadas").GetProperty("latitud").GetDecimal(), + Longitude = root.GetProperty("coordenadas").GetProperty("longitud").GetDecimal() + }, + Estimates = estimates + }); + } + catch (InvalidOperationException) + { + return new BadRequestObjectResult("Stop not found"); + } + } + + [HttpGet("GetStopTimetable")] + public async Task GetStopTimetable() + { + throw new NotImplementedException(); + } +} diff --git a/src/Costasdev.Busurbano.Backend/VigoController.cs b/src/Costasdev.Busurbano.Backend/VigoController.cs new file mode 100644 index 0000000..41a8765 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/VigoController.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Costasdev.VigoTransitApi; +using System.Text.Json; + +namespace Costasdev.Busurbano.Backend; + +[ApiController] +[Route("api/vigo")] +public class VigoController : ControllerBase +{ + private readonly VigoTransitApiClient _api; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public VigoController(HttpClient http, IMemoryCache cache) + { + _api = new VigoTransitApiClient(http); + _cache = cache; + _httpClient = http; + } + + [HttpGet("GetStopEstimates")] + public async Task Run() + { + var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); + if (!argumentAvailable) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); + } + + var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); + if (!argumentNumber) + { + return BadRequest("The provided stop id is not a valid number."); + } + + try + { + var estimates = await _api.GetStopEstimates(requestedStopId); + return new OkObjectResult(estimates); + } + catch (InvalidOperationException) + { + return new BadRequestObjectResult("Stop not found"); + } + } + + [HttpGet("GetStopTimetable")] + public async Task GetStopTimetable() + { + // Get date parameter (default to today if not provided) + var dateString = Request.Query.TryGetValue("date", out var requestedDate) + ? requestedDate.ToString() + : DateTime.Today.ToString("yyyy-MM-dd"); + + // Validate date format + if (!DateTime.TryParseExact(dateString, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var parsedDate)) + { + return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); + } + + // Get stopId parameter + if (!Request.Query.TryGetValue("stopId", out var requestedStopIdString)) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'stopId'."); + } + + if (!int.TryParse(requestedStopIdString, out var requestedStopId)) + { + return BadRequest("The provided stop id is not a valid number."); + } + + // Create cache key + var cacheKey = $"timetable_{dateString}_{requestedStopId}"; + + // Try to get from cache first + if (_cache.TryGetValue(cacheKey, out var cachedData)) + { + return new OkObjectResult(cachedData); + } + + try + { + // Fetch data from external API + var url = $"https://costas.dev/static-storage/vitrasa_svc/stops/{dateString}/{requestedStopId}.json"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return NotFound($"Timetable data not found for stop {requestedStopId} on {dateString}"); + } + return StatusCode((int)response.StatusCode, "Error fetching timetable data"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + var timetableData = JsonSerializer.Deserialize(jsonContent); + + // Cache the data for 12 hours + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12), + SlidingExpiration = TimeSpan.FromHours(6), // Refresh cache if accessed within 6 hours of expiry + Priority = CacheItemPriority.Normal + }; + + _cache.Set(cacheKey, timetableData, cacheOptions); + + return new OkObjectResult(timetableData); + } + catch (HttpRequestException ex) + { + return StatusCode(500, $"Error fetching timetable data: {ex.Message}"); + } + catch (JsonException ex) + { + return StatusCode(500, $"Error parsing timetable data: {ex.Message}"); + } + catch (Exception ex) + { + return StatusCode(500, $"Unexpected error: {ex.Message}"); + } + } +} + diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index 9013463..1a9b511 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -7,6 +7,7 @@ import { type ReactNode, } from "react"; import { type LngLatLike } from "maplibre-gl"; +import { type RegionId, DEFAULT_REGION, getRegionConfig, isValidRegion } from "./data/RegionConfig"; export type Theme = "light" | "dark" | "system"; type TableStyle = "regular" | "grouped"; @@ -37,6 +38,9 @@ interface AppContextProps { mapPositionMode: MapPositionMode; setMapPositionMode: (mode: MapPositionMode) => void; + + region: RegionId; + setRegion: (region: RegionId) => void; } // Coordenadas por defecto centradas en Vigo @@ -153,6 +157,29 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [mapPositionMode]); //#endregion + //#region Region + const [region, setRegionState] = useState(() => { + const savedRegion = localStorage.getItem("region"); + if (savedRegion && isValidRegion(savedRegion)) { + return savedRegion; + } + return DEFAULT_REGION; + }); + + const setRegion = (newRegion: RegionId) => { + setRegionState(newRegion); + localStorage.setItem("region", newRegion); + + // Update map to region's default center and zoom + const regionConfig = getRegionConfig(newRegion); + updateMapState(regionConfig.defaultCenter, regionConfig.defaultZoom); + }; + + useEffect(() => { + localStorage.setItem("region", region); + }, [region]); + //#endregion + //#region Map State const [mapState, setMapState] = useState(() => { const savedMapState = localStorage.getItem("mapState"); @@ -253,6 +280,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { updateMapState, mapPositionMode, setMapPositionMode, + region, + setRegion, }} > {children} diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx index 47c2d31..fd97d5b 100644 --- a/src/frontend/app/components/GroupedTable.tsx +++ b/src/frontend/app/components/GroupedTable.tsx @@ -1,12 +1,14 @@ import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; +import { type RegionConfig } from "../data/RegionConfig"; interface GroupedTable { data: StopDetails; dataDate: Date | null; + regionConfig: RegionConfig; } -export const GroupedTable: React.FC = ({ data, dataDate }) => { +export const GroupedTable: React.FC = ({ data, dataDate, regionConfig }) => { const formatDistance = (meters: number) => { if (meters > 1024) { return `${(meters / 1000).toFixed(1)} km`; @@ -43,7 +45,7 @@ export const GroupedTable: React.FC = ({ data, dataDate }) => { Línea Ruta Llegada - Distancia + {regionConfig.showMeters && Distancia} @@ -53,16 +55,18 @@ export const GroupedTable: React.FC = ({ data, dataDate }) => { {idx === 0 && ( - + )} {estimate.route} {`${estimate.minutes} min`} - - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : "No disponible"} - + {regionConfig.showMeters && ( + + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible"} + + )} )), )} @@ -71,7 +75,9 @@ export const GroupedTable: React.FC = ({ data, dataDate }) => { {data?.estimates.length === 0 && ( - No hay estimaciones disponibles + + No hay estimaciones disponibles + )} diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 4613a85..7d46b98 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -1,49 +1,75 @@ +/* Vigo line colors */ :root { - --line-c1: rgb(237, 71, 19); - --line-c3d: rgb(255, 204, 0); - --line-c3i: rgb(255, 204, 0); - --line-l4a: rgb(0, 153, 0); - --line-l4c: rgb(0, 153, 0); - --line-l5a: rgb(0, 176, 240); - --line-l5b: rgb(0, 176, 240); - --line-l6: rgb(204, 51, 153); - --line-l7: rgb(150, 220, 153); - --line-l9b: rgb(244, 202, 140); - --line-l10: rgb(153, 51, 0); - --line-l11: rgb(226, 0, 38); - --line-l12a: rgb(106, 150, 190); - --line-l12b: rgb(106, 150, 190); - --line-l13: rgb(0, 176, 240); - --line-l14: rgb(129, 142, 126); - --line-l15a: rgb(216, 168, 206); - --line-l15b: rgb(216, 168, 206); - --line-l15c: rgb(216, 168, 168); - --line-l16: rgb(129, 142, 126); - --line-l17: rgb(214, 245, 31); - --line-l18a: rgb(212, 80, 168); - --line-l18b: rgb(0, 0, 0); - --line-l18h: rgb(0, 0, 0); - --line-l23: rgb(0, 70, 210); - --line-l24: rgb(191, 191, 191); - --line-l25: rgb(172, 100, 4); - --line-l27: rgb(112, 74, 42); - --line-l28: rgb(176, 189, 254); - --line-l29: rgb(248, 184, 90); - --line-l31: rgb(255, 255, 0); - --line-a: rgb(119, 41, 143); - --line-h: rgb(0, 96, 168); - --line-h1: rgb(0, 96, 168); - --line-h2: rgb(0, 96, 168); - --line-h3: rgb(0, 96, 168); - --line-lzd: rgb(61, 78, 167); - --line-n1: rgb(191, 191, 191); - --line-n4: rgb(102, 51, 102); - --line-psa1: rgb(0, 153, 0); - --line-psa4: rgb(0, 153, 0); - --line-ptl: rgb(150, 220, 153); - --line-turistico: rgb(102, 51, 102); - --line-u1: rgb(172, 100, 4); - --line-u2: rgb(172, 100, 4); + --line-vigo-c1: rgb(237, 71, 19); + --line-vigo-c3d: rgb(255, 204, 0); + --line-vigo-c3i: rgb(255, 204, 0); + --line-vigo-l4a: rgb(0, 153, 0); + --line-vigo-l4c: rgb(0, 153, 0); + --line-vigo-l5a: rgb(0, 176, 240); + --line-vigo-l5b: rgb(0, 176, 240); + --line-vigo-l6: rgb(204, 51, 153); + --line-vigo-l7: rgb(150, 220, 153); + --line-vigo-l9b: rgb(244, 202, 140); + --line-vigo-l10: rgb(153, 51, 0); + --line-vigo-l11: rgb(226, 0, 38); + --line-vigo-l12a: rgb(106, 150, 190); + --line-vigo-l12b: rgb(106, 150, 190); + --line-vigo-l13: rgb(0, 176, 240); + --line-vigo-l14: rgb(129, 142, 126); + --line-vigo-l15a: rgb(216, 168, 206); + --line-vigo-l15b: rgb(216, 168, 206); + --line-vigo-l15c: rgb(216, 168, 168); + --line-vigo-l16: rgb(129, 142, 126); + --line-vigo-l17: rgb(214, 245, 31); + --line-vigo-l18a: rgb(212, 80, 168); + --line-vigo-l18b: rgb(0, 0, 0); + --line-vigo-l18h: rgb(0, 0, 0); + --line-vigo-l23: rgb(0, 70, 210); + --line-vigo-l24: rgb(191, 191, 191); + --line-vigo-l25: rgb(172, 100, 4); + --line-vigo-l27: rgb(112, 74, 42); + --line-vigo-l28: rgb(176, 189, 254); + --line-vigo-l29: rgb(248, 184, 90); + --line-vigo-l31: rgb(255, 255, 0); + --line-vigo-a: rgb(119, 41, 143); + --line-vigo-h: rgb(0, 96, 168); + --line-vigo-h1: rgb(0, 96, 168); + --line-vigo-h2: rgb(0, 96, 168); + --line-vigo-h3: rgb(0, 96, 168); + --line-vigo-lzd: rgb(61, 78, 167); + --line-vigo-n1: rgb(191, 191, 191); + --line-vigo-n4: rgb(102, 51, 102); + --line-vigo-psa1: rgb(0, 153, 0); + --line-vigo-psa4: rgb(0, 153, 0); + --line-vigo-ptl: rgb(150, 220, 153); + --line-vigo-turistico: rgb(102, 51, 102); + --line-vigo-u1: rgb(172, 100, 4); + --line-vigo-u2: rgb(172, 100, 4); + + --line-santiago-l1: #f32621; + --line-santiago-l4: #ffcc33; + --line-santiago-l5: #fa8405; + --line-santiago-l6: #d73983; + --line-santiago-l6a: #d73983; + --line-santiago-l7: #488bc1; + --line-santiago-l8: #6aaf48; + --line-santiago-l9: #46b8bb; + --line-santiago-c11: #aec741; + --line-santiago-l12: #842e14; + --line-santiago-l13: #336600; + --line-santiago-l15: #7a4b2a; + --line-santiago-c2: #283a87; + --line-santiago-c4: #283a87; + --line-santiago-c5: #999999; + --line-santiago-c6: #006666; + --line-santiago-p1: #537eb3; + --line-santiago-p2: #d23354; + --line-santiago-p3: #75bd96; + --line-santiago-p4: #f1c54f; + --line-santiago-p6: #999999; + --line-santiago-p7: #d2438c; + --line-santiago-p8: #e28c3a; + } .line-icon { @@ -55,187 +81,8 @@ font-weight: 600; text-transform: uppercase; border-radius: 0.25rem 0.25rem 0 0; - color: var(--text-color); background-color: var(--background-color); } -.line-c1 { - border-color: var(--line-c1); -} - -.line-c3d { - border-color: var(--line-c3d); -} - -.line-c3i { - border-color: var(--line-c3i); -} - -.line-l4a { - border-color: var(--line-l4a); -} - -.line-l4c { - border-color: var(--line-l4c); -} - -.line-l5a { - border-color: var(--line-l5a); -} - -.line-l5b { - border-color: var(--line-l5b); -} - -.line-l6 { - border-color: var(--line-l6); -} - -.line-l7 { - border-color: var(--line-l7); -} - -.line-l9b { - border-color: var(--line-l9b); -} - -.line-l10 { - border-color: var(--line-l10); -} - -.line-l11 { - border-color: var(--line-l11); -} - -.line-l12a { - border-color: var(--line-l12a); -} - -.line-l12b { - border-color: var(--line-l12b); -} - -.line-l13 { - border-color: var(--line-l13); -} - -.line-l14 { - border-color: var(--line-l14); -} - -.line-l15a { - border-color: var(--line-l15a); -} - -.line-l15b { - border-color: var(--line-l15b); -} - -.line-l15c { - border-color: var(--line-l15c); -} - -.line-l16 { - border-color: var(--line-l16); -} - -.line-l17 { - border-color: var(--line-l17); -} - -.line-l18a { - border-color: var(--line-l18a); -} - -.line-l18b { - border-color: var(--line-l18b); -} - -.line-l18h { - border-color: var(--line-l18h); -} - -.line-l23 { - border-color: var(--line-l23); -} - -.line-l24 { - border-color: var(--line-l24); -} - -.line-l25 { - border-color: var(--line-l25); -} - -.line-l27 { - border-color: var(--line-l27); -} -.line-l28 { - border-color: var(--line-l28); -} - -.line-l29 { - border-color: var(--line-l29); -} - -.line-l31 { - border-color: var(--line-l31); -} - -.line-a { - border-color: var(--line-a); -} - -.line-h { - border-color: var(--line-h); -} - -.line-h1 { - border-color: var(--line-h1); -} - -.line-h2 { - border-color: var(--line-h2); -} - -.line-h3 { - border-color: var(--line-h3); -} - -.line-lzd { - border-color: var(--line-lzd); -} - -.line-n1 { - border-color: var(--line-n1); -} - -.line-n4 { - border-color: var(--line-n4); -} - -.line-psa1 { - border-color: var(--line-psa1); -} - -.line-psa4 { - border-color: var(--line-psa4); -} - -.line-ptl { - border-color: var(--line-ptl); -} - -.line-turistico { - border-color: var(--line-turistico); -} - -.line-u1 { - border-color: var(--line-u1); -} - -.line-u2 { - border-color: var(--line-u2); -} diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx index 3d613e6..4f4bfd9 100644 --- a/src/frontend/app/components/LineIcon.tsx +++ b/src/frontend/app/components/LineIcon.tsx @@ -1,14 +1,23 @@ -import React from "react"; +import React, { useMemo } from "react"; import "./LineIcon.css"; +import { type RegionId } from "../data/RegionConfig"; interface LineIconProps { line: string; + region?: RegionId; } -const LineIcon: React.FC = ({ line }) => { - const formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`; +const LineIcon: React.FC = ({ line, region = "vigo" }) => { + const formattedLine = useMemo(() => { + return /^[a-zA-Z]/.test(line) ? line : `L${line}`; + }, [line]); + const cssVarName = `--line-${region}-${formattedLine.toLowerCase()}`; + return ( - + {formattedLine} ); diff --git a/src/frontend/app/components/RegionSelector.tsx b/src/frontend/app/components/RegionSelector.tsx new file mode 100644 index 0000000..6c9fe8b --- /dev/null +++ b/src/frontend/app/components/RegionSelector.tsx @@ -0,0 +1,33 @@ +import { useApp } from "../AppContext"; +import { getAvailableRegions } from "../data/RegionConfig"; +import "./RegionSelector.css"; + +export function RegionSelector() { + const { region, setRegion } = useApp(); + const regions = getAvailableRegions(); + + const handleRegionChange = (e: React.ChangeEvent) => { + const newRegion = e.target.value as any; + setRegion(newRegion); + }; + + return ( +
+ + +
+ ); +} diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx index 8b01410..68b732a 100644 --- a/src/frontend/app/components/RegularTable.tsx +++ b/src/frontend/app/components/RegularTable.tsx @@ -1,15 +1,18 @@ import { useTranslation } from "react-i18next"; import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; +import { type RegionConfig } from "../data/RegionConfig"; interface RegularTableProps { data: StopDetails; dataDate: Date | null; + regionConfig: RegionConfig; } export const RegularTable: React.FC = ({ data, dataDate, + regionConfig, }) => { const { t } = useTranslation(); @@ -46,7 +49,9 @@ export const RegularTable: React.FC = ({ {t("estimates.line", "Línea")} {t("estimates.route", "Ruta")} {t("estimates.arrival", "Llegada")} - {t("estimates.distance", "Distancia")} + {regionConfig.showMeters && ( + {t("estimates.distance", "Distancia")} + )} @@ -56,7 +61,7 @@ export const RegularTable: React.FC = ({ .map((estimate, idx) => ( - + {estimate.route} @@ -64,11 +69,13 @@ export const RegularTable: React.FC = ({ ? absoluteArrivalTime(estimate.minutes) : `${estimate.minutes} ${t("estimates.minutes", "min")}`} - - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : t("estimates.not_available", "No disponible")} - + {regionConfig.showMeters && ( + + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : t("estimates.not_available", "No disponible")} + + )} ))} @@ -76,7 +83,7 @@ export const RegularTable: React.FC = ({ {data?.estimates.length === 0 && ( - + {t("estimates.none", "No hay estimaciones disponibles")} diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx index b781eb9..7d89d7d 100644 --- a/src/frontend/app/components/StopItem.tsx +++ b/src/frontend/app/components/StopItem.tsx @@ -2,19 +2,22 @@ import React from "react"; import { Link } from "react-router"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import LineIcon from "./LineIcon"; +import { useApp } from "../AppContext"; interface StopItemProps { stop: Stop; } const StopItem: React.FC = ({ stop }) => { + const { region } = useApp(); + return (
  • {stop.favourite && } ( - {stop.stopId}) {StopDataProvider.getDisplayName(stop)} + {stop.stopId}) {StopDataProvider.getDisplayName(region, stop)}
    - {stop.lines?.map((line) => )} + {stop.lines?.map((line) => )}
  • diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx index 702c574..7255884 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSheet.tsx @@ -7,6 +7,8 @@ import LineIcon from "./LineIcon"; import { StopSheetSkeleton } from "./StopSheetSkeleton"; import { ErrorDisplay } from "./ErrorDisplay"; import { type StopDetails } from "../routes/estimates-$id"; +import { type RegionId, getRegionConfig } from "../data/RegionConfig"; +import { useApp } from "../AppContext"; import "./StopSheet.css"; interface StopSheetProps { @@ -22,8 +24,9 @@ interface ErrorInfo { message?: string; } -const loadStopData = async (stopId: number): Promise => { - const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { +const loadStopData = async (region: RegionId, stopId: number): Promise => { + const regionConfig = getRegionConfig(region); + const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, { headers: { Accept: "application/json", }, @@ -43,6 +46,8 @@ export const StopSheet: React.FC = ({ stopName, }) => { const { t } = useTranslation(); + const { region } = useApp(); + const regionConfig = getRegionConfig(region); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -72,7 +77,7 @@ export const StopSheet: React.FC = ({ setError(null); setData(null); - const stopData = await loadStopData(stopId); + const stopData = await loadStopData(region, stopId); setData(stopData); setLastUpdated(new Date()); } catch (err) { @@ -87,7 +92,7 @@ export const StopSheet: React.FC = ({ if (isOpen && stopId) { loadData(); } - }, [isOpen, stopId]); + }, [isOpen, stopId, region]); const formatTime = (minutes: number) => { if (minutes > 15) { @@ -157,7 +162,7 @@ export const StopSheet: React.FC = ({ {limitedEstimates.map((estimate, idx) => (
    - +
    @@ -165,7 +170,7 @@ export const StopSheet: React.FC = ({
    {formatTime(estimate.minutes)} - {estimate.meters > -1 && ( + {regionConfig.showMeters && estimate.meters > -1 && ( {" • "} {formatDistance(estimate.meters)} diff --git a/src/frontend/app/components/TimetableTable.tsx b/src/frontend/app/components/TimetableTable.tsx index 86896ca..8215141 100644 --- a/src/frontend/app/components/TimetableTable.tsx +++ b/src/frontend/app/components/TimetableTable.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import LineIcon from "./LineIcon"; import "./TimetableTable.css"; +import { useApp } from "../AppContext"; export interface TimetableEntry { line: { @@ -97,6 +98,7 @@ export const TimetableTable: React.FC = ({ currentTime }) => { const { t } = useTranslation(); + const { region } = useApp(); const displayData = showAll ? data : findNearbyEntries(data, currentTime || ''); const nowMinutes = currentTime ? timeToMinutes(currentTime) : timeToMinutes(new Date().toTimeString().slice(0, 8)); @@ -126,7 +128,7 @@ export const TimetableTable: React.FC = ({ >
    - +
    diff --git a/src/frontend/app/data/RegionConfig.ts b/src/frontend/app/data/RegionConfig.ts new file mode 100644 index 0000000..0ce66e6 --- /dev/null +++ b/src/frontend/app/data/RegionConfig.ts @@ -0,0 +1,49 @@ +export type RegionId = "vigo" | "santiago"; + +export interface RegionConfig { + id: RegionId; + name: string; + stopsEndpoint: string; + estimatesEndpoint: string; + timetableEndpoint: string | null; + defaultCenter: [number, number]; // [lat, lng] + defaultZoom: number; + showMeters: boolean; // Whether to show distance in meters +} + +export const REGIONS: Record = { + vigo: { + id: "vigo", + name: "Vigo", + stopsEndpoint: "/stops/vigo.json", + estimatesEndpoint: "/api/vigo/GetStopEstimates", + timetableEndpoint: "/api/vigo/GetStopTimetable", + defaultCenter: [42.229188855975046, -8.72246955783102], + defaultZoom: 14, + showMeters: true, + }, + santiago: { + id: "santiago", + name: "Santiago de Compostela", + stopsEndpoint: "/stops/santiago.json", + estimatesEndpoint: "/api/santiago/GetStopEstimates", + timetableEndpoint: null, // Not available for Santiago + defaultCenter: [42.8782, -8.5448], + defaultZoom: 14, + showMeters: false, // Santiago doesn't provide distance data + }, +}; + +export const DEFAULT_REGION: RegionId = "vigo"; + +export function getRegionConfig(regionId: RegionId): RegionConfig { + return REGIONS[regionId]; +} + +export function getAvailableRegions(): RegionConfig[] { + return Object.values(REGIONS); +} + +export function isValidRegion(regionId: string): regionId is RegionId { + return regionId === "vigo" || regionId === "santiago"; +} diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 3959400..e49faaa 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,3 +1,5 @@ +import { type RegionId, getRegionConfig } from "./RegionConfig"; + export interface CachedStopList { timestamp: number; data: Stop[]; @@ -17,48 +19,52 @@ export interface Stop { favourite?: boolean; } -// In-memory cache and lookup map -let cachedStops: Stop[] | null = null; -let stopsMap: Record = {}; -// Custom names loaded from localStorage -let customNames: Record = {}; +// In-memory cache and lookup map per region +const cachedStopsByRegion: Record = {}; +const stopsMapByRegion: Record> = {}; +// Custom names loaded from localStorage per region +const customNamesByRegion: Record> = {}; -// Initialize cachedStops and customNames once -async function initStops() { - if (!cachedStops) { - const response = await fetch("/stops.json"); +// Initialize cachedStops and customNames once per region +async function initStops(region: RegionId) { + if (!cachedStopsByRegion[region]) { + const regionConfig = getRegionConfig(region); + const response = await fetch(regionConfig.stopsEndpoint); const stops = (await response.json()) as Stop[]; // build array and map - stopsMap = {}; - cachedStops = stops.map((stop) => { + stopsMapByRegion[region] = {}; + cachedStopsByRegion[region] = stops.map((stop) => { const entry = { ...stop, favourite: false } as Stop; - stopsMap[stop.stopId] = entry; + stopsMapByRegion[region][stop.stopId] = entry; return entry; }); // load custom names - const rawCustom = localStorage.getItem("customStopNames"); - if (rawCustom) - customNames = JSON.parse(rawCustom) as Record; + const rawCustom = localStorage.getItem(`customStopNames_${region}`); + if (rawCustom) { + customNamesByRegion[region] = JSON.parse(rawCustom) as Record; + } else { + customNamesByRegion[region] = {}; + } } } -async function getStops(): Promise { - await initStops(); +async function getStops(region: RegionId): Promise { + await initStops(region); // update favourites - const rawFav = localStorage.getItem("favouriteStops"); + const rawFav = localStorage.getItem(`favouriteStops_${region}`); const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; - cachedStops!.forEach( + cachedStopsByRegion[region]!.forEach( (stop) => (stop.favourite = favouriteStops.includes(stop.stopId)), ); - return cachedStops!; + return cachedStopsByRegion[region]!; } // New: get single stop by id -async function getStopById(stopId: number): Promise { - await initStops(); - const stop = stopsMap[stopId]; +async function getStopById(region: RegionId, stopId: number): Promise { + await initStops(region); + const stop = stopsMapByRegion[region]?.[stopId]; if (stop) { - const rawFav = localStorage.getItem("favouriteStops"); + const rawFav = localStorage.getItem(`favouriteStops_${region}`); const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; stop.favourite = favouriteStops.includes(stopId); } @@ -66,30 +72,36 @@ async function getStopById(stopId: number): Promise { } // Updated display name to include custom names -function getDisplayName(stop: Stop): string { +function getDisplayName(region: RegionId, stop: Stop): string { + const customNames = customNamesByRegion[region] || {}; if (customNames[stop.stopId]) return customNames[stop.stopId]; const nameObj = stop.name; return nameObj.intersect || nameObj.original; } // New: set or remove custom names -function setCustomName(stopId: number, label: string) { - customNames[stopId] = label; - localStorage.setItem("customStopNames", JSON.stringify(customNames)); +function setCustomName(region: RegionId, stopId: number, label: string) { + if (!customNamesByRegion[region]) { + customNamesByRegion[region] = {}; + } + customNamesByRegion[region][stopId] = label; + localStorage.setItem(`customStopNames_${region}`, JSON.stringify(customNamesByRegion[region])); } -function removeCustomName(stopId: number) { - delete customNames[stopId]; - localStorage.setItem("customStopNames", JSON.stringify(customNames)); +function removeCustomName(region: RegionId, stopId: number) { + if (customNamesByRegion[region]) { + delete customNamesByRegion[region][stopId]; + localStorage.setItem(`customStopNames_${region}`, JSON.stringify(customNamesByRegion[region])); + } } // New: get custom label for a stop -function getCustomName(stopId: number): string | undefined { - return customNames[stopId]; +function getCustomName(region: RegionId, stopId: number): string | undefined { + return customNamesByRegion[region]?.[stopId]; } -function addFavourite(stopId: number) { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function addFavourite(region: RegionId, stopId: number) { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); let favouriteStops: number[] = []; if (rawFavouriteStops) { favouriteStops = JSON.parse(rawFavouriteStops) as number[]; @@ -97,23 +109,23 @@ function addFavourite(stopId: number) { if (!favouriteStops.includes(stopId)) { favouriteStops.push(stopId); - localStorage.setItem("favouriteStops", JSON.stringify(favouriteStops)); + localStorage.setItem(`favouriteStops_${region}`, JSON.stringify(favouriteStops)); } } -function removeFavourite(stopId: number) { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function removeFavourite(region: RegionId, stopId: number) { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); let favouriteStops: number[] = []; if (rawFavouriteStops) { favouriteStops = JSON.parse(rawFavouriteStops) as number[]; } const newFavouriteStops = favouriteStops.filter((id) => id !== stopId); - localStorage.setItem("favouriteStops", JSON.stringify(newFavouriteStops)); + localStorage.setItem(`favouriteStops_${region}`, JSON.stringify(newFavouriteStops)); } -function isFavourite(stopId: number): boolean { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function isFavourite(region: RegionId, stopId: number): boolean { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); if (rawFavouriteStops) { const favouriteStops = JSON.parse(rawFavouriteStops) as number[]; return favouriteStops.includes(stopId); @@ -123,8 +135,8 @@ function isFavourite(stopId: number): boolean { const RECENT_STOPS_LIMIT = 10; -function pushRecent(stopId: number) { - const rawRecentStops = localStorage.getItem("recentStops"); +function pushRecent(region: RegionId, stopId: number) { + const rawRecentStops = localStorage.getItem(`recentStops_${region}`); let recentStops: Set = new Set(); if (rawRecentStops) { recentStops = new Set(JSON.parse(rawRecentStops) as number[]); @@ -137,19 +149,19 @@ function pushRecent(stopId: number) { recentStops.delete(val); } - localStorage.setItem("recentStops", JSON.stringify(Array.from(recentStops))); + localStorage.setItem(`recentStops_${region}`, JSON.stringify(Array.from(recentStops))); } -function getRecent(): number[] { - const rawRecentStops = localStorage.getItem("recentStops"); +function getRecent(region: RegionId): number[] { + const rawRecentStops = localStorage.getItem(`recentStops_${region}`); if (rawRecentStops) { return JSON.parse(rawRecentStops) as number[]; } return []; } -function getFavouriteIds(): number[] { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function getFavouriteIds(region: RegionId): number[] { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); if (rawFavouriteStops) { return JSON.parse(rawFavouriteStops) as number[]; } @@ -157,8 +169,9 @@ function getFavouriteIds(): number[] { } // New function to load stops from network -async function loadStopsFromNetwork(): Promise { - const response = await fetch("/stops.json"); +async function loadStopsFromNetwork(region: RegionId): Promise { + const regionConfig = getRegionConfig(region); + const response = await fetch(regionConfig.stopsEndpoint); const stops = (await response.json()) as Stop[]; return stops.map((stop) => ({ ...stop, favourite: false } as Stop)); } diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx index dc45198..c48932c 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -13,6 +13,7 @@ import { TimetableSkeleton } from "../components/TimetableSkeleton"; import { ErrorDisplay } from "../components/ErrorDisplay"; import { PullToRefresh } from "../components/PullToRefresh"; import { useAutoRefresh } from "../hooks/useAutoRefresh"; +import { type RegionId, getRegionConfig } from "../data/RegionConfig"; export interface StopDetails { stop: { @@ -35,8 +36,9 @@ interface ErrorInfo { message?: string; } -const loadData = async (stopId: string): Promise => { - const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { +const loadData = async (region: RegionId, stopId: string): Promise => { + const regionConfig = getRegionConfig(region); + const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, { headers: { Accept: "application/json", }, @@ -49,9 +51,16 @@ const loadData = async (stopId: string): Promise => { return await resp.json(); }; -const loadTimetableData = async (stopId: string): Promise => { +const loadTimetableData = async (region: RegionId, stopId: string): Promise => { + const regionConfig = getRegionConfig(region); + + // Check if timetable is available for this region + if (!regionConfig.timetableEndpoint) { + throw new Error("Timetable not available for this region"); + } + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format - const resp = await fetch(`/api/GetStopTimetable?date=${today}&stopId=${stopId}`, { + const resp = await fetch(`${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, { headers: { Accept: "application/json", }, @@ -83,7 +92,8 @@ export default function Estimates() { const [favourited, setFavourited] = useState(false); const [isManualRefreshing, setIsManualRefreshing] = useState(false); - const { tableStyle } = useApp(); + const { tableStyle, region } = useApp(); + const regionConfig = getRegionConfig(region); const parseError = (error: any): ErrorInfo => { if (!navigator.onLine) { @@ -108,10 +118,10 @@ export default function Estimates() { setEstimatesLoading(true); setEstimatesError(null); - const body = await loadData(params.id!); + const body = await loadData(region, params.id!); setData(body); setDataDate(new Date()); - setCustomName(StopDataProvider.getCustomName(stopIdNum)); + setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); } catch (error) { console.error('Error loading estimates data:', error); setEstimatesError(parseError(error)); @@ -120,14 +130,20 @@ export default function Estimates() { } finally { setEstimatesLoading(false); } - }, [params.id, stopIdNum]); + }, [params.id, stopIdNum, region]); const loadTimetableDataAsync = useCallback(async () => { + // Skip loading timetable if not available for this region + if (!regionConfig.timetableEndpoint) { + setTimetableLoading(false); + return; + } + try { setTimetableLoading(true); setTimetableError(null); - const timetableBody = await loadTimetableData(params.id!); + const timetableBody = await loadTimetableData(region, params.id!); setTimetableData(timetableBody); } catch (error) { console.error('Error loading timetable data:', error); @@ -136,7 +152,7 @@ export default function Estimates() { } finally { setTimetableLoading(false); } - }, [params.id]); + }, [params.id, region, regionConfig.timetableEndpoint]); const refreshData = useCallback(async () => { await Promise.all([ @@ -168,16 +184,16 @@ export default function Estimates() { loadEstimatesData(); loadTimetableDataAsync(); - StopDataProvider.pushRecent(parseInt(params.id ?? "")); - setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? ""))); - }, [params.id, loadEstimatesData, loadTimetableDataAsync]); + StopDataProvider.pushRecent(region, parseInt(params.id ?? "")); + setFavourited(StopDataProvider.isFavourite(region, parseInt(params.id ?? ""))); + }, [params.id, region, loadEstimatesData, loadTimetableDataAsync]); const toggleFavourite = () => { if (favourited) { - StopDataProvider.removeFavourite(stopIdNum); + StopDataProvider.removeFavourite(region, stopIdNum); setFavourited(false); } else { - StopDataProvider.addFavourite(stopIdNum); + StopDataProvider.addFavourite(region, stopIdNum); setFavourited(true); } }; @@ -188,10 +204,10 @@ export default function Estimates() { if (input === null) return; // cancelled const trimmed = input.trim(); if (trimmed === "") { - StopDataProvider.removeCustomName(stopIdNum); + StopDataProvider.removeCustomName(region, stopIdNum); setCustomName(undefined); } else { - StopDataProvider.setCustomName(stopIdNum, trimmed); + StopDataProvider.setCustomName(region, stopIdNum, trimmed); setCustomName(trimmed); } }; @@ -270,9 +286,9 @@ export default function Estimates() { /> ) : data ? ( tableStyle === "grouped" ? ( - + ) : ( - + ) ) : null}
    diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 56a9c79..c3a1308 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -38,7 +38,7 @@ export default function StopMap() { name: string; } | null>(null); const [isSheetOpen, setIsSheetOpen] = useState(false); - const { mapState, updateMapState, theme } = useApp(); + const { mapState, updateMapState, theme, region } = useApp(); const mapRef = useRef(null); const [mapStyleKey, setMapStyleKey] = useState("light"); @@ -56,7 +56,7 @@ export default function StopMap() { }; useEffect(() => { - StopDataProvider.getStops().then((data) => { + StopDataProvider.getStops(region).then((data) => { const features: GeoJsonFeature< Point, { stopId: number; name: string; lines: string[] } @@ -70,7 +70,7 @@ export default function StopMap() { })); setStops(features); }); - }, []); + }, [region]); useEffect(() => { //const styleName = "carto"; @@ -115,7 +115,7 @@ export default function StopMap() { const handlePointClick = (feature: any) => { const props: any = feature.properties; // fetch full stop to get lines array - StopDataProvider.getStopById(props.stopId).then((stop) => { + StopDataProvider.getStopById(region, props.stopId).then((stop) => { if (!stop) return; setSelectedStop({ stopId: stop.stopId, diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index bcda311..eae6ad8 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -2,6 +2,7 @@ import { type Theme, useApp } from "../AppContext"; import "./settings.css"; import { useTranslation } from "react-i18next"; import { useState } from "react"; +import { getAvailableRegions } from "../data/RegionConfig"; export default function Settings() { const { t, i18n } = useTranslation(); @@ -12,14 +13,35 @@ export default function Settings() { setTableStyle, mapPositionMode, setMapPositionMode, + region, + setRegion, } = useApp(); + const regions = getAvailableRegions(); + return (

    {t("about.title")}

    {t("about.description")}

    {t("about.settings")}

    +
    + + +