Amélioration avec des fonctions d'un vrai dashboard automotive et containerisation du projet pour faciliter le déploiement

This commit is contained in:
Christian CUNAT-BRULE
2026-09-12 21:13:24 +02:00
parent d170f95c92
commit d8eb7f4375
31 changed files with 2233 additions and 65 deletions
+4
View File
@@ -1,4 +1,8 @@
FROM nginx:stable-alpine
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/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
+125 -33
View File
@@ -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 all = selector => document.querySelectorAll(selector);
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 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('[data-view]').forEach(link => {
const active = link.dataset.view === view;
link.classList.toggle('active', active);
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);
navigate();
@@ -35,14 +42,35 @@ function makeGauge(id, max, step, unit, label) {
for (let v = 0; v <= max; v += step / 4) {
const angle = -130 + v / max * 260;
const major = v % step === 0;
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 97);
ticks += `<line class="tick ${major ? 'major' : ''}" 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>`;
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 96);
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 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 => {
const valid = typeof value === 'number' && Number.isFinite(value);
$(`${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';
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'}`);
@@ -53,6 +81,37 @@ const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME
const savedProfile = storage.get('profile', 'route');
$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route';
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() {
$('#connection').textContent = 'Données indisponibles';
$('#connection').classList.add('error');
@@ -60,6 +119,7 @@ function offline() {
document.body.classList.add('stale');
all('[data-value]').forEach(el => el.textContent = '—');
all('[data-fuel]').forEach(el => el.style.width = '0%');
updateLevels(null);
speedGauge(null); rpmGauge(null);
}
async function poll() {
@@ -81,6 +141,7 @@ async function poll() {
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))}%`);
updateLevels(data);
speedGauge(data.speedKph); rpmGauge(data.rpm);
} catch { if (current === generation) offline(); }
finally { if (current === generation) timer = setTimeout(poll, 500); }
@@ -88,41 +149,34 @@ async function poll() {
$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); });
window.addEventListener('storage', event => {
if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); }
if (event.key === 'caria.stream') configureStream();
if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light');
if (['caria.theme', 'caria.accent', 'caria.customColor', 'caria.dialStyle'].includes(event.key)) applyTheme(storage);
});
poll();
function validUrl(value) {
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } catch { return ''; }
}
const defaultStream = 'https://media.cunatbrule.fr/web/';
function configureStream() {
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');
const jellyfinUrl = applications.find(app => app.id === 'stream').url;
$('#stream-link').hidden = false;
$('#stream-link').href = jellyfinUrl;
$('#stream-description').textContent = new URL(jellyfinUrl).hostname;
applyTheme(storage);
function openSettings() {
$('#stream-url').value = storage.get('stream', defaultStream);
$('#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').showModal();
}
$('#custom-color').addEventListener('input', () => { $('#accent').value = 'custom'; });
$('#settings-open').onclick = openSettings;
$('#stream-configure').onclick = openSettings;
$('#settings-close').onclick = () => $('#settings').close();
$('#settings-form').addEventListener('submit', event => {
event.preventDefault();
const raw = $('#stream-url').value.trim(), url = validUrl(raw);
if (raw && !url) { $('#settings-message').textContent = 'Saisissez une adresse HTTP ou HTTPS, sans identifiant ni mot de passe.'; return; }
const streamSaved = storage.set('stream', url), themeSaved = storage.set('theme', $('#theme').value);
if (!streamSaved || !themeSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
document.body.classList.toggle('light', $('#theme').value === 'light');
configureStream(); $('#settings').close(); toast('Réglages enregistrés');
const dialSaved = storage.set('dialStyle', $('#dial-style').value);
const themeSaved = storage.set('theme', $('#theme').value);
const accentSaved = storage.set('accent', $('#accent').value), colorSaved = storage.set('customColor', $('#custom-color').value);
if (!dialSaved || !themeSaved || !accentSaved || !colorSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
applyTheme(storage);
$('#settings').close(); toast('Réglages enregistrés');
});
$('#fullscreen').onclick = async () => {
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
@@ -131,6 +185,25 @@ $('#fullscreen').onclick = async () => {
const audio = $('#audio');
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() {
$('#playlist').replaceChildren();
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.'; }
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;
previousTap = -Infinity;
if (remember && selected >= 0 && index !== selected) {
playbackHistory.push(selected);
if (playbackHistory.length > 256) playbackHistory.shift();
}
selected = (index + tracks.length) % tracks.length;
shuffleBag = shuffleBag.filter(index => index !== selected);
audio.src = tracks[selected].url;
$('#track-title').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;
tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++;
}
shuffleBag = [];
renderPlaylist();
if (selected === -1 && tracks.length) selectTrack(0);
toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu');
event.target.value = '';
});
$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); };
$('#previous').onclick = () => selectTrack(selected - 1, true);
$('#next').onclick = () => selectTrack(selected + 1, true);
let previousTap = -Infinity;
$('#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('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.');
window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); });
renderPlaylist();
initJourney({ storage, toast });
initApplications({ storage, toast, navigate });
initEmbeds({ storage, toast });
initCockpit({ storage, toast });
+5
View File
@@ -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}}
+146
View File
@@ -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
View File
@@ -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}
+93
View File
@@ -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();
}
}
+47
View File
@@ -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();});
}
+44
View File
@@ -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
View File
@@ -3,49 +3,107 @@
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<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>
<body>
<div class="shell">
<aside class="rail">
<a href="#home" class="brand" aria-label="CARIA accueil">C<span> /</span></a>
<nav aria-label="Applications">
<aside class="rail" id="side-rail">
<nav id="favorite-apps" aria-label="Applications">
<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="#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="#stream" data-view="stream"><span class="icon">▷</span><span>Streaming</span></a>
</nav>
<button id="settings-open" class="settings-button"><span class="icon">⚙</span><span>Réglages</span></button>
</aside>
<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>
<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">
<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">
<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="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="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" 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="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">♫ &nbsp; Ouvrir ma musique <span>↗</span></a></div></article>
</div>
</section>
<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="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="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="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="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="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 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="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 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>
</main>
<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>
<section id="map" class="view" hidden>
<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>
<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>
</body>
</html>
+21
View File
@@ -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
View File
@@ -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
View File
@@ -5,11 +5,12 @@ server {
index index.html;
add_header X-Content-Type-Options nosniff;
location /api/ {
access_log off;
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend http://telemetry:8080;
proxy_pass $backend;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
proxy_read_timeout 12s;
}
location / { try_files $uri $uri/ =404; }
}
+24
View File
@@ -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'));
}
+26
View File
@@ -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.
+661
View File
@@ -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;
}
}
+6
View File
File diff suppressed because one or more lines are too long