Files
CARIA-AUTOMOTIVE/ui/app.js
T

182 lines
11 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.
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 view = ['home', 'drive', 'music', 'stream'].includes(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()}`;
}
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, 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>`;
}
$(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>`;
return value => {
const valid = typeof value === 'number' && Number.isFinite(value);
$(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—';
$(`${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 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%');
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))}%`);
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 (event.key === 'caria.stream') configureStream();
if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light');
});
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');
function openSettings() {
$('#stream-url').value = storage.get('stream', defaultStream);
$('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark';
$('#settings-message').textContent = '';
$('#settings').showModal();
}
$('#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');
});
$('#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;
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) {
if (!tracks.length) return;
selected = (index + tracks.length) % tracks.length;
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++;
}
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);
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('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();