Files
CARIA-AUTOMOTIVE/ui/app.js
T

274 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 += `<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>`;
}
// 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'}`);
};
}
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 });