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 = { get(key, fallback = '') { try { return localStorage.getItem(`caria.${key}`) ?? fallback; } catch { return fallback; } }, set(key, value) { try { localStorage.setItem(`caria.${key}`, value); return true; } catch { return false; } } }; function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); } function navigate() { 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 · ${titles[view]}`; document.dispatchEvent(new CustomEvent('caria-view', { detail: view })); } window.addEventListener('hashchange', navigate); navigate(); function clock() { const date = new Date(); const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); ['#hero-clock', '#header-clock'].forEach(id => { $(id).textContent = time; $(id).dateTime = date.toISOString(); }); $('#date').textContent = date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); } clock(); setInterval(clock, 1000); function makeGauge(id, max, step, unit, label) { const point = (angle, radius) => { const r = angle * Math.PI / 180; return [150 + radius * Math.sin(r), 150 - radius * Math.cos(r)]; }; let ticks = ''; 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, 96); ticks += ``; if (major) ticks += `${max === 7000 ? v / 1000 : v}`; } // 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 ? `` : ''; 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 ``; }).join(''); const level = `${levelTicks}${max===7000?'C':'E'}${max===7000?'H':'F'}${max===7000?'':'½'}${iconPath}— ${max===7000?'°C':'%'}`; const low = max === 7000 ? 'C' : 'E', high = max === 7000 ? 'H' : 'F'; const mini = `${low}${high}${max===7000?'':`½`}${iconPath}— ${max===7000?'°C':'%'}`; $(id).innerHTML = `${ticks}${redZone}${unit}${max === 7000 ? 'TR/MIN' : unit}—${label}${level}${mini}`; 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'}`); }; } const speedGauge = makeGauge('#speed-gauge', 220, 20, 'KM/H', 'VITESSE'); const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME MOTEUR'); 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'); $('#footer-status').textContent = 'Connexion perdue · tentative de reconnexion'; 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() { const current = ++generation; clearTimeout(timer); try { const response = await fetch(`/api/telemetry?profile=${encodeURIComponent($('#profile').value)}`, { signal: AbortSignal.timeout(3000), cache: 'no-store' }); if (!response.ok) throw new Error('API'); const data = await response.json(); if (current !== generation) return; const numeric = ['speedKph', 'rpm', 'fuelPercent', 'coolantCelsius', 'batteryVolts', 'outsideCelsius', 'rangeKm', 'tripKm', 'consumptionL100']; if (data.schemaVersion !== 1 || data.source !== 'demo' || !Number.isFinite(data.timestamp) || Math.abs(Date.now() - data.timestamp) > 10000 || numeric.some(key => !Number.isFinite(data[key]))) throw new Error('Données invalides'); $('#connection').textContent = '● Démonstration'; $('#connection').classList.remove('error'); document.body.classList.remove('stale'); $('#footer-status').textContent = 'Mode démonstration · CAN non connecté'; all('[data-value]').forEach(el => { const value = data[el.dataset.value]; el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value; }); all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`); updateLevels(data); speedGauge(data.speedKph); rpmGauge(data.rpm); } catch { if (current === generation) offline(); } finally { if (current === generation) timer = setTimeout(poll, 500); } } $('#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 (['caria.theme', 'caria.accent', 'caria.customColor', 'caria.dialStyle'].includes(event.key)) applyTheme(storage); }); poll(); 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() { $('#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; $('#settings-close').onclick = () => $('#settings').close(); $('#settings-form').addEventListener('submit', event => { event.preventDefault(); 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(); } catch { toast('Le plein écran n’est pas disponible dans ce navigateur.'); } }; 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) => { const li = document.createElement('li'), button = document.createElement('button'); const number = document.createElement('span'), name = document.createElement('b'); number.textContent = String(index + 1).padStart(2, '0'); name.textContent = track.name; button.append(number, name); button.classList.toggle('selected', index === selected); button.setAttribute('aria-pressed', String(index === selected)); button.onclick = () => selectTrack(index, true); li.append(button); $('#playlist').append(li); }); $('#track-count').textContent = `${tracks.length} TITRE${tracks.length > 1 ? 'S' : ''}`; $('#empty-library').hidden = tracks.length > 0; ['#play', '#previous', '#next'].forEach(id => $(id).disabled = tracks.length === 0); } 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, 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; $('#track-subtitle').textContent = `Morceau ${selected + 1} sur ${tracks.length} · Bibliothèque locale`; $('#home-track-sub').textContent = 'Bibliothèque locale'; renderPlaylist(); if (play) playAudio(); } $('#music-files').addEventListener('change', event => { let added = 0; for (const file of event.target.files) { 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(); }; 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 (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 });