Amélioration avec des fonctions d'un vrai dashboard automotive et containerisation du projet pour faciliter le déploiement
This commit is contained in:
@@ -3,10 +3,22 @@
|
|||||||
!server/
|
!server/
|
||||||
!server/server.mjs
|
!server/server.mjs
|
||||||
!server/telemetry.mjs
|
!server/telemetry.mjs
|
||||||
|
!server/services.mjs
|
||||||
!server/Dockerfile
|
!server/Dockerfile
|
||||||
!ui/
|
!ui/
|
||||||
!ui/index.html
|
!ui/index.html
|
||||||
!ui/styles.css
|
!ui/styles.css
|
||||||
!ui/app.js
|
!ui/app.js
|
||||||
|
!ui/applications.js
|
||||||
|
!ui/applications.css
|
||||||
|
!ui/cockpit.js
|
||||||
|
!ui/cockpit.css
|
||||||
|
!ui/embeds.js
|
||||||
|
!ui/journey.js
|
||||||
|
!ui/geo.js
|
||||||
|
!ui/theme.js
|
||||||
|
!ui/journey.css
|
||||||
|
!ui/vendor/
|
||||||
|
!ui/vendor/**
|
||||||
!ui/nginx.conf
|
!ui/nginx.conf
|
||||||
!ui/Dockerfile
|
!ui/Dockerfile
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CARIA_PORT=8080
|
||||||
|
# Optional compatible self-hosted providers. Empty uses the public prototype default.
|
||||||
|
WEATHER_URL=
|
||||||
|
GEOCODER_URL=
|
||||||
|
ROUTER_URL=
|
||||||
|
# Raster tile template, for example https://your-server/{z}/{x}/{y}.png
|
||||||
|
TILE_URL=
|
||||||
|
TILE_ATTRIBUTION=© OpenStreetMap contributors
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
# CARIA Automotive · Cockpit
|
# CARIA Automotive · Cockpit
|
||||||
|
|
||||||
Première version de l’interface intérieure : accueil multimédia et instrumentation automobile, indépendants du matériel. Le projet scolaire original reste dans `WebControl/` ; le cockpit utilise `ui/` et `server/`.
|
Version 0.3 de l’interface intérieure : accueil multimédia, instrumentation automobile, thèmes, météo, navigation et catalogue d’applications, indépendants du matériel. Référence d’affichage : **10 pouces tactile, paysage, 1200 × 1080 pixels** (dimensions fournies comme 1080 × 1200 puis orientation horizontale). Le projet scolaire original reste dans `WebControl/` ; le cockpit utilise `ui/` et `server/`.
|
||||||
|
|
||||||
|
**Mise à jour d’une installation existante** : récupérer les fichiers modifiés puis exécuter `docker compose up -d --build` et recharger la page (Ctrl+F5). La version 0.1 a été construite et démarrée avec succès par l’utilisateur ; la nouvelle image 0.3 reste à construire sur son serveur.
|
||||||
|
|
||||||
|
## Nouveautés 0.3
|
||||||
|
|
||||||
|
- Bouton hamburger pour masquer/afficher les favoris, préférence mémorisée ; en-tête intégré au contenu et pied de page conservé pour les tests. Titres de page visuellement masqués quand les commandes suffisent.
|
||||||
|
- Quatre favoris personnalisables et catalogue : Accueil, Conduite, Navigation, Musique locale, Jellyfin, Spotify, YouTube, **YouTube Music**, Waze. Afficher/masquer, ordre et mode d’ouverture réglables via Applications → Personnaliser ou Réglages → Personnaliser les applications.
|
||||||
|
- Conduite : compteurs agrandis, base aplatie sous leurs libellés, quatre mesures rapides et page de détails (`#vehicle-details`). Lien dédié `/?display=drive#drive` ouvrant Conduite avec la barre repliée. Le favori Conduite reste présent par défaut pour les essais.
|
||||||
|
- Musique : bibliothèque conservée et seconde vue `#now-playing`, accessible via « Vue lecture immersive ». Progression en arc, titre, durée, lecture/pause, précédent/suivant, ±15 s, déplacement dans la piste, volume, silence et répétition. Les deux vues partagent le même élément audio.
|
||||||
|
- Services externes : nouvel onglet, même onglet, ou lecteur intégré pour les contenus compatibles. Waze peut recevoir la destination choisie dans CARIA.
|
||||||
|
|
||||||
|
Voir [le guide applications et affichage tactile](docs/applications.md) pour les limites des intégrations Spotify, YouTube Music et Jellyfin.
|
||||||
|
|
||||||
## Démarrage avec Docker
|
## Démarrage avec Docker
|
||||||
|
|
||||||
@@ -20,7 +32,7 @@ docker compose down
|
|||||||
|
|
||||||
Le port est configurable avec `CARIA_PORT` dans un fichier `.env` à la racine (exemple : `CARIA_PORT=8090`). Deux services : Nginx sert l’interface et relaie `/api/` vers le simulateur Node.js. Seul le port de l’interface est publié. Aucun périphérique ni privilège matériel n’est demandé.
|
Le port est configurable avec `CARIA_PORT` dans un fichier `.env` à la racine (exemple : `CARIA_PORT=8090`). Deux services : Nginx sert l’interface et relaie `/api/` vers le simulateur Node.js. Seul le port de l’interface est publié. Aucun périphérique ni privilège matériel n’est demandé.
|
||||||
|
|
||||||
Les images de base ne forcent pas l’architecture. La construction sur l’ODroid utilisera les variantes disponibles pour son système. **Le modèle, l’OS et l’architecture de l’ODroid restent à confirmer avant de garantir la compatibilité**, notamment si son OS est en 32 bits. Le déploiement Docker n’a pas pu être exécuté sur le poste de développement, où Docker est absent.
|
Les images de base ne forcent pas l’architecture. La construction sur l’ODroid utilisera les variantes disponibles pour son système. **Le modèle, l’OS et l’architecture de l’ODroid restent à confirmer avant de garantir la compatibilité**, notamment si son OS est en 32 bits. Docker est absent du poste de développement.
|
||||||
|
|
||||||
## Démarrage sans Docker
|
## Démarrage sans Docker
|
||||||
|
|
||||||
@@ -34,18 +46,23 @@ Le serveur local sert également les trois fichiers de l’interface, sur le mê
|
|||||||
|
|
||||||
## Fonctions disponibles
|
## Fonctions disponibles
|
||||||
|
|
||||||
- **Accueil** : heure/date de l’appareil, météo de démonstration, carburant/autonomie et accès à la musique.
|
- **Accueil** : heure/date de l’appareil, météo réelle Open-Meteo après localisation, carburant/autonomie simulés et accès à la musique.
|
||||||
- **Conduite** : vitesse et régime moteur à aiguilles SVG, rapport, carburant, température moteur, tension batterie, consommation et trajet simulés.
|
- **Conduite** : vitesse et régime moteur à aiguilles SVG, rapport, carburant, température moteur, tension batterie, consommation et trajet simulés.
|
||||||
- **Musique** : sélection multiple de fichiers audio locaux, liste de lecture, lecture/pause, précédent/suivant, progression et volume via le lecteur natif. Les fichiers restent sur l’appareil qui affiche le navigateur ; ils ne sont pas transférés au serveur. La sélection doit être refaite après un rechargement. Une bibliothèque persistante sur disque/USB côté serveur pourra être ajoutée ensuite.
|
- **Musique** : sélection multiple de fichiers audio locaux, liste de lecture, lecture/pause, précédent/suivant, progression et volume via le lecteur natif. Les fichiers restent sur l’appareil qui affiche le navigateur ; ils ne sont pas transférés au serveur. La sélection doit être refaite après un rechargement. Une bibliothèque persistante sur disque/USB côté serveur pourra être ajoutée ensuite.
|
||||||
- **Streaming** : lien Jellyfin préconfiguré vers `https://media.cunatbrule.fr/web/`, modifiable dans les réglages. Ouverture dans un autre onglet ; aucune intégration de compte ni lecteur vidéo Jellyfin embarqué à ce stade.
|
- **Streaming** : lien Jellyfin préconfiguré vers `https://media.cunatbrule.fr/web/`, modifiable dans les réglages. Ouverture dans un autre onglet ; aucune intégration de compte ni lecteur vidéo Jellyfin embarqué à ce stade.
|
||||||
- **Réglages** : ambiance jour/nuit, adresse du streaming, raccourcis pour ouvrir deux écrans et bouton plein écran. Les réglages sont conservés dans le stockage local du navigateur.
|
- **Réglages** : ambiance jour/nuit combinable avec bleu, rouge, vert ou couleur utilisateur ; la palette colore les commandes, aiguilles, graphiques et illustrations. Luminosité adaptée au contraste. Adresse du streaming, raccourcis multiécran et plein écran. Les réglages sont conservés dans le stockage local du navigateur.
|
||||||
|
- **Navigation** : carte Leaflet/OpenStreetMap, recherche de départ/destination avec Photon, itinéraire automobile OSRM, étapes en français, suivi GPS et voix optionnelle. Recalcul manuel en cas de sortie de route. Pas de trafic temps réel ni de cartographie hors ligne.
|
||||||
|
- **Alertes de zones** : import GeoJSON local, sélection du pays, approche GPS, signal visuel et bip optionnel. Aucune base de radars ni alerte communautaire n’est fournie. Le test d’alerte est explicitement fictif. Les alertes sont désactivées à chaque rechargement/changement de pays et suspendues si le GPS est ancien ou imprécis.
|
||||||
|
|
||||||
Le menu à gauche contient les quatre applications : accueil, conduite, musique et streaming. Les réglages sont en bas. La météo est fictive et explicitement marquée « DÉMO » ; l’heure est réelle. L’interface et la musique sélectionnée fonctionnent sans Internet tant que le serveur local est accessible. Jellyfin dépend de l’accès à son serveur.
|
Le menu à gauche contient jusqu’à quatre favoris, avec un accès Applications permanent et les réglages en bas. Le badge « Démonstration » concerne les données du véhicule, pas la météo. La météo utilise une estimation de modèle et ne remplace pas une sonde extérieure. L’interface et la musique sélectionnée fonctionnent sans Internet tant que le serveur local est accessible. Les services météo, carte, recherche et itinéraires nécessitent Internet avec la configuration par défaut.
|
||||||
|
|
||||||
|
**GPS sur le réseau local** : les navigateurs demandent un contexte sécurisé, donc HTTPS avec certificat reconnu, ou localhost. Sur `http://IP_DU_SERVEUR:8080`, utilisez le départ manuel (recherche ou coordonnées) pour la météo et les aperçus d’itinéraire. Le GPS de l’ODroid devra être fourni à son navigateur ; cette version n’intègre pas encore gpsd ou un récepteur USB. Voir [le guide météo/navigation/alertes](docs/navigation.md).
|
||||||
|
|
||||||
## Un ou plusieurs écrans
|
## Un ou plusieurs écrans
|
||||||
|
|
||||||
- Instrumentation : `http://ADRESSE_DU_SERVEUR:8080/#drive`
|
- Instrumentation dédiée : `http://ADRESSE_DU_SERVEUR:8080/?display=drive#drive`
|
||||||
- Multimédia : `http://ADRESSE_DU_SERVEUR:8080/#home`
|
- Multimédia : `http://ADRESSE_DU_SERVEUR:8080/#home`
|
||||||
|
- Navigation : `http://ADRESSE_DU_SERVEUR:8080/#map`
|
||||||
|
|
||||||
Chaque écran ouvre sa propre page. Les valeurs proviennent du même serveur. Le profil route/ville/arrêt se synchronise entre les onglets d’un même navigateur et d’une même origine ; sur deux appareils, sélectionner le même profil sur chacun. Les fichiers audio et réglages restent propres à chaque navigateur.
|
Chaque écran ouvre sa propre page. Les valeurs proviennent du même serveur. Le profil route/ville/arrêt se synchronise entre les onglets d’un même navigateur et d’une même origine ; sur deux appareils, sélectionner le même profil sur chacun. Les fichiers audio et réglages restent propres à chaque navigateur.
|
||||||
|
|
||||||
@@ -65,22 +82,27 @@ Navigateur ODroid / tablette / second écran
|
|||||||
Étape suivante : passerelle Raspberry Pi CAN → données normalisées → interface
|
Étape suivante : passerelle Raspberry Pi CAN → données normalisées → interface
|
||||||
```
|
```
|
||||||
|
|
||||||
L’interface utilise HTML/CSS/JavaScript sans framework, sans CDN ni police distante. Les compteurs SVG s’adaptent à la résolution, avec une actualisation toutes les 500 ms et une transition d’aiguille. Le serveur utilise uniquement les modules standard de Node.js. Aucun gain de performance sur ODroid n’est encore mesuré.
|
L’interface utilise HTML/CSS/JavaScript sans framework, sans CDN ni police distante. Leaflet 1.9.4 (BSD-2-Clause) est livré localement et chargé à l’ouverture de la carte. Les tuiles sont téléchargées auprès du fournisseur cartographique. Les compteurs SVG s’adaptent à la résolution, avec une actualisation toutes les 500 ms et une transition d’aiguille. Le serveur utilise uniquement les modules standard de Node.js. Aucun gain de performance sur ODroid n’est encore mesuré.
|
||||||
|
|
||||||
La passerelle CAN est un futur composant : pas de décodage Mégane III, de SocketCAN ni de commande véhicule dans cette version. Elle pourra être écrite indépendamment en Python, Rust, C++ ou JavaScript. Le contrat proposé est décrit dans [docs/architecture.md](docs/architecture.md). Le passage à des données réelles nécessitera également une adaptation de leur validation et de leurs indicateurs dans l’interface.
|
La passerelle CAN est un futur composant : pas de décodage Mégane III, de SocketCAN ni de commande véhicule dans cette version. Elle pourra être écrite indépendamment en Python, Rust, C++ ou JavaScript. Le contrat proposé est décrit dans [docs/architecture.md](docs/architecture.md). Le passage à des données réelles nécessitera également une adaptation de leur validation et de leurs indicateurs dans l’interface.
|
||||||
|
|
||||||
Navigation, météo réelle, alertes radar et bibliothèque audio sur le serveur sont des étapes ultérieures. Les composants techniques choisis sont open source ; le dépôt historique n’a pas de licence globale, à définir avant une distribution publique du projet complet en tenant compte des fichiers tiers.
|
Restent notamment : source de zones autorisée et maintenue par pays, GPS matériel, cartographie hors ligne et bibliothèque audio persistante sur le serveur. Les composants techniques choisis sont open source ; le dépôt historique n’a pas de licence globale, à définir avant une distribution publique du projet complet en tenant compte des fichiers tiers.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node --test server/telemetry.test.mjs
|
node --test server/telemetry.test.mjs server/journey.test.mjs server/applications.test.mjs
|
||||||
node --check ui/app.js
|
node --check ui/app.js
|
||||||
|
node --check ui/journey.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests exécutés : contrat HTTP, limites des données simulées, profil invalide, API en lecture seule et impossibilité d’accéder aux fichiers du projet historique via le nouveau serveur. Tests Chrome automatisés : quatre vues à 1280×800, 1024×600, 800×480 et 390×844, absence de débordement horizontal, réglages persistants, lien Jellyfin, lecture de deux WAV locaux, changement de piste et retour après coupure API. Les captures accueil et conduite ont été examinées. Défilement vertical possible sur petits écrans selon la vue.
|
Tests exécutés : contrat HTTP, limites des données simulées, profil invalide, API en lecture seule et isolation des fichiers historiques ; cache des services, coordonnées invalides, limites de requêtes, erreurs fournisseur ; calcul géographique, progression et validation des zones, exclusion des données expirées ou du GPS imprécis/ancien ; couleurs extrêmes. Tests Chrome : cinq vues à 1280×800, 1024×600, 800×480 et 390×844, sans débordement horizontal ; persistance du thème, météo manuelle, recherche/itinéraire, GPS simulé, import et alerte de zone, pause à l’arrêt du GPS. Les fournisseurs sont simulés et les tuiles remplacées localement pendant ces tests automatiques. Les réponses météo, recherche et itinéraire ont aussi été vérifiées sur les services réels depuis Node.js avec des coordonnées de test à Paris. Les tests audio/Jellyfin/coupure API de la version précédente sont conservés comme validation de cette base. Défilement vertical possible sur petits écrans selon la vue.
|
||||||
|
|
||||||
À valider sur la cible : Docker/Compose, architecture ARM exacte, résolution et densité de l’écran, tactile, sortie audio, démarrage kiosque, consommation CPU/RAM et lecture Jellyfin réelle avec le compte utilisateur. Le lien Jellyfin a été vérifié dans l’interface, sans connexion au service ni test de son contenu.
|
Le scénario navigateur est dans `tests/browser.mjs`. Il demande Playwright et un Chromium installé dans l’environnement de test, et un serveur CARIA démarré. Exécuter `node tests/browser.mjs`. Variables optionnelles : `CARIA_URL`, `CHROME_PATH`, `PLAYWRIGHT_MODULE` (chemin de module sous forme d’URL `file:///...` si hors résolution npm). Playwright n’est pas une dépendance de production et n’est pas inclus dans Docker.
|
||||||
|
|
||||||
|
Scénarios 0.3 également exécutés : `node tests/applications-browser.mjs` (catalogue, favoris, masquage, persistance, onglets et continuité audio) et `node tests/touch-browser.mjs` (1200×1080 tactile, hamburger, conduite dédiée, détails, lecture en arc, avance/volume/répétition, intégration à la demande et arrêt en quittant la vue). Les services externes sont interceptés dans ces tests : aucune connexion à un compte ni lecture commerciale réelle n’y est effectuée. Les vues sont vérifiées sans débordement horizontal à plusieurs tailles, et les captures de Conduite et de la lecture immersive ont été examinées.
|
||||||
|
|
||||||
|
À valider sur la cible : construction Docker 0.3, architecture ARM exacte, densité tactile, sortie audio, démarrage kiosque, consommation CPU/RAM, GPS réel et lectures externes avec les comptes utilisateur. Le guidage n’a pas été testé sur route. Un contrôle HEAD public de Jellyfin a répondu HTTP 200 sans `X-Frame-Options` ni `Content-Security-Policy` ; cela ne constitue pas un test de connexion ou de lecture intégrée.
|
||||||
|
|
||||||
Voir [l’audit du dossier historique](docs/audit-initial.md).
|
Voir [l’audit du dossier historique](docs/audit-initial.md).
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
telemetry:
|
telemetry:
|
||||||
|
environment:
|
||||||
|
WEATHER_URL: ${WEATHER_URL:-https://api.open-meteo.com/v1/forecast}
|
||||||
|
GEOCODER_URL: ${GEOCODER_URL:-https://photon.komoot.io/api/}
|
||||||
|
ROUTER_URL: ${ROUTER_URL:-https://router.project-osrm.org/route/v1/driving/}
|
||||||
|
TILE_URL: ${TILE_URL:-}
|
||||||
|
TILE_ATTRIBUTION: ${TILE_ATTRIBUTION:-© OpenStreetMap contributors}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Applications, conduite et musique — 0.3
|
||||||
|
|
||||||
|
## Favoris et affichage tactile
|
||||||
|
|
||||||
|
La référence actuelle est un écran **10 pouces tactile en paysage**, interprété comme **1200 pixels de large et 1080 de haut**. Le rendu reste adaptatif. Le bouton hamburger replie la barre latérale sans recharger la page ; le choix est enregistré dans ce navigateur. L’en-tête est placé dans le contenu et le pied de page reste visible pour les essais.
|
||||||
|
|
||||||
|
Le catalogue se trouve dans Applications (`#apps`). Personnaliser permet de sélectionner jusqu’à quatre favoris, leur ordre, les applications visibles et le mode d’ouverture des services externes. Choisir un favori déjà présent échange les positions. Masquer un favori libère sa place ; cela ne désactive pas une fonction de CARIA et ne met pas la musique en pause. Applications et le logo d’accueil restent accessibles. Les préférences sont propres au navigateur et synchronisées entre ses onglets de même origine.
|
||||||
|
|
||||||
|
Conduite est un favori par défaut pour les essais. Pour son futur écran séparé : `/?display=drive#drive` ouvre directement les instruments et replie la barre. Les grands compteurs ont une base plate sous Vitesse/Régime moteur. Carburant, refroidissement, batterie et extérieur sont visibles rapidement ; Détails du véhicule ouvre les valeurs et leur explication. Toutes ces mesures restent simulées et sont masquées si l’API devient indisponible.
|
||||||
|
|
||||||
|
## Services Web et lecteurs intégrés
|
||||||
|
|
||||||
|
Les raccourcis externes par défaut sont Jellyfin, Spotify, YouTube, YouTube Music et Waze. Aucun de ces services n’est chargé en arrière-plan par le catalogue. L’ouverture n’intervient qu’après un appui. Aucun logiciel natif n’est installé dans Docker.
|
||||||
|
|
||||||
|
| Mode | Comportement |
|
||||||
|
| --- | --- |
|
||||||
|
| Nouvel onglet (défaut) | Conserve CARIA et son audio local. Le navigateur ou l’OS décide s’il ouvre aussi une application associée. |
|
||||||
|
| Même onglet | Quitte CARIA ; le bouton Retour permet de revenir. La musique locale et le GPS peuvent être interrompus. |
|
||||||
|
| Lecteur intégré | Reste dans CARIA, avec un cadre officiel/service compatible et un lien externe de secours. Disponible hors Waze. |
|
||||||
|
|
||||||
|
**Jellyfin** : utilise l’adresse configurée dans les réglages. Le serveur et son reverse proxy doivent autoriser l’origine de CARIA à l’afficher dans un cadre. Le contrôle public des en-têtes de `https://media.cunatbrule.fr/web/` a répondu HTTP 200 sans restriction de cadrage explicite dans ces en-têtes au moment du test ; les pages d’authentification, cookies et lectures restent à vérifier avec le compte réel. Aucune configuration de serveur Jellyfin n’a été changée. L’interface CARIA ne peut ni garantir ni inspecter une connexion dans un cadre d’une autre origine.
|
||||||
|
|
||||||
|
**Spotify** : le mode intégré attend un lien de morceau, album, playlist, épisode ou émission, et construit le lecteur officiel `open.spotify.com/embed/...`. Il ne reproduit pas la navigation complète de l’application. La connexion, la durée/possibilité de lecture et les restrictions appartiennent au lecteur Spotify. Voir les [Embeds officiels](https://developer.spotify.com/documentation/embeds).
|
||||||
|
|
||||||
|
**YouTube / YouTube Music** : un lien vidéo (`watch?v=...` ou `youtu.be/...`) ou playlist produit un lecteur vidéo YouTube officiel. La vidéo et ses commandes restent visibles. Ce n’est pas l’application YouTube Music complète, ni une extraction audio ; certains contenus ou playlists peuvent refuser la lecture intégrée. Voir [la documentation du lecteur](https://developers.google.com/youtube/iframe_api_reference).
|
||||||
|
|
||||||
|
**Waze** : le lien ouvre le site ou l’application selon l’OS. Après sélection d’une destination dans CARIA, le catalogue et le bouton de navigation utilisent un [lien Waze avec latitude/longitude et `navigate=yes`](https://developers.google.com/waze/deeplinks). Ce bouton fonctionne aussi si le calcul OSRM échoue, tant qu’une destination a été choisie. Il est masqué si Waze est masqué. Terminer le trajet retire la destination du lien. Aucune carte Waze n’est intégrée dans CARIA.
|
||||||
|
|
||||||
|
Les lecteurs intégrés se chargent après un appui et, pour Spotify/YouTube, après fourniture d’un lien compatible (mémorisé localement). Ils sont déchargés lorsque l’on quitte la vue intégrée, pour éviter une lecture invisible et libérer leurs ressources. Cela ne coupe pas le lecteur de musique locale. Un seul cadre est chargé à la fois. CARIA ne modifie pas les interfaces, marques, publicités ou restrictions de ces services. Leur apparence interne ne peut pas être recolorée librement depuis CARIA.
|
||||||
|
|
||||||
|
## Lecture locale immersive
|
||||||
|
|
||||||
|
Depuis Musique locale, « Vue lecture immersive » ouvre `#now-playing`. La bibliothèque reste disponible et le morceau en cours continue : les deux vues pilotent le même lecteur HTML audio.
|
||||||
|
|
||||||
|
L’arc coloré suit le temps réellement lu. La barre de progression permet de déplacer la lecture au tactile ou au clavier ; les commandes proposent lecture/pause, morceau précédent/suivant, recul/avance de 15 secondes, répétition du morceau, volume et silence. Les contrôles de transport sont désactivés sans morceau. Le titre, la durée et les commandes se synchronisent avec le lecteur de la bibliothèque. La sélection des fichiers reste locale et doit encore être refaite après rechargement de la page.
|
||||||
|
|
||||||
|
## Tests et limites
|
||||||
|
|
||||||
|
Les tests navigateur couvrent la navigation, les favoris et leur permutation, les modes d’ouverture, l’absence de préchargement des services, l’ouverture YouTube Music, le masquage sans interruption audio, la barre repliée persistante, l’affichage Conduite, les détails et la synchronisation de la lecture immersive. Les lecteurs externes sont simulés dans les tests ; leurs URL officielles et le déchargement du cadre sont vérifiés. Le tactile est émulé dans Chrome ; le toucher, le son et les comptes réels restent à tester sur l’appareil.
|
||||||
@@ -39,8 +39,8 @@ Le simulateur et la future passerelle doivent être sélectionnés explicitement
|
|||||||
## Évolutions indépendantes
|
## Évolutions indépendantes
|
||||||
|
|
||||||
- Bibliothèque audio persistante : volume en lecture seule, catalogue et prise en charge des requêtes Range ; la musique serait alors lue depuis le serveur plutôt que choisie dans l’appareil client.
|
- Bibliothèque audio persistante : volume en lecture seule, catalogue et prise en charge des requêtes Range ; la musique serait alors lue depuis le serveur plutôt que choisie dans l’appareil client.
|
||||||
- Météo réelle : fournisseur à définir, position configurable, cache et date de dernière mise à jour.
|
- Météo réelle et cartographie : intégrées en 0.2, voir [navigation.md](navigation.md). Restent le hors ligne et le GPS matériel.
|
||||||
- Cartographie/radar : fournisseur open source, besoin hors ligne, zone géographique et données à définir avec l’utilisateur.
|
- Alertes de zones : moteur local livré en 0.2 ; la source de données réelle et son utilisation par pays restent à définir.
|
||||||
- Affichage : kiosque Linux et configuration des moniteurs au niveau de l’OS ; paramètres propres à chaque écran si nécessaire.
|
- Affichage : kiosque Linux et configuration des moniteurs au niveau de l’OS ; paramètres propres à chaque écran si nécessaire.
|
||||||
- Distribution : figer les images par version/digest une fois la première construction ARM validée ; ajouter une construction automatisée multiarchitecture au dépôt si son hébergement CI le permet.
|
- Distribution : figer les images par version/digest une fois la première construction ARM validée ; ajouter une construction automatisée multiarchitecture au dépôt si son hébergement CI le permet.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Météo, navigation et zones — version 0.2
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
1. Ouvrir Navigation, puis activer le GPS ou choisir un départ par recherche/coordonnées. Le GPS demande l’autorisation du navigateur ; le mode manuel convient à la préparation d’un trajet et à la météo.
|
||||||
|
2. Rechercher une ville ou adresse, avec « Destination », puis sélectionner le résultat. Les résultats ne sont chargés qu’à la validation de la recherche.
|
||||||
|
3. L’itinéraire affiche sa distance, une durée indicative et les manœuvres. Un GPS de moins de 15 secondes et précis à 100 mètres ou mieux active le suivi. Le départ manuel reste un aperçu. Hors route à plus de 100 mètres : bouton Recalculer. Le suivi ne recentre pas automatiquement la carte, pour préserver le déplacement manuel ; utiliser Recentrer.
|
||||||
|
4. Activer Voix pour annoncer les changements d’étape si le navigateur propose la synthèse vocale française. Les capacités vocales dépendent de l’OS et du navigateur.
|
||||||
|
|
||||||
|
Ce premier guidage projette la position sur la ligne de l’itinéraire. Il n’effectue pas de map matching avancé : les boucles, croisements et routes parallèles peuvent donner une progression ambiguë. Il n’intègre pas le trafic ni les restrictions temporaires. La voix annonce les changements d’étape, sans plusieurs préannonces à distance. Ce prototype reste à tester sur le matériel et sur route.
|
||||||
|
|
||||||
|
## Localisation et météo
|
||||||
|
|
||||||
|
La [géolocalisation Web](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition) demande un contexte sécurisé. Un accès HTTP par IP LAN ne donne généralement pas accès au GPS. Prévoir un reverse proxy HTTPS avec certificat reconnu pour l’ODroid. Ne pas désactiver la sécurité du navigateur pour contourner cette contrainte.
|
||||||
|
|
||||||
|
Le bouton Arrêter le GPS ferme le suivi, suspend le guidage et les alertes et efface la météo GPS affichée. Le dernier lieu manuel, enregistré dans le navigateur, est repris au rechargement. La météo est actualisée toutes les 15 minutes ou après un déplacement de plus de 5 km, avec un bouton d’actualisation. Si le service échoue, les valeurs sont remplacées par des tirets. Les données météo ne sont pas remplacées par une simulation.
|
||||||
|
|
||||||
|
Open-Meteo fournit température, état du ciel, vent et minimum/maximum du jour. L’heure de modèle affichée est celle du lieu météo. Les coordonnées envoyées au fournisseur sont arrondies au centième de degré. Les mêmes valeurs peuvent être servies depuis le cache du serveur pendant 15 minutes.
|
||||||
|
|
||||||
|
## Fournisseurs et exploitation
|
||||||
|
|
||||||
|
| Fonction | Par défaut | Configuration serveur |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Météo | Open-Meteo | `WEATHER_URL`, endpoint compatible forecast |
|
||||||
|
| Recherche | Photon public | `GEOCODER_URL`, endpoint compatible Photon |
|
||||||
|
| Itinéraire voiture | OSRM public | `ROUTER_URL`, préfixe `.../route/v1/driving/` |
|
||||||
|
| Tuiles raster | OpenStreetMap standard | `TILE_URL`, template `{z}/{x}/{y}` |
|
||||||
|
| Crédit cartographique | © OpenStreetMap contributors | `TILE_ATTRIBUTION` |
|
||||||
|
|
||||||
|
Configurer ces variables dans `.env`, puis relancer Compose. Le serveur ne permet pas aux requêtes Web de fournir une URL arbitraire à récupérer. Les appels sortants utilisent un User-Agent CARIA, un délai de 8 secondes et une limite globale d’un appel par fournisseur toutes les 1,1 secondes. Cache mémoire borné à 128 entrées, regroupement des demandes simultanées identiques. Recherche conservée au maximum 24 heures dans le cache, route 1 minute, météo 15 minutes. L’API renvoie 429 lorsqu’il faut attendre, 502 en cas d’indisponibilité et 422 lorsqu’aucune route n’est trouvée.
|
||||||
|
|
||||||
|
Les logiciels sont open source ; les services publics ne constituent pas une infrastructure gratuite illimitée :
|
||||||
|
|
||||||
|
- [Open-Meteo](https://open-meteo.com/en/pricing) : accès gratuit pour usage non commercial dans les limites publiées, données avec attribution CC BY 4.0. Une commercialisation exige de revoir l’offre ou d’auto-héberger.
|
||||||
|
- [Photon](https://github.com/komoot/photon) : serveur de démonstration autorisant un usage raisonnable sans garantie de disponibilité ; auto-hébergement possible. Recherche à la demande seulement dans CARIA.
|
||||||
|
- [OSRM](https://github.com/Project-OSRM/osrm-backend/wiki/Demo-server) : serveur public de démonstration, sans garantie. Respecter ses conditions et prévoir son instance pour une exploitation soutenue.
|
||||||
|
- [OpenStreetMap](https://operations.osmfoundation.org/policies/tiles/) : attribution visible, HTTPS, cache natif du navigateur, pas de préchargement ni de téléchargement hors ligne. Les tests automatiques interceptent les tuiles pour ne pas solliciter les serveurs publics.
|
||||||
|
|
||||||
|
Les coordonnées météo, recherches et points d’itinéraire transitent par le serveur CARIA puis par le fournisseur concerné. Le navigateur demande les tuiles directement au fournisseur, qui connaît donc la zone affichée et l’adresse IP du client. Pas d’historique GPS en fichier : les coordonnées restent en mémoire, à l’exception du départ manuel mémorisé localement. Les journaux d’accès Nginx sont désactivés sur `/api/` pour ne pas y enregistrer les paramètres géographiques. Si vous ajoutez un reverse proxy, appliquer la même règle à ses journaux.
|
||||||
|
|
||||||
|
## Système d’alertes et couverture
|
||||||
|
|
||||||
|
Le système livré avertit à l’approche de **zones géographiques importées**. Il ne télécharge pas une base radar, ne détecte pas les contrôles et ne fournit pas de signalements communautaires. Sans fichier de zones, il n’a aucune couverture réelle. Le bouton de test génère une alerte fictive indépendante du GPS.
|
||||||
|
|
||||||
|
Les règles varient selon le pays ; le sélecteur de pays filtre les données et ne certifie aucune autorisation. Le [Code de la route français, article R413-15](https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000025111528/2016-03-19) vise notamment les dispositifs informant de la localisation des appareils de contrôle. Transformer un point radar en cercle ne suffit pas à établir la conformité d’un service. Aucune base n’est transformée automatiquement de cette manière dans CARIA.
|
||||||
|
|
||||||
|
Pour la France et les pays voisins, il reste à sélectionner une source maintenue de zones de vigilance, vérifier sa licence et ses conditions pays par pays. Les alertes sont désactivées au démarrage et à chaque changement de pays. Il faut sélectionner le pays manuellement : cette version ne détecte pas le franchissement d’une frontière.
|
||||||
|
|
||||||
|
## Import GeoJSON
|
||||||
|
|
||||||
|
Maximum 1,5 Mo, 1 000 zones. Chaque zone est un Point GeoJSON dont les coordonnées représentent le centre d’une **zone de vigilance** et dont `radiusM` donne l’étendue. Champs obligatoires : pays ISO à deux lettres, rayon entre 300 et 10 000 m, source, dates de mise à jour et d’expiration. Les points ne sont pas affichés comme emplacements de radars sur la carte. Le nom et la source sont affichés comme texte, pas interprétés comme HTML.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": { "type": "Point", "coordinates": [2.35, 48.85] },
|
||||||
|
"properties": {
|
||||||
|
"id": "exemple-fictif",
|
||||||
|
"name": "EXEMPLE FICTIF — ne correspond pas à un radar",
|
||||||
|
"country": "FR",
|
||||||
|
"radiusM": 500,
|
||||||
|
"source": "Jeu de test utilisateur",
|
||||||
|
"updatedAt": "2026-09-12T00:00:00Z",
|
||||||
|
"expiresAt": "2026-12-31T23:59:59Z"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Les zones expirées sont ignorées. L’alerte se déclenche à moins de 400 m de la limite d’une zone, ou à l’intérieur, avec un GPS récent et précis. Calcul de proximité à vol d’oiseau, sans sens de circulation ni rattachement à une route. Le son est optionnel et limité pour éviter les répétitions. Le fichier reste dans le navigateur ; Effacer les zones le retire. Le fichier ne constitue pas une preuve de qualité ou d’actualité des données importées.
|
||||||
|
|
||||||
|
## Priorités proposées pour la suite
|
||||||
|
|
||||||
|
1. HTTPS, modèle d’ODroid et récepteur GPS : indispensables pour valider le guidage embarqué.
|
||||||
|
2. Musique sur volume Docker/USB avec catalogue persistant, pour éviter la resélection des fichiers.
|
||||||
|
3. Cartographie hors ligne avec tuiles autorisées et routage auto-hébergé, adaptés à la mémoire disponible.
|
||||||
|
4. Passage des données CAN simulées aux mesures réelles, avec fraîcheur par signal.
|
||||||
|
5. Source de zones maintenue et règles locales vérifiées avant toute utilisation routière de l’avertisseur.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "caria-cockpit",
|
"name": "caria-cockpit",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": { "start": "node server/server.mjs", "test": "node --test server/*.test.mjs" },
|
"scripts": { "start": "node server/server.mjs", "test": "node --test server/*.test.mjs" },
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { normalizePreferences, safeWebUrl, wazeUrl } from '../ui/applications.js';
|
||||||
|
import { embedUrl } from '../ui/embeds.js';
|
||||||
|
test('launcher preferences migrate defaults and reject duplicate/unknown/hidden favorites', () => {
|
||||||
|
assert.deepEqual(normalizePreferences(null).favorites,['home','drive','map','music']);
|
||||||
|
const p=normalizePreferences({favorites:['youtube-music','youtube-music','invalid','spotify','home'],hidden:['spotify','invalid'],modes:{youtube:'same',spotify:'invalid'}});
|
||||||
|
assert.deepEqual(p.favorites,['youtube-music',null,null,null]);
|
||||||
|
assert.deepEqual(p.hidden,['spotify']); assert.equal(p.modes.youtube,'same'); assert.equal(p.modes.spotify,'new');
|
||||||
|
assert.deepEqual(normalizePreferences({favorites:[]}).favorites,[null,null,null,null]);
|
||||||
|
});
|
||||||
|
test('official embed URLs reject foreign origins and unsupported content',()=>{
|
||||||
|
assert.equal(embedUrl('spotify','https://open.spotify.com/playlist/abc123'),'https://open.spotify.com/embed/playlist/abc123');
|
||||||
|
assert.equal(embedUrl('spotify','https://open.spotify.com/'),'');
|
||||||
|
assert.equal(embedUrl('spotify','https://evil.example/playlist/abc123'),'');
|
||||||
|
assert.equal(embedUrl('youtube-music','https://music.youtube.com/watch?v=M7lc1UVf-VE'),'https://www.youtube.com/embed/M7lc1UVf-VE');
|
||||||
|
assert.equal(embedUrl('youtube-music','https://music.youtube.com/'),'');
|
||||||
|
assert.equal(embedUrl('stream','javascript:alert(1)'),'');
|
||||||
|
assert.equal(normalizePreferences({modes:{spotify:'embed',waze:'embed'}}).modes.spotify,'embed');
|
||||||
|
assert.equal(normalizePreferences({modes:{waze:'embed'}}).modes.waze,'new');
|
||||||
|
});
|
||||||
|
test('external links only allow web URLs and valid Waze destination coordinates', () => {
|
||||||
|
for (const value of ['javascript:alert(1)','file:///x','https://name:pass@example.org','bad']) assert.equal(safeWebUrl(value),'');
|
||||||
|
assert.equal(safeWebUrl('https://media.cunatbrule.fr/web/'),'https://media.cunatbrule.fr/web/');
|
||||||
|
const url = new URL(wazeUrl({lat:48.85,lon:2.35}));
|
||||||
|
assert.equal(url.searchParams.get('ll'),'48.85,2.35'); assert.equal(url.searchParams.get('navigate'),'yes');
|
||||||
|
assert.equal(new URL(wazeUrl({lat:NaN,lon:0})).searchParams.has('ll'),false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createServices } from './services.mjs';
|
||||||
|
import { distance, validateZones, nearbyZones, routeProgress, instruction } from '../ui/geo.js';
|
||||||
|
import { palette } from '../ui/theme.js';
|
||||||
|
|
||||||
|
test('geographic distances, segment projection and French instructions', () => {
|
||||||
|
assert.equal(distance({lat:48,lon:2},{lat:48,lon:2}), 0);
|
||||||
|
assert.ok(Math.abs(distance({lat:0,lon:0},{lat:0,lon:1}) - 111195) < 1);
|
||||||
|
const p = routeProgress([[2,48],[2.1,48]], {lon:2.05,lat:48});
|
||||||
|
assert.ok(p.away < 1 && Math.abs(p.fraction - .5) < .001);
|
||||||
|
assert.match(instruction({maneuver:{type:'turn',modifier:'left'},name:'Rue A'}), /gauche.*Rue A/);
|
||||||
|
});
|
||||||
|
const now = Date.UTC(2026,8,12);
|
||||||
|
const collection = {type:'FeatureCollection',features:[{type:'Feature',geometry:{type:'Point',coordinates:[2,48]},properties:{id:'a',country:'FR',radiusM:1000,source:'Test',updatedAt:'2026-09-01',expiresAt:'2026-10-01'}}]};
|
||||||
|
test('zone validation and alerts exclude expired, foreign, stale and inaccurate positions', () => {
|
||||||
|
const zones = validateZones(collection);
|
||||||
|
const gps = {lat:48,lon:2,source:'gps',accuracy:10,timestamp:now};
|
||||||
|
assert.equal(nearbyZones(zones,gps,'FR',now).length,1);
|
||||||
|
for (const change of [{source:'manual'},{accuracy:200},{timestamp:now-16000}]) assert.equal(nearbyZones(zones,{...gps,...change},'FR',now).length,0);
|
||||||
|
assert.equal(nearbyZones(zones,gps,'BE',now).length,0);
|
||||||
|
assert.equal(nearbyZones(zones,{...gps,timestamp:now+40*86400000},'FR',now+40*86400000).length,0);
|
||||||
|
assert.throws(()=>validateZones({type:'FeatureCollection',features:[{}]}));
|
||||||
|
const invalid = structuredClone(collection); invalid.features[0].properties.radiusM = 0;
|
||||||
|
assert.throws(()=>validateZones(invalid));
|
||||||
|
});
|
||||||
|
test('custom extreme colors are adjusted for light and dark contrast', () => {
|
||||||
|
assert.notEqual(palette('#ffffff',true).accent,'#ffffff');
|
||||||
|
assert.notEqual(palette('#000000',false).accent,'#000000');
|
||||||
|
assert.equal(palette('invalid').accent,palette('#d2ef92').accent);
|
||||||
|
});
|
||||||
|
test('weather cache, coordinate validation, upstream errors, search normalization', async () => {
|
||||||
|
let calls = 0, time = 100000;
|
||||||
|
const service = createServices({now:()=>time,env:{},fetcher:async url=>{
|
||||||
|
calls++;
|
||||||
|
if (url.hostname.includes('photon')) return {ok:true,json:async()=>({features:[{geometry:{type:'Point',coordinates:[2,48]},properties:{name:'Paris',city:'Paris',country:'France'}}]})};
|
||||||
|
return {ok:true,json:async()=>({current:{temperature_2m:20,wind_speed_10m:5,weather_code:0,time:'2026-09-12T12:00'},daily:{temperature_2m_min:[12],temperature_2m_max:[22]}})};
|
||||||
|
}});
|
||||||
|
const params = new URLSearchParams({lat:'48',lon:'2'});
|
||||||
|
const responses = await Promise.all([service('/api/weather',params),service('/api/weather',params)]);
|
||||||
|
assert.equal(responses[0].temperature,20); assert.equal(calls,1);
|
||||||
|
await service('/api/weather',params); assert.equal(calls,1);
|
||||||
|
await assert.rejects(service('/api/weather',new URLSearchParams({lat:'',lon:'2'})),{status:400});
|
||||||
|
await assert.rejects(service('/api/weather',new URLSearchParams({lat:'91',lon:'2'})),{status:400});
|
||||||
|
await assert.rejects(service('/api/weather',new URLSearchParams({lat:'49',lon:'2'})),{status:429});
|
||||||
|
time += 2000;
|
||||||
|
const places = await service('/api/search',new URLSearchParams({q:'Paris'}));
|
||||||
|
assert.equal(places[0].label,'Paris, France');
|
||||||
|
const broken = createServices({env:{},fetcher:async()=>{throw new Error('Offline');}});
|
||||||
|
await assert.rejects(broken('/api/weather',params),{status:502});
|
||||||
|
});
|
||||||
|
test('routing coordinates and NoRoute response are handled', async () => {
|
||||||
|
let target;
|
||||||
|
const service = createServices({env:{},fetcher:async url=>{target=url;return {ok:true,json:async()=>({code:'NoRoute'})};}});
|
||||||
|
await assert.rejects(service('/api/route',new URLSearchParams({fromLat:'48',fromLon:'2',toLat:'49',toLon:'3'})),{status:422});
|
||||||
|
assert.match(target.pathname,/2,48;3,49$/);
|
||||||
|
});
|
||||||
+14
-2
@@ -3,12 +3,20 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { telemetry, profiles } from './telemetry.mjs';
|
import { telemetry, profiles } from './telemetry.mjs';
|
||||||
|
import { createServices } from './services.mjs';
|
||||||
|
|
||||||
const assets = new Map([
|
const assets = new Map([
|
||||||
['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']],
|
['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']],
|
||||||
['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']]
|
['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']],
|
||||||
|
...['journey.js', 'geo.js', 'theme.js', 'applications.js', 'cockpit.js', 'embeds.js'].map(name => [`/${name}`, [name, 'text/javascript']]),
|
||||||
|
['/cockpit.css', ['cockpit.css', 'text/css']],
|
||||||
|
['/applications.css', ['applications.css', 'text/css']],
|
||||||
|
['/journey.css', ['journey.css', 'text/css']],
|
||||||
|
['/vendor/leaflet/leaflet.js', ['vendor/leaflet/leaflet.js', 'text/javascript']],
|
||||||
|
['/vendor/leaflet/leaflet.css', ['vendor/leaflet/leaflet.css', 'text/css']]
|
||||||
]);
|
]);
|
||||||
export function createServer() {
|
export function createServer(options) {
|
||||||
|
const services = createServices(options);
|
||||||
return http.createServer(async (req, res) => {
|
return http.createServer(async (req, res) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -23,6 +31,10 @@ export function createServer() {
|
|||||||
const profile = url.searchParams.get('profile') || 'route';
|
const profile = url.searchParams.get('profile') || 'route';
|
||||||
return profiles.includes(profile) ? send(200, telemetry(profile)) : send(400, { error: 'Profil inconnu' });
|
return profiles.includes(profile) ? send(200, telemetry(profile)) : send(400, { error: 'Profil inconnu' });
|
||||||
}
|
}
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
try { return send(200, await services(url.pathname, url.searchParams)); }
|
||||||
|
catch (error) { return send(error.status || 502, { error: error.status ? error.message : 'Service indisponible' }); }
|
||||||
|
}
|
||||||
const asset = assets.get(url.pathname);
|
const asset = assets.get(url.pathname);
|
||||||
if (!asset) return send(404, { error: 'Introuvable' });
|
if (!asset) return send(404, { error: 'Introuvable' });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Public prototype providers, replaceable with self-hosted instances.
|
||||||
|
export class ServiceError extends Error {
|
||||||
|
constructor(status, message) { super(message); this.status = status; }
|
||||||
|
}
|
||||||
|
function number(params, name, min, max) {
|
||||||
|
const raw = params.get(name), n = raw?.trim() ? Number(raw) : NaN;
|
||||||
|
if (!Number.isFinite(n) || n < min || n > max) throw new ServiceError(400, `Coordonnée ${name} invalide`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
export function createServices({ fetcher = fetch, now = Date.now, env = process.env } = {}) {
|
||||||
|
const cache = new Map(), inflight = new Map(), last = new Map();
|
||||||
|
const endpoints = {
|
||||||
|
weather: env.WEATHER_URL || 'https://api.open-meteo.com/v1/forecast',
|
||||||
|
search: env.GEOCODER_URL || 'https://photon.komoot.io/api/',
|
||||||
|
route: env.ROUTER_URL || 'https://router.project-osrm.org/route/v1/driving/'
|
||||||
|
};
|
||||||
|
async function request(kind, url, ttl) {
|
||||||
|
const key = url.href, saved = cache.get(key);
|
||||||
|
if (saved && now() - saved.time < ttl) return saved.data;
|
||||||
|
if (inflight.has(key)) return inflight.get(key);
|
||||||
|
if (now() - (last.get(kind) ?? -Infinity) < 1100 || inflight.size >= 6) throw new ServiceError(429, 'Patientez un instant avant de réessayer.');
|
||||||
|
last.set(kind, now());
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetcher(url, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'CARIA-Cockpit/0.2 (personal automotive interface)', Accept: 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error('Upstream');
|
||||||
|
const data = await response.json();
|
||||||
|
if (cache.size >= 128) cache.delete(cache.keys().next().value);
|
||||||
|
cache.set(key, { time: now(), data }); return data;
|
||||||
|
} catch { throw new ServiceError(502, 'Service externe indisponible. Réessayez avec une connexion Internet.'); }
|
||||||
|
finally { inflight.delete(key); }
|
||||||
|
})();
|
||||||
|
inflight.set(key, promise); return promise;
|
||||||
|
}
|
||||||
|
return async (path, params) => {
|
||||||
|
if (path === '/api/map-config') return {
|
||||||
|
tileUrl: env.TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
tileAttribution: env.TILE_ATTRIBUTION || '© OpenStreetMap contributors'
|
||||||
|
};
|
||||||
|
if (path === '/api/weather') {
|
||||||
|
const lat = number(params, 'lat', -90, 90), lon = number(params, 'lon', -180, 180);
|
||||||
|
const url = new URL(endpoints.weather);
|
||||||
|
url.search = new URLSearchParams({ latitude: lat.toFixed(2), longitude: lon.toFixed(2), current: 'temperature_2m,weather_code,wind_speed_10m', daily: 'temperature_2m_max,temperature_2m_min', timezone: 'auto', forecast_days: '1' });
|
||||||
|
const d = await request('weather', url, 15 * 60000);
|
||||||
|
if (![d.current?.temperature_2m, d.current?.wind_speed_10m, d.current?.weather_code, d.daily?.temperature_2m_min?.[0], d.daily?.temperature_2m_max?.[0]].every(Number.isFinite)) throw new ServiceError(502, 'Réponse météo incomplète');
|
||||||
|
return { source: 'Open-Meteo', fetchedAt: now(), observedAt: d.current.time, temperature: d.current.temperature_2m, code: d.current.weather_code, wind: d.current.wind_speed_10m, min: d.daily.temperature_2m_min[0], max: d.daily.temperature_2m_max[0] };
|
||||||
|
}
|
||||||
|
if (path === '/api/search') {
|
||||||
|
const q = params.get('q')?.trim();
|
||||||
|
if (!q || q.length < 3 || q.length > 180) throw new ServiceError(400, 'Saisissez entre 3 et 180 caractères.');
|
||||||
|
const url = new URL(endpoints.search); url.search = new URLSearchParams({ q, lang: 'fr', limit: '5' });
|
||||||
|
const d = await request('search', url, 24 * 3600000);
|
||||||
|
if (!Array.isArray(d.features)) throw new ServiceError(502, 'Réponse de recherche invalide');
|
||||||
|
return d.features.slice(0, 5).filter(f => f.geometry?.type === 'Point' && f.geometry.coordinates?.length === 2 && f.geometry.coordinates.every(Number.isFinite)).map(f => ({
|
||||||
|
lon: f.geometry.coordinates[0], lat: f.geometry.coordinates[1],
|
||||||
|
label: [...new Set([f.properties?.name, f.properties?.street, f.properties?.housenumber, f.properties?.postcode, f.properties?.city, f.properties?.country].filter(Boolean))].join(', ').slice(0, 300)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (path === '/api/route') {
|
||||||
|
const fromLat = number(params, 'fromLat', -85, 85), fromLon = number(params, 'fromLon', -180, 180);
|
||||||
|
const toLat = number(params, 'toLat', -85, 85), toLon = number(params, 'toLon', -180, 180);
|
||||||
|
const url = new URL(`${endpoints.route.replace(/\/$/, '')}/${fromLon},${fromLat};${toLon},${toLat}`);
|
||||||
|
url.search = new URLSearchParams({ overview: 'full', geometries: 'geojson', steps: 'true' });
|
||||||
|
const d = await request('route', url, 60000), route = d.routes?.[0];
|
||||||
|
if (d.code !== 'Ok' || !route || route.geometry?.type !== 'LineString' || !Array.isArray(route.geometry.coordinates) || route.geometry.coordinates.length < 2 || route.geometry.coordinates.length > 100000 || route.geometry.coordinates.some(c => !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite)) || !Number.isFinite(route.distance) || !Number.isFinite(route.duration) || !Array.isArray(route.legs)) throw new ServiceError(422, 'Aucun itinéraire routier trouvé.');
|
||||||
|
const steps = route.legs.flatMap(leg => leg.steps || []);
|
||||||
|
if (!steps.length || steps.some(s => !s.maneuver || !Number.isFinite(s.distance) || !Number.isFinite(s.duration))) throw new ServiceError(502, 'Étapes de navigation incomplètes');
|
||||||
|
return { source: 'OSRM', distance: route.distance, duration: route.duration, geometry: route.geometry, steps: steps.map(s => ({ distance: s.distance, duration: s.duration, name: s.name, maneuver: s.maneuver })) };
|
||||||
|
}
|
||||||
|
throw new ServiceError(404, 'Introuvable');
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const {chromium} = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const browser=await chromium.launch({...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{}),headless:true});
|
||||||
|
try {
|
||||||
|
const context=await browser.newContext({viewport:{width:1280,height:800}});
|
||||||
|
const external=[];
|
||||||
|
await context.route(/https:\/\/(music\.youtube\.com|www\.youtube\.com|open\.spotify\.com|waze\.com|media\.cunatbrule\.fr)\//,r=>{external.push(r.request().url());return r.fulfill({contentType:'text/html',body:'<title>Service de test</title>'});});
|
||||||
|
const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||||
|
const base=process.env.CARIA_URL||'http://127.0.0.1:8080';
|
||||||
|
await page.goto(base+'/#apps');
|
||||||
|
await page.locator('#applications-grid .application-card').first().waitFor();
|
||||||
|
assert.equal(await page.locator('#favorite-apps a').count(),5);
|
||||||
|
assert.equal(await page.locator('#applications-grid .application-card').count(),9);
|
||||||
|
assert.deepEqual(external,[]);
|
||||||
|
await page.locator('#applications-customize').click();
|
||||||
|
await page.locator('[data-favorite-slot="0"]').selectOption('youtube-music');
|
||||||
|
await page.locator('[data-favorite-slot="1"]').selectOption('youtube-music');
|
||||||
|
assert.equal(await page.locator('[data-favorite-slot="0"]').inputValue(),'drive');
|
||||||
|
await page.getByLabel('Afficher Spotify',{exact:true}).uncheck();
|
||||||
|
await page.getByLabel('Ouverture de YouTube',{exact:true}).selectOption('same');
|
||||||
|
await page.locator('#application-settings-close').click();
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await page.locator('#favorite-apps a').nth(1).getAttribute('data-application'),'youtube-music');
|
||||||
|
assert.equal(await page.locator('#applications-grid [data-application="spotify"]').count(),0);
|
||||||
|
assert.equal(await page.locator('#applications-grid [data-application="youtube"]').getAttribute('target'),'_self');
|
||||||
|
const popupEvent=page.waitForEvent('popup');
|
||||||
|
await page.locator('#applications-grid [data-application="youtube-music"]').click();
|
||||||
|
const popup=await popupEvent;await popup.waitForLoadState();
|
||||||
|
assert.equal(popup.url(),'https://music.youtube.com/');
|
||||||
|
assert.equal(page.url(),base+'/#apps');await popup.close();
|
||||||
|
await page.locator('#applications-customize').click();await page.locator('#applications-reset').click();await page.locator('#application-settings-close').click();
|
||||||
|
await page.locator('#favorite-apps [data-view="music"]').click();
|
||||||
|
const wav=Buffer.alloc(44+16000*60);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVE',8);wav.write('fmt ',12);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(8000,24);wav.writeUInt32LE(16000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(wav.length-44,40);
|
||||||
|
await page.locator('#music-files').setInputFiles({name:'Test.wav',mimeType:'audio/wav',buffer:wav});await page.locator('#play').click();
|
||||||
|
await page.waitForFunction(()=>!document.querySelector('#audio').paused);
|
||||||
|
await page.locator('#favorite-apps [data-view="apps"]').click();await page.locator('#applications-customize').click();
|
||||||
|
await page.getByLabel('Afficher Musique locale',{exact:true}).uncheck();await page.locator('#application-settings-close').click();
|
||||||
|
assert.equal(await page.evaluate(()=>document.querySelector('#audio').paused),false);
|
||||||
|
assert.equal(await page.locator('#favorite-apps [data-application="music"]').count(),0);
|
||||||
|
assert.equal(await page.locator('#applications-grid [data-application="music"]').count(),0);
|
||||||
|
await page.locator('#applications-customize').click();
|
||||||
|
const names=['Accueil','Conduite','Navigation','Musique locale','Jellyfin','Spotify','YouTube','YouTube Music','Waze'];
|
||||||
|
for (const name of names) await page.getByLabel(`Afficher ${name}`,{exact:true}).uncheck();
|
||||||
|
await page.locator('#application-settings-close').click();assert.equal(await page.locator('#favorite-apps a').count(),1);await page.locator('#applications-empty').waitFor({state:'visible'});
|
||||||
|
await page.locator('#applications-customize').click();await page.locator('#applications-reset').click();await page.locator('#application-settings-close').click();
|
||||||
|
for (const size of [{width:1280,height:800},{width:1024,height:600},{width:800,height:480},{width:390,height:844}]) {await page.setViewportSize(size);assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);}
|
||||||
|
await page.setViewportSize({width:1280,height:800});
|
||||||
|
if(process.env.QA_SCREENSHOT) await page.screenshot({path:process.env.QA_SCREENSHOT,fullPage:true});
|
||||||
|
assert.deepEqual(errors,[]);console.log('PASS catalog; favorites swap; hide/restore; persistence; opening modes; YouTube Music popup; no preloading; audio survives hiding; empty catalog; responsive layouts.');
|
||||||
|
} finally {await browser.close();}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const browser = await chromium.launch({...(process.env.CHROME_PATH ? {executablePath:process.env.CHROME_PATH} : {}),headless:true});
|
||||||
|
const context = await browser.newContext({viewport:{width:1024,height:600},permissions:['geolocation'],geolocation:{latitude:48.85,longitude:2.35,accuracy:10}});
|
||||||
|
const page = await context.newPage(), errors=[];
|
||||||
|
page.on('pageerror',e=>errors.push(e.message));
|
||||||
|
// Never load public OSM tiles from automated QA.
|
||||||
|
await context.route('https://tile.openstreetmap.org/**',r=>r.fulfill({status:200,contentType:'image/png',body:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j1ioAAAAASUVORK5CYII=','base64')}));
|
||||||
|
await page.route('**/api/weather?*',r=>r.fulfill({json:{source:'Open-Meteo',temperature:18,code:2,wind:8,min:12,max:22,observedAt:'2026-09-12T09:00'}}));
|
||||||
|
await page.route('**/api/search?*',r=>r.fulfill({json:[{lat:48.86,lon:2.36,label:'Destination de test'}]}));
|
||||||
|
await page.route('**/api/route?*',r=>r.fulfill({json:{source:'OSRM',distance:1500,duration:300,geometry:{type:'LineString',coordinates:[[2.35,48.85],[2.355,48.855],[2.36,48.86]]},steps:[{distance:700,duration:140,name:'Rue A',maneuver:{type:'depart',location:[2.35,48.85]}},{distance:800,duration:160,name:'Rue B',maneuver:{type:'turn',modifier:'left',location:[2.355,48.855]}},{distance:0,duration:0,name:'',maneuver:{type:'arrive',location:[2.36,48.86]}}]}}));
|
||||||
|
await page.goto(process.env.CARIA_URL || 'http://127.0.0.1:8080');
|
||||||
|
await page.getByText('● Démonstration',{exact:true}).waitFor();
|
||||||
|
await page.locator('#settings-open').click();
|
||||||
|
await page.locator('#accent').selectOption('blue');
|
||||||
|
await page.getByRole('button',{name:'Enregistrer',exact:true}).click();
|
||||||
|
assert.equal(await page.evaluate(()=>document.body.style.getPropertyValue('--accent')),'#8ac7ff');
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await page.evaluate(()=>document.body.style.getPropertyValue('--accent')),'#8ac7ff');
|
||||||
|
await page.locator('#favorite-apps [data-view="map"]').click();
|
||||||
|
await page.locator('.leaflet-container').waitFor();
|
||||||
|
await page.getByText('Coordonnées manuelles',{exact:true}).click();
|
||||||
|
await page.locator('#latitude').fill('48.85'); await page.locator('#longitude').fill('2.35');
|
||||||
|
await page.getByRole('button',{name:'Utiliser ce départ',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#weather-temperature').textContent==='18');
|
||||||
|
await page.locator('#place-query').fill('Paris'); await page.getByRole('button',{name:'Rechercher un lieu'}).click();
|
||||||
|
await page.getByRole('button',{name:'Destination de test',exact:true}).click();
|
||||||
|
await page.getByText('Aperçu du trajet · activez le GPS pour le guidage',{exact:true}).waitFor();
|
||||||
|
assert.equal(await page.locator('#route-steps li').count(),3);
|
||||||
|
await page.locator('[data-waze-destination]').waitFor({state:'visible'});
|
||||||
|
assert.equal(new URL(await page.locator('[data-waze-destination]').getAttribute('href')).searchParams.get('ll'),'48.86,2.36');
|
||||||
|
await page.getByRole('button',{name:'⌖ Activer le GPS',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#position-status').textContent.includes('GPS actif'));
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#next-instruction').textContent.includes('gauche'));
|
||||||
|
await page.getByText('Alertes de zones',{exact:true}).click();
|
||||||
|
const zones = {type:'FeatureCollection',features:[{type:'Feature',geometry:{type:'Point',coordinates:[2.35,48.85]},properties:{id:'test',name:'Zone QA',country:'FR',radiusM:500,source:'TEST AUTOMATISÉ',updatedAt:new Date(Date.now()-100000).toISOString(),expiresAt:new Date(Date.now()+86400000).toISOString()}}]};
|
||||||
|
await page.locator('#zones-file').setInputFiles({name:'zones.geojson',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(zones))});
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#zones-status').textContent.includes('1 zones'));
|
||||||
|
await page.locator('#alert-country').selectOption('FR'); await page.locator('#alerts-enabled').check();
|
||||||
|
await page.locator('#zone-alert').waitFor({state:'visible'});
|
||||||
|
await page.locator('#zone-alert-close').click();
|
||||||
|
await page.locator('#gps-stop').click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#next-instruction').textContent.includes('pause'));
|
||||||
|
for (const size of [{width:1280,height:800},{width:1024,height:600},{width:800,height:480},{width:390,height:844}]) {
|
||||||
|
await page.setViewportSize(size);
|
||||||
|
for (const view of ['home','drive','music','stream','map']) {
|
||||||
|
await page.evaluate(view=>location.hash=view,view); await page.locator(`#${view}`).waitFor({state:'visible'});
|
||||||
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,`Overflow ${view} ${size.width}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.setViewportSize({width:1024,height:600});
|
||||||
|
await page.locator('#favorite-apps [data-view="map"]').click();
|
||||||
|
await page.locator('#favorite-apps [data-view="home"]').click();
|
||||||
|
await page.locator('#settings-open').click(); await page.locator('#theme').selectOption('light'); await page.locator('#accent').selectOption('custom'); await page.locator('#custom-color').fill('#ff0000'); await page.getByRole('button',{name:'Enregistrer',exact:true}).click();
|
||||||
|
await page.locator('#favorite-apps [data-view="map"]').click();
|
||||||
|
await page.getByRole('button',{name:'Utiliser ce départ',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#weather-temperature').textContent==='18');
|
||||||
|
await page.route('**/api/weather?*',r=>r.fulfill({status:502,json:{error:'Météo hors ligne'}}));
|
||||||
|
await page.locator('#favorite-apps [data-view="home"]').click();
|
||||||
|
await page.locator('#weather-refresh').click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#weather-temperature').textContent==='—' && document.querySelector('#weather-status').textContent==='Météo hors ligne');
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await page.locator('#alerts-enabled').isChecked(),false);
|
||||||
|
assert.deepEqual(errors,[]);
|
||||||
|
console.log('PASS themes + persistence; manual weather; search/route; GPS guidance; zone import/proximity alert; GPS stop; 5 views × 4 resolutions; no JS errors. Providers mocked; tiles replaced locally.');
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const browser=await chromium.launch({...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{}),headless:true});
|
||||||
|
try {
|
||||||
|
const context=await browser.newContext({viewport:{width:1200,height:1080},hasTouch:true,reducedMotion:'reduce'});
|
||||||
|
const external=[];
|
||||||
|
await context.route(/https:\/\/(open\.spotify\.com|www\.youtube\.com|media\.cunatbrule\.fr)\//,r=>{external.push(r.request().url());return r.fulfill({contentType:'text/html',body:'<html><body>Lecteur de test</body></html>'});});
|
||||||
|
const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||||
|
const base=process.env.CARIA_URL||'http://127.0.0.1:8080';
|
||||||
|
await page.goto(base+'/#drive');await page.locator('#speed-gauge svg').waitFor();
|
||||||
|
assert.equal(await page.locator('main > header').count(),1);
|
||||||
|
assert.equal(await page.locator('.workspace > footer').count(),0);
|
||||||
|
assert.equal(await page.locator('#settings footer #footer-status').count(),1);
|
||||||
|
const pageCount = context.pages().length;
|
||||||
|
assert.equal(await page.locator('#drive-immersive').count(),0);
|
||||||
|
await page.locator('#rail-toggle').click();
|
||||||
|
assert.equal(context.pages().length,pageCount);
|
||||||
|
assert.equal(await page.locator('#side-rail').isVisible(),false);
|
||||||
|
assert.ok((await page.locator('.page-top').boundingBox()).height<=44);
|
||||||
|
await page.locator('#rail-toggle').click();
|
||||||
|
const gauge=await page.locator('#speed-gauge svg').boundingBox();assert.ok(gauge.width>350);
|
||||||
|
await page.locator('#rail-toggle').click();assert.equal(await page.locator('#side-rail').isVisible(),false);
|
||||||
|
await page.reload();assert.equal(await page.locator('#side-rail').isVisible(),false);
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-value="fuelPercent"]').textContent==='68');
|
||||||
|
if(process.env.QA_DIRECTORY)await page.screenshot({path:process.env.QA_DIRECTORY+'/caria-touch-drive.png',fullPage:true});
|
||||||
|
await page.getByRole('link',{name:'Détails du véhicule'}).click();await page.locator('#vehicle-details').waitFor({state:'visible'});
|
||||||
|
assert.equal(await page.locator('#vehicle-details .card').count(),8);
|
||||||
|
await page.locator('#rail-toggle').click();await page.locator('#favorite-apps [data-view="music"]').click();
|
||||||
|
await page.locator('#settings-open').click();
|
||||||
|
await page.locator('#dial-style').selectOption('digital');
|
||||||
|
await page.locator('#settings-form button[type="submit"]').click();
|
||||||
|
assert.equal(await page.evaluate(()=>document.body.classList.contains('digital-dials')),true);
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await page.evaluate(()=>document.body.classList.contains('digital-dials')),true);
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-level="fuelPercent"]').getAttribute('aria-valuenow')==='68');
|
||||||
|
assert.equal(await page.locator('[data-level="coolantCelsius"]').getAttribute('aria-valuenow'),'89');
|
||||||
|
await page.evaluate(()=>location.hash='drive');
|
||||||
|
await page.locator('#drive').waitFor({state:'visible'});
|
||||||
|
assert.equal(await page.locator('#speed-gauge .needle').isVisible(),false);
|
||||||
|
assert.equal(await page.locator('#speed-gauge .tick.major').first().isVisible(),true);
|
||||||
|
assert.equal(await page.locator('#speed-gauge .tick.intermediate').first().isVisible(),true);
|
||||||
|
assert.equal(await page.locator('#speed-gauge .tick.quarter').first().isVisible(),false);
|
||||||
|
assert.equal(await page.locator('#rpm-gauge .rpm-red-zone').isVisible(),true);
|
||||||
|
assert.equal(await page.locator('#speed-gauge .digital-progress').isVisible(),true);
|
||||||
|
assert.equal(await page.locator('#drive .quick-sensors').count(),0);
|
||||||
|
if(process.env.QA_DIRECTORY)await page.screenshot({path:process.env.QA_DIRECTORY+'/caria-digital-drive.png'});
|
||||||
|
await page.evaluate(()=>location.hash='music');
|
||||||
|
await page.locator('#settings-open').click();
|
||||||
|
assert.equal(await page.locator('#settings').evaluate(el=>getComputedStyle(el).overflowY),'auto');
|
||||||
|
await page.locator('#settings-close').click();
|
||||||
|
const wav=Buffer.alloc(44+16000*120);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVE',8);wav.write('fmt ',12);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(8000,24);wav.writeUInt32LE(16000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(wav.length-44,40);
|
||||||
|
await page.locator('#music-files').setInputFiles({name:'Night Drive.wav',mimeType:'audio/wav',buffer:wav});
|
||||||
|
await page.getByRole('link',{name:'Vue lecture immersive'}).click();await page.locator('#now-playing').waitFor({state:'visible'});
|
||||||
|
await page.locator('#immersive-play').click();await page.waitForFunction(()=>!document.querySelector('#audio').paused);
|
||||||
|
assert.equal(await page.locator('#immersive-seek').count(),0); await page.locator('#audio').evaluate(el=>el.currentTime=60);
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#audio').currentTime>=60);
|
||||||
|
await page.waitForFunction(()=>Number(document.querySelector('#track-arc').style.strokeDashoffset)<=50);
|
||||||
|
await page.locator('#immersive-volume').fill('0.3');await page.locator('#immersive-volume').dispatchEvent('input');assert.equal(await page.locator('#audio').evaluate(el=>el.volume),.3);
|
||||||
|
await page.locator('#immersive-repeat').click();assert.equal(await page.locator('#audio').evaluate(el=>el.loop),true);
|
||||||
|
await page.locator('#immersive-play').click();assert.equal(await page.locator('#audio').evaluate(el=>el.paused),true);
|
||||||
|
if(process.env.QA_DIRECTORY)await page.screenshot({path:process.env.QA_DIRECTORY+'/caria-touch-music.png',fullPage:true});
|
||||||
|
assert.equal(await page.locator('.brand,.wordmark').count(),0);
|
||||||
|
await page.locator('#immersive-mute').click();assert.equal(await page.locator('#audio').evaluate(el=>el.muted),true);
|
||||||
|
await page.locator('#immersive-mute').click();assert.equal(await page.locator('#audio').evaluate(el=>el.muted),false);
|
||||||
|
assert.equal(await page.locator('#audio').evaluate(el=>el.volume),.3);
|
||||||
|
await page.locator('#immersive-repeat').click();
|
||||||
|
await page.locator('#music-files').setInputFiles(['Second','Third'].map(name=>({name:name+'.wav',mimeType:'audio/wav',buffer:wav})));
|
||||||
|
await page.locator('#immersive-shuffle').click();
|
||||||
|
assert.equal(await page.locator('#immersive-shuffle').getAttribute('aria-pressed'),'true');
|
||||||
|
const titles=[await page.locator('#immersive-title').textContent()];
|
||||||
|
for(let i=0;i<2;i++){
|
||||||
|
await page.locator('#immersive-next').click();
|
||||||
|
await page.waitForFunction(previous=>document.querySelector('#immersive-title').textContent!==previous,titles.at(-1));
|
||||||
|
titles.push(await page.locator('#immersive-title').textContent());
|
||||||
|
}
|
||||||
|
assert.equal(new Set(titles).size,3,'Shuffle visits each remaining track once');
|
||||||
|
await page.locator('#audio').evaluate(el=>el.currentTime=30);
|
||||||
|
await page.locator('#immersive-previous').click();
|
||||||
|
assert.ok(await page.locator('#audio').evaluate(el=>el.currentTime<2));
|
||||||
|
assert.equal(await page.locator('#immersive-title').textContent(),titles[2]);
|
||||||
|
await page.locator('#immersive-previous').click();
|
||||||
|
await page.waitForFunction(title=>document.querySelector('#immersive-title').textContent===title,titles[1]);
|
||||||
|
await page.locator('#audio').evaluate(el=>el.dispatchEvent(new Event('ended')));
|
||||||
|
await page.waitForFunction(title=>document.querySelector('#immersive-title').textContent!==title,titles[1]);
|
||||||
|
await page.locator('#audio').evaluate(el=>el.pause());
|
||||||
|
await page.locator('#favorite-apps [data-view="apps"]').click();await page.locator('#applications-customize').click();await page.getByLabel('Ouverture de Spotify',{exact:true}).selectOption('embed');await page.locator('#application-settings-close').click();
|
||||||
|
assert.deepEqual(external,[]);
|
||||||
|
await page.locator('#applications-grid [data-application="spotify"]').click();await page.locator('#embedded').waitFor({state:'visible'});
|
||||||
|
assert.equal(await page.locator('#embed-container iframe').count(),0);
|
||||||
|
await page.locator('#embed-url').fill('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M');await page.getByRole('button',{name:'Charger le lecteur',exact:true}).click();
|
||||||
|
assert.equal(await page.locator('#embed-container iframe').getAttribute('src'),'https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M');
|
||||||
|
await page.locator('#favorite-apps [data-view="apps"]').click();await page.locator('#embed-container iframe').waitFor({state:'detached'});
|
||||||
|
for (const size of [{width:1200,height:1080},{width:1920,height:1080},{width:1024,height:600},{width:390,height:844}]) {
|
||||||
|
await page.setViewportSize(size);
|
||||||
|
for(const view of ['home','drive','vehicle-details','music','now-playing','apps','embedded']){
|
||||||
|
await page.evaluate(view=>location.hash=view,view);await page.locator('#'+view).waitFor({state:'visible'});
|
||||||
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,`Overflow ${view} ${size.width}`);
|
||||||
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollHeight<=innerHeight),true,`Vertical overflow ${view} ${size.width}`);
|
||||||
|
assert.ok(await page.locator('#'+view).evaluate(el=>el.getBoundingClientRect().bottom<=innerHeight+1),`View fits ${view} ${size.width}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.goto(base+'/?display=drive#drive');await page.locator('#speed-gauge svg').waitFor();assert.equal(await page.locator('#side-rail').isVisible(),false);
|
||||||
|
assert.deepEqual(errors,[]);console.log('PASS landscape/touch; sidebar persistence; dedicated drive; large gauges; sensor details; shared arc player/seek/volume/repeat; opt-in embed/unload; 7 views × 4 sizes.');
|
||||||
|
}finally{await browser.close();}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
FROM nginx:stable-alpine
|
FROM nginx:stable-alpine
|
||||||
COPY ui/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY ui/index.html ui/styles.css ui/app.js /usr/share/nginx/html/
|
COPY ui/index.html ui/styles.css ui/app.js /usr/share/nginx/html/
|
||||||
|
COPY ui/journey.js ui/geo.js ui/theme.js ui/journey.css /usr/share/nginx/html/
|
||||||
|
COPY ui/vendor /usr/share/nginx/html/vendor
|
||||||
|
COPY ui/applications.js ui/applications.css /usr/share/nginx/html/
|
||||||
|
COPY ui/cockpit.js ui/cockpit.css ui/embeds.js /usr/share/nginx/html/
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { applyTheme, accents } from './theme.js';
|
||||||
|
import { initJourney } from './journey.js';
|
||||||
|
import { initCockpit } from './cockpit.js';
|
||||||
|
import { initEmbeds } from './embeds.js';
|
||||||
|
import { initApplications, applications } from './applications.js';
|
||||||
const $ = selector => document.querySelector(selector);
|
const $ = selector => document.querySelector(selector);
|
||||||
const all = selector => document.querySelectorAll(selector);
|
const all = selector => document.querySelectorAll(selector);
|
||||||
const storage = {
|
const storage = {
|
||||||
@@ -6,14 +11,16 @@ const storage = {
|
|||||||
};
|
};
|
||||||
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
||||||
function navigate() {
|
function navigate() {
|
||||||
const view = ['home', 'drive', 'music', 'stream'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'home';
|
const titles = { home: 'Accueil', drive: 'Conduite', music: 'Musique', stream: 'Jellyfin', map: 'Navigation', apps: 'Applications', 'vehicle-details': 'Détails véhicule', 'now-playing': 'Lecture', embedded: 'Lecteur intégré' };
|
||||||
|
const view = Object.hasOwn(titles, location.hash.slice(1)) ? location.hash.slice(1) : 'home';
|
||||||
all('.view').forEach(section => section.hidden = section.id !== view);
|
all('.view').forEach(section => section.hidden = section.id !== view);
|
||||||
all('[data-view]').forEach(link => {
|
all('[data-view]').forEach(link => {
|
||||||
const active = link.dataset.view === view;
|
const active = link.dataset.view === view;
|
||||||
link.classList.toggle('active', active);
|
link.classList.toggle('active', active);
|
||||||
if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current');
|
if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current');
|
||||||
});
|
});
|
||||||
document.title = `CARIA · ${$(`[data-view="${view}"]`).textContent.trim()}`;
|
document.title = `CARIA · ${titles[view]}`;
|
||||||
|
document.dispatchEvent(new CustomEvent('caria-view', { detail: view }));
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', navigate);
|
window.addEventListener('hashchange', navigate);
|
||||||
navigate();
|
navigate();
|
||||||
@@ -35,14 +42,35 @@ function makeGauge(id, max, step, unit, label) {
|
|||||||
for (let v = 0; v <= max; v += step / 4) {
|
for (let v = 0; v <= max; v += step / 4) {
|
||||||
const angle = -130 + v / max * 260;
|
const angle = -130 + v / max * 260;
|
||||||
const major = v % step === 0;
|
const major = v % step === 0;
|
||||||
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 97);
|
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 96);
|
||||||
ticks += `<line class="tick ${major ? 'major' : ''}" x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/>`;
|
ticks += `<line class="tick ${major ? 'major' : v % (step / 2) === 0 ? 'intermediate' : 'quarter'}" x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/>`;
|
||||||
if (major) ticks += `<text x="${n[0]}" y="${n[1] + 4}">${max === 7000 ? v / 1000 : v}</text>`;
|
if (major) ticks += `<text class="dial-number" x="${n[0]}" y="${n[1] + 4}">${max === 7000 ? v / 1000 : v}</text>`;
|
||||||
}
|
}
|
||||||
$(id).innerHTML = `<svg viewBox="0 0 300 285" role="img" aria-label="${label} : en attente"><circle cx="150" cy="150" r="133" fill="none" stroke="var(--border)"/>${ticks}<text x="150" y="110" class="dial-label">${unit}</text><line class="needle" x1="150" y1="150" x2="150" y2="43" style="transform-origin:150px 150px;transform:rotate(-130deg)"/><circle cx="150" cy="150" r="7" fill="var(--accent)"/><circle cx="150" cy="150" r="3" fill="var(--surface)"/><text x="150" y="212" class="dial-value">—</text><text x="150" y="239" class="dial-label">${label}</text></svg>`;
|
// Decorative demonstration red zone; not a calibrated vehicle redline.
|
||||||
|
const redStart = point(-130 + 5500 / 7000 * 260, 128), redEnd = point(130, 128);
|
||||||
|
const redZone = max === 7000 ? `<defs><linearGradient id="rpm-red-zone" gradientUnits="userSpaceOnUse" x1="${redStart[0]}" y1="${redStart[1]}" x2="${redEnd[0]}" y2="${redEnd[1]}"><stop stop-color="#f7a65d" stop-opacity="0.1"/><stop offset="0.5" stop-color="#f67855"/><stop offset="1" stop-color="#fa4d5b"/></linearGradient></defs><path class="rpm-red-zone" d="M${redStart.join(' ')} A128 128 0 0 1 ${redEnd.join(' ')}"/>` : '';
|
||||||
|
const levelKey = max === 7000 ? 'coolantCelsius' : 'fuelPercent';
|
||||||
|
const iconPath = document.querySelector(`[data-level="${levelKey}"] .aux-title svg`).innerHTML;
|
||||||
|
// Compact auxiliary arc, separated from the main instrument.
|
||||||
|
const levelPoint = (angle, radius = 64) => [150+radius*Math.sin(angle*Math.PI/180),244-radius*Math.cos(angle*Math.PI/180)];
|
||||||
|
// 86.8 degrees: 30% shorter than the previous 124-degree auxiliary arc.
|
||||||
|
const arcPath = (from,to) => `M${levelPoint(from).join(' ')} A64 64 0 0 0 ${levelPoint(to).join(' ')}`;
|
||||||
|
const arc = arcPath(223.4,136.6);
|
||||||
|
const redArc = max===7000 ? arcPath(153.96,136.6) : arcPath(223.4,212.984);
|
||||||
|
const tickRatios = max === 7000 ? [0,.28,.56,.84,1] : [0,.12,.4133333333,.7066666667,1];
|
||||||
|
const levelTicks = tickRatios.map((fraction,index) => {
|
||||||
|
const angle = 223.4 - 86.8*fraction;
|
||||||
|
const inner=levelPoint(angle,54), outer=levelPoint(angle,61);
|
||||||
|
return `<path class="level-tick" d="M${inner.join(' ')} L${outer.join(' ')}"/>`;
|
||||||
|
}).join('');
|
||||||
|
const level = `<g class="digital-levels" data-digital-level="${levelKey}"><path class="level-arc-track" d="${arc}"/><path class="level-arc-progress" pathLength="100" d="${arc}"/><path class="level-arc-red" d="${redArc}"/>${levelTicks}<text class="level-end" x="95" y="304">${max===7000?'C':'E'}</text><text class="level-end" x="205" y="304">${max===7000?'H':'F'}</text>${max===7000?'':'<text class="level-end" x="150" y="325">½</text>'}<g class="level-icon" transform="translate(${max===7000?139.8:141.6} ${max===7000?267.8:269.6}) scale(${max===7000?.85:.7})" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${iconPath}</g><text class="level-value" x="150" y="348"><tspan data-value="${levelKey}">—</tspan> ${max===7000?'°C':'%'}</text></g>`;
|
||||||
|
const low = max === 7000 ? 'C' : 'E', high = max === 7000 ? 'H' : 'F';
|
||||||
|
const mini = `<path class="analog-separator" d="M59.590 240.609 L77 226 L223 226 L240.410 240.609"/><g class="analog-levels" data-analog-level="${levelKey}"><g transform="translate(150 264) scale(0.90) translate(-150 -264)"><g transform="translate(0 572) scale(1 -1)"><path class="mini-track" d="M90 308 A60 60 0 0 1 210 308"/><path class="mini-red" d="${max===7000?'M192.4 265.6 A60 60 0 0 1 210 308':'M90 308 A60 60 0 0 1 107.6 265.6'}"/><path class="mini-ticks" d="M90 308h8M107.6 265.6l6 6M150 248v8M192.4 265.6l-6 6M210 308h-8"/><text class="mini-end" transform="translate(0 604) scale(1 -1)" x="104" y="302">${low}</text><text class="mini-end" transform="translate(0 604) scale(1 -1)" x="196" y="302">${high}</text>${max===7000?'':`<text class="mini-end" transform="translate(0 520) scale(1 -1)" x="150" y="260">½</text>`}<line class="mini-needle" x1="150" y1="308" x2="150" y2="258"/><circle class="mini-hub" cx="150" cy="308" r="3"/></g></g><g class="level-icon" transform="translate(${max===7000?139.8:141.6} ${max===7000?277.8:279.6}) scale(${max===7000?.85:.7})" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${iconPath}</g><text class="level-value" x="150" y="368"><tspan data-value="${levelKey}">—</tspan> ${max===7000?'°C':'%'}</text></g>`;
|
||||||
|
$(id).innerHTML = `<svg viewBox="0 0 300 265" role="img" aria-label="${label} : en attente"><path class="dial-rim" d="M68.4 255 A133 133 0 1 1 231.6 255 Z" fill="none" stroke="var(--border)"/><path class="dial-light" d="M51.9 232.3 A128 128 0 1 1 248.1 232.3"/><path class="digital-track" d="M51.9 232.3 A128 128 0 1 1 248.1 232.3"/><path class="digital-progress" pathLength="100" d="M51.9 232.3 A128 128 0 1 1 248.1 232.3"/>${ticks}${redZone}<text x="150" y="110" class="dial-label analog-unit">${unit}</text><text x="150" y="110" class="dial-label digital-unit">${max === 7000 ? 'TR/MIN' : unit}</text><line class="needle" x1="150" y1="150" x2="150" y2="43" style="transform-origin:150px 150px;transform:rotate(-130deg)"/><circle class="needle-hub" cx="150" cy="150" r="7" fill="var(--accent)"/><circle class="needle-hub" cx="150" cy="150" r="3" fill="var(--surface)"/><text x="150" y="212" class="dial-value">—</text><text x="150" y="239" class="dial-label">${label}</text>${level}${mini}</svg>`;
|
||||||
return value => {
|
return value => {
|
||||||
const valid = typeof value === 'number' && Number.isFinite(value);
|
const valid = typeof value === 'number' && Number.isFinite(value);
|
||||||
$(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—';
|
$(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—';
|
||||||
|
$(`${id} .digital-progress`).style.strokeDashoffset = String(valid ? 100 - Math.max(0,Math.min(1,value/max))*100 : 100);
|
||||||
$(`${id} .needle`).style.opacity = valid ? '1' : '0';
|
$(`${id} .needle`).style.opacity = valid ? '1' : '0';
|
||||||
if (valid) $(`${id} .needle`).style.transform = `rotate(${-130 + Math.max(0, Math.min(max, value)) / max * 260}deg)`;
|
if (valid) $(`${id} .needle`).style.transform = `rotate(${-130 + Math.max(0, Math.min(max, value)) / max * 260}deg)`;
|
||||||
$(`${id} svg`).setAttribute('aria-label', `${label} : ${valid ? Math.round(value) : 'indisponible'} ${max === 7000 ? 'tr/min' : 'km/h'}`);
|
$(`${id} svg`).setAttribute('aria-label', `${label} : ${valid ? Math.round(value) : 'indisponible'} ${max === 7000 ? 'tr/min' : 'km/h'}`);
|
||||||
@@ -53,6 +81,37 @@ const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME
|
|||||||
const savedProfile = storage.get('profile', 'route');
|
const savedProfile = storage.get('profile', 'route');
|
||||||
$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route';
|
$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route';
|
||||||
let generation = 0, timer;
|
let generation = 0, timer;
|
||||||
|
function updateLevels(data) {
|
||||||
|
all('[data-level]').forEach(el => {
|
||||||
|
const key = el.dataset.level, value = data?.[key], valid = Number.isFinite(value);
|
||||||
|
const min = Number(el.getAttribute('aria-valuemin')), max = Number(el.getAttribute('aria-valuemax'));
|
||||||
|
const needle = el.querySelector('.aux-needle');
|
||||||
|
if (needle) { needle.style.transform = `rotate(${valid ? -90+Math.max(0,Math.min(1,(value-min)/(max-min)))*180 : -90}deg)`; needle.style.opacity = valid ? '1' : '0'; }
|
||||||
|
// Demo visualization ranges; actual CAN calibration belongs to the vehicle profile.
|
||||||
|
const ratio = valid ? Math.max(0,Math.min(1,(value-min)/(max-min))) : 0;
|
||||||
|
const starts = key === 'fuelPercent' ? [0,.1,.4,.7] : [0,.25,.5,.75];
|
||||||
|
el.querySelectorAll('.sensor-segments i').forEach((segment,i) => {
|
||||||
|
segment.classList.toggle('filled', valid && ratio > starts[i]);
|
||||||
|
});
|
||||||
|
const analog = document.querySelector(`[data-analog-level="${key}"]`);
|
||||||
|
analog.querySelector('.mini-needle').style.transform = `rotate(${-90 + ratio*180}deg)`;
|
||||||
|
analog.querySelector('.mini-needle').style.opacity = valid ? '1' : '0';
|
||||||
|
analog.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
|
||||||
|
const digital = document.querySelector(`[data-digital-level="${key}"]`);
|
||||||
|
const stops = key === 'fuelPercent' ? [0,.12,.4133333333,.7066666667,1] : [0,.28,.56,.84,1];
|
||||||
|
const stepped = stops.filter(stop => stop <= ratio).at(-1) ?? 0;
|
||||||
|
const progress = digital.querySelector('.level-arc-progress');
|
||||||
|
progress.style.strokeDashoffset = String(100*(1-stepped));
|
||||||
|
progress.style.opacity = valid && stepped > 0 ? '1' : '0';
|
||||||
|
digital.classList.toggle('last-level', valid && key === 'coolantCelsius' && ratio >= .84);
|
||||||
|
digital.classList.toggle('reserve-level', valid && key === 'fuelPercent' && stepped === .12);
|
||||||
|
digital.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
|
||||||
|
el.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
|
||||||
|
el.classList.toggle('reserve', valid && key === 'fuelPercent' && value <= 10);
|
||||||
|
if (valid) el.setAttribute('aria-valuenow', String(value)); else el.removeAttribute('aria-valuenow');
|
||||||
|
el.setAttribute('aria-valuetext', valid ? value + (key === 'fuelPercent' ? ' %' : ' °C') : 'Indisponible');
|
||||||
|
});
|
||||||
|
}
|
||||||
function offline() {
|
function offline() {
|
||||||
$('#connection').textContent = 'Données indisponibles';
|
$('#connection').textContent = 'Données indisponibles';
|
||||||
$('#connection').classList.add('error');
|
$('#connection').classList.add('error');
|
||||||
@@ -60,6 +119,7 @@ function offline() {
|
|||||||
document.body.classList.add('stale');
|
document.body.classList.add('stale');
|
||||||
all('[data-value]').forEach(el => el.textContent = '—');
|
all('[data-value]').forEach(el => el.textContent = '—');
|
||||||
all('[data-fuel]').forEach(el => el.style.width = '0%');
|
all('[data-fuel]').forEach(el => el.style.width = '0%');
|
||||||
|
updateLevels(null);
|
||||||
speedGauge(null); rpmGauge(null);
|
speedGauge(null); rpmGauge(null);
|
||||||
}
|
}
|
||||||
async function poll() {
|
async function poll() {
|
||||||
@@ -81,6 +141,7 @@ async function poll() {
|
|||||||
el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value;
|
el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value;
|
||||||
});
|
});
|
||||||
all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`);
|
all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`);
|
||||||
|
updateLevels(data);
|
||||||
speedGauge(data.speedKph); rpmGauge(data.rpm);
|
speedGauge(data.speedKph); rpmGauge(data.rpm);
|
||||||
} catch { if (current === generation) offline(); }
|
} catch { if (current === generation) offline(); }
|
||||||
finally { if (current === generation) timer = setTimeout(poll, 500); }
|
finally { if (current === generation) timer = setTimeout(poll, 500); }
|
||||||
@@ -88,41 +149,34 @@ async function poll() {
|
|||||||
$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); });
|
$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); });
|
||||||
window.addEventListener('storage', event => {
|
window.addEventListener('storage', event => {
|
||||||
if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); }
|
if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); }
|
||||||
if (event.key === 'caria.stream') configureStream();
|
if (['caria.theme', 'caria.accent', 'caria.customColor', 'caria.dialStyle'].includes(event.key)) applyTheme(storage);
|
||||||
if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light');
|
|
||||||
});
|
});
|
||||||
poll();
|
poll();
|
||||||
|
|
||||||
function validUrl(value) {
|
const jellyfinUrl = applications.find(app => app.id === 'stream').url;
|
||||||
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } catch { return ''; }
|
$('#stream-link').hidden = false;
|
||||||
}
|
$('#stream-link').href = jellyfinUrl;
|
||||||
const defaultStream = 'https://media.cunatbrule.fr/web/';
|
$('#stream-description').textContent = new URL(jellyfinUrl).hostname;
|
||||||
function configureStream() {
|
applyTheme(storage);
|
||||||
const url = validUrl(storage.get('stream', defaultStream));
|
|
||||||
$('#stream-link').hidden = !url;
|
|
||||||
if (url) $('#stream-link').href = url; else $('#stream-link').removeAttribute('href');
|
|
||||||
$('#stream-description').textContent = url ? new URL(url).hostname : 'Ajoutez l’adresse de votre site dans les réglages.';
|
|
||||||
$('#stream-configure').textContent = url ? 'Modifier le site' : 'Configurer mon site';
|
|
||||||
}
|
|
||||||
configureStream();
|
|
||||||
document.body.classList.toggle('light', storage.get('theme') === 'light');
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
$('#stream-url').value = storage.get('stream', defaultStream);
|
|
||||||
$('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark';
|
$('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark';
|
||||||
|
$('#accent').value = storage.get('accent', 'green');
|
||||||
|
$('#dial-style').value = ['digital', 'luminous'].includes(storage.get('dialStyle')) ? storage.get('dialStyle') : 'analog';
|
||||||
|
$('#custom-color').value = storage.get('customColor', accents.green);
|
||||||
$('#settings-message').textContent = '';
|
$('#settings-message').textContent = '';
|
||||||
$('#settings').showModal();
|
$('#settings').showModal();
|
||||||
}
|
}
|
||||||
|
$('#custom-color').addEventListener('input', () => { $('#accent').value = 'custom'; });
|
||||||
$('#settings-open').onclick = openSettings;
|
$('#settings-open').onclick = openSettings;
|
||||||
$('#stream-configure').onclick = openSettings;
|
|
||||||
$('#settings-close').onclick = () => $('#settings').close();
|
$('#settings-close').onclick = () => $('#settings').close();
|
||||||
$('#settings-form').addEventListener('submit', event => {
|
$('#settings-form').addEventListener('submit', event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const raw = $('#stream-url').value.trim(), url = validUrl(raw);
|
const dialSaved = storage.set('dialStyle', $('#dial-style').value);
|
||||||
if (raw && !url) { $('#settings-message').textContent = 'Saisissez une adresse HTTP ou HTTPS, sans identifiant ni mot de passe.'; return; }
|
const themeSaved = storage.set('theme', $('#theme').value);
|
||||||
const streamSaved = storage.set('stream', url), themeSaved = storage.set('theme', $('#theme').value);
|
const accentSaved = storage.set('accent', $('#accent').value), colorSaved = storage.set('customColor', $('#custom-color').value);
|
||||||
if (!streamSaved || !themeSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
|
if (!dialSaved || !themeSaved || !accentSaved || !colorSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
|
||||||
document.body.classList.toggle('light', $('#theme').value === 'light');
|
applyTheme(storage);
|
||||||
configureStream(); $('#settings').close(); toast('Réglages enregistrés');
|
$('#settings').close(); toast('Réglages enregistrés');
|
||||||
});
|
});
|
||||||
$('#fullscreen').onclick = async () => {
|
$('#fullscreen').onclick = async () => {
|
||||||
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
|
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
|
||||||
@@ -131,6 +185,25 @@ $('#fullscreen').onclick = async () => {
|
|||||||
|
|
||||||
const audio = $('#audio');
|
const audio = $('#audio');
|
||||||
let tracks = [], selected = -1;
|
let tracks = [], selected = -1;
|
||||||
|
let shuffle = storage.get('shuffle') === 'true', shuffleBag = [], playbackHistory = [];
|
||||||
|
$('#immersive-shuffle').setAttribute('aria-pressed', String(shuffle));
|
||||||
|
$('#immersive-shuffle').onclick = () => {
|
||||||
|
shuffle = !shuffle; shuffleBag = []; playbackHistory = [];
|
||||||
|
storage.set('shuffle', String(shuffle));
|
||||||
|
$('#immersive-shuffle').setAttribute('aria-pressed', String(shuffle));
|
||||||
|
toast(shuffle ? 'Lecture aléatoire activée' : 'Lecture aléatoire désactivée');
|
||||||
|
};
|
||||||
|
function nextTrack() {
|
||||||
|
if (!shuffle || tracks.length < 2) return selectTrack(selected + 1, true);
|
||||||
|
if (!shuffleBag.length) {
|
||||||
|
shuffleBag = tracks.map((_, index) => index).filter(index => index !== selected);
|
||||||
|
for (let i = shuffleBag.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffleBag[i], shuffleBag[j]] = [shuffleBag[j], shuffleBag[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectTrack(shuffleBag.pop(), true);
|
||||||
|
}
|
||||||
function renderPlaylist() {
|
function renderPlaylist() {
|
||||||
$('#playlist').replaceChildren();
|
$('#playlist').replaceChildren();
|
||||||
tracks.forEach((track, index) => {
|
tracks.forEach((track, index) => {
|
||||||
@@ -149,9 +222,15 @@ async function playAudio() {
|
|||||||
try { await audio.play(); $('#audio-message').textContent = 'Lecture locale · aucun fichier envoyé au serveur.'; }
|
try { await audio.play(); $('#audio-message').textContent = 'Lecture locale · aucun fichier envoyé au serveur.'; }
|
||||||
catch { $('#audio-message').textContent = 'Lecture impossible : réessayez ou choisissez un format audio pris en charge.'; }
|
catch { $('#audio-message').textContent = 'Lecture impossible : réessayez ou choisissez un format audio pris en charge.'; }
|
||||||
}
|
}
|
||||||
function selectTrack(index, play = false) {
|
function selectTrack(index, play = false, remember = true) {
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
|
previousTap = -Infinity;
|
||||||
|
if (remember && selected >= 0 && index !== selected) {
|
||||||
|
playbackHistory.push(selected);
|
||||||
|
if (playbackHistory.length > 256) playbackHistory.shift();
|
||||||
|
}
|
||||||
selected = (index + tracks.length) % tracks.length;
|
selected = (index + tracks.length) % tracks.length;
|
||||||
|
shuffleBag = shuffleBag.filter(index => index !== selected);
|
||||||
audio.src = tracks[selected].url;
|
audio.src = tracks[selected].url;
|
||||||
$('#track-title').textContent = tracks[selected].name;
|
$('#track-title').textContent = tracks[selected].name;
|
||||||
$('#home-track').textContent = tracks[selected].name;
|
$('#home-track').textContent = tracks[selected].name;
|
||||||
@@ -165,17 +244,30 @@ $('#music-files').addEventListener('change', event => {
|
|||||||
if (!file.type.startsWith('audio/') && !/\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(file.name)) continue;
|
if (!file.type.startsWith('audio/') && !/\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(file.name)) continue;
|
||||||
tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++;
|
tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++;
|
||||||
}
|
}
|
||||||
|
shuffleBag = [];
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
if (selected === -1 && tracks.length) selectTrack(0);
|
if (selected === -1 && tracks.length) selectTrack(0);
|
||||||
toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu');
|
toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu');
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
});
|
});
|
||||||
$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); };
|
$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); };
|
||||||
$('#previous').onclick = () => selectTrack(selected - 1, true);
|
let previousTap = -Infinity;
|
||||||
$('#next').onclick = () => selectTrack(selected + 1, true);
|
$('#previous').onclick = () => {
|
||||||
|
const now = performance.now(), twice = now - previousTap < 600;
|
||||||
|
previousTap = twice ? -Infinity : now;
|
||||||
|
if (!twice) { audio.currentTime = 0; return; }
|
||||||
|
if (shuffle) { shuffleBag = []; selectTrack(playbackHistory.pop() ?? selected, true, false); }
|
||||||
|
else selectTrack(selected - 1, true);
|
||||||
|
};
|
||||||
|
$('#next').onclick = nextTrack;
|
||||||
audio.addEventListener('play', () => $('#play').textContent = 'Pause');
|
audio.addEventListener('play', () => $('#play').textContent = 'Pause');
|
||||||
audio.addEventListener('pause', () => $('#play').textContent = 'Lecture');
|
audio.addEventListener('pause', () => $('#play').textContent = 'Lecture');
|
||||||
audio.addEventListener('ended', () => { if (selected + 1 < tracks.length) selectTrack(selected + 1, true); else $('#play').textContent = 'Lecture'; });
|
audio.addEventListener('ended', () => { if (audio.loop) return; if (shuffle || selected + 1 < tracks.length) nextTrack(); else $('#play').textContent = 'Lecture'; });
|
||||||
audio.addEventListener('error', () => $('#audio-message').textContent = 'Ce fichier ne peut pas être lu. Essayez un autre format audio.');
|
audio.addEventListener('error', () => $('#audio-message').textContent = 'Ce fichier ne peut pas être lu. Essayez un autre format audio.');
|
||||||
window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); });
|
window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); });
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
|
initJourney({ storage, toast });
|
||||||
|
initApplications({ storage, toast, navigate });
|
||||||
|
initEmbeds({ storage, toast });
|
||||||
|
|
||||||
|
initCockpit({ storage, toast });
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#applications-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.catalog-heading{display:flex;justify-content:space-between;gap:15px;align-items:center;margin-bottom:18px}.catalog-heading p{font-size:13px;color:var(--muted);margin:0}.application-card{padding:0;position:relative;box-shadow:none}.application-open{display:block;padding:25px;min-height:205px}.application-open>.icon{display:grid;place-items:center;width:52px;height:52px;border-radius:15px;background:color-mix(in srgb,var(--accent) 10%,var(--surface));color:var(--accent);font-size:29px;margin-bottom:18px}.application-open h2{font-size:19px;margin-bottom:8px}.application-open p{font-size:12px;color:var(--muted);margin:0 0 16px;min-height:36px}.application-kind{font-size:10px;color:var(--accent)}.application-card:hover{border-color:var(--accent)}.favorite-toggle{position:absolute;right:12px;top:12px;font-size:23px;padding:4px 10px;border-color:transparent;background:transparent;color:var(--accent)}.favorite-toggle[aria-pressed=true]{background:color-mix(in srgb,var(--accent) 10%,var(--surface))}.application-open[aria-disabled=true]{opacity:.55;cursor:default}#application-settings{width:min(640px,calc(100% - 24px))}#application-settings h3{font-size:16px;margin:24px 0 8px}#favorite-slots{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}#favorite-slots label{margin-top:6px}#favorite-slots select{width:100%}.application-setting{display:grid;grid-template-columns:minmax(0,1fr) minmax(130px,180px);gap:12px;align-items:center;min-height:60px;border-bottom:1px solid var(--border);padding:8px 0}.application-setting label{display:flex;flex-direction:row;align-items:center;margin:0;min-height:44px;gap:12px}.application-setting input{width:20px;height:20px;accent-color:var(--accent)}.application-setting>span{color:var(--muted);font-size:12px}.application-setting select{width:100%;font-size:12px}.rail nav a span:last-child{overflow-wrap:anywhere;text-align:center}.map-actions a[data-waze-destination]{font-size:11px;padding:12px;border:1px solid var(--border);border-radius:10px;color:var(--accent)}
|
||||||
|
@media(min-width:1450px){#applications-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
||||||
|
@media(max-width:900px){#applications-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.application-open{padding:20px}}
|
||||||
|
@media(max-width:520px){#applications-grid{grid-template-columns:1fr}.application-open{min-height:165px}.application-open>.icon{margin-bottom:12px}.application-open p{min-height:0}#favorite-slots{grid-template-columns:1fr}.application-setting{grid-template-columns:1fr}.application-setting select{margin-bottom:6px}.catalog-heading{align-items:flex-start}.catalog-heading p{font-size:11px}}
|
||||||
|
@media(max-height:650px) and (min-width:760px){.application-open{padding:18px;min-height:180px}.application-open>.icon{width:44px;height:44px;margin-bottom:12px}.application-open h2{font-size:17px}.application-open p{font-size:11px;margin-bottom:10px}}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
export const applications = [
|
||||||
|
{ id: 'home', name: 'Accueil', icon: '⌂', description: 'Heure, météo et votre espace à bord.' },
|
||||||
|
{ id: 'drive', name: 'Conduite', icon: '◉', description: 'Les instruments de votre véhicule.' },
|
||||||
|
{ id: 'map', name: 'Navigation', icon: '⌖', description: 'Carte, itinéraires et zones de vigilance.' },
|
||||||
|
{ id: 'music', name: 'Musique locale', icon: '♫', description: 'Vos morceaux sur cet appareil.' },
|
||||||
|
{ id: 'stream', name: 'Jellyfin', icon: '▷', external: true, url: 'https://media.cunatbrule.fr/web/', description: 'Votre bibliothèque multimédia personnelle.' },
|
||||||
|
{ id: 'spotify', name: 'Spotify', icon: '≋', external: true, url: 'https://open.spotify.com/', description: 'Ouvrir le lecteur Web Spotify.' },
|
||||||
|
{ id: 'youtube', name: 'YouTube', icon: '▶', external: true, url: 'https://www.youtube.com/', description: 'Vidéos, chaînes et playlists.' },
|
||||||
|
{ id: 'youtube-music', name: 'YouTube Music', icon: '◉', external: true, url: 'https://music.youtube.com/', description: 'Votre musique sur YouTube Music.' },
|
||||||
|
{ id: 'waze', name: 'Waze', icon: '⌁', external: true, url: 'https://waze.com/ul?utm_source=caria', description: 'Ouvrir Waze ou lui transmettre votre destination.' }
|
||||||
|
];
|
||||||
|
const ids = new Set(applications.map(a => a.id));
|
||||||
|
export function normalizePreferences(value) {
|
||||||
|
const raw = value && typeof value === 'object' ? value : {};
|
||||||
|
const hidden = [...new Set(Array.isArray(raw.hidden) ? raw.hidden.filter(id => ids.has(id)) : [])];
|
||||||
|
const seen = new Set();
|
||||||
|
const favorites = Array.from({ length: 4 }, (_, i) => {
|
||||||
|
const id = (Array.isArray(raw.favorites) ? raw.favorites : ['home', 'drive', 'map', 'music'])[i];
|
||||||
|
if (!ids.has(id) || hidden.includes(id) || seen.has(id)) return null;
|
||||||
|
seen.add(id); return id;
|
||||||
|
});
|
||||||
|
const modes = Object.fromEntries(applications.filter(a => a.external).map(a => [a.id, raw.modes?.[a.id] === 'same' ? 'same' : raw.modes?.[a.id] === 'embed' && a.id !== 'waze' ? 'embed' : 'new']));
|
||||||
|
return { hidden, favorites, modes };
|
||||||
|
}
|
||||||
|
export function wazeUrl(destination) {
|
||||||
|
const url = new URL('https://waze.com/ul'); url.searchParams.set('utm_source', 'caria');
|
||||||
|
if (destination && Number.isFinite(destination.lat) && Number.isFinite(destination.lon) && Math.abs(destination.lat) <= 90 && Math.abs(destination.lon) <= 180) {
|
||||||
|
url.searchParams.set('ll', `${destination.lat},${destination.lon}`); url.searchParams.set('navigate', 'yes');
|
||||||
|
}
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
export function safeWebUrl(value) {
|
||||||
|
try { const u = new URL(value); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password ? u.href : ''; } catch { return ''; }
|
||||||
|
}
|
||||||
|
export function initApplications({ storage, toast, navigate }) {
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
let preferences, destination = null;
|
||||||
|
function load() { let raw; try { raw = JSON.parse(storage.get('applications', 'null')); } catch {} preferences = normalizePreferences(raw); }
|
||||||
|
load();
|
||||||
|
function urlFor(app) {
|
||||||
|
if (!app.external) return `#${app.id}`;
|
||||||
|
if (app.id === 'waze') return wazeUrl(destination);
|
||||||
|
return app.url;
|
||||||
|
}
|
||||||
|
function configureLink(link, app) {
|
||||||
|
link.onclick=null;
|
||||||
|
const href = urlFor(app);
|
||||||
|
link.dataset.application = app.id;
|
||||||
|
if (href) { link.href = href; link.removeAttribute('aria-disabled'); }
|
||||||
|
else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); }
|
||||||
|
if (app.external && preferences.modes[app.id] === 'embed') {
|
||||||
|
link.href='#embedded';link.removeAttribute('target');
|
||||||
|
link.onclick=e=>{e.preventDefault();location.hash='embedded';navigate();document.dispatchEvent(new CustomEvent('caria-embed',{detail:app.id}));};
|
||||||
|
}
|
||||||
|
else if (app.external) { link.target = preferences.modes[app.id] === 'same' ? '_self' : '_blank'; link.rel = 'noopener noreferrer'; }
|
||||||
|
else link.dataset.view = app.id;
|
||||||
|
}
|
||||||
|
function icon(app) { const span = document.createElement('span'); span.className = 'icon'; span.textContent = app.icon; span.setAttribute('aria-hidden', 'true'); return span; }
|
||||||
|
function save(next) {
|
||||||
|
const normalized = normalizePreferences(next);
|
||||||
|
if (!storage.set('applications', JSON.stringify(normalized))) { toast('Le navigateur bloque l’enregistrement des applications.'); renderSettings(); return; }
|
||||||
|
preferences = normalized; render(); renderSettings();
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
const nav = $('#favorite-apps'); nav.replaceChildren();
|
||||||
|
preferences.favorites.filter(Boolean).forEach(id => {
|
||||||
|
const app = applications.find(a => a.id === id), link = document.createElement('a'); configureLink(link, app);
|
||||||
|
const name = document.createElement('span'); name.textContent = app.id === 'music' ? 'Musique' : app.name;
|
||||||
|
link.append(icon(app), name); nav.append(link);
|
||||||
|
});
|
||||||
|
const catalogLink = document.createElement('a'); catalogLink.href = '#apps'; catalogLink.dataset.view = 'apps';
|
||||||
|
const label = document.createElement('span'); label.textContent = 'Applications';
|
||||||
|
catalogLink.append(icon({ icon: '▦' }), label); nav.append(catalogLink);
|
||||||
|
const catalog = $('#applications-grid'); catalog.replaceChildren();
|
||||||
|
applications.filter(a => !preferences.hidden.includes(a.id)).forEach(app => {
|
||||||
|
const card = document.createElement('article'); card.className = 'card application-card';
|
||||||
|
const link = document.createElement('a'); configureLink(link, app); link.className = 'application-open';
|
||||||
|
const name = document.createElement('h2'); name.textContent = app.name;
|
||||||
|
const description = document.createElement('p'); description.textContent = app.description;
|
||||||
|
const hint = document.createElement('span'); hint.className = 'application-kind';
|
||||||
|
hint.textContent = !urlFor(app) ? 'Adresse à configurer dans les réglages' : app.external ? preferences.modes[app.id] === 'embed' ? 'Lecteur intégré · dans CARIA' : preferences.modes[app.id] === 'same' ? 'Site externe · même onglet ↗' : 'Site externe · nouvel onglet ↗' : 'Dans CARIA';
|
||||||
|
link.append(icon(app), name, description, hint);
|
||||||
|
const favorite = document.createElement('button'); favorite.className = 'favorite-toggle';
|
||||||
|
const selected = preferences.favorites.includes(app.id); favorite.textContent = selected ? '★' : '☆';
|
||||||
|
favorite.setAttribute('aria-label', `${selected ? 'Retirer' : 'Ajouter'} ${app.name} ${selected ? 'des' : 'aux'} favoris`); favorite.setAttribute('aria-pressed', String(selected));
|
||||||
|
favorite.onclick = () => {
|
||||||
|
const next = structuredClone(preferences), slot = next.favorites.indexOf(selected ? app.id : null);
|
||||||
|
if (slot < 0) return toast('Quatre favoris maximum. Utilisez Personnaliser pour remplacer un favori.');
|
||||||
|
next.favorites[slot] = selected ? null : app.id; save(next);
|
||||||
|
};
|
||||||
|
card.append(link, favorite); catalog.append(card);
|
||||||
|
});
|
||||||
|
$('#applications-empty').hidden = catalog.childElementCount !== 0;
|
||||||
|
$('#favorite-count').textContent = `${preferences.favorites.filter(Boolean).length} / 4 favoris`;
|
||||||
|
document.querySelectorAll('[data-waze-destination]').forEach(link => {
|
||||||
|
configureLink(link, applications.find(a => a.id === 'waze'));
|
||||||
|
link.hidden = !destination || preferences.hidden.includes('waze');
|
||||||
|
});
|
||||||
|
configureLink($('#stream-link'), applications.find(a=>a.id==='stream'));
|
||||||
|
$('#stream-opening').textContent = preferences.modes.stream === 'embed' ? 'S’ouvre dans le lecteur intégré CARIA, si votre serveur Jellyfin autorise cet affichage.' : preferences.modes.stream === 'same' ? 'S’ouvre dans cet onglet. Utilisez Retour pour retrouver CARIA ; la lecture locale peut être interrompue.' : 'S’ouvre dans un nouvel onglet. Votre cockpit reste accessible.';
|
||||||
|
navigate();
|
||||||
|
}
|
||||||
|
function renderSettings() {
|
||||||
|
const slots = $('#favorite-slots'); slots.replaceChildren();
|
||||||
|
preferences.favorites.forEach((id, index) => {
|
||||||
|
const label = document.createElement('label'); label.textContent = `Favori ${index + 1}`;
|
||||||
|
const select = document.createElement('select'); select.dataset.favoriteSlot = index;
|
||||||
|
select.add(new Option('Aucun', ''));
|
||||||
|
applications.filter(a => !preferences.hidden.includes(a.id)).forEach(app => select.add(new Option(app.name, app.id)));
|
||||||
|
select.value = id || '';
|
||||||
|
select.onchange = () => {
|
||||||
|
const next = structuredClone(preferences), chosen = select.value || null, other = chosen ? next.favorites.indexOf(chosen) : -1;
|
||||||
|
if (other >= 0 && other !== index) next.favorites[other] = id;
|
||||||
|
next.favorites[index] = chosen; save(next);
|
||||||
|
};
|
||||||
|
label.append(select); slots.append(label);
|
||||||
|
});
|
||||||
|
const list = $('#application-settings-list'); list.replaceChildren();
|
||||||
|
applications.forEach(app => {
|
||||||
|
const row = document.createElement('div'); row.className = 'application-setting'; row.dataset.appSetting = app.id;
|
||||||
|
const visible = document.createElement('label'), checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = !preferences.hidden.includes(app.id);
|
||||||
|
checkbox.setAttribute('aria-label', `Afficher ${app.name}`); visible.append(checkbox, document.createTextNode(app.name));
|
||||||
|
checkbox.onchange = () => {
|
||||||
|
const next = structuredClone(preferences); next.hidden = checkbox.checked ? next.hidden.filter(id => id !== app.id) : [...next.hidden, app.id]; save(next);
|
||||||
|
};
|
||||||
|
row.append(visible);
|
||||||
|
if (app.external) {
|
||||||
|
const mode = document.createElement('select'); mode.setAttribute('aria-label', `Ouverture de ${app.name}`);
|
||||||
|
mode.add(new Option('Nouvel onglet', 'new')); mode.add(new Option('Même onglet', 'same')); mode.value = preferences.modes[app.id];
|
||||||
|
if(app.id !== 'waze') mode.add(new Option('Lecteur intégré','embed'));
|
||||||
|
mode.value=preferences.modes[app.id];
|
||||||
|
mode.onchange = () => { const next = structuredClone(preferences); next.modes[app.id] = mode.value; save(next); }; row.append(mode);
|
||||||
|
} else { const hint = document.createElement('span'); hint.textContent = 'Dans CARIA'; row.append(hint); }
|
||||||
|
list.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function openSettings() { renderSettings(); $('#application-settings').showModal(); }
|
||||||
|
$('#applications-customize').onclick = openSettings;
|
||||||
|
$('#applications-settings-open').onclick = () => { $('#settings').close(); openSettings(); };
|
||||||
|
$('#application-settings-close').onclick = () => $('#application-settings').close();
|
||||||
|
$('#applications-reset').onclick = () => save(normalizePreferences(null));
|
||||||
|
document.addEventListener('caria-destination', event => { destination = event.detail; render(); });
|
||||||
|
document.addEventListener('caria-stream', render);
|
||||||
|
window.addEventListener('storage', event => { if (event.key === 'caria.applications' || event.key === null) { load(); render(); renderSettings(); } });
|
||||||
|
render();
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
/* Touch layout, reference panel: 1200 wide × 1080 high, 10 inches, landscape. */
|
||||||
|
.rail{position:sticky;top:0;height:100dvh;min-height:440px}.rail-collapsed .rail{display:none}.page-top{height:auto;min-height:62px;padding:0 0 20px;border:0;margin-bottom:14px}.header-left{display:flex;align-items:center;gap:18px}#rail-toggle{font-size:25px;min-width:52px;min-height:52px;background:transparent}.page-heading>div:first-child:not(.map-actions){position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.page-heading>.subtle{display:none}.page-heading{justify-content:flex-end;margin-bottom:16px}.page-heading:has(>div:only-child),#home>.page-heading,#drive>.page-heading{margin-bottom:14px}#home>.page-heading{display:none}main{padding:22px 26px}.rail nav a{min-height:72px}.rail nav a span:last-child{font-size:12px}.page-top .wordmark{font-size:21px}.page-top .wordmark span{font-size:8px}.page-top .header-right{gap:16px}.view-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:18px}.view-toolbar a{display:inline-flex;align-items:center;min-height:48px;color:var(--accent);font-size:14px}.cluster{padding:18px 12px 0}.gauges{grid-template-columns:minmax(0,1fr) 62px minmax(0,1fr);max-width:none;gap:0}.gauge svg{max-height:none;aspect-ratio:300/265}.gauge .dial-value{font-size:44px}.gauge .dial-label{font-size:10px}.gear strong{font-size:54px}.cluster-top{padding:0 12px;margin-bottom:14px}.cluster-bottom{margin-top:14px;padding:16px 12px;font-size:13px}.quick-sensors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:22px 0}.quick-sensor{padding:24px;display:flex;align-items:center;gap:20px}.quick-sensor>.sensor-icon{font-size:31px;color:var(--accent);width:42px}.quick-sensor .eyebrow{font-size:12px;letter-spacing:1px}.quick-sensor h2{font-size:46px;line-height:1.2;margin:8px 0 0}.quick-sensor h2 small{font-size:20px;color:var(--muted)}.quick-sensor p:last-child{font-size:12px;color:var(--muted);margin:8px 0 0}.drive-links{display:flex;justify-content:space-between;gap:15px;align-items:center}.drive-links a{min-height:52px}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.detail-grid .card{padding:26px}.detail-grid h2{font-size:18px}.detail-grid strong{font-size:36px;font-weight:500}.detail-grid p{font-size:14px;color:var(--muted)}.details-notice{color:var(--muted);font-size:13px;line-height:1.7}.player-view-link{display:inline-flex;min-height:48px;align-items:center;justify-content:center;margin-top:15px;color:var(--accent);font-size:14px}.embed-frame{border:1px solid var(--border);border-radius:16px;width:100%;min-height:500px;background:var(--surface)}.embed-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px}.embed-controls input{flex:1;min-width:180px;min-height:48px}.embed-controls button,.embed-controls a{min-height:48px}.embed-notice{font-size:13px;color:var(--muted);line-height:1.6}.embedded-card{padding:24px}.embedded-card iframe{display:block}.embedded-card iframe[data-provider=spotify]{min-height:352px}.embedded-card iframe[data-provider=youtube-music],.embedded-card iframe[data-provider=youtube]{aspect-ratio:16/9;min-height:220px}
|
||||||
|
@media(min-width:900px) and (min-height:950px){.home-grid{grid-template-columns:1fr 1fr}.clock-card{min-height:355px}.weather-card{min-height:355px}.home-grid .vehicle-card,.home-grid .music-card{min-height:265px}.clock-card>time{font-size:100px}.application-open{min-height:230px}.cluster{margin-top:12px}.quick-sensors{margin-top:28px}.quick-sensor{min-height:152px}.player-grid{grid-template-columns:1fr 1fr}.album-art.large{width:230px}.library{min-height:620px}}
|
||||||
|
@media(max-width:759px){main{padding:15px}.page-top{gap:8px;min-height:54px;padding-bottom:12px}.page-top .header-left{gap:7px}.page-top .header-right{gap:5px}.page-top .wordmark{font-size:15px;letter-spacing:2px}.page-top .wordmark span{display:none}.page-top .badge{font-size:7px;max-width:100px;white-space:normal}.page-top .header-right time{font-size:12px}#rail-toggle{min-width:44px;padding:5px}.gauges{grid-template-columns:1fr 1fr}.gear{grid-column:1/-1;grid-row:2;display:flex;gap:14px}.gear strong{font-size:32px}.quick-sensor{padding:16px;gap:8px}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:32px}.quick-sensor .eyebrow{font-size:10px}.detail-grid{grid-template-columns:1fr}.drive-links{flex-wrap:wrap}.cluster-bottom{font-size:10px}.quick-sensors{gap:10px}.view-toolbar{flex-wrap:wrap}}
|
||||||
|
@media(max-height:650px) and (min-width:760px){.page-top{margin-bottom:8px;padding-bottom:6px}.gauge svg{max-height:260px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:14px 0}.quick-sensor{padding:14px;gap:0}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:30px}.quick-sensor .eyebrow{font-size:9px}.quick-sensor p:last-child{font-size:10px}.cluster-top{margin-bottom:0}.cluster-bottom{padding:10px;font-size:11px}.rail nav a{min-height:55px}}
|
||||||
|
@media(min-width:1000px) and (min-height:651px){.gauge svg{max-height:440px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:20px 0}.quick-sensor{padding:22px 16px;min-height:140px}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:39px}.quick-sensor .eyebrow{font-size:11px}.quick-sensor p:last-child{font-size:11px}}
|
||||||
|
|
||||||
|
/* Shared local player: centered controls inside a cropped circular progress arc. */
|
||||||
|
.drive-actions{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.drive-actions .profile-label{margin:0}.drive-actions select{min-height:48px}
|
||||||
|
.immersive-player{max-width:920px;margin:auto;padding:20px 28px 28px;text-align:center;background:radial-gradient(ellipse at 50% 35%,color-mix(in srgb,var(--accent) 8%,transparent),transparent 70%)}
|
||||||
|
.immersive-stage{position:relative;width:100%;max-width:780px;aspect-ratio:600/430;margin:auto}
|
||||||
|
.music-orbit{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
|
||||||
|
.orbit-track,#track-arc{fill:none;stroke:var(--border);stroke-width:7;stroke-linecap:round}
|
||||||
|
#track-arc{stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;transition:stroke-dashoffset .2s linear}
|
||||||
|
.immersive-caption{position:absolute;top:43%;left:12%;right:12%}
|
||||||
|
.immersive-caption h2{font-size:clamp(18px,2.4vw,30px);line-height:1.25;margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
|
||||||
|
.immersive-caption p{font-size:13px;color:var(--muted);margin:12px 0 0}
|
||||||
|
.immersive-controls{position:absolute;top:68%;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:18px}
|
||||||
|
.immersive-player button{display:inline-flex;align-items:center;justify-content:center;min-width:54px;min-height:54px;padding:12px;border-radius:16px}
|
||||||
|
.immersive-player button svg{width:25px;height:25px;flex-shrink:0}
|
||||||
|
.immersive-controls #immersive-play{width:82px;height:82px;border-radius:50%;background:var(--accent);color:var(--ink);box-shadow:0 4px 26px color-mix(in srgb,var(--accent) 18%,transparent)}
|
||||||
|
#immersive-play svg{width:32px;height:32px}
|
||||||
|
.immersive-times{position:absolute;left:4%;right:4%;bottom:5%;display:flex;justify-content:space-between;font-size:16px;font-variant-numeric:tabular-nums;color:var(--muted)}
|
||||||
|
.immersive-progress{max-width:680px;margin:-8px auto 6px}
|
||||||
|
.immersive-player input[type=range]{height:44px;min-width:0;width:100%;margin:0;accent-color:var(--accent);cursor:pointer}
|
||||||
|
.immersive-bottom{display:flex;align-items:center;justify-content:space-between;gap:24px;max-width:680px;margin:auto}
|
||||||
|
.immersive-secondary{display:flex;gap:10px}
|
||||||
|
.immersive-player button[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
|
||||||
|
.immersive-volume{display:flex;align-items:center;gap:12px;width:340px;max-width:100%}
|
||||||
|
.immersive-volume input{flex:1}.immersive-volume output{min-width:48px;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||||
|
@media(max-width:600px){.immersive-player{padding:12px}.immersive-stage{min-height:300px}.immersive-caption{top:39%;left:8%;right:8%}.immersive-caption p{font-size:11px;margin-top:8px}.immersive-controls{top:65%;gap:12px}.immersive-controls #immersive-play{width:66px;height:66px}.immersive-times{bottom:1%;font-size:13px}.immersive-progress{margin:8px auto}.immersive-bottom{flex-wrap:wrap;justify-content:center;gap:12px}.immersive-volume{width:100%}}
|
||||||
|
@media(max-height:700px) and (min-width:760px){.immersive-player{max-width:820px;padding:8px 24px 12px}.immersive-stage{max-width:440px}.immersive-caption h2{font-size:18px}.immersive-caption p{font-size:11px;margin-top:6px}.immersive-controls{top:67%;gap:14px}.immersive-controls #immersive-play{width:58px;height:58px}.immersive-times{bottom:0}.immersive-progress{margin:0 auto}.immersive-bottom{gap:16px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){#track-arc{transition:none}}
|
||||||
|
/* Viewport-sized cockpit. Long collections use explicit pages. */
|
||||||
|
html,body{height:100%;overflow:hidden}.shell{height:100dvh;min-height:0}.workspace{height:100%;min-height:0}main{height:100%;min-height:0;display:flex;flex-direction:column;padding:12px 22px;margin:0 auto}.page-top{flex-shrink:0;min-height:52px;padding:0;margin-bottom:10px}.view:not([hidden]){flex:1;min-height:0;display:flex;flex-direction:column;gap:12px}.view>.page-heading{flex-shrink:0;margin:0}.view>.page-heading:has(>div:only-child){display:none}.view-toolbar{margin:0;flex-shrink:0}.rail{min-height:0;padding-top:12px;padding-bottom:12px;gap:8px}.rail nav{gap:6px}.rail nav a{min-height:52px}.rail-collapsed .page-top{min-height:44px;height:44px;margin-bottom:4px}.rail-collapsed .header-right .badge,.rail-collapsed .header-temp{display:none}.rail-collapsed #rail-toggle{min-height:44px;height:44px}.rail-collapsed main{padding-top:6px}
|
||||||
|
#settings footer{padding:12px 0;flex-wrap:wrap;margin:14px 0}#settings footer>span:last-child{display:none}
|
||||||
|
.home-grid{flex:1;min-height:0;grid-template-rows:1.1fr 1fr}.home-grid>.card{min-height:0!important}.clock-card>time{font-size:clamp(52px,9vh,100px)}.weather-main{margin:8px 0}.home-grid .card{padding:18px}.home-grid .album-art{width:90px}.home-grid .music-card{gap:14px}
|
||||||
|
#drive .cluster{flex:1;min-height:0;display:flex;flex-direction:column;margin:0}.gauges{flex:1;min-height:0;width:100%}.gauge{height:100%;min-height:0}.gauge svg{height:100%;max-height:none!important;width:100%;aspect-ratio:auto}.cluster-top,.cluster-bottom{flex-shrink:0}.cluster-bottom{margin-top:4px;padding:10px}.quick-sensors{flex-shrink:0;margin:0!important;gap:12px}.quick-sensor{min-height:0!important;padding:14px!important}.quick-sensor h2{font-size:clamp(26px,4vh,42px)}.drive-links{flex-shrink:0;flex-wrap:wrap}.drive-links a{display:inline-flex;align-items:center}.drive-actions{gap:12px}
|
||||||
|
#now-playing .immersive-player{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:10px 24px 18px;gap:8px}#now-playing .immersive-stage{flex:1;min-height:0;width:100%;max-width:min(780px,calc((100dvh - 230px)*1.395));margin:0;aspect-ratio:600/430;max-height:559px}#now-playing .immersive-bottom{flex-shrink:0;width:100%;margin:0;z-index:1}.immersive-stage:before{content:'';position:absolute;inset:20% 15% 10%;border-radius:50%;background:radial-gradient(ellipse,color-mix(in srgb,var(--accent) 17%,transparent),transparent 70%);opacity:.65;pointer-events:none;animation:music-halo 7s ease-in-out infinite;animation-play-state:paused}.playing .immersive-stage:before{animation-play-state:running}@keyframes music-halo{50%{opacity:1;transform:scale(1.06)}}@media(prefers-reduced-motion:reduce){.immersive-stage:before{animation:none}}
|
||||||
|
.player-grid{flex:1;min-height:0}.player-grid>.card{min-height:0!important}.player{display:flex;flex-direction:column;justify-content:center;align-items:center}.player>*{flex-shrink:0}.player .album-art.large{width:min(170px,20vh);margin-bottom:10px}.player h2{font-size:22px;margin:0}.player p{margin:8px 0}.player audio{height:44px}.library{display:flex;flex-direction:column}#playlist{flex:1;min-height:0;max-height:none;overflow:hidden;margin:10px 0}.collection-pages{display:flex;justify-content:center;align-items:center;gap:16px;flex-shrink:0;min-height:48px}.collection-pages span{font-size:12px;color:var(--muted)}.collection-pages button{min-width:48px}.detail-grid{flex:1;min-height:0;grid-template-columns:repeat(4,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));gap:12px}.detail-grid .card{padding:16px}.detail-grid strong{font-size:30px}.detail-grid p{font-size:12px}.details-notice{margin:0;font-size:11px}#applications-grid{flex:1;min-height:0;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:minmax(0,1fr)}.catalog-heading{margin:0}.application-open{min-height:0!important;height:100%;padding:16px}.application-open>.icon{margin-bottom:10px}.application-open p{min-height:0;margin-bottom:8px}#apps>.subtle{margin:0}
|
||||||
|
.journey-grid{flex:1;min-height:0}.map-column{display:flex;flex-direction:column;min-height:0}#map-canvas{flex:1;min-height:100px;height:auto!important}.journey-panel{min-height:0;overflow:auto}.embedded-card{flex:1;min-height:0;display:flex;flex-direction:column}.embed-controls{margin-bottom:8px}#embed-container{flex:1;min-height:0}#embed-container iframe{height:100%;min-height:0}.stream-card{margin:auto 0}
|
||||||
|
@media(max-height:750px){.home-grid .clock-card,.home-grid .weather-card{padding:14px}.home-grid .weather-card>p{margin-bottom:6px}.home-grid .music-card h2{font-size:17px}.home-grid .vehicle-card h2{display:none}.home-grid .clock-bottom{bottom:14px}.home-grid .weather-bottom{padding-top:8px}.home-grid .weather-main strong{font-size:40px}.home-grid .weather-sun{font-size:40px}.quick-sensor p:last-child{margin-top:4px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr))}.quick-sensor .eyebrow{font-size:9px}.quick-sensor h2{font-size:28px;margin-top:4px}.quick-sensor p:last-child{font-size:10px}.cluster-top{margin:0}.view:not([hidden]){gap:8px}.player .album-art.large{width:80px}.player p{font-size:11px}.detail-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.detail-grid h2{font-size:15px}.detail-grid strong{font-size:25px}.detail-grid p{font-size:11px;line-height:1.4}.detail-grid .card{padding:12px}.application-open p{display:none}.application-open>.icon{width:36px;height:36px;font-size:23px}.application-open h2{font-size:16px}.application-open{padding:12px}.application-kind{font-size:9px}.catalog-heading{display:none}}
|
||||||
|
@media(max-width:759px){main{padding:8px}.home-grid{grid-template-rows:1fr 1fr}.home-grid .card{padding:14px}.player-grid{grid-template-columns:1fr;grid-template-rows:auto minmax(0,1fr)}.player .album-art.large,.player .eyebrow,.player #audio-message{display:none}.player{padding:12px}.player h2{font-size:17px}.player-view-link{margin-top:0}.player p{margin:4px 0}.quick-sensors{grid-template-columns:1fr 1fr}.gear{padding:0}.detail-grid{grid-template-columns:1fr 1fr}.details-notice{font-size:10px}.drive-links>a:last-child{font-size:11px;min-height:44px}.drive-actions{gap:6px}.drive-actions .pill{padding:10px;font-size:10px}.profile-label{gap:4px}#applications-grid{grid-template-columns:1fr 1fr}.application-open h2{font-size:15px}.application-open p{display:none}.application-open{padding:12px}#now-playing .immersive-stage{max-width:100%;flex:0 1 auto;min-height:280px}#now-playing .immersive-bottom{gap:8px}.immersive-caption h2{font-size:18px}.journey-grid{grid-template-columns:1fr}.journey-panel{max-height:40vh}}
|
||||||
|
main{width:100%}
|
||||||
|
dialog[open]{display:flex;flex-direction:column;overflow:hidden;max-height:calc(100dvh - 24px)}.journey-panel{display:flex;flex-direction:column;overflow:hidden;max-height:none}.panel-content{flex:1;min-height:0;overflow:hidden}.panel-pages{flex-shrink:0;margin-top:8px}.panel-content>form{margin:0}.rail nav a span:last-child{font-size:11px}
|
||||||
|
@media(max-width:759px){.journey-grid{grid-template-rows:minmax(120px,1fr) minmax(220px,1fr)}.journey-panel{max-height:none}.rail nav a span:last-child{font-size:9px}}
|
||||||
|
/* Dial appearance and touch menu refinements. */
|
||||||
|
dialog[open]{display:block;overflow:auto;overscroll-behavior:contain}#apps{overflow-y:auto;overscroll-behavior:contain}#applications-grid{flex:none;grid-auto-rows:auto}.application-open{min-height:180px!important}#apps>.page-heading{position:sticky;top:0;z-index:2;background:var(--bg);padding-bottom:8px}
|
||||||
|
.page-top .header-left{height:44px;display:flex;align-items:center}#rail-toggle,.rail-collapsed #rail-toggle{display:grid;place-items:center;width:44px;min-width:44px;height:44px;min-height:44px;padding:0;line-height:1;font-size:24px;flex-shrink:0}.rail-collapsed .page-top{gap:12px;align-items:center}.rail-collapsed .header-right{height:44px;gap:12px}.rail-collapsed #fullscreen{height:44px;min-height:44px;padding:0 8px}
|
||||||
|
.immersive-stage:before{inset:5% -8% -5%;background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 32%,transparent) 0%,color-mix(in srgb,var(--accent) 13%,transparent) 38%,transparent 70%);opacity:.8}.immersive-player{isolation:isolate}@keyframes music-halo{50%{opacity:1;transform:scale(1.09)}}
|
||||||
|
.gauge text{font-size:15px;font-weight:600}.gauge .dial-value{font-size:49px;font-weight:600;letter-spacing:-1px}.gauge .dial-label{font-size:11px;font-weight:500;letter-spacing:.6px}.gauge .major{stroke-width:2.3}.dial-light{fill:none;stroke:transparent;stroke-width:2.5;pointer-events:none}.luminous-dials .dial-light{stroke:var(--accent);filter:drop-shadow(0 0 4px var(--accent))}.luminous-dials .gauge .major,.luminous-dials .gauge .tick{stroke:var(--accent)}.luminous-dials .gauge text{fill:var(--accent)}.luminous-dials .gauge .dial-value{fill:var(--text)}.luminous-dials .dial-rim{stroke:color-mix(in srgb,var(--accent) 30%,var(--border))}.luminous-dials .gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 6%,transparent),transparent 65%)}
|
||||||
|
.quick-sensor>div{flex:1;min-width:0}.sensor-level{height:9px;background:var(--border);border-radius:8px;overflow:hidden;margin-top:10px}.sensor-level>span{display:block;height:100%;width:0;background:var(--accent);border-radius:inherit;transition:width .4s ease}.quick-sensor .sensor-level+p{font-size:11px}@media(prefers-reduced-motion:reduce){.sensor-level>span{transition:none}}
|
||||||
|
/* Analog instruments and needle-free digital instruments. */
|
||||||
|
.gauges{grid-template-columns:minmax(0,1fr) 100px minmax(0,1fr)}.gear strong{font-size:82px;font-weight:500}.gear .eyebrow{font-size:12px}.gear>span{font-size:10px}.gauge .dial-number{font-size:16px}.digital-track,.digital-progress{display:none;fill:none;stroke-width:9;stroke-linecap:round}.digital-dials .digital-track{display:block;stroke:color-mix(in srgb,var(--accent) 13%,var(--surface))}.digital-dials .digital-progress{display:block;stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;filter:drop-shadow(0 0 5px var(--accent));transition:stroke-dashoffset .45s linear}.digital-dials .needle,.digital-dials .needle-hub,.digital-dials .tick,.digital-dials .dial-number,.digital-dials .dial-rim,.digital-dials .dial-light{display:none}.digital-dials .gauge .dial-value{font-size:59px;transform:translateY(-40px);fill:var(--text);font-weight:500;letter-spacing:-2px}.digital-dials .gauge .dial-label{font-size:12px;fill:var(--accent)}.digital-dials .gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 14%,transparent),transparent 64%)}
|
||||||
|
.aux-gauges{display:flex;justify-content:center;gap:clamp(24px,6vw,100px);flex-shrink:0;margin:0 auto 8px;width:min(100%,650px)}.aux-gauge{position:relative;text-align:center;flex:1;max-width:250px;min-width:0}.aux-analog{display:block;width:160px;height:96px;margin:auto;overflow:visible}.aux-analog path{fill:none;stroke:var(--muted);stroke-width:2}.aux-analog .aux-ticks{stroke:var(--text)}.aux-needle{stroke:var(--accent);stroke-width:3;stroke-linecap:round;transform-origin:80px 76px;transition:transform .45s}.aux-analog circle{fill:var(--accent)}.aux-title{font-size:12px;color:var(--muted);margin:4px 0}.aux-gauge strong{font-size:18px;font-weight:400}.aux-gauge strong b{font-weight:600}.aux-gauge .sensor-level{display:none}.aux-limits{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);margin:6px auto 0;max-width:160px}.digital-dials .aux-analog{display:none}.digital-dials .aux-gauge .sensor-level{display:block;height:12px;box-shadow:0 0 14px color-mix(in srgb,var(--accent) 15%,transparent)}.digital-dials .aux-gauge .sensor-level>span{box-shadow:0 0 10px var(--accent)}.digital-dials .aux-gauge strong{font-size:28px}.digital-dials .aux-limits{max-width:none}.digital-dials .aux-gauge{padding:12px 8px}.digital-dials .gear strong{color:var(--accent);text-shadow:0 0 22px color-mix(in srgb,var(--accent) 45%,transparent)}
|
||||||
|
@media(max-height:750px){.aux-analog{width:112px;height:67px}.aux-gauges{margin-bottom:4px;max-width:480px}.aux-title{font-size:10px}.aux-gauge strong{font-size:15px}.aux-limits{margin-top:2px;font-size:9px}.digital-dials .aux-gauge{padding:2px 8px}.digital-dials .aux-gauge strong{font-size:23px}.gear strong{font-size:62px}}
|
||||||
|
@media(max-width:759px){.gauges{grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr)}.gear{grid-column:2;grid-row:1;display:block}.gauge:last-child{grid-column:3;grid-row:1}.gear strong{font-size:40px}.gear .eyebrow{font-size:8px}.gear>span{font-size:7px}.aux-gauges{gap:20px}.aux-analog{width:110px;height:66px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.digital-progress,.aux-needle{transition:none!important}}
|
||||||
|
.digital-unit{display:none}.digital-dials .digital-unit{display:block}.digital-dials .analog-unit{display:none}
|
||||||
|
.digital-scale{display:none}.digital-dials .digital-scale{display:block}.digital-scale line{stroke:var(--accent);stroke-width:1.3}.gauge .digital-scale text{font-size:10px;font-weight:600;fill:var(--accent)}.digital-dials .gauge .dial-value{font-size:48px}.luminous-dials .tick:not(.major){display:none}
|
||||||
|
.sensor-segments{display:none}.digital-dials .sensor-segments{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px;margin-top:10px;height:20px}.digital-dials [data-level=fuelPercent] .sensor-segments{grid-template-columns:.5fr repeat(4,minmax(0,1fr))}.sensor-segments i{border:1px solid var(--border);border-radius:4px;background:var(--surface2);--segment-color:#7acb9b}.sensor-segments i.filled{background:var(--segment-color);border-color:var(--segment-color);box-shadow:0 0 10px color-mix(in srgb,var(--segment-color) 28%,transparent)}[data-level=fuelPercent] .sensor-segments i:first-child{--segment-color:#ed6b60}[data-level=fuelPercent] .sensor-segments i:nth-child(2){--segment-color:#e7b65a}[data-level=coolantCelsius] .sensor-segments i:first-child{--segment-color:#74b9ef}[data-level=coolantCelsius] .sensor-segments i:nth-child(2){--segment-color:#70c7cf}[data-level=coolantCelsius] .sensor-segments i:nth-child(4){--segment-color:#e7b65a}[data-level=coolantCelsius] .sensor-segments i:nth-child(5){--segment-color:#ed6b60}.aux-mark{fill:var(--text);text-anchor:middle;font:600 12px system-ui}.aux-analog .temperature-hot{stroke:url(#temperature-red);stroke-width:6;stroke-linecap:round}.aux-gauge:not(.digital-dials .aux-gauge) .aux-limits{visibility:hidden}.digital-dials .aux-limits{visibility:visible}.reserve strong{color:#ed6b60}
|
||||||
|
.page-top .header-temp,.rail-collapsed .page-top .header-temp{display:inline-block;white-space:nowrap}@media(max-width:759px){.page-top .header-temp{font-size:12px}.page-top .header-temp small{display:none}.page-top .header-right .badge{display:none}}
|
||||||
|
/* Shared luminous graduations: labelled twenties, intermediate tens. */
|
||||||
|
.digital-dials .gauge .dial-number{display:block;fill:var(--accent);font-size:16px}.digital-dials .gauge .tick.major,.digital-dials .gauge .tick.intermediate,.luminous-dials .gauge .tick.intermediate{display:block;stroke:var(--accent)}.gauge .tick.intermediate{stroke-width:1.6}.digital-dials .gauge .dial-value{font-size:46px}.rpm-red-zone{fill:none;stroke:url(#rpm-red-zone);stroke-width:7;stroke-linecap:round;pointer-events:none}.digital-dials .rpm-red-zone,.luminous-dials .rpm-red-zone{filter:drop-shadow(0 0 3px #ef535977)}
|
||||||
|
.aux-analog .fuel-reserve-zone{fill:none;stroke:url(#fuel-reserve-red);stroke-width:6;stroke-linecap:round}
|
||||||
|
.aux-title{display:flex;justify-content:center;align-items:center}.aux-title svg{width:24px;height:24px;color:var(--accent)}.gauge .tick.quarter{display:none}.aux-limits{font-weight:600}@media(max-height:750px){.aux-title svg{width:20px;height:20px}}
|
||||||
|
.gear strong{border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border));border-radius:14px;background:var(--surface2);line-height:1.1;padding:10px 4px;min-width:0}.gear .eyebrow{margin-bottom:10px}
|
||||||
|
body:not(.digital-dials) .aux-gauges{--aux-size:clamp(150px,23vh,240px);width:var(--aux-size);height:calc(var(--aux-size) + 22px);max-width:none;gap:0;position:relative;margin-bottom:4px}body:not(.digital-dials) .aux-gauge{width:50%;max-width:none;flex:none;height:100%}body:not(.digital-dials) .aux-analog{position:absolute;top:0;width:var(--aux-size);height:var(--aux-size);max-width:none;pointer-events:none}body:not(.digital-dials) [data-level=fuelPercent] .aux-analog{left:0}body:not(.digital-dials) [data-level=coolantCelsius] .aux-analog{right:0}body:not(.digital-dials) .aux-title{position:absolute;top:48%;left:0;right:0;margin:0;z-index:1}body:not(.digital-dials) .aux-gauge>strong{position:absolute;top:88%;left:0;right:0;font-size:16px}body:not(.digital-dials) .aux-limits{display:none}.aux-gauge.red-zone .aux-title svg{color:#fa4d5b;filter:drop-shadow(0 0 3px #fa4d5b55)}
|
||||||
|
@media(max-width:759px){.gear strong{border-radius:9px;padding:7px 2px}}
|
||||||
|
/* Keep a small physical-looking gap between the two half-dials. */
|
||||||
|
body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-5px)}
|
||||||
|
body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(5px)}
|
||||||
|
#settings .theme-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);gap:14px}.color-setting{display:flex;align-items:flex-end;gap:8px;min-width:0}.color-setting label{flex:1;min-width:0}.color-setting select{width:100%}#settings #custom-color{flex:0 0 44px;width:44px;height:44px;min-height:44px;padding:8px;border:1px solid var(--border);border-radius:50%;background:var(--surface2);cursor:pointer}#custom-color::-webkit-color-swatch-wrapper{padding:0}#custom-color::-webkit-color-swatch{border:0;border-radius:50%}#custom-color::-moz-color-swatch{border:0;border-radius:50%}
|
||||||
|
/* Clearly separated analog instruments, aligned digital level segments. */
|
||||||
|
body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-18px)}body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(18px)}body:not(.digital-dials) .aux-gauges:after{content:'';position:absolute;left:50%;top:24%;height:48%;width:1px;background:var(--border);pointer-events:none}
|
||||||
|
.digital-dials #speed-gauge svg,.luminous-dials #speed-gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 5%,transparent),transparent 64%)}
|
||||||
|
.digital-dials .aux-gauges{width:100%;max-width:none;display:grid;grid-template-columns:minmax(0,1fr) 100px minmax(0,1fr);gap:0;margin:0 0 14px}.digital-dials .aux-gauge{width:min(100%,340px);max-width:none;justify-self:center;padding:0 16px}.digital-dials [data-level=coolantCelsius].aux-gauge{grid-column:3}.digital-dials .sensor-segments,.digital-dials [data-level=fuelPercent] .sensor-segments{grid-template-columns:.5fr 1fr 1fr 1.4fr;height:22px;gap:7px;margin:14px 0 0;align-items:center}.digital-dials .sensor-segments i{height:18px;--segment-color:var(--accent);border-radius:5px}.digital-dials .sensor-segments i:first-child{--segment-color:#ed6b60;transform:translateY(-7px) rotate(12deg)}.digital-dials .sensor-segments i:nth-child(2){transform:translateY(1px) rotate(4deg)}.digital-dials .sensor-segments i:nth-child(3){transform:translateY(1px) rotate(-4deg)}.digital-dials .sensor-segments i:nth-child(4){height:22px;transform:translateY(-7px) rotate(-12deg)}.digital-dials .aux-limits{margin-top:10px}
|
||||||
|
@media(max-width:759px){.digital-dials .aux-gauges{grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr)}.digital-dials .aux-gauge{padding:0 4px}.digital-dials .sensor-segments{gap:4px}.digital-dials .aux-gauge strong{font-size:20px}body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-14px)}body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(14px)}}
|
||||||
|
.digital-dials .aux-gauge .sensor-segments i{--segment-color:var(--accent)}.digital-dials .aux-gauge .sensor-segments i:first-child{--segment-color:#ed6b60}.digital-dials #drive .cluster{justify-content:center}.digital-dials .gauges{flex:0 1 auto;height:min(55vh,620px)}.digital-dials .aux-gauges{margin-top:0}.digital-dials .cluster-bottom{margin-top:16px}
|
||||||
|
.digital-levels{display:none}.digital-dials .digital-levels{display:block}.digital-dials .aux-gauges{display:none}.digital-dials .gauges{flex:1;height:auto;min-height:0}.digital-dials .level-segment{fill:var(--surface2);stroke:var(--border);stroke-width:1.5;stroke-linejoin:round}.digital-dials .level-segment.filled{fill:var(--accent);stroke:var(--accent);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--accent) 25%,transparent))}.digital-dials .level-segment:first-child.filled{fill:#ed6b60;stroke:#ed6b60}.digital-levels .level-icon{color:var(--accent)}.digital-levels.red-zone .level-icon{color:#ed6b60}.gauge .digital-levels .level-end{font-size:10px;fill:var(--muted);font-weight:500}.gauge .digital-levels .level-value{font-size:23px;fill:var(--text);font-weight:600}.digital-dials .gear{align-self:center}
|
||||||
|
/* Analog auxiliary instruments integrated into each main dial. */
|
||||||
|
body:not(.digital-dials) .aux-gauges{display:none}.analog-levels{display:block}.digital-dials .analog-levels{display:none}.analog-levels .mini-track{fill:none;stroke:var(--muted);stroke-width:2.5}.analog-levels .mini-red{fill:none;stroke:#ed6b60;stroke-width:4;stroke-linecap:round}.analog-levels .mini-ticks{fill:none;stroke:var(--text);stroke-width:1.5}.analog-levels .mini-needle{stroke:var(--accent);stroke-width:3;stroke-linecap:round;transform-origin:150px 226px;transition:transform .45s linear}.gauge .analog-levels .mini-end{font-size:11px;fill:var(--text)}.analog-levels .level-icon{color:var(--accent)}.analog-levels.red-zone .level-icon{color:#ed6b60}.gauge .analog-levels .level-value{font-size:23px;fill:var(--text)}body:not(.digital-dials) .gauge .dial-value{transform:translateY(-23px);font-size:43px}body:not(.digital-dials) .gauge .dial-label:last-of-type{transform:translateY(-22px)}body:not(.digital-dials) .dial-rim{display:none}.luminous-dials .analog-levels .mini-track,.luminous-dials .analog-levels .mini-ticks{stroke:var(--accent)}.luminous-dials .mini-track,.luminous-dials .mini-needle{filter:drop-shadow(0 0 3px var(--accent))}@media(prefers-reduced-motion:reduce){.mini-needle{transition:none!important}}
|
||||||
|
.analog-levels .mini-needle{transform-origin:150px 150px}body:not(.digital-dials):not(.luminous-dials) .dial-light{stroke:var(--border);stroke-width:1.5}
|
||||||
|
/* Separate the analog auxiliary dials from the main instruments. */
|
||||||
|
body:not(.digital-dials) .analog-levels{transform:translateY(22px)}
|
||||||
|
body:not(.digital-dials) .analog-levels{transform:none}.analog-levels .mini-needle{transform-origin:150px 308px}.mini-hub{fill:var(--accent)}
|
||||||
|
/* Float the gear above the center gap so both instruments gain width. */
|
||||||
|
#drive .gauges{position:relative;grid-template-columns:minmax(0,1fr) minmax(0,1fr);column-gap:12px}
|
||||||
|
#drive #speed-gauge{grid-column:1;grid-row:1}#drive #rpm-gauge{grid-column:2;grid-row:1}
|
||||||
|
#drive .gear{position:absolute;z-index:1;top:10px;left:50%;transform:translateX(-50%);width:86px;display:block;padding:0;grid-column:auto;grid-row:auto}
|
||||||
|
#drive .gear strong{font-size:58px;padding:5px 4px}#drive .gear .eyebrow{font-size:10px;margin-bottom:6px}
|
||||||
|
@media(max-height:750px){#drive .gear{top:0;width:66px}#drive .gear strong{font-size:42px}#drive .gear .eyebrow{font-size:9px}}
|
||||||
|
@media(max-width:759px){#drive .gauges{column-gap:6px}#drive .gear{top:0;width:52px}#drive .gear strong{font-size:34px}#drive .gear .eyebrow{font-size:8px}}
|
||||||
|
body:not(.digital-dials) .analog-levels{transform:translateY(-24px)}.analog-separator{fill:none;stroke:color-mix(in srgb,var(--accent) 35%,var(--border));stroke-width:1.2;stroke-linejoin:round;stroke-linecap:round}.digital-dials .analog-separator{display:none}.analog-levels .level-icon{paint-order:stroke;stroke:currentColor}.luminous-dials .analog-separator{stroke:color-mix(in srgb,var(--accent) 55%,var(--border))}
|
||||||
|
/* Use the available width and height for all four instruments together. */
|
||||||
|
#drive .cluster{padding-left:4px;padding-right:4px;padding-top:8px}#drive .gauges{column-gap:0;overflow:visible}#drive .gauge{overflow:visible}#drive .gauge>svg{overflow:visible}#drive .gear{top:0}
|
||||||
|
.analog-separator{stroke:color-mix(in srgb,var(--accent) 65%,var(--muted));stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
/* Three straight segments, ending on the main dial circumference. */
|
||||||
|
body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke:var(--border);stroke-width:1.5;filter:none}
|
||||||
|
.luminous-dials .analog-separator{stroke:var(--accent);stroke-width:2.5;filter:drop-shadow(0 0 4px var(--accent))}
|
||||||
|
.analog-separator{fill:none;stroke-linecap:butt;stroke-linejoin:miter}
|
||||||
|
/* Slightly finer separator while retaining the dial color and glow. */
|
||||||
|
body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke-width:1.3}
|
||||||
|
.luminous-dials .analog-separator{stroke-width:2.2}
|
||||||
|
/* Compact illuminated auxiliary arcs, digital style only. */
|
||||||
|
.digital-dials .digital-levels .level-segment{fill:none;stroke:var(--border);stroke-width:8;stroke-linecap:round;filter:none}
|
||||||
|
.digital-dials .digital-levels .level-segment.filled{fill:none;stroke:var(--accent);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--accent) 45%,transparent))}
|
||||||
|
.digital-dials .digital-levels .danger-segment{stroke:color-mix(in srgb,#ed6b60 40%,var(--surface))}
|
||||||
|
.digital-dials .digital-levels .danger-segment.filled{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b6060)}
|
||||||
|
.level-outline{fill:none;stroke:var(--border);stroke-width:1}.gauge .digital-levels .level-end{font-size:11px}
|
||||||
|
.digital-dials .digital-levels .level-segment{fill:var(--border);stroke:none;stroke-width:0;stroke-linejoin:miter}
|
||||||
|
.digital-dials .digital-levels .level-segment.filled{fill:var(--accent);stroke:none}
|
||||||
|
.digital-dials .digital-levels .danger-segment{fill:color-mix(in srgb,#ed6b60 40%,var(--surface));stroke:none}
|
||||||
|
.digital-dials .digital-levels .danger-segment.filled{fill:#ed6b60;stroke:none}
|
||||||
|
.level-arc-track,.level-arc-progress,.level-arc-red{fill:none;stroke-width:6;stroke-linecap:round}.level-arc-track{stroke:var(--border)}.level-arc-progress{stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;filter:drop-shadow(0 0 3px var(--accent));transition:stroke-dashoffset .4s linear}.level-arc-red{stroke:#ed6b60;stroke-width:3}.digital-levels.red-zone .level-arc-red{filter:drop-shadow(0 0 3px #ed6b60)}@media(prefers-reduced-motion:reduce){.level-arc-progress{transition:none}}
|
||||||
|
.digital-levels .level-tick{fill:none;stroke:var(--text);stroke-width:1.2;stroke-linecap:butt}
|
||||||
|
.digital-dials .digital-levels{transform:translateY(-14px)}.digital-levels .level-arc-track{display:none}.digital-levels .level-arc-progress{transition:none}.aux-gauge[data-level=fuelPercent] .aux-title svg{width:17px;height:17px}.aux-gauge[data-level=coolantCelsius] .aux-title svg{width:20px;height:20px}
|
||||||
|
body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke-width:1}
|
||||||
|
.luminous-dials .analog-separator{stroke-width:1.6}
|
||||||
|
.digital-levels[data-digital-level=coolantCelsius] .level-arc-red{display:none}
|
||||||
|
.digital-levels[data-digital-level=coolantCelsius].last-level .level-arc-progress{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b60)}
|
||||||
|
.digital-levels[data-digital-level=fuelPercent].reserve-level .level-arc-progress{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b60)}
|
||||||
|
.digital-levels[data-digital-level=fuelPercent] .level-arc-red{display:none}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export function initCockpit({ storage, toast }) {
|
||||||
|
const $ = s => document.querySelector(s), audio = $('#audio');
|
||||||
|
const dedicated = new URLSearchParams(location.search).get('display') === 'drive';
|
||||||
|
let collapsed = dedicated || storage.get('railCollapsed') === 'true';
|
||||||
|
function rail() {
|
||||||
|
document.body.classList.toggle('rail-collapsed', collapsed);
|
||||||
|
$('#rail-toggle').setAttribute('aria-expanded', String(!collapsed));
|
||||||
|
$('#rail-toggle').setAttribute('aria-label', collapsed ? 'Afficher les favoris' : 'Masquer les favoris');
|
||||||
|
document.dispatchEvent(new Event('caria-layout'));
|
||||||
|
}
|
||||||
|
$('#rail-toggle').onclick = () => { collapsed = !collapsed; storage.set('railCollapsed', String(collapsed)); rail(); };
|
||||||
|
document.querySelectorAll('.screen-links a').forEach(link => link.onclick = () => $('#settings').close());
|
||||||
|
rail();
|
||||||
|
if (dedicated && !location.hash) location.hash = 'drive';
|
||||||
|
const time = seconds => Number.isFinite(seconds) ? `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2,'0')}` : '0:00';
|
||||||
|
function updatePlayer() {
|
||||||
|
const duration = Number.isFinite(audio.duration) ? audio.duration : 0, progress = duration ? audio.currentTime / duration : 0;
|
||||||
|
$('#track-arc').style.strokeDashoffset = String(100 * (1 - progress));
|
||||||
|
$('#immersive-current').textContent = time(audio.currentTime); $('#immersive-duration').textContent = time(duration);
|
||||||
|
$('#immersive-title').textContent = $('#track-title').textContent;
|
||||||
|
$('#immersive-subtitle').textContent = $('#track-subtitle').textContent;
|
||||||
|
$('#immersive-play').innerHTML = audio.paused ? '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 11 7-11 7Z"/></svg>' : '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5v14M16 5v14"/></svg>';
|
||||||
|
$('#immersive-play').setAttribute('aria-label', audio.paused ? 'Lire la musique' : 'Mettre en pause');
|
||||||
|
$('#now-playing').classList.toggle('playing', !audio.paused);
|
||||||
|
$('#immersive-volume').value = audio.muted ? '0' : String(audio.volume);
|
||||||
|
const silent = audio.muted || audio.volume === 0;
|
||||||
|
$('#immersive-mute').innerHTML = silent ? '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4 6 8H3v8h3l5 4Zm5 5 6 6m0-6-6 6"/></svg>' : '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4 6 8H3v8h3l5 4Zm4 4a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14"/></svg>';
|
||||||
|
$('#immersive-mute').setAttribute('aria-label', silent ? 'Activer le son' : 'Couper le son');
|
||||||
|
$('#immersive-mute').title = silent ? 'Activer le son' : 'Couper le son';
|
||||||
|
$('#immersive-mute').setAttribute('aria-pressed', String(silent));
|
||||||
|
$('#immersive-volume-value').textContent = Math.round((silent ? 0 : audio.volume) * 100) + ' %';
|
||||||
|
document.querySelectorAll('[data-audio-control]').forEach(button => button.disabled = !audio.getAttribute('src'));
|
||||||
|
}
|
||||||
|
$('#immersive-play').onclick = () => $('#play').click();
|
||||||
|
$('#immersive-previous').onclick = () => $('#previous').click();
|
||||||
|
$('#immersive-next').onclick = () => $('#next').click();
|
||||||
|
$('#immersive-volume').oninput = e => { audio.volume = Number(e.target.value); audio.muted = false; };
|
||||||
|
$('#immersive-mute').onclick = () => { if (audio.volume === 0) { audio.volume = .5; audio.muted = false; } else audio.muted = !audio.muted; };
|
||||||
|
$('#immersive-repeat').onclick = () => { audio.loop = !audio.loop; $('#immersive-repeat').setAttribute('aria-pressed', String(audio.loop)); toast(audio.loop ? 'Répétition du morceau activée' : 'Répétition désactivée'); };
|
||||||
|
for (const event of ['timeupdate','loadedmetadata','durationchange','play','pause','volumechange','emptied','ended']) audio.addEventListener(event,updatePlayer);
|
||||||
|
new MutationObserver(updatePlayer).observe($('#track-title'),{childList:true,subtree:true});
|
||||||
|
audio.addEventListener('error', () => $('#immersive-subtitle').textContent = 'Lecture impossible. Choisissez un autre morceau dans la bibliothèque.');
|
||||||
|
updatePlayer();
|
||||||
|
// Keep lengthy setup panels reachable with large page buttons.
|
||||||
|
for (const panel of document.querySelectorAll('.journey-panel')) {
|
||||||
|
const content = document.createElement('div'), nav = document.createElement('nav');
|
||||||
|
content.className = 'panel-content'; nav.className = 'collection-pages panel-pages';
|
||||||
|
content.append(...panel.childNodes); panel.append(content,nav);
|
||||||
|
const back = document.createElement('button'), next = document.createElement('button'), label = document.createElement('span');
|
||||||
|
back.type = next.type = 'button'; back.textContent = '‹'; next.textContent = '›';
|
||||||
|
back.setAttribute('aria-label','Page précédente du panneau'); next.setAttribute('aria-label','Page suivante du panneau');
|
||||||
|
nav.append(back,label,next);
|
||||||
|
function refresh() {
|
||||||
|
if (!panel.getClientRects().length) return;
|
||||||
|
const max = content.scrollHeight - content.clientHeight;
|
||||||
|
nav.hidden = max <= 2;
|
||||||
|
back.disabled = content.scrollTop < 2; next.disabled = content.scrollTop >= max - 2;
|
||||||
|
label.textContent = max <= 2 ? '' : `${Math.floor(content.scrollTop / Math.max(1,content.clientHeight - 60)) + 1} / ${Math.ceil(max / Math.max(1,content.clientHeight - 60)) + 1}`;
|
||||||
|
}
|
||||||
|
back.onclick = () => { content.scrollTop -= Math.max(44,content.clientHeight - 60); refresh(); };
|
||||||
|
next.onclick = () => { content.scrollTop += Math.max(44,content.clientHeight - 60); refresh(); };
|
||||||
|
new ResizeObserver(refresh).observe(content);
|
||||||
|
new MutationObserver(refresh).observe(content,{childList:true,subtree:true,attributes:true,attributeFilter:['open','hidden']});
|
||||||
|
new MutationObserver(refresh).observe(panel,{attributes:true,attributeFilter:['open']});
|
||||||
|
content.addEventListener('scroll',refresh); document.addEventListener('caria-view',refresh);
|
||||||
|
}
|
||||||
|
// Paginate collections rather than shrinking touch targets or scrolling the screen.
|
||||||
|
for (const selector of ['#playlist', '.detail-grid', '.home-grid']) {
|
||||||
|
const container = $(selector), pager = document.createElement('nav');
|
||||||
|
pager.className = 'collection-pages'; pager.setAttribute('aria-label', 'Pages de contenu');
|
||||||
|
const back = document.createElement('button'), next = document.createElement('button'), label = document.createElement('span');
|
||||||
|
back.textContent = '‹'; next.textContent = '›';
|
||||||
|
back.setAttribute('aria-label', 'Page précédente'); next.setAttribute('aria-label', 'Page suivante');
|
||||||
|
pager.append(back,label,next); container.after(pager);
|
||||||
|
let page = 0;
|
||||||
|
function layout() {
|
||||||
|
if (!container.getClientRects().length) return;
|
||||||
|
const items = [...container.children], small = innerWidth < 760;
|
||||||
|
let count = items.length || 1;
|
||||||
|
if (selector === '#playlist') count = Math.max(1, Math.floor(container.clientHeight / 58));
|
||||||
|
if (selector === '.detail-grid') count = small || innerHeight < 750 ? 4 : 8;
|
||||||
|
if (selector === '.home-grid') count = small ? 2 : 4;
|
||||||
|
const pages = Math.max(1,Math.ceil(items.length / count)); page = Math.min(page,pages - 1);
|
||||||
|
items.forEach((item,i) => item.hidden = i < page * count || i >= (page + 1) * count);
|
||||||
|
pager.hidden = pages === 1; label.textContent = `${page + 1} / ${pages}`;
|
||||||
|
back.disabled = page === 0; next.disabled = page === pages - 1;
|
||||||
|
}
|
||||||
|
back.onclick = () => { page--; layout(); }; next.onclick = () => { page++; layout(); };
|
||||||
|
new ResizeObserver(layout).observe(container);
|
||||||
|
new MutationObserver(() => { page = 0; layout(); }).observe(container,{childList:true});
|
||||||
|
document.addEventListener('caria-view',layout); layout();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { applications, safeWebUrl } from './applications.js';
|
||||||
|
export function embedUrl(provider, value) {
|
||||||
|
const safe = safeWebUrl(value); if (!safe) return '';
|
||||||
|
const url = new URL(safe);
|
||||||
|
if (provider === 'stream') return safe;
|
||||||
|
if (provider === 'spotify' && url.hostname === 'open.spotify.com') {
|
||||||
|
const match = url.pathname.match(/^\/(?:intl-[a-z]+\/)?(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)\/?$/);
|
||||||
|
return match ? `https://open.spotify.com/embed/${match[1]}/${match[2]}` : '';
|
||||||
|
}
|
||||||
|
if (['youtube','youtube-music'].includes(provider) && ['music.youtube.com','www.youtube.com','youtube.com','youtu.be'].includes(url.hostname)) {
|
||||||
|
const video = url.hostname === 'youtu.be' ? url.pathname.slice(1) : url.searchParams.get('v');
|
||||||
|
const list = url.searchParams.get('list');
|
||||||
|
if (video && /^[\w-]{11}$/.test(video)) return `https://www.youtube.com/embed/${video}`;
|
||||||
|
if (list && /^[\w-]{10,200}$/.test(list)) return `https://www.youtube.com/embed/videoseries?list=${encodeURIComponent(list)}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
export function initEmbeds({storage,toast}) {
|
||||||
|
const $=s=>document.querySelector(s); let provider=null;
|
||||||
|
function stop() { $('#embed-container').replaceChildren(); $('#embed-status').textContent='Lecteur arrêté. Vous pouvez le charger à nouveau.'; }
|
||||||
|
function load() {
|
||||||
|
if (!provider) return;
|
||||||
|
const url=embedUrl(provider,provider==='stream'?applications.find(app=>app.id==='stream').url:$('#embed-url').value.trim());
|
||||||
|
if (!url) return toast('Lien non reconnu. Choisissez un contenu compatible avec ce lecteur.');
|
||||||
|
if (location.protocol==='https:' && url.startsWith('http:')) return toast('Ce lecteur doit utiliser HTTPS sur votre accès sécurisé.');
|
||||||
|
stop();
|
||||||
|
const frame=document.createElement('iframe');frame.className='embed-frame';frame.dataset.provider=provider;
|
||||||
|
frame.title=`Lecteur ${applications.find(a=>a.id===provider).name}`;frame.allow='autoplay; encrypted-media; fullscreen; picture-in-picture';frame.referrerPolicy='strict-origin-when-cross-origin';
|
||||||
|
frame.setAttribute('sandbox','allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation');
|
||||||
|
frame.src=url;$('#embed-container').append(frame);
|
||||||
|
storage.set(`embed.${provider}`,$('#embed-url').value.trim());
|
||||||
|
$('#embed-status').textContent=provider==='stream' ? 'Si Jellyfin refuse l’affichage, son serveur doit autoriser l’origine CARIA dans sa politique d’intégration. Le lien externe reste disponible.' : 'Le lecteur officiel gère la connexion et les restrictions de lecture. Si le contenu est indisponible, utilisez le lien externe.';
|
||||||
|
}
|
||||||
|
document.addEventListener('caria-embed',e=>{
|
||||||
|
provider=e.detail;
|
||||||
|
const app=applications.find(a=>a.id===provider);if(!app?.external)return;
|
||||||
|
stop();$('#embedded-name').textContent=app.name;
|
||||||
|
$('#embed-form').hidden=provider==='stream';
|
||||||
|
$('#embedded-explanation').textContent=provider==='stream' ? 'Jellyfin dans CARIA. La connexion reste gérée par votre serveur ; son autorisation d’affichage intégré est nécessaire.' : provider==='spotify' ? 'Lecteur officiel Spotify pour un morceau, album, podcast ou playlist. Ce n’est pas l’application Spotify complète ; la lecture dépend de votre compte et des règles du lecteur.' : 'Lecteur vidéo officiel YouTube à partir d’un lien YouTube ou YouTube Music. La vidéo reste visible. L’application YouTube Music complète ne peut pas être intégrée ici.';
|
||||||
|
$('#embed-url').value=provider==='stream'?app.url:storage.get(`embed.${provider}`);
|
||||||
|
$('#embed-external').href=provider==='stream'?app.url:app.url;
|
||||||
|
if (embedUrl(provider,$('#embed-url').value)) load();
|
||||||
|
else $('#embed-status').textContent='Collez le lien d’un contenu pour charger son lecteur dans CARIA.';
|
||||||
|
});
|
||||||
|
$('#embed-form').onsubmit=e=>{e.preventDefault();load();};$('#embed-stop').onclick=stop;
|
||||||
|
document.addEventListener('caria-view',e=>{if(e.detail!=='embedded' && $('#embed-container').childElementCount)stop();});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export function distance(a, b) {
|
||||||
|
const rad = Math.PI / 180, lat = (b.lat - a.lat) * rad, lon = (b.lon - a.lon) * rad;
|
||||||
|
const h = Math.sin(lat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(lon / 2) ** 2;
|
||||||
|
return 6371000 * 2 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||||
|
}
|
||||||
|
export function validateZones(data) {
|
||||||
|
if (data?.type !== 'FeatureCollection' || !Array.isArray(data.features) || data.features.length > 1000) throw new Error('Fichier GeoJSON attendu, 1 000 zones maximum.');
|
||||||
|
const ids = new Set();
|
||||||
|
return data.features.map((f, i) => {
|
||||||
|
const p = f.properties || {}, c = f.geometry?.coordinates;
|
||||||
|
if (f.geometry?.type !== 'Point' || !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite) || Math.abs(c[0]) > 180 || Math.abs(c[1]) > 85 || !Number.isFinite(p.radiusM) || p.radiusM < 300 || p.radiusM > 10000 || !/^[A-Z]{2}$/.test(p.country || '') || typeof p.source !== 'string' || !p.source.trim() || !Number.isFinite(Date.parse(p.updatedAt)) || !Number.isFinite(Date.parse(p.expiresAt)) || Date.parse(p.expiresAt) <= Date.parse(p.updatedAt)) throw new Error(`Zone ${i + 1} invalide : coordonnées, pays, rayon (300–10 000 m), source et dates requis.`);
|
||||||
|
const id = String(p.id ?? i).slice(0, 100);
|
||||||
|
if (ids.has(id)) throw new Error('Identifiants de zones en double.'); ids.add(id);
|
||||||
|
return { id, lat: c[1], lon: c[0], radiusM: p.radiusM, country: p.country, name: String(p.name || 'Zone de vigilance').slice(0, 120), source: p.source.slice(0, 150), updatedAt: p.updatedAt, expiresAt: p.expiresAt };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function nearbyZones(zones, position, country, now = Date.now()) {
|
||||||
|
if (!position || position.source !== 'gps' || !Number.isFinite(position.accuracy) || position.accuracy > 100 || !Number.isFinite(position.timestamp) || Math.abs(now - position.timestamp) > 15000) return [];
|
||||||
|
return zones.filter(z => z.country === country && Date.parse(z.updatedAt) <= now && Date.parse(z.expiresAt) > now)
|
||||||
|
.map(z => ({ ...z, remaining: Math.max(0, distance(position, z) - z.radiusM) }))
|
||||||
|
.filter(z => z.remaining <= 400).sort((a, b) => a.remaining - b.remaining);
|
||||||
|
}
|
||||||
|
export function instruction(step) {
|
||||||
|
const m = step.maneuver || {}, ways = { left: 'à gauche', right: 'à droite', 'slight left': 'légèrement à gauche', 'slight right': 'légèrement à droite', 'sharp left': 'franchement à gauche', 'sharp right': 'franchement à droite', straight: 'tout droit', uturn: 'en faisant demi-tour' };
|
||||||
|
if (m.type === 'arrive') return 'Vous arrivez à destination';
|
||||||
|
if (m.type === 'depart') return `Départ${step.name ? ` sur ${step.name}` : ''}`;
|
||||||
|
if (['roundabout', 'rotary'].includes(m.type)) return `Au rond-point, ${m.exit ? `prenez la sortie ${m.exit}` : 'suivez l’itinéraire'}`;
|
||||||
|
return `${m.type === 'merge' ? 'Insérez-vous' : m.type === 'fork' ? 'Restez' : 'Continuez'} ${ways[m.modifier] || 'sur l’itinéraire'}${step.name ? ` sur ${step.name}` : ''}`;
|
||||||
|
}
|
||||||
|
// Nearest segment projection gives progress even on long rural road segments.
|
||||||
|
export function routeProgress(coords, position) {
|
||||||
|
let best = { away: Infinity, travelled: 0 }, total = 0;
|
||||||
|
const scale = Math.cos(position.lat * Math.PI / 180);
|
||||||
|
for (let i = 1; i < coords.length; i++) {
|
||||||
|
const a = { lon: coords[i - 1][0], lat: coords[i - 1][1] }, b = { lon: coords[i][0], lat: coords[i][1] };
|
||||||
|
const dx = (b.lon - a.lon) * scale, dy = b.lat - a.lat;
|
||||||
|
const fraction = Math.max(0, Math.min(1, (((position.lon - a.lon) * scale) * dx + (position.lat - a.lat) * dy) / (dx * dx + dy * dy || 1)));
|
||||||
|
const point = { lon: a.lon + fraction * (b.lon - a.lon), lat: a.lat + fraction * (b.lat - a.lat) };
|
||||||
|
const length = distance(a, b), away = distance(position, point);
|
||||||
|
if (away < best.away) best = { away, travelled: total + fraction * length };
|
||||||
|
total += length;
|
||||||
|
}
|
||||||
|
return { ...best, total, fraction: total ? best.travelled / total : 0 };
|
||||||
|
}
|
||||||
+73
-15
@@ -3,49 +3,107 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta name="theme-color" content="#101313"><title>CARIA · Cockpit</title>
|
<meta name="theme-color" content="#101313"><title>CARIA · Cockpit</title>
|
||||||
<link rel="stylesheet" href="/styles.css"><script type="module" src="/app.js"></script>
|
<link rel="stylesheet" href="/styles.css"><link rel="stylesheet" href="/vendor/leaflet/leaflet.css"><link rel="stylesheet" href="/journey.css"><link rel="stylesheet" href="/applications.css"><link rel="stylesheet" href="/cockpit.css"><script type="module" src="/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="rail">
|
<aside class="rail" id="side-rail">
|
||||||
<a href="#home" class="brand" aria-label="CARIA accueil">C<span> /</span></a>
|
<nav id="favorite-apps" aria-label="Applications">
|
||||||
<nav aria-label="Applications">
|
|
||||||
<a href="#home" data-view="home"><span class="icon">⌂</span><span>Accueil</span></a>
|
<a href="#home" data-view="home"><span class="icon">⌂</span><span>Accueil</span></a>
|
||||||
<a href="#drive" data-view="drive"><span class="icon">◉</span><span>Conduite</span></a>
|
<a href="#drive" data-view="drive"><span class="icon">◉</span><span>Conduite</span></a>
|
||||||
|
<a href="#map" data-view="map"><span class="icon">⌖</span><span>Navigation</span></a>
|
||||||
<a href="#music" data-view="music"><span class="icon">♫</span><span>Musique</span></a>
|
<a href="#music" data-view="music"><span class="icon">♫</span><span>Musique</span></a>
|
||||||
<a href="#stream" data-view="stream"><span class="icon">▷</span><span>Streaming</span></a>
|
<a href="#stream" data-view="stream"><span class="icon">▷</span><span>Streaming</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<button id="settings-open" class="settings-button"><span class="icon">⚙</span><span>Réglages</span></button>
|
<button id="settings-open" class="settings-button"><span class="icon">⚙</span><span>Réglages</span></button>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<header><div class="wordmark">CARIA<span>AUTOMOTIVE</span></div><div class="header-right"><span id="connection" class="badge">Connexion…</span><span class="header-temp">21° <small>EXT. · DÉMO</small></span><time id="header-clock">--:--</time><button id="fullscreen" aria-label="Plein écran" title="Plein écran">⛶</button></div></header>
|
|
||||||
<main>
|
<main>
|
||||||
|
<header class="page-top"><div class="header-left"><button id="rail-toggle" aria-label="Masquer les favoris" aria-controls="side-rail" aria-expanded="true">☰</button></div><div class="header-right"><span id="connection" class="badge">Connexion…</span><span class="header-temp" title="Température extérieure du véhicule · simulée en mode démonstration"><span id="vehicle-temperature-header" data-value="outsideCelsius">—</span> °C <small>EXT.</small></span><time id="header-clock">--:--</time><button id="fullscreen" aria-label="Plein écran" title="Plein écran">⛶</button></div></header>
|
||||||
<section id="home" class="view">
|
<section id="home" class="view">
|
||||||
<div class="page-heading"><div><p class="eyebrow">VOTRE ESPACE À BORD</p><h1>Le plaisir du trajet.</h1></div><span class="subtle">Cockpit personnel <span class="accent">/ 01</span></span></div>
|
<div class="page-heading"><div><p class="eyebrow">VOTRE ESPACE À BORD</p><h1>Le plaisir du trajet.</h1></div><span class="subtle">Cockpit personnel <span class="accent">/ 01</span></span></div>
|
||||||
<div class="home-grid">
|
<div class="home-grid">
|
||||||
<article class="clock-card card"><div class="card-top"><span class="eyebrow">BONJOUR, CONDUCTEUR</span><span class="tiny-dot"></span></div><time id="hero-clock">--:--</time><p id="date">—</p><div class="horizon" aria-hidden="true"><div class="sun"></div><div class="mountain one"></div><div class="mountain two"></div><div class="road"></div></div><div class="clock-bottom"><span>Un nouvel horizon vous attend.</span><span>CARIA / ON BOARD</span></div></article>
|
<article class="clock-card card"><div class="card-top"><span class="eyebrow">BONJOUR, CONDUCTEUR</span><span class="tiny-dot"></span></div><time id="hero-clock">--:--</time><p id="date">—</p><div class="horizon" aria-hidden="true"><div class="sun"></div><div class="mountain one"></div><div class="mountain two"></div><div class="road"></div></div><div class="clock-bottom"><span>Un nouvel horizon vous attend.</span></div></article>
|
||||||
<article class="weather-card card"><div class="card-top"><span class="eyebrow">À L’EXTÉRIEUR</span><span class="label">DÉMO</span></div><div class="weather-main"><span class="weather-sun">☀</span><strong>21<span>°</span></strong></div><h2>Quelques éclaircies</h2><p>Une belle journée pour prendre la route.</p><div class="weather-bottom"><span>Min. <b>16°</b></span><span>Max. <b>24°</b></span><span>Vent <b>8 km/h</b></span></div></article>
|
<article class="weather-card card"><div class="card-top"><span class="eyebrow">À L’EXTÉRIEUR</span><span class="label" id="weather-source">NON LOCALISÉ</span></div><div class="weather-main"><span class="weather-sun" id="weather-icon">◌</span><strong><span id="weather-temperature">—</span>°</strong></div><h2 id="weather-title">La météo de votre trajet</h2><p id="weather-status" role="status">Choisissez votre position pour afficher la météo réelle.</p><div class="weather-actions"><button data-locate>⌖ Me localiser</button><a href="#map">Choisir un lieu ↗</a><button id="weather-refresh" aria-label="Actualiser la météo">↻</button></div><div class="weather-bottom"><span>Min. <b id="weather-min">—</b></span><span>Max. <b id="weather-max">—</b></span><span>Vent <b id="weather-wind">—</b></span></div><a class="weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Météo : Open-Meteo · CC BY 4.0</a></article>
|
||||||
<article class="vehicle-card card"><div class="card-top"><span class="eyebrow">VOTRE VÉHICULE</span><span class="label">DÉMO</span></div><h2>Mégane III</h2><div class="vehicle-stats"><div><strong data-value="rangeKm">—</strong><span>km d’autonomie</span></div><div><strong><span data-value="fuelPercent">—</span><small> %</small></strong><span>carburant</span></div></div><div class="fuel-track"><span data-fuel></span></div><a class="text-link" href="#drive">Ouvrir le tableau de bord <span>↗</span></a></article>
|
<article class="vehicle-card card"><div class="card-top"><span class="eyebrow">VOTRE VÉHICULE</span><span class="label">DÉMO</span></div><h2>Mégane III</h2><div class="vehicle-stats"><div><strong data-value="rangeKm">—</strong><span>km d’autonomie</span></div><div><strong><span data-value="fuelPercent">—</span><small> %</small></strong><span>carburant</span></div></div><div class="fuel-track"><span data-fuel></span></div><a class="text-link" href="#drive">Ouvrir le tableau de bord <span>↗</span></a></article>
|
||||||
<article class="music-card card"><div class="album-art" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><div><p class="eyebrow">LA BANDE-SON DU VOYAGE</p><h2 id="home-track">Votre musique, partout.</h2><p id="home-track-sub">Emportez votre bibliothèque locale.</p><a class="pill" href="#music">♫ Ouvrir ma musique <span>↗</span></a></div></article>
|
<article class="music-card card"><div class="album-art" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><div><p class="eyebrow">LA BANDE-SON DU VOYAGE</p><h2 id="home-track">Votre musique, partout.</h2><p id="home-track-sub">Emportez votre bibliothèque locale.</p><a class="pill" href="#music">♫ Ouvrir ma musique <span>↗</span></a></div></article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="drive" class="view" hidden>
|
<section id="drive" class="view" hidden>
|
||||||
<div class="page-heading"><div><p class="eyebrow">LES ESSENTIELS EN UN REGARD</p><h1>Tableau de bord</h1></div><label class="profile-label">Simulation <select id="profile"><option value="route">Route</option><option value="ville">Ville</option><option value="arret">À l’arrêt</option></select></label></div>
|
<div class="page-heading"><div><p class="eyebrow">LES ESSENTIELS EN UN REGARD</p><h1>Tableau de bord</h1></div></div>
|
||||||
<div class="cluster card"><div class="cluster-top"><span>MÉGANE III <span class="subtle">/ INSTRUMENTATION</span></span><span class="label">DONNÉES DE DÉMONSTRATION</span></div><div class="gauges"><div class="gauge" id="speed-gauge"></div><div class="gear"><p class="eyebrow">RAPPORT</p><strong data-value="gear">—</strong><span>DEMO</span></div><div class="gauge" id="rpm-gauge"></div></div><div class="cluster-bottom"><span>Trajet <b><span data-value="tripKm">—</span> km</b></span><span>Consommation <b><span data-value="consumptionL100">—</span> L/100</b></span></div></div>
|
<div class="cluster card"><div class="gauges"><div class="gauge" id="speed-gauge"></div><div class="gear"><p class="eyebrow">RAPPORT</p><strong data-value="gear">—</strong></div><div class="gauge" id="rpm-gauge"></div></div><div class="aux-gauges"><div class="aux-gauge" role="meter" aria-label="Carburant" aria-valuemin="0" aria-valuemax="100" data-level="fuelPercent"><svg class="aux-analog" viewBox="0 0 160 160" aria-hidden="true"><g transform="translate(0 4) rotate(-90 80 76)"><defs><linearGradient id="fuel-reserve-red" gradientUnits="userSpaceOnUse" x1="20" y1="76" x2="37.6" y2="33.6"><stop stop-color="#fa4d5b"/><stop offset="0.5" stop-color="#f67855"/><stop offset="1" stop-color="#f7a65d" stop-opacity="0.1"/></linearGradient></defs><path d="M20 76 A60 60 0 0 1 140 76"/><path class="aux-ticks" d="M20 76h9M38 34l7 7M80 16v10M122 34l-7 7M140 76h-9"/><text class="aux-mark" transform="rotate(90 33 72)" x="33" y="72" dx="-7">E</text><text class="aux-mark" transform="rotate(90 80 40)" x="80" y="40">½</text><text class="aux-mark" transform="rotate(90 127 72)" x="127" y="72" dx="-7">F</text><path class="fuel-reserve-zone" d="M20 76 A60 60 0 0 1 37.6 33.6"/><line class="aux-needle" x1="80" y1="76" x2="80" y2="24"/><circle cx="80" cy="76" r="4"/></g></svg><div class="aux-title" title="Carburant"><svg viewBox="0 0 24 24" role="img" aria-label="Carburant" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16M3 21h12M4 10h10m3-6 3 3v11a2 2 0 0 1-4 0v-5a2 2 0 0 0-2-2m4-6v4h2"/></svg></div><strong><b data-value="fuelPercent">—</b> %</strong><div class="sensor-segments" aria-hidden="true"><i></i><i></i><i></i><i></i></div><div class="aux-limits"><span>E</span><span>F</span></div></div><div class="aux-gauge" role="meter" aria-label="Température moteur" aria-valuemin="40" aria-valuemax="130" data-level="coolantCelsius"><svg class="aux-analog" viewBox="0 0 160 160" aria-hidden="true"><g transform="translate(0 4) rotate(90 80 76)"><defs><linearGradient id="temperature-red" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#f5ac63" stop-opacity="0.15"/><stop offset="0.5" stop-color="#f57b56"/><stop offset="1" stop-color="#f04f55"/></linearGradient></defs><path d="M20 76 A60 60 0 0 1 140 76"/><path class="aux-ticks" d="M20 76h9M38 34l7 7M80 16v10M122 34l-7 7M140 76h-9"/><path class="temperature-hot" d="M122.4 33.6 A60 60 0 0 1 140 76"/><text class="aux-mark" transform="rotate(-90 33 72)" x="33" y="72" dx="7">C</text><text class="aux-mark" transform="rotate(-90 127 72)" x="127" y="72" dx="7">H</text><line class="aux-needle" x1="80" y1="76" x2="80" y2="24"/><circle cx="80" cy="76" r="4"/></g></svg><div class="aux-title" title="Température moteur"><svg viewBox="0 0 24 24" role="img" aria-label="Température moteur" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 12V4a2 2 0 0 1 4 0v8a3.5 3.5 0 1 1-4 0Zm2-6v9m4-9h2m-2 3h2M2 19q2-2 4 0t4 0t4 0t4 0t4 0M2 23q2-2 4 0t4 0t4 0t4 0t4 0"/></svg></div><strong><b data-value="coolantCelsius">—</b> °C</strong><div class="sensor-segments" aria-hidden="true"><i></i><i></i><i></i><i></i></div><div class="aux-limits"><span>C</span><span>H</span></div></div></div><div class="cluster-bottom"><span>Trajet <b><span data-value="tripKm">—</span> km</b></span><span>Consommation <b><span data-value="consumptionL100">—</span> L/100</b></span></div></div>
|
||||||
<div class="instrument-grid"><article class="card"><p class="eyebrow">CARBURANT</p><h2><span data-value="fuelPercent">—</span><small> %</small></h2><div class="fuel-track"><span data-fuel></span></div><p><span data-value="rangeKm">—</span> km d’autonomie</p></article><article class="card"><p class="eyebrow">TEMPÉRATURE MOTEUR</p><h2><span data-value="coolantCelsius">—</span><small> °C</small></h2><p>Liquide de refroidissement</p></article><article class="card"><p class="eyebrow">BATTERIE</p><h2><span data-value="batteryVolts">—</span><small> V</small></h2><p>Tension simulée</p></article><article class="card"><p class="eyebrow">TEMPÉRATURE EXTÉRIEURE</p><h2><span data-value="outsideCelsius">—</span><small> °C</small></h2><p>Mesure de démonstration</p></article></div>
|
<div class="drive-links"><div class="drive-actions"><a class="pill" href="#vehicle-details">Détails du véhicule ↗</a><label class="profile-label">Simulation <select id="profile"><option value="route">Route</option><option value="ville">Ville</option><option value="arret">À l’arrêt</option></select></label></div></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="music" class="view" hidden>
|
<section id="music" class="view" hidden>
|
||||||
<div class="page-heading"><div><p class="eyebrow">VOTRE BIBLIOTHÈQUE EMBARQUÉE</p><h1>Musique locale</h1></div><label class="pill file-picker">+ Ajouter des morceaux<input id="music-files" type="file" accept="audio/*,.mp3,.flac,.ogg,.wav,.m4a" multiple></label></div>
|
<div class="page-heading"><div><p class="eyebrow">VOTRE BIBLIOTHÈQUE EMBARQUÉE</p><h1>Musique locale</h1></div><label class="pill file-picker">+ Ajouter des morceaux<input id="music-files" type="file" accept="audio/*,.mp3,.flac,.ogg,.wav,.m4a" multiple></label></div>
|
||||||
<div class="player-grid"><article class="card player"><div class="album-art large" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><p class="eyebrow">EN ÉCOUTE</p><h2 id="track-title">À vous de donner le tempo.</h2><p id="track-subtitle">Choisissez des fichiers sur cet appareil.</p><audio id="audio" controls preload="metadata"></audio><div class="player-actions"><button id="previous" aria-label="Morceau précédent">❮</button><button id="play" class="pill">Lecture</button><button id="next" aria-label="Morceau suivant">❯</button></div><p id="audio-message" role="status">MP3, WAV, OGG… selon les formats pris en charge par votre navigateur.</p></article><article class="card library"><div class="card-top"><h2>Vos morceaux</h2><span id="track-count" class="label">0 TITRE</span></div><ol id="playlist"></ol><div id="empty-library"><span>♫</span><h3>La route a sa bande-son.</h3><p>Ajoutez vos morceaux avec le bouton ci-dessus.<br>Ils restent sur cet appareil, sans envoi au serveur.</p></div></article></div>
|
<div class="player-grid"><article class="card player"><div class="album-art large" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><p class="eyebrow">EN ÉCOUTE</p><h2 id="track-title">À vous de donner le tempo.</h2><p id="track-subtitle">Choisissez des fichiers sur cet appareil.</p><a class="player-view-link" href="#now-playing">Vue lecture immersive ↗</a><audio id="audio" controls preload="metadata"></audio><div class="player-actions"><button id="previous" aria-label="Morceau précédent">❮</button><button id="play" class="pill">Lecture</button><button id="next" aria-label="Morceau suivant">❯</button></div><p id="audio-message" role="status">MP3, WAV, OGG… selon les formats pris en charge par votre navigateur.</p></article><article class="card library"><div class="card-top"><h2>Vos morceaux</h2><span id="track-count" class="label">0 TITRE</span></div><ol id="playlist"></ol><div id="empty-library"><span>♫</span><h3>La route a sa bande-son.</h3><p>Ajoutez vos morceaux avec le bouton ci-dessus.<br>Ils restent sur cet appareil, sans envoi au serveur.</p></div></article></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="stream" class="view" hidden>
|
<section id="stream" class="view" hidden>
|
||||||
<div class="page-heading"><div><p class="eyebrow">VOTRE APPLICATION WEB</p><h1>Streaming</h1></div></div><article class="card stream-card"><span class="stream-symbol">▷</span><p class="eyebrow">VOTRE CINÉMA À BORD</p><h2>Un accès direct à votre univers.</h2><p id="stream-description">Ajoutez l’adresse de votre site dans les réglages.</p><a id="stream-link" class="pill" target="_blank" rel="noopener noreferrer" hidden>Ouvrir mon streaming ↗</a><button id="stream-configure" class="pill">Configurer mon site</button><p class="subtle">S’ouvre dans un nouvel onglet. Votre cockpit reste accessible.</p></article>
|
<div class="page-heading"><div><p class="eyebrow">VOTRE APPLICATION WEB</p><h1>Streaming</h1></div></div><article class="card stream-card"><span class="stream-symbol">▷</span><p class="eyebrow">VOTRE CINÉMA À BORD</p><h2>Un accès direct à votre univers.</h2><p id="stream-description">Votre serveur Jellyfin.</p><a id="stream-link" class="pill" target="_blank" rel="noopener noreferrer" hidden>Ouvrir mon streaming ↗</a><p id="stream-opening" class="subtle">S’ouvre dans un nouvel onglet. Votre cockpit reste accessible.</p></article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
<section id="map" class="view" hidden>
|
||||||
<footer><span><i class="tiny-dot"></i> COCKPIT <span class="subtle">/ VERSION 0.1</span></span><span id="footer-status">Mode démonstration · CAN non connecté</span><span>FAIT POUR LE VOYAGE <span class="accent">↗</span></span></footer>
|
<div class="page-heading"><div><p class="eyebrow">PRENEZ LA BONNE DIRECTION</p><h1>Navigation</h1></div><div class="map-actions"><button data-locate>⌖ Activer le GPS</button><button id="gps-stop" hidden>Arrêter le GPS</button><button id="map-center">Recentrer</button></div></div>
|
||||||
|
<div class="journey-grid">
|
||||||
|
<div class="map-column"><div id="map-canvas" role="region" aria-label="Carte interactive OpenStreetMap"></div><p id="map-status" role="status">Carte en ligne · choisissez un départ ou activez le GPS.</p><div id="guidance" class="card" hidden><p class="eyebrow">PROCHAINE DIRECTION</p><strong id="next-instruction"></strong><p id="route-summary"></p><div class="map-actions"><button id="route-recalculate">Recalculer</button><button id="route-clear">Terminer</button><a data-waze-destination hidden>Ouvrir la destination dans Waze ↗</a><label><input id="voice" type="checkbox"> Voix</label></div></div></div>
|
||||||
|
<aside class="journey-panel card">
|
||||||
|
<p class="eyebrow">PRÉPARER LE TRAJET</p><p id="position-status" role="status">Départ non défini</p>
|
||||||
|
<form id="place-form"><label for="place-query">Ville ou adresse</label><div class="search-line"><input id="place-query" required minlength="3" maxlength="180" placeholder="Ville, rue…"><button type="submit" aria-label="Rechercher un lieu">⌕</button></div><label for="place-use">Utiliser comme</label><select id="place-use"><option value="origin">Départ / lieu météo</option><option value="destination">Destination</option></select></form>
|
||||||
|
<div id="place-results" aria-live="polite"></div>
|
||||||
|
<details><summary>Coordonnées manuelles</summary><form id="coordinates-form"><label>Latitude<input id="latitude" type="number" step="any" min="-85" max="85" required placeholder="48.85"></label><label>Longitude<input id="longitude" type="number" step="any" min="-180" max="180" required placeholder="2.35"></label><button type="submit">Utiliser ce départ</button></form></details>
|
||||||
|
<p class="provider-credit">Recherche <a href="https://github.com/komoot/photon" target="_blank" rel="noopener">Photon</a> · Itinéraires <a href="https://project-osrm.org/" target="_blank" rel="noopener">OSRM</a> · Données <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a></p>
|
||||||
|
<details id="alerts-settings"><summary>Alertes de zones</summary><p>Importez vos zones de vigilance. Aucune base radar n’est fournie. Vérifiez les règles du pays et la source avant activation.</p><label>Pays d’utilisation<select id="alert-country"><option value="">Désactivé</option><option value="FR">France</option><option value="BE">Belgique</option><option value="LU">Luxembourg</option><option value="DE">Allemagne</option><option value="CH">Suisse</option><option value="IT">Italie</option><option value="ES">Espagne</option><option value="AD">Andorre</option><option value="MC">Monaco</option></select></label><label class="file-picker pill">Importer des zones<input id="zones-file" type="file" accept=".json,.geojson,application/json"></label><p id="zones-status">Aucune zone chargée</p><label class="check-label"><input id="alerts-enabled" type="checkbox"> Activer les avertissements</label><label class="check-label"><input id="alert-sound" type="checkbox"> Avertissement sonore</label><div class="map-actions"><button id="alert-test">Tester une alerte fictive</button><button id="zones-clear">Effacer les zones</button></div></details>
|
||||||
|
<details><summary>Étapes de l’itinéraire</summary><ol id="route-steps"></ol></details>
|
||||||
|
<p class="subtle">En ligne : les coordonnées météo, les recherches et les trajets sont envoyés aux fournisseurs indiqués. GPS uniquement après activation ; aucun historique de déplacement enregistré.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="apps" class="view" hidden>
|
||||||
|
<div class="page-heading"><div><p class="eyebrow">VOTRE UNIVERS À BORD</p><h1>Applications</h1></div><button id="applications-customize" class="pill">Personnaliser</button></div>
|
||||||
|
<div class="catalog-heading"><p>Vos essentiels, à portée de main.</p><span id="favorite-count" class="label">4 / 4 favoris</span></div>
|
||||||
|
<div id="applications-grid"></div><p id="applications-empty" hidden>Aucune application affichée. Utilisez Personnaliser pour les retrouver.</p>
|
||||||
|
<p class="subtle">Les services Web s’ouvrent uniquement quand vous les choisissez. Leur connexion et leur lecture restent gérées par leur propre site.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="vehicle-details" class="view" hidden><div class="view-toolbar"><a href="#drive">← Instruments</a><span class="label">DÉTAILS · DÉMONSTRATION</span></div><div class="detail-grid"><article class="card"><h2>Carburant</h2><strong><span data-value="fuelPercent">—</span> <small>%</small></strong><p>Niveau du réservoir. Donnée fixe de démonstration.</p></article><article class="card"><h2>Autonomie</h2><strong><span data-value="rangeKm">—</span> <small>km</small></strong><p>Estimation fictive, non calculée à partir du véhicule.</p></article><article class="card"><h2>Refroidissement</h2><strong><span data-value="coolantCelsius">—</span> <small>°C</small></strong><p>Température simulée du liquide de refroidissement.</p></article><article class="card"><h2>Batterie</h2><strong><span data-value="batteryVolts">—</span> <small>V</small></strong><p>Tension simulée du circuit électrique.</p></article><article class="card"><h2>Température extérieure</h2><strong><span data-value="outsideCelsius">—</span> <small>°C</small></strong><p>Sonde véhicule simulée, distincte de la météo Open-Meteo.</p></article><article class="card"><h2>Consommation moyenne</h2><strong><span data-value="consumptionL100">—</span> <small>L/100 km</small></strong><p>Valeur de démonstration sur le trajet.</p></article><article class="card"><h2>Distance du trajet</h2><strong><span data-value="tripKm">—</span> <small>km</small></strong><p>Distance fictive constante.</p></article><article class="card"><h2>Régime moteur</h2><strong><span data-value="rpm">—</span> <small>tr/min</small></strong><p>Régime animé par le profil de simulation.</p></article></div><p class="details-notice">Les mesures proviennent du simulateur. La passerelle CAN n’est pas connectée. Lors d’une coupure du service, les valeurs sont masquées ; aucun diagnostic mécanique réel n’est effectué.</p></section>
|
||||||
|
<section id="now-playing" class="view" hidden>
|
||||||
|
<div class="view-toolbar"><a href="#music">← Bibliothèque</a><span class="label">MUSIQUE LOCALE</span></div>
|
||||||
|
<div class="immersive-player card">
|
||||||
|
<div class="immersive-stage">
|
||||||
|
<svg class="music-orbit" viewBox="0 0 600 430" aria-hidden="true">
|
||||||
|
<path class="orbit-track" d="M65 365 A250 250 0 1 1 535 365"/>
|
||||||
|
<path id="track-arc" pathLength="100" d="M65 365 A250 250 0 1 1 535 365"/>
|
||||||
|
</svg>
|
||||||
|
<div class="immersive-caption"><h2 id="immersive-title">À vous de donner le tempo.</h2><p id="immersive-subtitle">Choisissez des morceaux dans la bibliothèque.</p></div>
|
||||||
|
<div class="immersive-controls">
|
||||||
|
<button id="immersive-previous" aria-label="Morceau précédent" title="Morceau précédent" data-audio-control><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5 8 12l7 7"/></svg></button>
|
||||||
|
<button id="immersive-play" aria-label="Lire la musique" title="Lire la musique" data-audio-control><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 11 7-11 7Z"/></svg></button>
|
||||||
|
<button id="immersive-next" aria-label="Morceau suivant" title="Morceau suivant" data-audio-control><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="immersive-times"><span id="immersive-current">0:00</span><span id="immersive-duration">0:00</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="immersive-bottom">
|
||||||
|
<div class="immersive-secondary">
|
||||||
|
<button id="immersive-shuffle" aria-label="Lecture aléatoire" title="Lecture aléatoire" aria-pressed="false" data-audio-control><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h3c5 0 7 12 12 12h3m-4-4 4 4-4 4M3 18h3c2 0 3-2 4-4m4-4c1-2 2-4 4-4h3m-4-4 4 4-4 4"/></svg></button>
|
||||||
|
<button id="immersive-repeat" aria-label="Répéter le morceau" title="Répéter le morceau" aria-pressed="false" data-audio-control><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2 4 4-4 4M3 11V9a3 3 0 0 1 3-3h15M7 22l-4-4 4-4m14-1v2a3 3 0 0 1-3 3H3m8-9 1-1v6"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="immersive-volume">
|
||||||
|
<button id="immersive-mute" aria-label="Couper le son" title="Couper le son" aria-pressed="false"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4 6 8H3v8h3l5 4Zm4 4a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14"/></svg></button>
|
||||||
|
<input id="immersive-volume" type="range" min="0" max="1" step="0.01" value="1" aria-label="Volume">
|
||||||
|
<output id="immersive-volume-value" for="immersive-volume">100 %</output>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dialog id="settings"><form id="settings-form"><div class="card-top"><h2>Votre cockpit</h2><button type="button" id="settings-close" aria-label="Fermer les réglages">✕</button></div><label>Adresse de votre streaming<input id="stream-url" type="url" placeholder="https://votre-site.fr"></label><p class="subtle">Enregistrée dans ce navigateur. Laissez vide pour retirer le lien.</p><label>Ambiance<select id="theme"><option value="dark">Nuit</option><option value="light">Jour</option></select></label><p>Plusieurs écrans : ouvrez la conduite sur l’un et l’accueil sur l’autre.</p><div class="screen-links"><a href="/#drive" target="_blank" rel="noopener">Écran conduite ↗</a><a href="/#home" target="_blank" rel="noopener">Écran multimédia ↗</a></div><p class="subtle">Météo et véhicule : démonstration. Navigation et radar seront ajoutés dans une prochaine étape.</p><button class="pill" type="submit">Enregistrer</button><p id="settings-message" role="status"></p></form></dialog>
|
</section>
|
||||||
|
<section id="embedded" class="view" hidden><div class="view-toolbar"><a href="#apps">← Applications</a><span id="embedded-name" class="label">LECTEUR INTÉGRÉ</span></div><article class="card embedded-card"><p id="embedded-explanation" class="embed-notice"></p><form id="embed-form" class="embed-controls"><input id="embed-url" type="url" required aria-label="Lien du contenu à intégrer" placeholder="Collez un lien de morceau, album ou playlist"><button class="pill" type="submit">Charger le lecteur</button></form><div class="embed-controls"><a id="embed-external" class="pill" target="_blank" rel="noopener noreferrer">Ouvrir le site ↗</a><button id="embed-stop">Arrêter le lecteur intégré</button></div><div id="embed-container"></div><p id="embed-status" role="status" class="embed-notice">Le lecteur se charge uniquement à votre demande.</p></article></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dialog id="settings"><form id="settings-form"><div class="card-top"><h2>Votre cockpit</h2><button type="button" id="settings-close" aria-label="Fermer les réglages">✕</button></div><button type="button" id="applications-settings-open">Personnaliser les applications</button><div class="theme-grid"><label>Ambiance<select id="theme"><option value="dark">Nuit</option><option value="light">Jour</option></select></label><div class="color-setting"><label>Couleur<select id="accent"><option value="green">Vert</option><option value="blue">Bleu</option><option value="red">Rouge</option><option value="custom">Utilisateur</option></select></label><input id="custom-color" type="color" value="#d2ef92" aria-label="Couleur personnalisée" title="Choisir une couleur personnalisée"></div></div><label>Style des cadrans<select id="dial-style"><option value="analog">Analogique · aiguilles</option><option value="luminous">Analogique lumineux · graduations simplifiées</option><option value="digital">Numérique · arcs lumineux</option></select></label><p class="subtle">Une palette pour toute l’interface. La luminosité de la couleur est ajustée pour préserver la lisibilité.</p><p class="subtle">Les compteurs restent simulés. La météo et les itinéraires utilisent des services en ligne.</p> <footer><span><i class="tiny-dot"></i> COCKPIT <span class="subtle">/ VERSION 0.3</span></span><span id="footer-status">Mode démonstration · CAN non connecté</span><span>FAIT POUR LE VOYAGE <span class="accent">↗</span></span></footer><button class="pill" type="submit">Enregistrer</button><p id="settings-message" role="status"></p></form></dialog>
|
||||||
|
<div id="zone-alert" role="status" hidden><strong id="zone-alert-title"></strong><span id="zone-alert-description"></span><button id="zone-alert-close" aria-label="Fermer l’alerte">✕</button></div>
|
||||||
|
<dialog id="application-settings" aria-labelledby="application-settings-title"><div class="card-top"><h2 id="application-settings-title">Vos applications</h2><button id="application-settings-close" aria-label="Fermer les préférences applications">✕</button></div><p class="subtle">Modifications enregistrées automatiquement sur ce navigateur.</p><h3>La barre de favoris</h3><p class="subtle">Quatre places, de haut en bas. Choisir un favori déjà présent échange les deux places.</p><div id="favorite-slots"></div><h3>Affichage et ouverture</h3><div id="application-settings-list"></div><p class="subtle">Masquer retire le raccourci et son favori, sans arrêter une musique en cours. Le bouton Applications reste accessible.</p><p class="subtle">Nouvel onglet conserve votre cockpit. Même onglet quitte CARIA et peut interrompre la lecture locale et le GPS. Le lancement d’une application native dépend de votre appareil.</p><button id="applications-reset">Rétablir les applications par défaut</button></dialog>
|
||||||
<div id="toast" role="status" hidden></div>
|
<div id="toast" role="status" hidden></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* The accent is the only configurable color; surfaces derive from it. */
|
||||||
|
body.light{--bg:color-mix(in srgb,var(--accent) 3%,#f2f2ee);--surface2:color-mix(in srgb,var(--accent) 4%,#f5f5f2);--border:color-mix(in srgb,var(--accent) 9%,#deded8)}
|
||||||
|
.rail,body.light .rail{background:color-mix(in srgb,var(--accent) 4%,var(--bg))}
|
||||||
|
.brand span,.gear>span,.text-link span{color:var(--accent)}
|
||||||
|
.clock-card{background:color-mix(in srgb,var(--accent) 13%,#14201e)}
|
||||||
|
.sun{background:var(--accent);box-shadow:0 0 80px color-mix(in srgb,var(--accent) 20%,transparent)}
|
||||||
|
.mountain{background:color-mix(in srgb,var(--accent) 22%,#23312d)}
|
||||||
|
.mountain.two{background:color-mix(in srgb,var(--accent) 8%,#12221e)}
|
||||||
|
.road{background:color-mix(in srgb,var(--accent) 45%,#48534c)}
|
||||||
|
.album-art{background:linear-gradient(140deg,color-mix(in srgb,var(--accent) 70%,#849484),color-mix(in srgb,var(--accent) 12%,#243a33))}
|
||||||
|
.record:after{border-color:color-mix(in srgb,var(--accent) 45%,#314436)}
|
||||||
|
.tiny-dot{box-shadow:0 0 12px color-mix(in srgb,var(--accent) 25%,transparent)}
|
||||||
|
body.light .rail nav a.active{color:var(--ink)}
|
||||||
|
.theme-grid{display:grid;grid-template-columns:1fr 1fr;gap:15px}input[type=color]{width:100%;height:46px;padding:4px;border:1px solid var(--border);border-radius:10px;background:var(--surface2)}
|
||||||
|
.weather-actions{display:flex;align-items:center;gap:9px;margin:0 0 10px;flex-wrap:wrap}.weather-actions button{font-size:10px;padding:6px 10px}.weather-actions a{font-size:10px;color:var(--accent)}.weather-credit{font-size:8px;color:var(--muted);margin-top:8px}.weather-main strong #weather-temperature{color:var(--text)}.weather-card{min-height:280px}.weather-card #weather-status{font-size:10px;min-height:26px;margin-bottom:8px}
|
||||||
|
.journey-grid{display:grid;grid-template-columns:minmax(0,1fr) 285px;gap:16px}.map-column{min-width:0}#map-canvas{height:420px;min-height:200px;border-radius:17px;border:1px solid var(--border);background:var(--surface2);z-index:0;isolation:isolate}.journey-panel{padding:18px;overflow:auto;max-height:680px}.journey-panel label{font-size:11px;display:block;margin:12px 0 7px}.journey-panel input:not([type=checkbox]):not([type=file]),.journey-panel select{width:100%;min-width:0;min-height:44px;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);background:var(--surface2)}.search-line{display:flex;gap:6px}.search-line input{flex:1}.search-line button{font-size:23px;padding:5px 14px}#place-results button{text-align:left;width:100%;margin-top:8px;font-size:11px;overflow-wrap:anywhere}#place-results p{font-size:11px;color:var(--muted)}.map-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.map-actions button,.map-actions label{font-size:11px}.journey-panel p,#map-status{font-size:11px;color:var(--muted);line-height:1.5}.journey-panel details{padding:14px 0;border-top:1px solid var(--border);margin-top:10px}.journey-panel summary{font-size:12px;cursor:pointer;min-height:34px;padding-top:8px}.provider-credit a{color:var(--accent)}#route-steps{padding-left:20px;font-size:11px;color:var(--muted)}#route-steps li{padding:8px 0}#route-steps li.current{color:var(--accent)}#guidance{padding:15px;margin-top:10px}#guidance strong{font-size:18px}#guidance p{font-size:11px}.journey-panel label.check-label{display:flex;align-items:center;gap:10px;min-height:36px}.check-label input{accent-color:var(--accent)}#zone-alert{position:fixed;z-index:1500;bottom:22px;left:50%;transform:translateX(-50%);background:#fbd499;color:#30200d;border:2px solid #af6d16;box-shadow:var(--shadow);border-radius:14px;padding:18px 65px 18px 22px;max-width:calc(100vw - 28px);width:520px}#zone-alert strong,#zone-alert span{display:block}#zone-alert span{font-size:12px;margin-top:6px}#zone-alert button{position:absolute;right:10px;top:10px;background:#fff4;color:#30200d;border-color:#af6d16}
|
||||||
|
.leaflet-container{font-family:inherit;color:#20312a}.leaflet-container .leaflet-control-attribution{font-size:10px}.leaflet-container .leaflet-control-attribution a{color:#22557b}.leaflet-control-zoom a{min-width:40px;min-height:40px;line-height:40px!important}.leaflet-tooltip{font-size:12px}.leaflet-control-attribution{max-width:100%;white-space:normal}.rail nav{gap:8px}.rail nav a{min-height:65px}
|
||||||
|
@media(max-width:1100px){.journey-grid{grid-template-columns:minmax(0,1fr) 250px}#map-canvas{height:340px}.journey-panel{padding:14px}}
|
||||||
|
@media(max-height:650px) and (min-width:760px){.weather-card{min-height:260px}.weather-card .weather-main{margin:4px 0}.weather-card #weather-status{min-height:0}.weather-actions{margin-bottom:6px}.weather-credit{margin-top:5px}#map-canvas{height:270px}.journey-panel{max-height:480px}.rail nav a{min-height:56px}.rail nav{gap:4px}}
|
||||||
|
@media(max-height:520px) and (min-width:760px){.rail nav a{min-height:48px;gap:4px}.rail nav .icon{font-size:21px}.weather-card{min-height:220px}.weather-actions{gap:5px}.weather-actions button{font-size:9px;padding:5px}.weather-card #weather-status{display:block}.weather-credit{font-size:7px}#map-canvas{height:230px}.journey-panel{max-height:420px}}
|
||||||
|
@media(max-width:759px){.journey-grid{grid-template-columns:1fr}.journey-panel{max-height:none}#map-canvas{height:320px}.map-actions button{padding:8px}.weather-card{min-height:290px}.theme-grid{gap:10px}.rail nav a{min-height:60px}.leaflet-container .leaflet-control-attribution{font-size:9px}}
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
import { distance, instruction, nearbyZones, routeProgress, validateZones } from './geo.js';
|
||||||
|
|
||||||
|
export function initJourney({ storage, toast }) {
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
let position = null, watch = null, map = null, marker = null, routeLayer = null, targetMarker = null;
|
||||||
|
let destination = null, route = null, routeGeneration = 0, searchGeneration = 0, weatherGeneration = 0;
|
||||||
|
let weatherAt = 0, weatherRetryAt = 0, weatherPosition = null, weatherBusy = false, mapLoading = null, zones = [], audioContext;
|
||||||
|
let lastSpoken = '', lastZone = '', lastZoneTime = 0;
|
||||||
|
const fmtDistance = m => m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;
|
||||||
|
const readJSON = key => { try { return JSON.parse(storage.get(key, 'null')); } catch { return null; } };
|
||||||
|
async function api(path, params = {}) {
|
||||||
|
const r = await fetch(`/api/${path}?${new URLSearchParams(params)}`, { signal: AbortSignal.timeout(12000) });
|
||||||
|
const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Service indisponible'); return d;
|
||||||
|
}
|
||||||
|
function weatherLabel(code) {
|
||||||
|
if (code === 0) return ['☀', 'Ciel dégagé'];
|
||||||
|
if (code <= 3) return ['⛅', 'Nuages et éclaircies'];
|
||||||
|
if (code <= 48) return ['≋', 'Brouillard'];
|
||||||
|
if (code >= 95) return ['ϟ', 'Orages'];
|
||||||
|
if ([71,73,75,77,85,86].includes(code)) return ['❄', 'Neige'];
|
||||||
|
return ['☂', 'Pluie ou bruine'];
|
||||||
|
}
|
||||||
|
function clearWeather(message) {
|
||||||
|
['#weather-temperature','#weather-min','#weather-max','#weather-wind'].forEach(id => $(id).textContent = '—');
|
||||||
|
$('#weather-source').textContent = 'INDISPONIBLE'; $('#weather-status').textContent = message;
|
||||||
|
$('#weather-title').textContent = 'Météo indisponible'; $('#weather-icon').textContent = '◌';
|
||||||
|
}
|
||||||
|
async function weather(force = false) {
|
||||||
|
if (!position) return;
|
||||||
|
if (!force && (weatherBusy || Date.now() < weatherRetryAt || (Date.now() - weatherAt < 15 * 60000 && weatherPosition && distance(position, weatherPosition) < 5000))) return;
|
||||||
|
const id = ++weatherGeneration, at = { ...position }; weatherBusy = true;
|
||||||
|
if (force || !weatherPosition || distance(at, weatherPosition) >= 5000) clearWeather('Actualisation…');
|
||||||
|
$('#weather-source').textContent = 'CHARGEMENT';
|
||||||
|
try {
|
||||||
|
const d = await api('weather', { lat: at.lat, lon: at.lon });
|
||||||
|
if (id !== weatherGeneration) return;
|
||||||
|
if (!position || distance(at, position) > 5000) { clearWeather('Position modifiée · nouvelle météo en attente'); weatherAt = 0; return; }
|
||||||
|
const [icon, label] = weatherLabel(d.code);
|
||||||
|
$('#weather-icon').textContent = icon; $('#weather-title').textContent = label;
|
||||||
|
$('#weather-temperature').textContent = Math.round(d.temperature);
|
||||||
|
$('#weather-min').textContent = `${Math.round(d.min)}°`; $('#weather-max').textContent = `${Math.round(d.max)}°`;
|
||||||
|
$('#weather-wind').textContent = `${Math.round(d.wind)} km/h`;
|
||||||
|
$('#weather-source').textContent = 'OPEN-METEO';
|
||||||
|
$('#weather-status').textContent = `${at.label} · modèle météo ${d.observedAt?.slice(11,16) || ''} (heure du lieu)`;
|
||||||
|
weatherAt = Date.now(); weatherRetryAt = 0; weatherPosition = at;
|
||||||
|
} catch (error) { if (id === weatherGeneration) { clearWeather(error.message); weatherAt = 0; weatherRetryAt = Date.now() + 60000; } }
|
||||||
|
finally { if (id === weatherGeneration) weatherBusy = false; }
|
||||||
|
}
|
||||||
|
$('#weather-refresh').onclick = () => { if (!position) toast('Choisissez un lieu ou activez le GPS.'); else weather(true); };
|
||||||
|
|
||||||
|
async function ensureMap() {
|
||||||
|
if (map) { map.invalidateSize(); return map; }
|
||||||
|
if (mapLoading) return mapLoading;
|
||||||
|
mapLoading = (async () => {
|
||||||
|
if (!window.L) await new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script'); script.src = '/vendor/leaflet/leaflet.js';
|
||||||
|
script.onload = resolve; script.onerror = () => { script.remove(); reject(new Error('Bibliothèque cartographique indisponible')); }; document.head.append(script);
|
||||||
|
});
|
||||||
|
const config = await api('map-config');
|
||||||
|
map = window.L.map('map-canvas', { zoomControl: true }).setView(position ? [position.lat, position.lon] : [46.6, 2.4], position ? 13 : 6);
|
||||||
|
const attribution = document.createElement('span'); attribution.textContent = config.tileAttribution;
|
||||||
|
const tiles = window.L.tileLayer(config.tileUrl, { maxZoom: 19, attribution: attribution.innerHTML + ' · <a href="https://www.openstreetmap.org/copyright">Licence</a>' }).addTo(map);
|
||||||
|
tiles.on('tileerror', () => $('#map-status').textContent = 'Fond de carte indisponible : vérifiez la connexion.');
|
||||||
|
tiles.on('load', () => { $('#map-status').textContent = 'Carte © OpenStreetMap · Internet requis'; });
|
||||||
|
updateMarker(); drawRoute(); return map;
|
||||||
|
})().catch(error => { $('#map-status').textContent = error.message; mapLoading = null; return null; });
|
||||||
|
return mapLoading;
|
||||||
|
}
|
||||||
|
function updateMarker() {
|
||||||
|
if (!map) return;
|
||||||
|
if (marker) { marker.remove(); marker = null; }
|
||||||
|
if (position) {
|
||||||
|
marker = window.L.circleMarker([position.lat, position.lon], { radius: 8, color: '#fff', weight: 3, fillColor: getComputedStyle(document.body).getPropertyValue('--accent').trim(), fillOpacity: 1 }).addTo(map);
|
||||||
|
const label = document.createElement('span'); label.textContent = position.source === 'gps' ? `GPS ± ${Math.round(position.accuracy)} m` : 'Départ manuel'; marker.bindTooltip(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function drawRoute() {
|
||||||
|
if (!map) return;
|
||||||
|
if (routeLayer) { routeLayer.remove(); routeLayer = null; }
|
||||||
|
if (targetMarker) { targetMarker.remove(); targetMarker = null; }
|
||||||
|
if (route) routeLayer = window.L.geoJSON(route.geometry, { style: { color: getComputedStyle(document.body).getPropertyValue('--accent').trim(), weight: 6 } }).addTo(map);
|
||||||
|
if (destination) targetMarker = window.L.circleMarker([destination.lat, destination.lon], { radius: 8, color: '#1d3530', fillColor: '#fff', fillOpacity: 1 }).addTo(map);
|
||||||
|
}
|
||||||
|
document.addEventListener('caria-view', e => { if (e.detail === 'map') ensureMap(); });
|
||||||
|
document.addEventListener('caria-layout', () => { if(map) requestAnimationFrame(()=>map.invalidateSize()); });
|
||||||
|
document.addEventListener('caria-theme', () => { updateMarker(); drawRoute(); });
|
||||||
|
$('#map-center').onclick = async () => { const m = await ensureMap(); if (m && position) m.setView([position.lat, position.lon], 15); else if (!position) toast('Définissez d’abord un départ.'); };
|
||||||
|
|
||||||
|
function stopGPS() {
|
||||||
|
gpsGeneration++;
|
||||||
|
if (watch !== null) navigator.geolocation.clearWatch(watch);
|
||||||
|
watch = null; $('#gps-stop').hidden = true;
|
||||||
|
}
|
||||||
|
function setPosition(value) {
|
||||||
|
position = value;
|
||||||
|
$('#position-status').textContent = value.source === 'gps' ? `GPS actif · précision ± ${Math.round(value.accuracy)} m` : `Départ manuel : ${value.label}`;
|
||||||
|
updateMarker(); weather(); guidance(); checkAlerts();
|
||||||
|
}
|
||||||
|
function manual(value) {
|
||||||
|
stopGPS(); clearRoute();
|
||||||
|
setPosition({ ...value, source: 'manual', timestamp: Date.now() });
|
||||||
|
storage.set('position', JSON.stringify({ lat: value.lat, lon: value.lon, label: value.label }));
|
||||||
|
weather(true);
|
||||||
|
if (map) map.setView([value.lat, value.lon], 13);
|
||||||
|
$('#place-use').value = 'destination';
|
||||||
|
}
|
||||||
|
let gpsGeneration = 0;
|
||||||
|
function locate() {
|
||||||
|
if (!window.isSecureContext || !navigator.geolocation) { toast('GPS : ouvrez CARIA en HTTPS ou sur localhost. Le départ manuel reste disponible.'); return; }
|
||||||
|
stopGPS(); const generation = ++gpsGeneration;
|
||||||
|
$('#position-status').textContent = 'Recherche de la position GPS…';
|
||||||
|
watch = navigator.geolocation.watchPosition(p => {
|
||||||
|
if (generation !== gpsGeneration) return;
|
||||||
|
const first = position?.source !== 'gps';
|
||||||
|
setPosition({ lat: p.coords.latitude, lon: p.coords.longitude, accuracy: p.coords.accuracy, timestamp: p.timestamp, source: 'gps', label: 'Position GPS' });
|
||||||
|
$('#gps-stop').hidden = false;
|
||||||
|
if (map && first) map.setView([position.lat, position.lon], 15);
|
||||||
|
}, error => {
|
||||||
|
if (generation !== gpsGeneration) return;
|
||||||
|
$('#position-status').textContent = error.code === 1 ? 'GPS refusé. Choisissez un départ manuel.' : 'GPS indisponible. Choisissez un départ manuel ou réessayez.';
|
||||||
|
if (position?.source === 'gps') position.timestamp = 0;
|
||||||
|
guidance(); checkAlerts();
|
||||||
|
if (error.code === 1) stopGPS();
|
||||||
|
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 });
|
||||||
|
$('#gps-stop').hidden = false;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-locate]').forEach(button => button.onclick = locate);
|
||||||
|
$('#gps-stop').onclick = () => {
|
||||||
|
++gpsGeneration; stopGPS(); position = null; updateMarker();
|
||||||
|
$('#position-status').textContent = 'GPS arrêté · choisissez un nouveau départ';
|
||||||
|
weatherGeneration++; weatherBusy = false; clearWeather('GPS arrêté · choisissez un lieu'); guidance(); checkAlerts();
|
||||||
|
};
|
||||||
|
$('#coordinates-form').onsubmit = e => {
|
||||||
|
e.preventDefault(); const lat = Number($('#latitude').value), lon = Number($('#longitude').value);
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 85 && Math.abs(lon) <= 180) manual({ lat, lon, label: `${lat.toFixed(3)}, ${lon.toFixed(3)}` });
|
||||||
|
};
|
||||||
|
$('#place-form').onsubmit = async e => {
|
||||||
|
e.preventDefault(); const id = ++searchGeneration, use = $('#place-use').value;
|
||||||
|
const results = $('#place-results'); results.textContent = 'Recherche…';
|
||||||
|
try {
|
||||||
|
const places = await api('search', { q: $('#place-query').value.trim() });
|
||||||
|
if (id !== searchGeneration) return;
|
||||||
|
results.replaceChildren(); if (!places.length) results.textContent = 'Aucun lieu trouvé. Précisez la ville ou utilisez les coordonnées.';
|
||||||
|
for (const place of places) {
|
||||||
|
const button = document.createElement('button'); button.textContent = place.label;
|
||||||
|
button.onclick = () => {
|
||||||
|
results.replaceChildren();
|
||||||
|
if (use === 'origin') manual(place);
|
||||||
|
else if (!position) toast('Définissez un départ avant la destination.');
|
||||||
|
else { destination = place; document.dispatchEvent(new CustomEvent('caria-destination', { detail: place })); calculateRoute(); }
|
||||||
|
}; results.append(button);
|
||||||
|
}
|
||||||
|
} catch (error) { if (id === searchGeneration) results.textContent = error.message; }
|
||||||
|
};
|
||||||
|
|
||||||
|
function clearRoute() {
|
||||||
|
window.speechSynthesis?.cancel();
|
||||||
|
routeGeneration++; route = null; destination = null; lastSpoken = '';
|
||||||
|
document.dispatchEvent(new CustomEvent('caria-destination', { detail: null }));
|
||||||
|
$('#guidance').hidden = true; $('#route-steps').replaceChildren(); drawRoute();
|
||||||
|
}
|
||||||
|
$('#route-clear').onclick = clearRoute;
|
||||||
|
$('#route-recalculate').onclick = () => calculateRoute();
|
||||||
|
async function calculateRoute() {
|
||||||
|
if (!position || !destination) return toast('Choisissez un départ et une destination.');
|
||||||
|
const id = ++routeGeneration, origin = { ...position }, target = { ...destination };
|
||||||
|
route = null; drawRoute(); $('#route-steps').replaceChildren(); $('#guidance').hidden = false;
|
||||||
|
$('#next-instruction').textContent = 'Calcul de l’itinéraire…'; $('#route-summary').textContent = target.label;
|
||||||
|
try {
|
||||||
|
const data = await api('route', { fromLat: origin.lat, fromLon: origin.lon, toLat: target.lat, toLon: target.lon });
|
||||||
|
if (id !== routeGeneration) return;
|
||||||
|
if (!position || distance(origin, position) > 200) throw new Error('Le départ a changé. Recalculez le trajet.');
|
||||||
|
route = data; lastSpoken = ''; drawRoute();
|
||||||
|
if (map && routeLayer) map.fitBounds(routeLayer.getBounds(), { padding: [25, 25] });
|
||||||
|
for (const step of route.steps) {
|
||||||
|
const li = document.createElement('li'); li.textContent = `${instruction(step)} · ${fmtDistance(step.distance)}`; $('#route-steps').append(li);
|
||||||
|
}
|
||||||
|
guidance();
|
||||||
|
} catch (error) { if (id === routeGeneration) { $('#next-instruction').textContent = error.message; $('#route-summary').textContent = 'Itinéraire non disponible'; } }
|
||||||
|
}
|
||||||
|
function speak(text) {
|
||||||
|
if (!('speechSynthesis' in window)) return;
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'fr-FR'; window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance);
|
||||||
|
}
|
||||||
|
$('#voice').onchange = () => { if ($('#voice').checked) { if ('speechSynthesis' in window) speak('Guidage vocal activé'); else toast('La synthèse vocale n’est pas disponible.'); } else window.speechSynthesis?.cancel(); };
|
||||||
|
function guidance() {
|
||||||
|
if (!route) return;
|
||||||
|
const freshGPS = position?.source === 'gps' && Math.abs(Date.now() - position.timestamp) < 15000 && position.accuracy <= 100;
|
||||||
|
let progress = 0, index = 0;
|
||||||
|
if (freshGPS) {
|
||||||
|
const p = routeProgress(route.geometry.coordinates, position);
|
||||||
|
if (p.away > 100) { $('#next-instruction').textContent = 'Hors itinéraire · recalculez le trajet'; return; }
|
||||||
|
progress = p.fraction * route.distance;
|
||||||
|
let cumulative = 0;
|
||||||
|
while (index < route.steps.length - 1 && cumulative + route.steps[index].distance < progress + 30) cumulative += route.steps[index++].distance;
|
||||||
|
}
|
||||||
|
const next = Math.min(index + 1, route.steps.length - 1);
|
||||||
|
const text = instruction(route.steps[next]);
|
||||||
|
$('#next-instruction').textContent = freshGPS ? text : position?.source === 'manual' ? 'Aperçu du trajet · activez le GPS pour le guidage' : 'Guidage en pause · GPS absent ou imprécis';
|
||||||
|
const remaining = Math.max(0, route.distance - progress);
|
||||||
|
$('#route-summary').textContent = `${fmtDistance(remaining)} · environ ${Math.max(1, Math.round(route.duration * (route.distance ? remaining / route.distance : 0) / 60))} min · ${destination.label} · sans trafic temps réel`;
|
||||||
|
[...$('#route-steps').children].forEach((li, i) => li.classList.toggle('current', i === next));
|
||||||
|
if (freshGPS && $('#voice').checked && lastSpoken !== `${next}:${text}`) { lastSpoken = `${next}:${text}`; speak(text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneStatus() {
|
||||||
|
const expired = zones.filter(z => Date.parse(z.expiresAt) <= Date.now()).length;
|
||||||
|
$('#zones-status').textContent = zones.length ? `${zones.length} zones importées · ${expired} expirée(s). Source : ${[...new Set(zones.map(z => z.source))].join(', ')}` : 'Aucune zone chargée · aucune couverture radar';
|
||||||
|
}
|
||||||
|
const savedZones = readJSON('zones');
|
||||||
|
if (savedZones) { try { zones = validateZones(savedZones); } catch { /* Invalid persisted data is ignored. */ } }
|
||||||
|
zoneStatus();
|
||||||
|
$('#zones-file').onchange = async e => {
|
||||||
|
const file = e.target.files[0]; if (!file) return;
|
||||||
|
try {
|
||||||
|
if (file.size > 1500000) throw new Error('Fichier trop volumineux (1,5 Mo maximum).');
|
||||||
|
const data = JSON.parse(await file.text()), validated = validateZones(data);
|
||||||
|
if (!storage.set('zones', JSON.stringify(data))) throw new Error('Stockage local indisponible.');
|
||||||
|
zones = validated; zoneStatus(); checkAlerts(); toast('Zones importées. Choisissez le pays puis activez les alertes.');
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
$('#zones-clear').onclick = () => { zones = []; storage.set('zones', 'null'); $('#alerts-enabled').checked = false; zoneStatus(); checkAlerts(); };
|
||||||
|
function sound() {
|
||||||
|
if (!audioContext) return;
|
||||||
|
const oscillator = audioContext.createOscillator(), gain = audioContext.createGain();
|
||||||
|
oscillator.connect(gain); gain.connect(audioContext.destination); oscillator.frequency.value = 660;
|
||||||
|
gain.gain.setValueAtTime(.12, audioContext.currentTime); gain.gain.exponentialRampToValueAtTime(.001, audioContext.currentTime + .35);
|
||||||
|
oscillator.start(); oscillator.stop(audioContext.currentTime + .36);
|
||||||
|
}
|
||||||
|
$('#alert-sound').onchange = async () => {
|
||||||
|
if ($('#alert-sound').checked) try { audioContext ||= new (window.AudioContext || window.webkitAudioContext)(); await audioContext.resume(); sound(); } catch { toast('Son indisponible dans ce navigateur.'); }
|
||||||
|
};
|
||||||
|
function showAlert(title, description) { $('#zone-alert-title').textContent = title; $('#zone-alert-description').textContent = description; $('#zone-alert').hidden = false; }
|
||||||
|
let testUntil = 0, dismissedZone = '';
|
||||||
|
$('#alert-test').onclick = () => { testUntil = Date.now() + 6000; showAlert('TEST · Zone fictive', 'Exemple d’avertissement, sans lien avec un radar ou votre position.'); if ($('#alert-sound').checked) sound(); };
|
||||||
|
$('#zone-alert-close').onclick = () => { testUntil = 0; dismissedZone = lastZone; $('#zone-alert').hidden = true; };
|
||||||
|
function checkAlerts() {
|
||||||
|
if (Date.now() < testUntil) return;
|
||||||
|
const candidates = $('#alerts-enabled').checked && $('#alert-country').value ? nearbyZones(zones, position, $('#alert-country').value) : [];
|
||||||
|
const candidate = candidates[0];
|
||||||
|
if (!candidate) { $('#zone-alert').hidden = true; dismissedZone = ''; return; }
|
||||||
|
const newAlert = candidate.id !== lastZone || Date.now() - lastZoneTime > 120000;
|
||||||
|
if (newAlert || (dismissedZone !== candidate.id && $('#zone-alert').hidden)) {
|
||||||
|
lastZone = candidate.id; lastZoneTime = Date.now();
|
||||||
|
showAlert(candidate.remaining ? 'Zone de vigilance à proximité' : 'Zone de vigilance', `${candidate.name} · ${candidate.country} · source : ${candidate.source}. Proximité géographique, sens de circulation non vérifié.`);
|
||||||
|
if (newAlert && $('#alert-sound').checked) sound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#alerts-enabled').onchange = () => { lastZone = ''; if ($('#alerts-enabled').checked && (!zones.length || !$('#alert-country').value)) { $('#alerts-enabled').checked = false; toast('Importez des zones et choisissez le pays d’utilisation.'); } else if ($('#alerts-enabled').checked && position?.source !== 'gps') toast('Alertes en attente d’un GPS précis.'); checkAlerts(); };
|
||||||
|
$('#alert-country').onchange = () => { $('#alerts-enabled').checked = false; lastZone = ''; checkAlerts(); toast('Pays modifié : réactivez les alertes après vérification des règles locales.'); };
|
||||||
|
setInterval(() => {
|
||||||
|
guidance(); checkAlerts();
|
||||||
|
if (position?.source === 'gps' && Date.now() - position.timestamp > 15000) $('#position-status').textContent = 'Position GPS ancienne · guidage et alertes en pause';
|
||||||
|
}, 3000);
|
||||||
|
setInterval(() => weather(), 60000);
|
||||||
|
window.addEventListener('pagehide', () => { stopGPS(); window.speechSynthesis?.cancel(); });
|
||||||
|
const saved = readJSON('position');
|
||||||
|
if (saved && Number.isFinite(saved.lat) && Number.isFinite(saved.lon) && Math.abs(saved.lat) <= 85 && Math.abs(saved.lon) <= 180) setPosition({ ...saved, label: String(saved.label || 'Lieu enregistré'), source: 'manual', timestamp: Date.now() });
|
||||||
|
if (location.hash === '#map') ensureMap();
|
||||||
|
}
|
||||||
+2
-1
@@ -5,11 +5,12 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
add_header X-Content-Type-Options nosniff;
|
add_header X-Content-Type-Options nosniff;
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
access_log off;
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $backend http://telemetry:8080;
|
set $backend http://telemetry:8080;
|
||||||
proxy_pass $backend;
|
proxy_pass $backend;
|
||||||
proxy_connect_timeout 2s;
|
proxy_connect_timeout 2s;
|
||||||
proxy_read_timeout 5s;
|
proxy_read_timeout 12s;
|
||||||
}
|
}
|
||||||
location / { try_files $uri $uri/ =404; }
|
location / { try_files $uri $uri/ =404; }
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
export const accents = { green: '#d2ef92', blue: '#8ac7ff', red: '#ff9e99' };
|
||||||
|
export function palette(color, light = false) {
|
||||||
|
color = /^#[0-9a-f]{6}$/i.test(color) ? color : accents.green;
|
||||||
|
const rgb = [1, 3, 5].map(i => parseInt(color.slice(i, i + 2), 16));
|
||||||
|
const luminance = c => { const n = c / 255; return n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4; };
|
||||||
|
const lum = values => values.map(luminance).reduce((n, v, i) => n + v * [.2126, .7152, .0722][i], 0);
|
||||||
|
// Maintain readable accent text on either background, even for arbitrary colors.
|
||||||
|
for (let i = 0; i < 40 && (light ? lum(rgb) > .17 : lum(rgb) < .36); i++) {
|
||||||
|
rgb.forEach((n, j) => rgb[j] = Math.round(light ? n * .9 : n + (255 - n) * .12));
|
||||||
|
}
|
||||||
|
return { accent: `#${rgb.map(n => n.toString(16).padStart(2, '0')).join('')}`, ink: light ? '#ffffff' : '#101313' };
|
||||||
|
}
|
||||||
|
export function applyTheme(storage) {
|
||||||
|
const light = storage.get('theme') === 'light';
|
||||||
|
const color = accents[storage.get('accent', 'green')] || storage.get('customColor', accents.green);
|
||||||
|
const values = palette(color, light);
|
||||||
|
document.body.classList.toggle('light', light);
|
||||||
|
document.body.classList.toggle('digital-dials', storage.get('dialStyle') === 'digital');
|
||||||
|
document.body.classList.toggle('luminous-dials', storage.get('dialStyle') === 'luminous');
|
||||||
|
document.querySelectorAll('.gauge > svg').forEach(svg => svg.setAttribute('viewBox', '12 10 276 340'));
|
||||||
|
document.body.style.setProperty('--accent', values.accent);
|
||||||
|
document.body.style.setProperty('--ink', values.ink);
|
||||||
|
document.dispatchEvent(new Event('caria-theme'));
|
||||||
|
}
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
BSD 2-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||||
|
Copyright (c) 2010-2011, CloudMade
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
Vendored
+661
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user