diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 98c1999..183116f 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -3,9 +3,12 @@ name: Build Android APK on: workflow_dispatch: push: + branches: + - main + - work + - 'codex/**' tags: - 'v*' - - 'version*' jobs: build-apk: diff --git a/README.md b/README.md index 2a96cf5..82dde6c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Aplicación con frontend en React y backend en FastAPI. ## Cambios aplicados +- Ajuste de dependencias del frontend para evitar conflictos de instalación (`react` 18 + `date-fns` 3 + `react-leaflet` 4 compatible). - Ajuste de dependencias del frontend para evitar conflictos de instalación (`react` 18 + `date-fns` 3). - Ajuste de dependencias del frontend para evitar conflictos de instalación (`react` 18 + `date-fns` 3 + `react-leaflet` 4 compatible). - Fallback de `REACT_APP_BACKEND_URL` en frontend para que no falle cuando la variable no esté definida. @@ -12,6 +13,10 @@ Aplicación con frontend en React y backend en FastAPI. ## Ejecutar backend +```bash +cd backend +pip install -r requirements.txt +# opcional: export MONGO_URL="mongodb://localhost:27017" (o MONGODB_URI) y DB_NAME="paraweather" Variables recomendadas: - `AEMET_API_KEY`: API key de AEMET (si no está definida, el backend usa Open-Meteo como fallback automático para `/api/conditions`). @@ -52,6 +57,325 @@ npx cap sync android npx cap open android ``` +Con eso se abre Android Studio para generar el APK/AAB. + + + +## Export rápido para Android Studio (ZIP) + +Para generar un ZIP importable directamente en Android Studio: + +```bash +cd frontend +npm run apk:zip +``` + +Salida esperada: + +- `frontend/dist/paraweather-android-studio.zip` + +Qué incluye el ZIP: + +- Carpeta `android/` lista para abrir en Android Studio +- `capacitor.config.*` +- `package.json` y `package-lock.json` + +Importación: + +1. Descomprime el ZIP. +2. En Android Studio: **File > Open**. +3. Selecciona la carpeta descomprimida y abre `android/`. +4. Espera a que Gradle sincronice y compila APK/AAB desde Android Studio. + +> Nota: el script crea/sincroniza Capacitor automáticamente y reconstruye el frontend antes de empaquetar. + +## APK compilada en GitHub (automático) + +Sí: ahora el repo incluye un workflow de GitHub Actions que compila la APK y la sube a GitHub. + +- Archivo: `.github/workflows/android-apk.yml` +- Evento manual: **Actions > Build Android APK > Run workflow** +- Evento por push: ramas `main`, `work` y `codex/**` (incluye `codex/paraweather`). +- Evento por release: al crear un tag `v*` (ejemplo: `v1.0.0`) también compila. + +Dónde descargar la APK: + +1. **Artifacts de Actions**: descarga `paraweather-debug-apk` desde la ejecución. +2. **Releases**: si disparas con tag `v*`, se adjunta `app-debug.apk` al release. + +Comandos recomendados para publicar una APK en Releases: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + + +## Métodos para extraer la APK (detallado) + +### Método A — GitHub Actions (recomendado, sin Android Studio local) + +1. Ve a **GitHub > Actions > Build Android APK**. +2. Pulsa **Run workflow** sobre `main`. +3. Espera a que termine `build-apk`. +4. Descarga el artifact `paraweather-debug-apk`. + +Ventajas: +- Reproducible para todo el equipo. +- No depende de tu SDK local. + +### Método B — Generar release con APK adjunta + +Si creas un tag `v*`, el workflow adjunta automáticamente `app-debug.apk` al release. + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +Luego descarga la APK desde **GitHub > Releases**. + +### Método C — Android Studio (local) + +```bash +cd frontend +npm install +npm run build +npx cap add android # solo la primera vez +npx cap sync android +npx cap open android +``` + +En Android Studio: +- **Build > Build Bundle(s) / APK(s) > Build APK(s)**. +- Ruta típica de salida: `frontend/android/app/build/outputs/apk/debug/app-debug.apk`. + +### Método D — ZIP importable para Android Studio + +```bash +cd frontend +npm run apk:zip +``` + +Genera `frontend/dist/paraweather-android-studio.zip` para compartir/importar proyecto Android rápidamente. + +### Error común de build local: `Unsupported class file major version 69` + +Ese error suele ocurrir cuando Gradle se ejecuta con Java muy nuevo/incompatible. + +Solución recomendada (Linux/macOS): + +```bash +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 +export PATH="$JAVA_HOME/bin:$PATH" +cd frontend/android +./gradlew --no-daemon assembleDebug +``` + +En Windows PowerShell: + +```powershell +$env:JAVA_HOME="C:\Program Files\Java\jdk-17" +$env:Path="$env:JAVA_HOME\bin;$env:Path" +cd frontend\android +.\gradlew.bat assembleDebug +``` + + +### Permisos Android (GPS + barómetro) + +Se añadió el script: + +- `frontend/scripts/apply_android_permissions.sh` + +Este script inyecta en `AndroidManifest.xml` los permisos/feature necesarios para uso de ubicación y sensor barométrico: + +- `ACCESS_COARSE_LOCATION` +- `ACCESS_FINE_LOCATION` +- `BODY_SENSORS` +- `uses-feature android.hardware.sensor.barometer` + +Se ejecuta automáticamente en: + +- `npm run apk:zip` +- Workflow `.github/workflows/android-apk.yml` + +También puedes ejecutarlo manualmente: + +```bash +cd frontend +npm run apk:permissions +``` + +### Método E — Disparo por terminal (Bash/PowerShell) + +Usa `npm run apk:trigger:github` para lanzar la compilación remota (ver sección siguiente con ejemplos completos). + +### ¿Se puede dejar la APK directamente dentro del repositorio? + +Sí, técnicamente se puede, **pero no es recomendable** para un repo de código: + +- Aumenta mucho el peso y el historial de Git. +- Cada build binaria ensucia los diffs. +- Es mejor publicar APK en **Releases** o en **Artifacts**. + +Si igualmente quieres versionar binarios en Git, usa **Git LFS** y una carpeta dedicada (`artifacts/`), pero para este proyecto se recomienda Releases. + +## Disparar la compilación APK en GitHub desde terminal + +Si quieres ejecutarlo ya en GitHub (sin entrar al panel web), usa este script: + +```bash +cd frontend +export GITHUB_TOKEN="" +export GITHUB_REPO="tu-usuario/tu-repo" # opcional si tienes remote origin +export GITHUB_REF="codex/paraweather" # opcional; por defecto usa tu rama actual +npm run apk:trigger:github +``` + +En **Windows PowerShell** usa esto (cada línea por separado): + +```powershell +cd frontend +$env:GITHUB_TOKEN="" +$env:GITHUB_REPO="tu-usuario/tu-repo" # opcional si tienes remote origin +$env:GITHUB_REF="codex/paraweather" # opcional; por defecto usa tu rama actual +npm run apk:trigger:github +``` + +También puedes lanzarlo en una sola línea en PowerShell: + +```powershell +cd frontend; $env:GITHUB_TOKEN=""; $env:GITHUB_REPO="Capybla/Paraweather"; $env:GITHUB_REF="codex/paraweather"; npm run apk:trigger:github +``` + + +### Problemas comunes en Windows (PowerShell) + +Si te aparece: + +- `cd : ... \Desktop\frontend no existe` +- `npm error Missing script: "apk:trigger:github"` + +significa que **no estás dentro del repositorio correcto** (o tienes una copia vieja). + +Pasos correctos desde cero: + +```powershell +cd $HOME\Desktop +git clone https://github.com/Capybla/Paraweather.git +cd Paraweather +git pull +cd frontend +$env:GITHUB_TOKEN="" +$env:GITHUB_REPO="Capybla/Paraweather" +$env:GITHUB_REF="codex/paraweather" +npm install +npm run apk:trigger:github +``` + +Si ya tienes el repo clonado en otra ruta, entra a esa carpeta primero: + +```powershell +cd "C:\ruta\donde\tengas\Paraweather\frontend" +npm run +``` + +En la salida de `npm run` debe aparecer `apk:trigger:github`. + +Qué hace: + +- Llama a la API de GitHub (`workflow_dispatch`) del workflow `.github/workflows/android-apk.yml`. +- Inicia la build en GitHub Actions para generar la APK. +- Si no defines `GITHUB_REF`, usa automáticamente la rama actual (por ejemplo `codex/paraweather`). +- Si no defines `GITHUB_REPO`, intenta leerlo desde `git remote origin`. + + +Alternativa sin npm (disparo directo de API desde PowerShell): + +```powershell +$headers = @{ + Authorization = "Bearer $env:GITHUB_TOKEN" + Accept = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" +} +$body = @{ ref = "codex/paraweather" } | ConvertTo-Json +Invoke-RestMethod -Method POST -Uri "https://api.github.com/repos/Capybla/Paraweather/actions/workflows/android-apk.yml/dispatches" -Headers $headers -Body $body +``` + +Si todo va bien, GitHub responde sin contenido (HTTP 204) y la ejecución aparece en Actions. + +Luego descárgala desde: + +- **Actions artifacts**: `paraweather-debug-apk` +- **Releases** (si disparas por tag `v*`) + +## Modo offline + +- La app guarda el último parte meteorológico y lo muestra cuando no hay red. +- Se muestra alerta de desconexión y se mantiene navegación con GPS local. +- En build de producción se registra un service worker para cachear recursos base. + + +## Estructura para mantenimiento (Open Source) + +- `frontend/src/App.js`: UI principal y lógica de widgets. +- `frontend/src/config.js`: valores por defecto de páginas/widgets y claves de almacenamiento (punto recomendado para personalización). +- `frontend/src/serviceWorkerRegistration.js` + `frontend/public/sw.js`: estrategia offline/cache. +- `backend/server.py`: API y lógica de reglas de vuelo. + +## Guía rápida para contribuir + +- Haz cambios pequeños y atómicos por PR. +- Mantén configuraciones editables en archivos de configuración (ejemplo: `frontend/src/config.js`) en lugar de hardcodear en componentes. +- Verifica siempre con: + - `cd frontend && node --check src/App.js` + - `cd frontend && npm run build` + +## Personalización Open Source + +- Puedes definir `REACT_APP_REPO_URL` para que el panel Open Source de la UI apunte a tu repositorio real en GitHub/GitLab. +- El diseño está pensado para ser profesional y reusable: componentes visuales y configuración separada en `frontend/src/App.js` + `frontend/src/config.js`. + + +## Integración Mapbox en React (mapa, espacios aéreos, NOTAMs y rutas) + +Instalación: + +```bash +cd frontend +npm install mapbox-gl +``` + +Configura tu token de Mapbox: + +```bash +export REACT_APP_MAPBOX_TOKEN="pk.xxxxx" +``` + +En Windows PowerShell: + +```powershell +$env:REACT_APP_MAPBOX_TOKEN="pk.xxxxx" +``` + +Qué incluye la integración: + +- Componente `frontend/src/components/MapboxFlightMap.js` (Mapbox GL JS en React). +- Capa de espacios aéreos (GeoJSON) desde: + - `airspaces` por props (transformación interna a GeoJSON), y/o + - endpoint backend `GET /api/airspaces/geojson`. +- Capa dinámica de NOTAMs desde backend `GET /api/notams` (GeoJSON). +- Popup al hacer clic en polígonos de espacio aéreo o NOTAMs. +- Dibujo interactivo de rutas por clic (waypoints + polyline configurable). +- Controles de navegación (zoom/pan + geolocate). +- Toggler UI para mostrar/ocultar capas de espacios aéreos y NOTAMs. + +Notas para Capacitor/Android: + +- Esta integración usa WebGL (compatible con WebView moderna). +- Mantén `REACT_APP_MAPBOX_TOKEN` en `.env` del frontend para builds Android. +- Los permisos de GPS y barómetro se inyectan en AndroidManifest con `npm run apk:permissions`. Github token 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 (expira en 31/1/2027) Con eso se abre Android Studio para generar el APK/AAB. Con eso se abre Android Studio para generar el APK/AAB. diff --git a/backend/server.py b/backend/server.py index 418df5b..d88a6d7 100644 --- a/backend/server.py +++ b/backend/server.py @@ -243,6 +243,40 @@ def parse_openair_airspace(openair_content: str) -> List[Dict]: return airspaces + + +def airspaces_to_geojson(airspaces: List[Dict]) -> Dict: + features = [] + for idx, airspace in enumerate(airspaces): + coords = airspace.get('coordinates', []) + ring = [] + for c in coords: + lat = c.get('lat') + lng = c.get('lng') + if lat is None or lng is None: + continue + ring.append([float(lng), float(lat)]) + + if len(ring) < 3: + continue + if ring[0] != ring[-1]: + ring.append(ring[0]) + + features.append({ + "type": "Feature", + "id": airspace.get('id', f"airspace-{idx}"), + "geometry": {"type": "Polygon", "coordinates": [ring]}, + "properties": { + "name": airspace.get('name', 'Unknown'), + "type": airspace.get('type', 'Unknown'), + "class": airspace.get('type', 'Unknown'), + "country": airspace.get('country', 'EU'), + "floor": airspace.get('floor'), + "ceiling": airspace.get('ceiling') + } + }) + + return {"type": "FeatureCollection", "features": features} def get_airspace_requirements(airspace_type: str) -> List[str]: """Get special requirements for airspace types""" requirements = { @@ -738,133 +772,6 @@ def compute_flight_score(wind_speed: float, visibility_km: float, weather_code: return score, "not_recommended" -AEMET_API_KEY = os.getenv("AEMET_API_KEY") -AEMET_BASE_URL = "https://opendata.aemet.es/opendata/api" -_AEMET_STATIONS_CACHE: Dict[str, Any] = {"stations": None, "expires_at": None} - - -def to_float(value: Any, default: float) -> float: - try: - return float(value) - except (TypeError, ValueError): - return default - - -def parse_aemet_coordinate(value: str, is_latitude: bool) -> Optional[float]: - """Parse AEMET compact DMS format (e.g., 412342N, 0021034W).""" - if not value: - return None - - text = value.strip().upper() - match = re.match(r"^(\d+)([NSEW])$", text) - if not match: - return None - - raw_digits, hemisphere = match.groups() - expected_length = 6 if is_latitude else 7 - if len(raw_digits) != expected_length: - return None - - deg_len = 2 if is_latitude else 3 - degrees = int(raw_digits[:deg_len]) - minutes = int(raw_digits[deg_len:deg_len + 2]) - seconds = int(raw_digits[deg_len + 2:deg_len + 4]) - - decimal = degrees + (minutes / 60.0) + (seconds / 3600.0) - if hemisphere in {"S", "W"}: - decimal *= -1 - - return decimal - - -async def fetch_aemet_dataset(path: str, client: httpx.AsyncClient) -> Any: - """AEMET API uses a two-step retrieval: metadata URL then dataset URL.""" - if not AEMET_API_KEY: - raise ValueError("AEMET_API_KEY is not configured") - - params = urlencode({"api_key": AEMET_API_KEY}) - metadata_url = f"{AEMET_BASE_URL}{path}?{params}" - metadata_response = await client.get(metadata_url) - metadata_response.raise_for_status() - metadata = metadata_response.json() - - data_url = metadata.get("datos") - if not data_url: - raise ValueError("AEMET response does not include a data URL") - - dataset_response = await client.get(data_url) - dataset_response.raise_for_status() - return dataset_response.json() - - -async def get_aemet_stations(client: httpx.AsyncClient) -> List[Dict[str, Any]]: - """Get and cache AEMET stations for 6 hours.""" - now = datetime.now(timezone.utc) - cached_stations = _AEMET_STATIONS_CACHE.get("stations") - expires_at = _AEMET_STATIONS_CACHE.get("expires_at") - - if cached_stations and expires_at and now < expires_at: - return cached_stations - - stations_raw = await fetch_aemet_dataset("/valores/climatologicos/inventarioestaciones/todasestaciones", client) - stations: List[Dict[str, Any]] = [] - - for station in stations_raw: - station_lat = parse_aemet_coordinate(station.get("latitud"), is_latitude=True) - station_lng = parse_aemet_coordinate(station.get("longitud"), is_latitude=False) - if station_lat is None or station_lng is None: - continue - stations.append({ - "indicativo": station.get("indicativo"), - "nombre": station.get("nombre", "Estación AEMET"), - "lat": station_lat, - "lng": station_lng, - }) - - _AEMET_STATIONS_CACHE["stations"] = stations - _AEMET_STATIONS_CACHE["expires_at"] = now.replace(microsecond=0) + timedelta(hours=6) - return stations - - -def parse_aemet_weather_description(observation: Dict[str, Any]) -> str: - if observation.get("prec") not in (None, "Ip", ""): # "Ip" = inappreciable precipitation - return "Rain observed" - - humidity = observation.get("hr") - try: - if humidity is not None and float(humidity) > 90: - return "High humidity" - except (TypeError, ValueError): - pass - - return "Stable conditions" - - -def parse_aemet_observation(observation: Dict[str, Any], lat: float, lng: float) -> FlightCondition: - temperature_c = to_float(observation.get("ta"), 20.0) - wind_speed = to_float(observation.get("v"), 0.0) - wind_direction = to_float(observation.get("dv"), 0.0) - visibility_km = to_float(observation.get("vis"), 10000.0) / 1000.0 - - weather_code = 63 if observation.get("prec") not in (None, "Ip", "") else 1 - score, recommendation = compute_flight_score(wind_speed, visibility_km, weather_code) - runway_heading = (wind_direction + 180) % 360 - - return FlightCondition( - lat=lat, - lng=lng, - temperature_c=temperature_c, - weather_description=parse_aemet_weather_description(observation), - wind_speed_ms=wind_speed, - wind_direction_deg=wind_direction, - visibility_km=visibility_km, - flight_score=score, - recommendation=recommendation, - takeoff_heading_deg=runway_heading, - landing_heading_deg=runway_heading, - ) - - # Ultra-Detailed Spanish Airspace Data + European Coverage EUROPEAN_AIRSPACE_DATA = """ * Ultra-Detailed Spanish Airspace Database for Paramotoring @@ -1759,6 +1666,30 @@ async def get_airspaces(type_filter: Optional[str] = None, country: Optional[str logger.error(f"Error getting airspaces: {e}") raise HTTPException(status_code=500, detail="Failed to get airspace data") + +@api_router.get("/airspaces/geojson") +async def get_airspaces_geojson(type_filter: Optional[str] = None, country: Optional[str] = None): + """Return airspaces as GeoJSON FeatureCollection for map layers.""" + parsed_airspaces = parse_openair_airspace(EUROPEAN_AIRSPACE_DATA) + filtered = [] + for airspace in parsed_airspaces: + if type_filter and airspace.get('type') != type_filter: + continue + if country and airspace.get('country') != country: + continue + filtered.append(airspace) + + return airspaces_to_geojson(filtered) + + +@api_router.get("/notams") +async def get_notams_geojson(): + """Return active NOTAM areas as GeoJSON. Placeholder empty collection if no provider configured.""" + return { + "type": "FeatureCollection", + "features": [] + } + @api_router.get("/countries") async def get_countries(): """Get available countries with airspace data""" @@ -1871,7 +1802,18 @@ async def create_route(route_data: RouteCreate): @api_router.get("/conditions", response_model=FlightCondition) async def get_flight_conditions(lat: float, lng: float): """Get current weather/wind/visibility and flight recommendation for a location.""" + weather_code = 0 + temperature_c = 20.0 + wind_speed = 4.0 + wind_direction = 0.0 + visibility_km = 10.0 + try: + weather_url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={lat}&longitude={lng}" + "¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,visibility" + ) async with httpx.AsyncClient(timeout=10) as client: # Primary source: AEMET observations from nearest station if AEMET_API_KEY: @@ -1918,30 +1860,13 @@ async def get_flight_conditions(lat: float, lng: float): response.raise_for_status() payload = response.json() - current = payload.get("current", {}) - temperature_c = float(current.get("temperature_2m", 20.0)) - weather_code = int(current.get("weather_code", 0)) - wind_speed = float(current.get("wind_speed_10m", 0.0)) - wind_direction = float(current.get("wind_direction_10m", 0.0)) - visibility_m = float(current.get("visibility", 10000.0)) - visibility_km = visibility_m / 1000.0 - - score, recommendation = compute_flight_score(wind_speed, visibility_km, weather_code) - runway_heading = (wind_direction + 180) % 360 - - return FlightCondition( - lat=lat, - lng=lng, - temperature_c=temperature_c, - weather_description=weather_code_to_text(weather_code), - wind_speed_ms=wind_speed, - wind_direction_deg=wind_direction, - visibility_km=visibility_km, - flight_score=score, - recommendation=recommendation, - takeoff_heading_deg=runway_heading, - landing_heading_deg=runway_heading, - ) + current = payload.get("current", {}) + temperature_c = float(current.get("temperature_2m", temperature_c)) + weather_code = int(current.get("weather_code", weather_code)) + wind_speed = float(current.get("wind_speed_10m", wind_speed)) + wind_direction = float(current.get("wind_direction_10m", wind_direction)) + visibility_m = float(current.get("visibility", visibility_km * 1000.0)) + visibility_km = visibility_m / 1000.0 except Exception as e: logger.error(f"Error getting flight conditions: {e}") raise HTTPException(status_code=500, detail="Failed to get flight conditions") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b2bbee..386c685 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,6 +46,7 @@ "input-otp": "^1.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.507.0", + "mapbox-gl": "^3.13.0", "next-themes": "^0.4.6", "react": "^18.2.0", "react-day-picker": "8.10.1", @@ -3542,6 +3543,49 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "license": "MIT" }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", + "integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==", + "license": "BSD-3-Clause" + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -5861,6 +5905,21 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -5957,6 +6016,12 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -6063,6 +6128,15 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -7744,6 +7818,12 @@ "node": ">=10" } }, + "node_modules/cheap-ruler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", + "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", + "license": "ISC" + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -8506,6 +8586,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, "node_modules/cssdb": { "version": "7.11.2", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.11.2.tgz", @@ -9133,6 +9219,12 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "license": "MIT" }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -10602,6 +10694,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -10701,6 +10799,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10851,6 +10955,12 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "license": "MIT" }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -13179,6 +13289,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13452,6 +13568,55 @@ "tmpl": "1.0.5" } }, + "node_modules/mapbox-gl": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.19.1.tgz", + "integrity": "sha512-p1/nJ85IqpjKk7IKNDnH9AB4qE/MXbR5ToagNurofTpUgnXX9NXcOzm8WsRnrovQIwiefC4MiEWUK6Sa2HfWtg==", + "license": "SEE LICENSE IN LICENSE.txt", + "workspaces": [ + "src/style-spec", + "test/build/vite", + "test/build/webpack", + "test/build/typings" + ], + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^3.0.0", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "^3.2.5", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "cheap-ruler": "^4.0.0", + "csscolorparser": "~1.0.3", + "earcut": "^3.0.1", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "grid-index": "^1.1.0", + "kdbush": "^4.0.2", + "martinez-polygon-clipping": "^0.8.1", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + } + }, + "node_modules/martinez-polygon-clipping": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz", + "integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^2.0.4", + "splaytree": "^0.1.4", + "tinyqueue": "3.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13677,6 +13842,12 @@ "multicast-dns": "cli.js" } }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -14334,6 +14505,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -15800,6 +15983,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -15902,6 +16091,12 @@ "react-is": "^16.13.1" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -16003,6 +16198,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -17135,6 +17336,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/resolve-url-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", @@ -17245,6 +17455,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", @@ -18026,6 +18242,12 @@ "wbuf": "^1.7.3" } }, + "node_modules/splaytree": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz", + "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", + "license": "MIT" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -18390,6 +18612,15 @@ "node": ">= 6" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -19007,6 +19238,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index b753e2c..3ad546f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,7 +54,8 @@ "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "^3.24.4" + "zod": "^3.24.4", + "mapbox-gl": "^3.13.0" }, "scripts": { "start": "craco start", @@ -62,6 +63,8 @@ "test": "craco test", "apk:zip": "bash ./scripts/export_android_studio_zip.sh", "apk:zip:custom": "bash ./scripts/export_android_studio_zip.sh ./dist/paraweather-android-studio-custom.zip", + "apk:trigger:github": "bash ./scripts/trigger_github_apk.sh", + "apk:permissions": "bash ./scripts/apply_android_permissions.sh" "apk:trigger:github": "bash ./scripts/trigger_github_apk.sh" }, "browserslist": { diff --git a/frontend/resources/android-permissions.xml b/frontend/resources/android-permissions.xml new file mode 100644 index 0000000..7324ad6 --- /dev/null +++ b/frontend/resources/android-permissions.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/scripts/apply_android_permissions.sh b/frontend/scripts/apply_android_permissions.sh new file mode 100755 index 0000000..720897e --- /dev/null +++ b/frontend/scripts/apply_android_permissions.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRONTEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +MANIFEST="$FRONTEND_DIR/android/app/src/main/AndroidManifest.xml" +PERMS_FILE="$FRONTEND_DIR/resources/android-permissions.xml" + +if [[ ! -f "$MANIFEST" ]]; then + echo "⚠️ AndroidManifest no encontrado en $MANIFEST (ejecuta 'npx cap add android' primero)." + exit 0 +fi + +if [[ ! -f "$PERMS_FILE" ]]; then + echo "⚠️ Archivo de permisos no encontrado: $PERMS_FILE" + exit 0 +fi + +python3 - "$MANIFEST" "$PERMS_FILE" <<'PY' +from pathlib import Path +import re +import sys + +manifest_path = Path(sys.argv[1]) +perms_path = Path(sys.argv[2]) +manifest = manifest_path.read_text() +perms = perms_path.read_text() + +entries = re.findall(r']+>', perms) +updated = manifest +for entry in entries: + if entry not in updated: + updated = updated.replace(' token with repo/actions permissions +# Optional: +# GITHUB_REPO -> owner/repo (auto-detect from git remote origin if omitted) +# GITHUB_REF -> git ref to build (auto-detect current branch if omitted) # GITHUB_REPO -> owner/repo (example: capybla/paraweather) # Optional: # GITHUB_REF -> git ref to build (default: main) @@ -15,6 +18,25 @@ if ! command -v curl >/dev/null 2>&1; then fi : "${GITHUB_TOKEN:?Falta GITHUB_TOKEN (token con permisos repo/actions).}" + +WORKFLOW_FILE="${WORKFLOW_FILE:-android-apk.yml}" + +if [[ -z "${GITHUB_REPO:-}" ]]; then + ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)" + if [[ -n "$ORIGIN_URL" ]]; then + GITHUB_REPO="$(echo "$ORIGIN_URL" | sed -E 's#(git@github.com:|https://github.com/)##; s#\.git$##')" + fi +fi + +: "${GITHUB_REPO:?Falta GITHUB_REPO con formato owner/repo (o remote origin configurado).}" + +if [[ -z "${GITHUB_REF:-}" ]]; then + GITHUB_REF="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -z "$GITHUB_REF" || "$GITHUB_REF" == "HEAD" ]]; then + GITHUB_REF="main" + fi +fi + : "${GITHUB_REPO:?Falta GITHUB_REPO con formato owner/repo.}" GITHUB_REF="${GITHUB_REF:-main}" diff --git a/frontend/src/App.css b/frontend/src/App.css index a47c26c..e87e32b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1935,3 +1935,130 @@ body { margin: 0.6rem; } } + +/* Compact top bar + highly visible sidebar controls */ +.app-header { + position: sticky; + top: 0; +} + +.header-controls { + display: flex; + align-items: center; + gap: 0.45rem; + position: relative; +} + +.controls-menu-button { + font-size: 0.9rem; + padding: 0.45rem 0.75rem; +} + +.quick-sidebar-toggle, +.sidebar-toggle.prominent { + background: #ffeb3b; + color: #3e2723; + border: 1px solid #f57f17; + border-radius: 999px; + padding: 0.45rem 0.75rem; + font-weight: 700; + box-shadow: 0 2px 8px rgba(245, 127, 23, 0.35); +} + +.quick-sidebar-toggle:hover, +.sidebar-toggle.prominent:hover { + background: #ffd54f; +} + +.controls-dropdown { + min-width: 200px; + max-width: min(82vw, 280px); + right: 0.2rem; + top: 3.2rem; + padding: 0.4rem; +} + +.controls-dropdown-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + font-weight: 700; + color: #5d4037; + margin-bottom: 0.2rem; +} + +.controls-close-btn { + border: 1px solid #ffb74d; + background: #fff8e1; + color: #5d4037; + border-radius: 6px; + padding: 0.15rem 0.4rem; + cursor: pointer; +} + +.sidebar { + width: min(320px, 82vw); +} + +.leaflet-container { + z-index: 2; +} + +@media (max-width: 1024px) { + .quick-sidebar-toggle { + font-size: 0.8rem; + padding: 0.35rem 0.55rem; + } + + .controls-dropdown { + left: auto; + right: 0.35rem; + width: min(74vw, 260px); + top: 3.4rem; + } + + .sidebar { + max-height: 34vh; + } +} + +.mapbox-wrapper { + position: relative; + height: 100%; + min-height: 420px; +} + +.mapbox-map { + width: 100%; + height: calc(100% - 66px); + min-height: 360px; + border-radius: 10px; + overflow: hidden; + border: 1px solid #ffb74d; +} + +.mapbox-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.4rem; +} + +.mapbox-toolbar button { + border: 1px solid #ffb74d; + background: #fff3e0; + color: #5d4037; + border-radius: 8px; + padding: 0.28rem 0.5rem; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; +} + +.mapbox-waypoints { + margin-bottom: 0.35rem; + font-size: 0.78rem; + color: #5d4037; + font-weight: 600; +} diff --git a/frontend/src/App.js b/frontend/src/App.js index e0e77c3..317aa25 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,9 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; -import { MapContainer, TileLayer, Polygon, Marker, Popup, Polyline, useMapEvents, useMap } from 'react-leaflet'; +import { Marker, Popup, Polyline, useMapEvents, useMap } from 'react-leaflet'; import L from 'leaflet'; import axios from 'axios'; import './App.css'; import { UI_DEFAULT_PAGES, UI_DEFAULT_WIDGETS, STORAGE_KEYS, WEATHER_CACHE_TTL_MS } from './config'; +import MapboxFlightMap from './components/MapboxFlightMap'; // Fix for default markers in react-leaflet delete L.Icon.Default.prototype._getIconUrl; @@ -57,6 +58,32 @@ const speakDirection = (text) => { }; // Custom icons for different airspace types +const normalizeAirspaceCoordinates = (coordinates = []) => { + if (!Array.isArray(coordinates)) return []; + + const normalized = coordinates + .map((coord) => { + const lat = Number(coord?.lat); + const lng = Number(coord?.lng); + if (Number.isFinite(lat) && Number.isFinite(lng)) { + if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) return [lat, lng]; + if (Math.abs(lng) <= 90 && Math.abs(lat) <= 180) return [lng, lat]; + } + return null; + }) + .filter(Boolean); + + if (normalized.length > 2) { + const [firstLat, firstLng] = normalized[0]; + const [lastLat, lastLng] = normalized[normalized.length - 1]; + if (firstLat !== lastLat || firstLng !== lastLng) { + normalized.push([firstLat, firstLng]); + } + } + + return normalized; +}; + const getAirspaceColor = (type) => { const colors = { 'R': '#ff4444', // Restricted - Red @@ -241,7 +268,6 @@ const AirspacePreferences = ({ preferences, onPreferencesChange, isOpen, onToggl // GPS Position Tracker Component const GPSTracker = ({ onPositionUpdate, onSensorStatus, onMotionUpdate }) => { - const [watchId, setWatchId] = useState(null); const [sensorStatus, setSensorStatus] = useState({ gps: 'checking', barometer: 'checking', @@ -665,18 +691,77 @@ const SensorWarnings = ({ sensorStatus }) => { -const FlightConditionsPanel = ({ conditions, sensorStatus, motionData }) => { - if (!conditions) return null; - const recommendationLabel = { - recommended: '✅ Recomendado volar', - caution: '⚠️ Volar con precaución', - not_recommended: '⛔ No recomendado volar' +const NetworkStatusBanner = ({ isOnline, weatherFromCache, weatherUpdatedAt }) => { + if (isOnline) return null; + return ( +
+ ⚠️ Sin conexión de red. Modo offline activo con GPS y datos guardados. + {weatherFromCache && weatherUpdatedAt && ( + Último parte meteo: {new Date(weatherUpdatedAt).toLocaleTimeString()}. + )} +
+ ); +}; + +const recommendationLabel = { + recommended: '✅ Recomendado volar', + caution: '⚠️ Volar con precaución', + not_recommended: '⛔ No recomendado volar' +}; + +const DraggableWidget = ({ id, title, children, config, onUpdate }) => { + const [dragging, setDragging] = useState(false); + const dragRef = useRef({ offsetX: 0, offsetY: 0 }); + + useEffect(() => { + const handleMove = (event) => { + if (!dragging) return; + onUpdate(id, { + x: Math.max(0, event.clientX - dragRef.current.offsetX), + y: Math.max(70, event.clientY - dragRef.current.offsetY) + }); + }; + + const handleUp = () => setDragging(false); + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + return () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + }; + }, [dragging, id, onUpdate]); + + const startDrag = (event) => { + setDragging(true); + dragRef.current = { + offsetX: event.clientX - config.x, + offsetY: event.clientY - config.y + }; }; return ( -
-

🌤️ Condiciones de vuelo

+
+
+

{title}

+ Arrastra + redimensiona ↘ +
+
{children}
+
+ ); +}; + +const WeatherWidget = ({ conditions, sensorStatus, motionData }) => { + if (!conditions) { + return

Cargando condiciones meteorológicas...

; + } + + return ( +

Tiempo: {conditions.weather_description} · {conditions.temperature_c.toFixed(1)}°C

Viento: {conditions.wind_speed_ms.toFixed(1)} m/s ({Math.round(conditions.wind_direction_deg)}°)

Visibilidad: {conditions.visibility_km.toFixed(1)} km

@@ -1284,6 +1369,8 @@ const App = () => { setSensorStatus(status); }; + const motionUpdateRaf = useRef(null); + const handleMotionUpdate = (data) => { setMotionData(prev => ({ ...prev, ...data })); const motionUpdateRaf = useRef(null); @@ -1335,6 +1422,20 @@ const App = () => { params: { lat: currentPosition.lat, lng: currentPosition.lng } }); setFlightConditions(response.data); + const minimizedData = { + weather_description: response.data.weather_description, + temperature_c: response.data.temperature_c, + wind_speed_ms: response.data.wind_speed_ms, + wind_direction_deg: response.data.wind_direction_deg, + visibility_km: response.data.visibility_km, + flight_score: response.data.flight_score, + recommendation: response.data.recommendation, + takeoff_heading_deg: response.data.takeoff_heading_deg, + landing_heading_deg: response.data.landing_heading_deg, + }; + const snapshot = { data: minimizedData, updatedAt: Date.now() }; + setLastWeatherSnapshot(snapshot); + localStorage.setItem(STORAGE_KEYS.lastWeatherSnapshot, JSON.stringify(snapshot)); } catch (error) { console.error('Error loading flight conditions:', error); const minimizedData = { @@ -1376,6 +1477,14 @@ const App = () => { selectedAirspaceTypes.length === 0 || selectedAirspaceTypes.includes(airspace.type) ); + const normalizedAirspaces = filteredAirspaces + .map((airspace, index) => ({ + ...airspace, + normalizedCoordinates: normalizeAirspaceCoordinates(airspace.coordinates), + _renderKey: airspace.id || `${airspace.country || 'XX'}-${airspace.type || 'U'}-${airspace.name || 'airspace'}-${index}` + })) + .filter((airspace) => airspace.normalizedCoordinates.length >= 4); + if (loading) { return (
@@ -1402,6 +1511,26 @@ const App = () => { + + {controlsMenuOpen && ( +
+
+ Acciones rápidas + +
+ {selectedRoute && !navigationMode && ( + @@ -1418,6 +1547,7 @@ const App = () => { )} {navigationMode && ( )} + + {!navigationMode && ( + + + + + +
+
Waypoints: {waypoints.length}
+
+
+ ); +}; + +export default MapboxFlightMap;