Amélioration avec des fonctions d'un vrai dashboard automotive et containerisation du projet pour faciliter le déploiement
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#applications-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.catalog-heading{display:flex;justify-content:space-between;gap:15px;align-items:center;margin-bottom:18px}.catalog-heading p{font-size:13px;color:var(--muted);margin:0}.application-card{padding:0;position:relative;box-shadow:none}.application-open{display:block;padding:25px;min-height:205px}.application-open>.icon{display:grid;place-items:center;width:52px;height:52px;border-radius:15px;background:color-mix(in srgb,var(--accent) 10%,var(--surface));color:var(--accent);font-size:29px;margin-bottom:18px}.application-open h2{font-size:19px;margin-bottom:8px}.application-open p{font-size:12px;color:var(--muted);margin:0 0 16px;min-height:36px}.application-kind{font-size:10px;color:var(--accent)}.application-card:hover{border-color:var(--accent)}.favorite-toggle{position:absolute;right:12px;top:12px;font-size:23px;padding:4px 10px;border-color:transparent;background:transparent;color:var(--accent)}.favorite-toggle[aria-pressed=true]{background:color-mix(in srgb,var(--accent) 10%,var(--surface))}.application-open[aria-disabled=true]{opacity:.55;cursor:default}#application-settings{width:min(640px,calc(100% - 24px))}#application-settings h3{font-size:16px;margin:24px 0 8px}#favorite-slots{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}#favorite-slots label{margin-top:6px}#favorite-slots select{width:100%}.application-setting{display:grid;grid-template-columns:minmax(0,1fr) minmax(130px,180px);gap:12px;align-items:center;min-height:60px;border-bottom:1px solid var(--border);padding:8px 0}.application-setting label{display:flex;flex-direction:row;align-items:center;margin:0;min-height:44px;gap:12px}.application-setting input{width:20px;height:20px;accent-color:var(--accent)}.application-setting>span{color:var(--muted);font-size:12px}.application-setting select{width:100%;font-size:12px}.rail nav a span:last-child{overflow-wrap:anywhere;text-align:center}.map-actions a[data-waze-destination]{font-size:11px;padding:12px;border:1px solid var(--border);border-radius:10px;color:var(--accent)}
|
||||
@media(min-width:1450px){#applications-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
||||
@media(max-width:900px){#applications-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.application-open{padding:20px}}
|
||||
@media(max-width:520px){#applications-grid{grid-template-columns:1fr}.application-open{min-height:165px}.application-open>.icon{margin-bottom:12px}.application-open p{min-height:0}#favorite-slots{grid-template-columns:1fr}.application-setting{grid-template-columns:1fr}.application-setting select{margin-bottom:6px}.catalog-heading{align-items:flex-start}.catalog-heading p{font-size:11px}}
|
||||
@media(max-height:650px) and (min-width:760px){.application-open{padding:18px;min-height:180px}.application-open>.icon{width:44px;height:44px;margin-bottom:12px}.application-open h2{font-size:17px}.application-open p{font-size:11px;margin-bottom:10px}}
|
||||
@@ -0,0 +1,146 @@
|
||||
export const applications = [
|
||||
{ id: 'home', name: 'Accueil', icon: '⌂', description: 'Heure, météo et votre espace à bord.' },
|
||||
{ id: 'drive', name: 'Conduite', icon: '◉', description: 'Les instruments de votre véhicule.' },
|
||||
{ id: 'map', name: 'Navigation', icon: '⌖', description: 'Carte, itinéraires et zones de vigilance.' },
|
||||
{ id: 'music', name: 'Musique locale', icon: '♫', description: 'Vos morceaux sur cet appareil.' },
|
||||
{ id: 'stream', name: 'Jellyfin', icon: '▷', external: true, url: 'https://media.cunatbrule.fr/web/', description: 'Votre bibliothèque multimédia personnelle.' },
|
||||
{ id: 'spotify', name: 'Spotify', icon: '≋', external: true, url: 'https://open.spotify.com/', description: 'Ouvrir le lecteur Web Spotify.' },
|
||||
{ id: 'youtube', name: 'YouTube', icon: '▶', external: true, url: 'https://www.youtube.com/', description: 'Vidéos, chaînes et playlists.' },
|
||||
{ id: 'youtube-music', name: 'YouTube Music', icon: '◉', external: true, url: 'https://music.youtube.com/', description: 'Votre musique sur YouTube Music.' },
|
||||
{ id: 'waze', name: 'Waze', icon: '⌁', external: true, url: 'https://waze.com/ul?utm_source=caria', description: 'Ouvrir Waze ou lui transmettre votre destination.' }
|
||||
];
|
||||
const ids = new Set(applications.map(a => a.id));
|
||||
export function normalizePreferences(value) {
|
||||
const raw = value && typeof value === 'object' ? value : {};
|
||||
const hidden = [...new Set(Array.isArray(raw.hidden) ? raw.hidden.filter(id => ids.has(id)) : [])];
|
||||
const seen = new Set();
|
||||
const favorites = Array.from({ length: 4 }, (_, i) => {
|
||||
const id = (Array.isArray(raw.favorites) ? raw.favorites : ['home', 'drive', 'map', 'music'])[i];
|
||||
if (!ids.has(id) || hidden.includes(id) || seen.has(id)) return null;
|
||||
seen.add(id); return id;
|
||||
});
|
||||
const modes = Object.fromEntries(applications.filter(a => a.external).map(a => [a.id, raw.modes?.[a.id] === 'same' ? 'same' : raw.modes?.[a.id] === 'embed' && a.id !== 'waze' ? 'embed' : 'new']));
|
||||
return { hidden, favorites, modes };
|
||||
}
|
||||
export function wazeUrl(destination) {
|
||||
const url = new URL('https://waze.com/ul'); url.searchParams.set('utm_source', 'caria');
|
||||
if (destination && Number.isFinite(destination.lat) && Number.isFinite(destination.lon) && Math.abs(destination.lat) <= 90 && Math.abs(destination.lon) <= 180) {
|
||||
url.searchParams.set('ll', `${destination.lat},${destination.lon}`); url.searchParams.set('navigate', 'yes');
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
export function safeWebUrl(value) {
|
||||
try { const u = new URL(value); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password ? u.href : ''; } catch { return ''; }
|
||||
}
|
||||
export function initApplications({ storage, toast, navigate }) {
|
||||
const $ = s => document.querySelector(s);
|
||||
let preferences, destination = null;
|
||||
function load() { let raw; try { raw = JSON.parse(storage.get('applications', 'null')); } catch {} preferences = normalizePreferences(raw); }
|
||||
load();
|
||||
function urlFor(app) {
|
||||
if (!app.external) return `#${app.id}`;
|
||||
if (app.id === 'waze') return wazeUrl(destination);
|
||||
return app.url;
|
||||
}
|
||||
function configureLink(link, app) {
|
||||
link.onclick=null;
|
||||
const href = urlFor(app);
|
||||
link.dataset.application = app.id;
|
||||
if (href) { link.href = href; link.removeAttribute('aria-disabled'); }
|
||||
else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); }
|
||||
if (app.external && preferences.modes[app.id] === 'embed') {
|
||||
link.href='#embedded';link.removeAttribute('target');
|
||||
link.onclick=e=>{e.preventDefault();location.hash='embedded';navigate();document.dispatchEvent(new CustomEvent('caria-embed',{detail:app.id}));};
|
||||
}
|
||||
else if (app.external) { link.target = preferences.modes[app.id] === 'same' ? '_self' : '_blank'; link.rel = 'noopener noreferrer'; }
|
||||
else link.dataset.view = app.id;
|
||||
}
|
||||
function icon(app) { const span = document.createElement('span'); span.className = 'icon'; span.textContent = app.icon; span.setAttribute('aria-hidden', 'true'); return span; }
|
||||
function save(next) {
|
||||
const normalized = normalizePreferences(next);
|
||||
if (!storage.set('applications', JSON.stringify(normalized))) { toast('Le navigateur bloque l’enregistrement des applications.'); renderSettings(); return; }
|
||||
preferences = normalized; render(); renderSettings();
|
||||
}
|
||||
function render() {
|
||||
const nav = $('#favorite-apps'); nav.replaceChildren();
|
||||
preferences.favorites.filter(Boolean).forEach(id => {
|
||||
const app = applications.find(a => a.id === id), link = document.createElement('a'); configureLink(link, app);
|
||||
const name = document.createElement('span'); name.textContent = app.id === 'music' ? 'Musique' : app.name;
|
||||
link.append(icon(app), name); nav.append(link);
|
||||
});
|
||||
const catalogLink = document.createElement('a'); catalogLink.href = '#apps'; catalogLink.dataset.view = 'apps';
|
||||
const label = document.createElement('span'); label.textContent = 'Applications';
|
||||
catalogLink.append(icon({ icon: '▦' }), label); nav.append(catalogLink);
|
||||
const catalog = $('#applications-grid'); catalog.replaceChildren();
|
||||
applications.filter(a => !preferences.hidden.includes(a.id)).forEach(app => {
|
||||
const card = document.createElement('article'); card.className = 'card application-card';
|
||||
const link = document.createElement('a'); configureLink(link, app); link.className = 'application-open';
|
||||
const name = document.createElement('h2'); name.textContent = app.name;
|
||||
const description = document.createElement('p'); description.textContent = app.description;
|
||||
const hint = document.createElement('span'); hint.className = 'application-kind';
|
||||
hint.textContent = !urlFor(app) ? 'Adresse à configurer dans les réglages' : app.external ? preferences.modes[app.id] === 'embed' ? 'Lecteur intégré · dans CARIA' : preferences.modes[app.id] === 'same' ? 'Site externe · même onglet ↗' : 'Site externe · nouvel onglet ↗' : 'Dans CARIA';
|
||||
link.append(icon(app), name, description, hint);
|
||||
const favorite = document.createElement('button'); favorite.className = 'favorite-toggle';
|
||||
const selected = preferences.favorites.includes(app.id); favorite.textContent = selected ? '★' : '☆';
|
||||
favorite.setAttribute('aria-label', `${selected ? 'Retirer' : 'Ajouter'} ${app.name} ${selected ? 'des' : 'aux'} favoris`); favorite.setAttribute('aria-pressed', String(selected));
|
||||
favorite.onclick = () => {
|
||||
const next = structuredClone(preferences), slot = next.favorites.indexOf(selected ? app.id : null);
|
||||
if (slot < 0) return toast('Quatre favoris maximum. Utilisez Personnaliser pour remplacer un favori.');
|
||||
next.favorites[slot] = selected ? null : app.id; save(next);
|
||||
};
|
||||
card.append(link, favorite); catalog.append(card);
|
||||
});
|
||||
$('#applications-empty').hidden = catalog.childElementCount !== 0;
|
||||
$('#favorite-count').textContent = `${preferences.favorites.filter(Boolean).length} / 4 favoris`;
|
||||
document.querySelectorAll('[data-waze-destination]').forEach(link => {
|
||||
configureLink(link, applications.find(a => a.id === 'waze'));
|
||||
link.hidden = !destination || preferences.hidden.includes('waze');
|
||||
});
|
||||
configureLink($('#stream-link'), applications.find(a=>a.id==='stream'));
|
||||
$('#stream-opening').textContent = preferences.modes.stream === 'embed' ? 'S’ouvre dans le lecteur intégré CARIA, si votre serveur Jellyfin autorise cet affichage.' : preferences.modes.stream === 'same' ? 'S’ouvre dans cet onglet. Utilisez Retour pour retrouver CARIA ; la lecture locale peut être interrompue.' : 'S’ouvre dans un nouvel onglet. Votre cockpit reste accessible.';
|
||||
navigate();
|
||||
}
|
||||
function renderSettings() {
|
||||
const slots = $('#favorite-slots'); slots.replaceChildren();
|
||||
preferences.favorites.forEach((id, index) => {
|
||||
const label = document.createElement('label'); label.textContent = `Favori ${index + 1}`;
|
||||
const select = document.createElement('select'); select.dataset.favoriteSlot = index;
|
||||
select.add(new Option('Aucun', ''));
|
||||
applications.filter(a => !preferences.hidden.includes(a.id)).forEach(app => select.add(new Option(app.name, app.id)));
|
||||
select.value = id || '';
|
||||
select.onchange = () => {
|
||||
const next = structuredClone(preferences), chosen = select.value || null, other = chosen ? next.favorites.indexOf(chosen) : -1;
|
||||
if (other >= 0 && other !== index) next.favorites[other] = id;
|
||||
next.favorites[index] = chosen; save(next);
|
||||
};
|
||||
label.append(select); slots.append(label);
|
||||
});
|
||||
const list = $('#application-settings-list'); list.replaceChildren();
|
||||
applications.forEach(app => {
|
||||
const row = document.createElement('div'); row.className = 'application-setting'; row.dataset.appSetting = app.id;
|
||||
const visible = document.createElement('label'), checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = !preferences.hidden.includes(app.id);
|
||||
checkbox.setAttribute('aria-label', `Afficher ${app.name}`); visible.append(checkbox, document.createTextNode(app.name));
|
||||
checkbox.onchange = () => {
|
||||
const next = structuredClone(preferences); next.hidden = checkbox.checked ? next.hidden.filter(id => id !== app.id) : [...next.hidden, app.id]; save(next);
|
||||
};
|
||||
row.append(visible);
|
||||
if (app.external) {
|
||||
const mode = document.createElement('select'); mode.setAttribute('aria-label', `Ouverture de ${app.name}`);
|
||||
mode.add(new Option('Nouvel onglet', 'new')); mode.add(new Option('Même onglet', 'same')); mode.value = preferences.modes[app.id];
|
||||
if(app.id !== 'waze') mode.add(new Option('Lecteur intégré','embed'));
|
||||
mode.value=preferences.modes[app.id];
|
||||
mode.onchange = () => { const next = structuredClone(preferences); next.modes[app.id] = mode.value; save(next); }; row.append(mode);
|
||||
} else { const hint = document.createElement('span'); hint.textContent = 'Dans CARIA'; row.append(hint); }
|
||||
list.append(row);
|
||||
});
|
||||
}
|
||||
function openSettings() { renderSettings(); $('#application-settings').showModal(); }
|
||||
$('#applications-customize').onclick = openSettings;
|
||||
$('#applications-settings-open').onclick = () => { $('#settings').close(); openSettings(); };
|
||||
$('#application-settings-close').onclick = () => $('#application-settings').close();
|
||||
$('#applications-reset').onclick = () => save(normalizePreferences(null));
|
||||
document.addEventListener('caria-destination', event => { destination = event.detail; render(); });
|
||||
document.addEventListener('caria-stream', render);
|
||||
window.addEventListener('storage', event => { if (event.key === 'caria.applications' || event.key === null) { load(); render(); renderSettings(); } });
|
||||
render();
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/* Touch layout, reference panel: 1200 wide × 1080 high, 10 inches, landscape. */
|
||||
.rail{position:sticky;top:0;height:100dvh;min-height:440px}.rail-collapsed .rail{display:none}.page-top{height:auto;min-height:62px;padding:0 0 20px;border:0;margin-bottom:14px}.header-left{display:flex;align-items:center;gap:18px}#rail-toggle{font-size:25px;min-width:52px;min-height:52px;background:transparent}.page-heading>div:first-child:not(.map-actions){position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.page-heading>.subtle{display:none}.page-heading{justify-content:flex-end;margin-bottom:16px}.page-heading:has(>div:only-child),#home>.page-heading,#drive>.page-heading{margin-bottom:14px}#home>.page-heading{display:none}main{padding:22px 26px}.rail nav a{min-height:72px}.rail nav a span:last-child{font-size:12px}.page-top .wordmark{font-size:21px}.page-top .wordmark span{font-size:8px}.page-top .header-right{gap:16px}.view-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:18px}.view-toolbar a{display:inline-flex;align-items:center;min-height:48px;color:var(--accent);font-size:14px}.cluster{padding:18px 12px 0}.gauges{grid-template-columns:minmax(0,1fr) 62px minmax(0,1fr);max-width:none;gap:0}.gauge svg{max-height:none;aspect-ratio:300/265}.gauge .dial-value{font-size:44px}.gauge .dial-label{font-size:10px}.gear strong{font-size:54px}.cluster-top{padding:0 12px;margin-bottom:14px}.cluster-bottom{margin-top:14px;padding:16px 12px;font-size:13px}.quick-sensors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:22px 0}.quick-sensor{padding:24px;display:flex;align-items:center;gap:20px}.quick-sensor>.sensor-icon{font-size:31px;color:var(--accent);width:42px}.quick-sensor .eyebrow{font-size:12px;letter-spacing:1px}.quick-sensor h2{font-size:46px;line-height:1.2;margin:8px 0 0}.quick-sensor h2 small{font-size:20px;color:var(--muted)}.quick-sensor p:last-child{font-size:12px;color:var(--muted);margin:8px 0 0}.drive-links{display:flex;justify-content:space-between;gap:15px;align-items:center}.drive-links a{min-height:52px}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.detail-grid .card{padding:26px}.detail-grid h2{font-size:18px}.detail-grid strong{font-size:36px;font-weight:500}.detail-grid p{font-size:14px;color:var(--muted)}.details-notice{color:var(--muted);font-size:13px;line-height:1.7}.player-view-link{display:inline-flex;min-height:48px;align-items:center;justify-content:center;margin-top:15px;color:var(--accent);font-size:14px}.embed-frame{border:1px solid var(--border);border-radius:16px;width:100%;min-height:500px;background:var(--surface)}.embed-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px}.embed-controls input{flex:1;min-width:180px;min-height:48px}.embed-controls button,.embed-controls a{min-height:48px}.embed-notice{font-size:13px;color:var(--muted);line-height:1.6}.embedded-card{padding:24px}.embedded-card iframe{display:block}.embedded-card iframe[data-provider=spotify]{min-height:352px}.embedded-card iframe[data-provider=youtube-music],.embedded-card iframe[data-provider=youtube]{aspect-ratio:16/9;min-height:220px}
|
||||
@media(min-width:900px) and (min-height:950px){.home-grid{grid-template-columns:1fr 1fr}.clock-card{min-height:355px}.weather-card{min-height:355px}.home-grid .vehicle-card,.home-grid .music-card{min-height:265px}.clock-card>time{font-size:100px}.application-open{min-height:230px}.cluster{margin-top:12px}.quick-sensors{margin-top:28px}.quick-sensor{min-height:152px}.player-grid{grid-template-columns:1fr 1fr}.album-art.large{width:230px}.library{min-height:620px}}
|
||||
@media(max-width:759px){main{padding:15px}.page-top{gap:8px;min-height:54px;padding-bottom:12px}.page-top .header-left{gap:7px}.page-top .header-right{gap:5px}.page-top .wordmark{font-size:15px;letter-spacing:2px}.page-top .wordmark span{display:none}.page-top .badge{font-size:7px;max-width:100px;white-space:normal}.page-top .header-right time{font-size:12px}#rail-toggle{min-width:44px;padding:5px}.gauges{grid-template-columns:1fr 1fr}.gear{grid-column:1/-1;grid-row:2;display:flex;gap:14px}.gear strong{font-size:32px}.quick-sensor{padding:16px;gap:8px}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:32px}.quick-sensor .eyebrow{font-size:10px}.detail-grid{grid-template-columns:1fr}.drive-links{flex-wrap:wrap}.cluster-bottom{font-size:10px}.quick-sensors{gap:10px}.view-toolbar{flex-wrap:wrap}}
|
||||
@media(max-height:650px) and (min-width:760px){.page-top{margin-bottom:8px;padding-bottom:6px}.gauge svg{max-height:260px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:14px 0}.quick-sensor{padding:14px;gap:0}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:30px}.quick-sensor .eyebrow{font-size:9px}.quick-sensor p:last-child{font-size:10px}.cluster-top{margin-bottom:0}.cluster-bottom{padding:10px;font-size:11px}.rail nav a{min-height:55px}}
|
||||
@media(min-width:1000px) and (min-height:651px){.gauge svg{max-height:440px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:20px 0}.quick-sensor{padding:22px 16px;min-height:140px}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:39px}.quick-sensor .eyebrow{font-size:11px}.quick-sensor p:last-child{font-size:11px}}
|
||||
|
||||
/* Shared local player: centered controls inside a cropped circular progress arc. */
|
||||
.drive-actions{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.drive-actions .profile-label{margin:0}.drive-actions select{min-height:48px}
|
||||
.immersive-player{max-width:920px;margin:auto;padding:20px 28px 28px;text-align:center;background:radial-gradient(ellipse at 50% 35%,color-mix(in srgb,var(--accent) 8%,transparent),transparent 70%)}
|
||||
.immersive-stage{position:relative;width:100%;max-width:780px;aspect-ratio:600/430;margin:auto}
|
||||
.music-orbit{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
|
||||
.orbit-track,#track-arc{fill:none;stroke:var(--border);stroke-width:7;stroke-linecap:round}
|
||||
#track-arc{stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;transition:stroke-dashoffset .2s linear}
|
||||
.immersive-caption{position:absolute;top:43%;left:12%;right:12%}
|
||||
.immersive-caption h2{font-size:clamp(18px,2.4vw,30px);line-height:1.25;margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
|
||||
.immersive-caption p{font-size:13px;color:var(--muted);margin:12px 0 0}
|
||||
.immersive-controls{position:absolute;top:68%;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:18px}
|
||||
.immersive-player button{display:inline-flex;align-items:center;justify-content:center;min-width:54px;min-height:54px;padding:12px;border-radius:16px}
|
||||
.immersive-player button svg{width:25px;height:25px;flex-shrink:0}
|
||||
.immersive-controls #immersive-play{width:82px;height:82px;border-radius:50%;background:var(--accent);color:var(--ink);box-shadow:0 4px 26px color-mix(in srgb,var(--accent) 18%,transparent)}
|
||||
#immersive-play svg{width:32px;height:32px}
|
||||
.immersive-times{position:absolute;left:4%;right:4%;bottom:5%;display:flex;justify-content:space-between;font-size:16px;font-variant-numeric:tabular-nums;color:var(--muted)}
|
||||
.immersive-progress{max-width:680px;margin:-8px auto 6px}
|
||||
.immersive-player input[type=range]{height:44px;min-width:0;width:100%;margin:0;accent-color:var(--accent);cursor:pointer}
|
||||
.immersive-bottom{display:flex;align-items:center;justify-content:space-between;gap:24px;max-width:680px;margin:auto}
|
||||
.immersive-secondary{display:flex;gap:10px}
|
||||
.immersive-player button[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
|
||||
.immersive-volume{display:flex;align-items:center;gap:12px;width:340px;max-width:100%}
|
||||
.immersive-volume input{flex:1}.immersive-volume output{min-width:48px;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
@media(max-width:600px){.immersive-player{padding:12px}.immersive-stage{min-height:300px}.immersive-caption{top:39%;left:8%;right:8%}.immersive-caption p{font-size:11px;margin-top:8px}.immersive-controls{top:65%;gap:12px}.immersive-controls #immersive-play{width:66px;height:66px}.immersive-times{bottom:1%;font-size:13px}.immersive-progress{margin:8px auto}.immersive-bottom{flex-wrap:wrap;justify-content:center;gap:12px}.immersive-volume{width:100%}}
|
||||
@media(max-height:700px) and (min-width:760px){.immersive-player{max-width:820px;padding:8px 24px 12px}.immersive-stage{max-width:440px}.immersive-caption h2{font-size:18px}.immersive-caption p{font-size:11px;margin-top:6px}.immersive-controls{top:67%;gap:14px}.immersive-controls #immersive-play{width:58px;height:58px}.immersive-times{bottom:0}.immersive-progress{margin:0 auto}.immersive-bottom{gap:16px}}
|
||||
@media(prefers-reduced-motion:reduce){#track-arc{transition:none}}
|
||||
/* Viewport-sized cockpit. Long collections use explicit pages. */
|
||||
html,body{height:100%;overflow:hidden}.shell{height:100dvh;min-height:0}.workspace{height:100%;min-height:0}main{height:100%;min-height:0;display:flex;flex-direction:column;padding:12px 22px;margin:0 auto}.page-top{flex-shrink:0;min-height:52px;padding:0;margin-bottom:10px}.view:not([hidden]){flex:1;min-height:0;display:flex;flex-direction:column;gap:12px}.view>.page-heading{flex-shrink:0;margin:0}.view>.page-heading:has(>div:only-child){display:none}.view-toolbar{margin:0;flex-shrink:0}.rail{min-height:0;padding-top:12px;padding-bottom:12px;gap:8px}.rail nav{gap:6px}.rail nav a{min-height:52px}.rail-collapsed .page-top{min-height:44px;height:44px;margin-bottom:4px}.rail-collapsed .header-right .badge,.rail-collapsed .header-temp{display:none}.rail-collapsed #rail-toggle{min-height:44px;height:44px}.rail-collapsed main{padding-top:6px}
|
||||
#settings footer{padding:12px 0;flex-wrap:wrap;margin:14px 0}#settings footer>span:last-child{display:none}
|
||||
.home-grid{flex:1;min-height:0;grid-template-rows:1.1fr 1fr}.home-grid>.card{min-height:0!important}.clock-card>time{font-size:clamp(52px,9vh,100px)}.weather-main{margin:8px 0}.home-grid .card{padding:18px}.home-grid .album-art{width:90px}.home-grid .music-card{gap:14px}
|
||||
#drive .cluster{flex:1;min-height:0;display:flex;flex-direction:column;margin:0}.gauges{flex:1;min-height:0;width:100%}.gauge{height:100%;min-height:0}.gauge svg{height:100%;max-height:none!important;width:100%;aspect-ratio:auto}.cluster-top,.cluster-bottom{flex-shrink:0}.cluster-bottom{margin-top:4px;padding:10px}.quick-sensors{flex-shrink:0;margin:0!important;gap:12px}.quick-sensor{min-height:0!important;padding:14px!important}.quick-sensor h2{font-size:clamp(26px,4vh,42px)}.drive-links{flex-shrink:0;flex-wrap:wrap}.drive-links a{display:inline-flex;align-items:center}.drive-actions{gap:12px}
|
||||
#now-playing .immersive-player{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:10px 24px 18px;gap:8px}#now-playing .immersive-stage{flex:1;min-height:0;width:100%;max-width:min(780px,calc((100dvh - 230px)*1.395));margin:0;aspect-ratio:600/430;max-height:559px}#now-playing .immersive-bottom{flex-shrink:0;width:100%;margin:0;z-index:1}.immersive-stage:before{content:'';position:absolute;inset:20% 15% 10%;border-radius:50%;background:radial-gradient(ellipse,color-mix(in srgb,var(--accent) 17%,transparent),transparent 70%);opacity:.65;pointer-events:none;animation:music-halo 7s ease-in-out infinite;animation-play-state:paused}.playing .immersive-stage:before{animation-play-state:running}@keyframes music-halo{50%{opacity:1;transform:scale(1.06)}}@media(prefers-reduced-motion:reduce){.immersive-stage:before{animation:none}}
|
||||
.player-grid{flex:1;min-height:0}.player-grid>.card{min-height:0!important}.player{display:flex;flex-direction:column;justify-content:center;align-items:center}.player>*{flex-shrink:0}.player .album-art.large{width:min(170px,20vh);margin-bottom:10px}.player h2{font-size:22px;margin:0}.player p{margin:8px 0}.player audio{height:44px}.library{display:flex;flex-direction:column}#playlist{flex:1;min-height:0;max-height:none;overflow:hidden;margin:10px 0}.collection-pages{display:flex;justify-content:center;align-items:center;gap:16px;flex-shrink:0;min-height:48px}.collection-pages span{font-size:12px;color:var(--muted)}.collection-pages button{min-width:48px}.detail-grid{flex:1;min-height:0;grid-template-columns:repeat(4,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));gap:12px}.detail-grid .card{padding:16px}.detail-grid strong{font-size:30px}.detail-grid p{font-size:12px}.details-notice{margin:0;font-size:11px}#applications-grid{flex:1;min-height:0;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:minmax(0,1fr)}.catalog-heading{margin:0}.application-open{min-height:0!important;height:100%;padding:16px}.application-open>.icon{margin-bottom:10px}.application-open p{min-height:0;margin-bottom:8px}#apps>.subtle{margin:0}
|
||||
.journey-grid{flex:1;min-height:0}.map-column{display:flex;flex-direction:column;min-height:0}#map-canvas{flex:1;min-height:100px;height:auto!important}.journey-panel{min-height:0;overflow:auto}.embedded-card{flex:1;min-height:0;display:flex;flex-direction:column}.embed-controls{margin-bottom:8px}#embed-container{flex:1;min-height:0}#embed-container iframe{height:100%;min-height:0}.stream-card{margin:auto 0}
|
||||
@media(max-height:750px){.home-grid .clock-card,.home-grid .weather-card{padding:14px}.home-grid .weather-card>p{margin-bottom:6px}.home-grid .music-card h2{font-size:17px}.home-grid .vehicle-card h2{display:none}.home-grid .clock-bottom{bottom:14px}.home-grid .weather-bottom{padding-top:8px}.home-grid .weather-main strong{font-size:40px}.home-grid .weather-sun{font-size:40px}.quick-sensor p:last-child{margin-top:4px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr))}.quick-sensor .eyebrow{font-size:9px}.quick-sensor h2{font-size:28px;margin-top:4px}.quick-sensor p:last-child{font-size:10px}.cluster-top{margin:0}.view:not([hidden]){gap:8px}.player .album-art.large{width:80px}.player p{font-size:11px}.detail-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.detail-grid h2{font-size:15px}.detail-grid strong{font-size:25px}.detail-grid p{font-size:11px;line-height:1.4}.detail-grid .card{padding:12px}.application-open p{display:none}.application-open>.icon{width:36px;height:36px;font-size:23px}.application-open h2{font-size:16px}.application-open{padding:12px}.application-kind{font-size:9px}.catalog-heading{display:none}}
|
||||
@media(max-width:759px){main{padding:8px}.home-grid{grid-template-rows:1fr 1fr}.home-grid .card{padding:14px}.player-grid{grid-template-columns:1fr;grid-template-rows:auto minmax(0,1fr)}.player .album-art.large,.player .eyebrow,.player #audio-message{display:none}.player{padding:12px}.player h2{font-size:17px}.player-view-link{margin-top:0}.player p{margin:4px 0}.quick-sensors{grid-template-columns:1fr 1fr}.gear{padding:0}.detail-grid{grid-template-columns:1fr 1fr}.details-notice{font-size:10px}.drive-links>a:last-child{font-size:11px;min-height:44px}.drive-actions{gap:6px}.drive-actions .pill{padding:10px;font-size:10px}.profile-label{gap:4px}#applications-grid{grid-template-columns:1fr 1fr}.application-open h2{font-size:15px}.application-open p{display:none}.application-open{padding:12px}#now-playing .immersive-stage{max-width:100%;flex:0 1 auto;min-height:280px}#now-playing .immersive-bottom{gap:8px}.immersive-caption h2{font-size:18px}.journey-grid{grid-template-columns:1fr}.journey-panel{max-height:40vh}}
|
||||
main{width:100%}
|
||||
dialog[open]{display:flex;flex-direction:column;overflow:hidden;max-height:calc(100dvh - 24px)}.journey-panel{display:flex;flex-direction:column;overflow:hidden;max-height:none}.panel-content{flex:1;min-height:0;overflow:hidden}.panel-pages{flex-shrink:0;margin-top:8px}.panel-content>form{margin:0}.rail nav a span:last-child{font-size:11px}
|
||||
@media(max-width:759px){.journey-grid{grid-template-rows:minmax(120px,1fr) minmax(220px,1fr)}.journey-panel{max-height:none}.rail nav a span:last-child{font-size:9px}}
|
||||
/* Dial appearance and touch menu refinements. */
|
||||
dialog[open]{display:block;overflow:auto;overscroll-behavior:contain}#apps{overflow-y:auto;overscroll-behavior:contain}#applications-grid{flex:none;grid-auto-rows:auto}.application-open{min-height:180px!important}#apps>.page-heading{position:sticky;top:0;z-index:2;background:var(--bg);padding-bottom:8px}
|
||||
.page-top .header-left{height:44px;display:flex;align-items:center}#rail-toggle,.rail-collapsed #rail-toggle{display:grid;place-items:center;width:44px;min-width:44px;height:44px;min-height:44px;padding:0;line-height:1;font-size:24px;flex-shrink:0}.rail-collapsed .page-top{gap:12px;align-items:center}.rail-collapsed .header-right{height:44px;gap:12px}.rail-collapsed #fullscreen{height:44px;min-height:44px;padding:0 8px}
|
||||
.immersive-stage:before{inset:5% -8% -5%;background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 32%,transparent) 0%,color-mix(in srgb,var(--accent) 13%,transparent) 38%,transparent 70%);opacity:.8}.immersive-player{isolation:isolate}@keyframes music-halo{50%{opacity:1;transform:scale(1.09)}}
|
||||
.gauge text{font-size:15px;font-weight:600}.gauge .dial-value{font-size:49px;font-weight:600;letter-spacing:-1px}.gauge .dial-label{font-size:11px;font-weight:500;letter-spacing:.6px}.gauge .major{stroke-width:2.3}.dial-light{fill:none;stroke:transparent;stroke-width:2.5;pointer-events:none}.luminous-dials .dial-light{stroke:var(--accent);filter:drop-shadow(0 0 4px var(--accent))}.luminous-dials .gauge .major,.luminous-dials .gauge .tick{stroke:var(--accent)}.luminous-dials .gauge text{fill:var(--accent)}.luminous-dials .gauge .dial-value{fill:var(--text)}.luminous-dials .dial-rim{stroke:color-mix(in srgb,var(--accent) 30%,var(--border))}.luminous-dials .gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 6%,transparent),transparent 65%)}
|
||||
.quick-sensor>div{flex:1;min-width:0}.sensor-level{height:9px;background:var(--border);border-radius:8px;overflow:hidden;margin-top:10px}.sensor-level>span{display:block;height:100%;width:0;background:var(--accent);border-radius:inherit;transition:width .4s ease}.quick-sensor .sensor-level+p{font-size:11px}@media(prefers-reduced-motion:reduce){.sensor-level>span{transition:none}}
|
||||
/* Analog instruments and needle-free digital instruments. */
|
||||
.gauges{grid-template-columns:minmax(0,1fr) 100px minmax(0,1fr)}.gear strong{font-size:82px;font-weight:500}.gear .eyebrow{font-size:12px}.gear>span{font-size:10px}.gauge .dial-number{font-size:16px}.digital-track,.digital-progress{display:none;fill:none;stroke-width:9;stroke-linecap:round}.digital-dials .digital-track{display:block;stroke:color-mix(in srgb,var(--accent) 13%,var(--surface))}.digital-dials .digital-progress{display:block;stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;filter:drop-shadow(0 0 5px var(--accent));transition:stroke-dashoffset .45s linear}.digital-dials .needle,.digital-dials .needle-hub,.digital-dials .tick,.digital-dials .dial-number,.digital-dials .dial-rim,.digital-dials .dial-light{display:none}.digital-dials .gauge .dial-value{font-size:59px;transform:translateY(-40px);fill:var(--text);font-weight:500;letter-spacing:-2px}.digital-dials .gauge .dial-label{font-size:12px;fill:var(--accent)}.digital-dials .gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 14%,transparent),transparent 64%)}
|
||||
.aux-gauges{display:flex;justify-content:center;gap:clamp(24px,6vw,100px);flex-shrink:0;margin:0 auto 8px;width:min(100%,650px)}.aux-gauge{position:relative;text-align:center;flex:1;max-width:250px;min-width:0}.aux-analog{display:block;width:160px;height:96px;margin:auto;overflow:visible}.aux-analog path{fill:none;stroke:var(--muted);stroke-width:2}.aux-analog .aux-ticks{stroke:var(--text)}.aux-needle{stroke:var(--accent);stroke-width:3;stroke-linecap:round;transform-origin:80px 76px;transition:transform .45s}.aux-analog circle{fill:var(--accent)}.aux-title{font-size:12px;color:var(--muted);margin:4px 0}.aux-gauge strong{font-size:18px;font-weight:400}.aux-gauge strong b{font-weight:600}.aux-gauge .sensor-level{display:none}.aux-limits{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);margin:6px auto 0;max-width:160px}.digital-dials .aux-analog{display:none}.digital-dials .aux-gauge .sensor-level{display:block;height:12px;box-shadow:0 0 14px color-mix(in srgb,var(--accent) 15%,transparent)}.digital-dials .aux-gauge .sensor-level>span{box-shadow:0 0 10px var(--accent)}.digital-dials .aux-gauge strong{font-size:28px}.digital-dials .aux-limits{max-width:none}.digital-dials .aux-gauge{padding:12px 8px}.digital-dials .gear strong{color:var(--accent);text-shadow:0 0 22px color-mix(in srgb,var(--accent) 45%,transparent)}
|
||||
@media(max-height:750px){.aux-analog{width:112px;height:67px}.aux-gauges{margin-bottom:4px;max-width:480px}.aux-title{font-size:10px}.aux-gauge strong{font-size:15px}.aux-limits{margin-top:2px;font-size:9px}.digital-dials .aux-gauge{padding:2px 8px}.digital-dials .aux-gauge strong{font-size:23px}.gear strong{font-size:62px}}
|
||||
@media(max-width:759px){.gauges{grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr)}.gear{grid-column:2;grid-row:1;display:block}.gauge:last-child{grid-column:3;grid-row:1}.gear strong{font-size:40px}.gear .eyebrow{font-size:8px}.gear>span{font-size:7px}.aux-gauges{gap:20px}.aux-analog{width:110px;height:66px}}
|
||||
@media(prefers-reduced-motion:reduce){.digital-progress,.aux-needle{transition:none!important}}
|
||||
.digital-unit{display:none}.digital-dials .digital-unit{display:block}.digital-dials .analog-unit{display:none}
|
||||
.digital-scale{display:none}.digital-dials .digital-scale{display:block}.digital-scale line{stroke:var(--accent);stroke-width:1.3}.gauge .digital-scale text{font-size:10px;font-weight:600;fill:var(--accent)}.digital-dials .gauge .dial-value{font-size:48px}.luminous-dials .tick:not(.major){display:none}
|
||||
.sensor-segments{display:none}.digital-dials .sensor-segments{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px;margin-top:10px;height:20px}.digital-dials [data-level=fuelPercent] .sensor-segments{grid-template-columns:.5fr repeat(4,minmax(0,1fr))}.sensor-segments i{border:1px solid var(--border);border-radius:4px;background:var(--surface2);--segment-color:#7acb9b}.sensor-segments i.filled{background:var(--segment-color);border-color:var(--segment-color);box-shadow:0 0 10px color-mix(in srgb,var(--segment-color) 28%,transparent)}[data-level=fuelPercent] .sensor-segments i:first-child{--segment-color:#ed6b60}[data-level=fuelPercent] .sensor-segments i:nth-child(2){--segment-color:#e7b65a}[data-level=coolantCelsius] .sensor-segments i:first-child{--segment-color:#74b9ef}[data-level=coolantCelsius] .sensor-segments i:nth-child(2){--segment-color:#70c7cf}[data-level=coolantCelsius] .sensor-segments i:nth-child(4){--segment-color:#e7b65a}[data-level=coolantCelsius] .sensor-segments i:nth-child(5){--segment-color:#ed6b60}.aux-mark{fill:var(--text);text-anchor:middle;font:600 12px system-ui}.aux-analog .temperature-hot{stroke:url(#temperature-red);stroke-width:6;stroke-linecap:round}.aux-gauge:not(.digital-dials .aux-gauge) .aux-limits{visibility:hidden}.digital-dials .aux-limits{visibility:visible}.reserve strong{color:#ed6b60}
|
||||
.page-top .header-temp,.rail-collapsed .page-top .header-temp{display:inline-block;white-space:nowrap}@media(max-width:759px){.page-top .header-temp{font-size:12px}.page-top .header-temp small{display:none}.page-top .header-right .badge{display:none}}
|
||||
/* Shared luminous graduations: labelled twenties, intermediate tens. */
|
||||
.digital-dials .gauge .dial-number{display:block;fill:var(--accent);font-size:16px}.digital-dials .gauge .tick.major,.digital-dials .gauge .tick.intermediate,.luminous-dials .gauge .tick.intermediate{display:block;stroke:var(--accent)}.gauge .tick.intermediate{stroke-width:1.6}.digital-dials .gauge .dial-value{font-size:46px}.rpm-red-zone{fill:none;stroke:url(#rpm-red-zone);stroke-width:7;stroke-linecap:round;pointer-events:none}.digital-dials .rpm-red-zone,.luminous-dials .rpm-red-zone{filter:drop-shadow(0 0 3px #ef535977)}
|
||||
.aux-analog .fuel-reserve-zone{fill:none;stroke:url(#fuel-reserve-red);stroke-width:6;stroke-linecap:round}
|
||||
.aux-title{display:flex;justify-content:center;align-items:center}.aux-title svg{width:24px;height:24px;color:var(--accent)}.gauge .tick.quarter{display:none}.aux-limits{font-weight:600}@media(max-height:750px){.aux-title svg{width:20px;height:20px}}
|
||||
.gear strong{border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border));border-radius:14px;background:var(--surface2);line-height:1.1;padding:10px 4px;min-width:0}.gear .eyebrow{margin-bottom:10px}
|
||||
body:not(.digital-dials) .aux-gauges{--aux-size:clamp(150px,23vh,240px);width:var(--aux-size);height:calc(var(--aux-size) + 22px);max-width:none;gap:0;position:relative;margin-bottom:4px}body:not(.digital-dials) .aux-gauge{width:50%;max-width:none;flex:none;height:100%}body:not(.digital-dials) .aux-analog{position:absolute;top:0;width:var(--aux-size);height:var(--aux-size);max-width:none;pointer-events:none}body:not(.digital-dials) [data-level=fuelPercent] .aux-analog{left:0}body:not(.digital-dials) [data-level=coolantCelsius] .aux-analog{right:0}body:not(.digital-dials) .aux-title{position:absolute;top:48%;left:0;right:0;margin:0;z-index:1}body:not(.digital-dials) .aux-gauge>strong{position:absolute;top:88%;left:0;right:0;font-size:16px}body:not(.digital-dials) .aux-limits{display:none}.aux-gauge.red-zone .aux-title svg{color:#fa4d5b;filter:drop-shadow(0 0 3px #fa4d5b55)}
|
||||
@media(max-width:759px){.gear strong{border-radius:9px;padding:7px 2px}}
|
||||
/* Keep a small physical-looking gap between the two half-dials. */
|
||||
body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-5px)}
|
||||
body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(5px)}
|
||||
#settings .theme-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);gap:14px}.color-setting{display:flex;align-items:flex-end;gap:8px;min-width:0}.color-setting label{flex:1;min-width:0}.color-setting select{width:100%}#settings #custom-color{flex:0 0 44px;width:44px;height:44px;min-height:44px;padding:8px;border:1px solid var(--border);border-radius:50%;background:var(--surface2);cursor:pointer}#custom-color::-webkit-color-swatch-wrapper{padding:0}#custom-color::-webkit-color-swatch{border:0;border-radius:50%}#custom-color::-moz-color-swatch{border:0;border-radius:50%}
|
||||
/* Clearly separated analog instruments, aligned digital level segments. */
|
||||
body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-18px)}body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(18px)}body:not(.digital-dials) .aux-gauges:after{content:'';position:absolute;left:50%;top:24%;height:48%;width:1px;background:var(--border);pointer-events:none}
|
||||
.digital-dials #speed-gauge svg,.luminous-dials #speed-gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 5%,transparent),transparent 64%)}
|
||||
.digital-dials .aux-gauges{width:100%;max-width:none;display:grid;grid-template-columns:minmax(0,1fr) 100px minmax(0,1fr);gap:0;margin:0 0 14px}.digital-dials .aux-gauge{width:min(100%,340px);max-width:none;justify-self:center;padding:0 16px}.digital-dials [data-level=coolantCelsius].aux-gauge{grid-column:3}.digital-dials .sensor-segments,.digital-dials [data-level=fuelPercent] .sensor-segments{grid-template-columns:.5fr 1fr 1fr 1.4fr;height:22px;gap:7px;margin:14px 0 0;align-items:center}.digital-dials .sensor-segments i{height:18px;--segment-color:var(--accent);border-radius:5px}.digital-dials .sensor-segments i:first-child{--segment-color:#ed6b60;transform:translateY(-7px) rotate(12deg)}.digital-dials .sensor-segments i:nth-child(2){transform:translateY(1px) rotate(4deg)}.digital-dials .sensor-segments i:nth-child(3){transform:translateY(1px) rotate(-4deg)}.digital-dials .sensor-segments i:nth-child(4){height:22px;transform:translateY(-7px) rotate(-12deg)}.digital-dials .aux-limits{margin-top:10px}
|
||||
@media(max-width:759px){.digital-dials .aux-gauges{grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr)}.digital-dials .aux-gauge{padding:0 4px}.digital-dials .sensor-segments{gap:4px}.digital-dials .aux-gauge strong{font-size:20px}body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-14px)}body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(14px)}}
|
||||
.digital-dials .aux-gauge .sensor-segments i{--segment-color:var(--accent)}.digital-dials .aux-gauge .sensor-segments i:first-child{--segment-color:#ed6b60}.digital-dials #drive .cluster{justify-content:center}.digital-dials .gauges{flex:0 1 auto;height:min(55vh,620px)}.digital-dials .aux-gauges{margin-top:0}.digital-dials .cluster-bottom{margin-top:16px}
|
||||
.digital-levels{display:none}.digital-dials .digital-levels{display:block}.digital-dials .aux-gauges{display:none}.digital-dials .gauges{flex:1;height:auto;min-height:0}.digital-dials .level-segment{fill:var(--surface2);stroke:var(--border);stroke-width:1.5;stroke-linejoin:round}.digital-dials .level-segment.filled{fill:var(--accent);stroke:var(--accent);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--accent) 25%,transparent))}.digital-dials .level-segment:first-child.filled{fill:#ed6b60;stroke:#ed6b60}.digital-levels .level-icon{color:var(--accent)}.digital-levels.red-zone .level-icon{color:#ed6b60}.gauge .digital-levels .level-end{font-size:10px;fill:var(--muted);font-weight:500}.gauge .digital-levels .level-value{font-size:23px;fill:var(--text);font-weight:600}.digital-dials .gear{align-self:center}
|
||||
/* Analog auxiliary instruments integrated into each main dial. */
|
||||
body:not(.digital-dials) .aux-gauges{display:none}.analog-levels{display:block}.digital-dials .analog-levels{display:none}.analog-levels .mini-track{fill:none;stroke:var(--muted);stroke-width:2.5}.analog-levels .mini-red{fill:none;stroke:#ed6b60;stroke-width:4;stroke-linecap:round}.analog-levels .mini-ticks{fill:none;stroke:var(--text);stroke-width:1.5}.analog-levels .mini-needle{stroke:var(--accent);stroke-width:3;stroke-linecap:round;transform-origin:150px 226px;transition:transform .45s linear}.gauge .analog-levels .mini-end{font-size:11px;fill:var(--text)}.analog-levels .level-icon{color:var(--accent)}.analog-levels.red-zone .level-icon{color:#ed6b60}.gauge .analog-levels .level-value{font-size:23px;fill:var(--text)}body:not(.digital-dials) .gauge .dial-value{transform:translateY(-23px);font-size:43px}body:not(.digital-dials) .gauge .dial-label:last-of-type{transform:translateY(-22px)}body:not(.digital-dials) .dial-rim{display:none}.luminous-dials .analog-levels .mini-track,.luminous-dials .analog-levels .mini-ticks{stroke:var(--accent)}.luminous-dials .mini-track,.luminous-dials .mini-needle{filter:drop-shadow(0 0 3px var(--accent))}@media(prefers-reduced-motion:reduce){.mini-needle{transition:none!important}}
|
||||
.analog-levels .mini-needle{transform-origin:150px 150px}body:not(.digital-dials):not(.luminous-dials) .dial-light{stroke:var(--border);stroke-width:1.5}
|
||||
/* Separate the analog auxiliary dials from the main instruments. */
|
||||
body:not(.digital-dials) .analog-levels{transform:translateY(22px)}
|
||||
body:not(.digital-dials) .analog-levels{transform:none}.analog-levels .mini-needle{transform-origin:150px 308px}.mini-hub{fill:var(--accent)}
|
||||
/* Float the gear above the center gap so both instruments gain width. */
|
||||
#drive .gauges{position:relative;grid-template-columns:minmax(0,1fr) minmax(0,1fr);column-gap:12px}
|
||||
#drive #speed-gauge{grid-column:1;grid-row:1}#drive #rpm-gauge{grid-column:2;grid-row:1}
|
||||
#drive .gear{position:absolute;z-index:1;top:10px;left:50%;transform:translateX(-50%);width:86px;display:block;padding:0;grid-column:auto;grid-row:auto}
|
||||
#drive .gear strong{font-size:58px;padding:5px 4px}#drive .gear .eyebrow{font-size:10px;margin-bottom:6px}
|
||||
@media(max-height:750px){#drive .gear{top:0;width:66px}#drive .gear strong{font-size:42px}#drive .gear .eyebrow{font-size:9px}}
|
||||
@media(max-width:759px){#drive .gauges{column-gap:6px}#drive .gear{top:0;width:52px}#drive .gear strong{font-size:34px}#drive .gear .eyebrow{font-size:8px}}
|
||||
body:not(.digital-dials) .analog-levels{transform:translateY(-24px)}.analog-separator{fill:none;stroke:color-mix(in srgb,var(--accent) 35%,var(--border));stroke-width:1.2;stroke-linejoin:round;stroke-linecap:round}.digital-dials .analog-separator{display:none}.analog-levels .level-icon{paint-order:stroke;stroke:currentColor}.luminous-dials .analog-separator{stroke:color-mix(in srgb,var(--accent) 55%,var(--border))}
|
||||
/* Use the available width and height for all four instruments together. */
|
||||
#drive .cluster{padding-left:4px;padding-right:4px;padding-top:8px}#drive .gauges{column-gap:0;overflow:visible}#drive .gauge{overflow:visible}#drive .gauge>svg{overflow:visible}#drive .gear{top:0}
|
||||
.analog-separator{stroke:color-mix(in srgb,var(--accent) 65%,var(--muted));stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||
/* Three straight segments, ending on the main dial circumference. */
|
||||
body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke:var(--border);stroke-width:1.5;filter:none}
|
||||
.luminous-dials .analog-separator{stroke:var(--accent);stroke-width:2.5;filter:drop-shadow(0 0 4px var(--accent))}
|
||||
.analog-separator{fill:none;stroke-linecap:butt;stroke-linejoin:miter}
|
||||
/* Slightly finer separator while retaining the dial color and glow. */
|
||||
body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke-width:1.3}
|
||||
.luminous-dials .analog-separator{stroke-width:2.2}
|
||||
/* Compact illuminated auxiliary arcs, digital style only. */
|
||||
.digital-dials .digital-levels .level-segment{fill:none;stroke:var(--border);stroke-width:8;stroke-linecap:round;filter:none}
|
||||
.digital-dials .digital-levels .level-segment.filled{fill:none;stroke:var(--accent);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--accent) 45%,transparent))}
|
||||
.digital-dials .digital-levels .danger-segment{stroke:color-mix(in srgb,#ed6b60 40%,var(--surface))}
|
||||
.digital-dials .digital-levels .danger-segment.filled{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b6060)}
|
||||
.level-outline{fill:none;stroke:var(--border);stroke-width:1}.gauge .digital-levels .level-end{font-size:11px}
|
||||
.digital-dials .digital-levels .level-segment{fill:var(--border);stroke:none;stroke-width:0;stroke-linejoin:miter}
|
||||
.digital-dials .digital-levels .level-segment.filled{fill:var(--accent);stroke:none}
|
||||
.digital-dials .digital-levels .danger-segment{fill:color-mix(in srgb,#ed6b60 40%,var(--surface));stroke:none}
|
||||
.digital-dials .digital-levels .danger-segment.filled{fill:#ed6b60;stroke:none}
|
||||
.level-arc-track,.level-arc-progress,.level-arc-red{fill:none;stroke-width:6;stroke-linecap:round}.level-arc-track{stroke:var(--border)}.level-arc-progress{stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;filter:drop-shadow(0 0 3px var(--accent));transition:stroke-dashoffset .4s linear}.level-arc-red{stroke:#ed6b60;stroke-width:3}.digital-levels.red-zone .level-arc-red{filter:drop-shadow(0 0 3px #ed6b60)}@media(prefers-reduced-motion:reduce){.level-arc-progress{transition:none}}
|
||||
.digital-levels .level-tick{fill:none;stroke:var(--text);stroke-width:1.2;stroke-linecap:butt}
|
||||
.digital-dials .digital-levels{transform:translateY(-14px)}.digital-levels .level-arc-track{display:none}.digital-levels .level-arc-progress{transition:none}.aux-gauge[data-level=fuelPercent] .aux-title svg{width:17px;height:17px}.aux-gauge[data-level=coolantCelsius] .aux-title svg{width:20px;height:20px}
|
||||
body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke-width:1}
|
||||
.luminous-dials .analog-separator{stroke-width:1.6}
|
||||
.digital-levels[data-digital-level=coolantCelsius] .level-arc-red{display:none}
|
||||
.digital-levels[data-digital-level=coolantCelsius].last-level .level-arc-progress{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b60)}
|
||||
.digital-levels[data-digital-level=fuelPercent].reserve-level .level-arc-progress{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b60)}
|
||||
.digital-levels[data-digital-level=fuelPercent] .level-arc-red{display:none}
|
||||
@@ -0,0 +1,93 @@
|
||||
export function initCockpit({ storage, toast }) {
|
||||
const $ = s => document.querySelector(s), audio = $('#audio');
|
||||
const dedicated = new URLSearchParams(location.search).get('display') === 'drive';
|
||||
let collapsed = dedicated || storage.get('railCollapsed') === 'true';
|
||||
function rail() {
|
||||
document.body.classList.toggle('rail-collapsed', collapsed);
|
||||
$('#rail-toggle').setAttribute('aria-expanded', String(!collapsed));
|
||||
$('#rail-toggle').setAttribute('aria-label', collapsed ? 'Afficher les favoris' : 'Masquer les favoris');
|
||||
document.dispatchEvent(new Event('caria-layout'));
|
||||
}
|
||||
$('#rail-toggle').onclick = () => { collapsed = !collapsed; storage.set('railCollapsed', String(collapsed)); rail(); };
|
||||
document.querySelectorAll('.screen-links a').forEach(link => link.onclick = () => $('#settings').close());
|
||||
rail();
|
||||
if (dedicated && !location.hash) location.hash = 'drive';
|
||||
const time = seconds => Number.isFinite(seconds) ? `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2,'0')}` : '0:00';
|
||||
function updatePlayer() {
|
||||
const duration = Number.isFinite(audio.duration) ? audio.duration : 0, progress = duration ? audio.currentTime / duration : 0;
|
||||
$('#track-arc').style.strokeDashoffset = String(100 * (1 - progress));
|
||||
$('#immersive-current').textContent = time(audio.currentTime); $('#immersive-duration').textContent = time(duration);
|
||||
$('#immersive-title').textContent = $('#track-title').textContent;
|
||||
$('#immersive-subtitle').textContent = $('#track-subtitle').textContent;
|
||||
$('#immersive-play').innerHTML = audio.paused ? '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 11 7-11 7Z"/></svg>' : '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5v14M16 5v14"/></svg>';
|
||||
$('#immersive-play').setAttribute('aria-label', audio.paused ? 'Lire la musique' : 'Mettre en pause');
|
||||
$('#now-playing').classList.toggle('playing', !audio.paused);
|
||||
$('#immersive-volume').value = audio.muted ? '0' : String(audio.volume);
|
||||
const silent = audio.muted || audio.volume === 0;
|
||||
$('#immersive-mute').innerHTML = silent ? '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4 6 8H3v8h3l5 4Zm5 5 6 6m0-6-6 6"/></svg>' : '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4 6 8H3v8h3l5 4Zm4 4a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14"/></svg>';
|
||||
$('#immersive-mute').setAttribute('aria-label', silent ? 'Activer le son' : 'Couper le son');
|
||||
$('#immersive-mute').title = silent ? 'Activer le son' : 'Couper le son';
|
||||
$('#immersive-mute').setAttribute('aria-pressed', String(silent));
|
||||
$('#immersive-volume-value').textContent = Math.round((silent ? 0 : audio.volume) * 100) + ' %';
|
||||
document.querySelectorAll('[data-audio-control]').forEach(button => button.disabled = !audio.getAttribute('src'));
|
||||
}
|
||||
$('#immersive-play').onclick = () => $('#play').click();
|
||||
$('#immersive-previous').onclick = () => $('#previous').click();
|
||||
$('#immersive-next').onclick = () => $('#next').click();
|
||||
$('#immersive-volume').oninput = e => { audio.volume = Number(e.target.value); audio.muted = false; };
|
||||
$('#immersive-mute').onclick = () => { if (audio.volume === 0) { audio.volume = .5; audio.muted = false; } else audio.muted = !audio.muted; };
|
||||
$('#immersive-repeat').onclick = () => { audio.loop = !audio.loop; $('#immersive-repeat').setAttribute('aria-pressed', String(audio.loop)); toast(audio.loop ? 'Répétition du morceau activée' : 'Répétition désactivée'); };
|
||||
for (const event of ['timeupdate','loadedmetadata','durationchange','play','pause','volumechange','emptied','ended']) audio.addEventListener(event,updatePlayer);
|
||||
new MutationObserver(updatePlayer).observe($('#track-title'),{childList:true,subtree:true});
|
||||
audio.addEventListener('error', () => $('#immersive-subtitle').textContent = 'Lecture impossible. Choisissez un autre morceau dans la bibliothèque.');
|
||||
updatePlayer();
|
||||
// Keep lengthy setup panels reachable with large page buttons.
|
||||
for (const panel of document.querySelectorAll('.journey-panel')) {
|
||||
const content = document.createElement('div'), nav = document.createElement('nav');
|
||||
content.className = 'panel-content'; nav.className = 'collection-pages panel-pages';
|
||||
content.append(...panel.childNodes); panel.append(content,nav);
|
||||
const back = document.createElement('button'), next = document.createElement('button'), label = document.createElement('span');
|
||||
back.type = next.type = 'button'; back.textContent = '‹'; next.textContent = '›';
|
||||
back.setAttribute('aria-label','Page précédente du panneau'); next.setAttribute('aria-label','Page suivante du panneau');
|
||||
nav.append(back,label,next);
|
||||
function refresh() {
|
||||
if (!panel.getClientRects().length) return;
|
||||
const max = content.scrollHeight - content.clientHeight;
|
||||
nav.hidden = max <= 2;
|
||||
back.disabled = content.scrollTop < 2; next.disabled = content.scrollTop >= max - 2;
|
||||
label.textContent = max <= 2 ? '' : `${Math.floor(content.scrollTop / Math.max(1,content.clientHeight - 60)) + 1} / ${Math.ceil(max / Math.max(1,content.clientHeight - 60)) + 1}`;
|
||||
}
|
||||
back.onclick = () => { content.scrollTop -= Math.max(44,content.clientHeight - 60); refresh(); };
|
||||
next.onclick = () => { content.scrollTop += Math.max(44,content.clientHeight - 60); refresh(); };
|
||||
new ResizeObserver(refresh).observe(content);
|
||||
new MutationObserver(refresh).observe(content,{childList:true,subtree:true,attributes:true,attributeFilter:['open','hidden']});
|
||||
new MutationObserver(refresh).observe(panel,{attributes:true,attributeFilter:['open']});
|
||||
content.addEventListener('scroll',refresh); document.addEventListener('caria-view',refresh);
|
||||
}
|
||||
// Paginate collections rather than shrinking touch targets or scrolling the screen.
|
||||
for (const selector of ['#playlist', '.detail-grid', '.home-grid']) {
|
||||
const container = $(selector), pager = document.createElement('nav');
|
||||
pager.className = 'collection-pages'; pager.setAttribute('aria-label', 'Pages de contenu');
|
||||
const back = document.createElement('button'), next = document.createElement('button'), label = document.createElement('span');
|
||||
back.textContent = '‹'; next.textContent = '›';
|
||||
back.setAttribute('aria-label', 'Page précédente'); next.setAttribute('aria-label', 'Page suivante');
|
||||
pager.append(back,label,next); container.after(pager);
|
||||
let page = 0;
|
||||
function layout() {
|
||||
if (!container.getClientRects().length) return;
|
||||
const items = [...container.children], small = innerWidth < 760;
|
||||
let count = items.length || 1;
|
||||
if (selector === '#playlist') count = Math.max(1, Math.floor(container.clientHeight / 58));
|
||||
if (selector === '.detail-grid') count = small || innerHeight < 750 ? 4 : 8;
|
||||
if (selector === '.home-grid') count = small ? 2 : 4;
|
||||
const pages = Math.max(1,Math.ceil(items.length / count)); page = Math.min(page,pages - 1);
|
||||
items.forEach((item,i) => item.hidden = i < page * count || i >= (page + 1) * count);
|
||||
pager.hidden = pages === 1; label.textContent = `${page + 1} / ${pages}`;
|
||||
back.disabled = page === 0; next.disabled = page === pages - 1;
|
||||
}
|
||||
back.onclick = () => { page--; layout(); }; next.onclick = () => { page++; layout(); };
|
||||
new ResizeObserver(layout).observe(container);
|
||||
new MutationObserver(() => { page = 0; layout(); }).observe(container,{childList:true});
|
||||
document.addEventListener('caria-view',layout); layout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { applications, safeWebUrl } from './applications.js';
|
||||
export function embedUrl(provider, value) {
|
||||
const safe = safeWebUrl(value); if (!safe) return '';
|
||||
const url = new URL(safe);
|
||||
if (provider === 'stream') return safe;
|
||||
if (provider === 'spotify' && url.hostname === 'open.spotify.com') {
|
||||
const match = url.pathname.match(/^\/(?:intl-[a-z]+\/)?(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)\/?$/);
|
||||
return match ? `https://open.spotify.com/embed/${match[1]}/${match[2]}` : '';
|
||||
}
|
||||
if (['youtube','youtube-music'].includes(provider) && ['music.youtube.com','www.youtube.com','youtube.com','youtu.be'].includes(url.hostname)) {
|
||||
const video = url.hostname === 'youtu.be' ? url.pathname.slice(1) : url.searchParams.get('v');
|
||||
const list = url.searchParams.get('list');
|
||||
if (video && /^[\w-]{11}$/.test(video)) return `https://www.youtube.com/embed/${video}`;
|
||||
if (list && /^[\w-]{10,200}$/.test(list)) return `https://www.youtube.com/embed/videoseries?list=${encodeURIComponent(list)}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
export function initEmbeds({storage,toast}) {
|
||||
const $=s=>document.querySelector(s); let provider=null;
|
||||
function stop() { $('#embed-container').replaceChildren(); $('#embed-status').textContent='Lecteur arrêté. Vous pouvez le charger à nouveau.'; }
|
||||
function load() {
|
||||
if (!provider) return;
|
||||
const url=embedUrl(provider,provider==='stream'?applications.find(app=>app.id==='stream').url:$('#embed-url').value.trim());
|
||||
if (!url) return toast('Lien non reconnu. Choisissez un contenu compatible avec ce lecteur.');
|
||||
if (location.protocol==='https:' && url.startsWith('http:')) return toast('Ce lecteur doit utiliser HTTPS sur votre accès sécurisé.');
|
||||
stop();
|
||||
const frame=document.createElement('iframe');frame.className='embed-frame';frame.dataset.provider=provider;
|
||||
frame.title=`Lecteur ${applications.find(a=>a.id===provider).name}`;frame.allow='autoplay; encrypted-media; fullscreen; picture-in-picture';frame.referrerPolicy='strict-origin-when-cross-origin';
|
||||
frame.setAttribute('sandbox','allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation');
|
||||
frame.src=url;$('#embed-container').append(frame);
|
||||
storage.set(`embed.${provider}`,$('#embed-url').value.trim());
|
||||
$('#embed-status').textContent=provider==='stream' ? 'Si Jellyfin refuse l’affichage, son serveur doit autoriser l’origine CARIA dans sa politique d’intégration. Le lien externe reste disponible.' : 'Le lecteur officiel gère la connexion et les restrictions de lecture. Si le contenu est indisponible, utilisez le lien externe.';
|
||||
}
|
||||
document.addEventListener('caria-embed',e=>{
|
||||
provider=e.detail;
|
||||
const app=applications.find(a=>a.id===provider);if(!app?.external)return;
|
||||
stop();$('#embedded-name').textContent=app.name;
|
||||
$('#embed-form').hidden=provider==='stream';
|
||||
$('#embedded-explanation').textContent=provider==='stream' ? 'Jellyfin dans CARIA. La connexion reste gérée par votre serveur ; son autorisation d’affichage intégré est nécessaire.' : provider==='spotify' ? 'Lecteur officiel Spotify pour un morceau, album, podcast ou playlist. Ce n’est pas l’application Spotify complète ; la lecture dépend de votre compte et des règles du lecteur.' : 'Lecteur vidéo officiel YouTube à partir d’un lien YouTube ou YouTube Music. La vidéo reste visible. L’application YouTube Music complète ne peut pas être intégrée ici.';
|
||||
$('#embed-url').value=provider==='stream'?app.url:storage.get(`embed.${provider}`);
|
||||
$('#embed-external').href=provider==='stream'?app.url:app.url;
|
||||
if (embedUrl(provider,$('#embed-url').value)) load();
|
||||
else $('#embed-status').textContent='Collez le lien d’un contenu pour charger son lecteur dans CARIA.';
|
||||
});
|
||||
$('#embed-form').onsubmit=e=>{e.preventDefault();load();};$('#embed-stop').onclick=stop;
|
||||
document.addEventListener('caria-view',e=>{if(e.detail!=='embedded' && $('#embed-container').childElementCount)stop();});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export function distance(a, b) {
|
||||
const rad = Math.PI / 180, lat = (b.lat - a.lat) * rad, lon = (b.lon - a.lon) * rad;
|
||||
const h = Math.sin(lat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(lon / 2) ** 2;
|
||||
return 6371000 * 2 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
export function validateZones(data) {
|
||||
if (data?.type !== 'FeatureCollection' || !Array.isArray(data.features) || data.features.length > 1000) throw new Error('Fichier GeoJSON attendu, 1 000 zones maximum.');
|
||||
const ids = new Set();
|
||||
return data.features.map((f, i) => {
|
||||
const p = f.properties || {}, c = f.geometry?.coordinates;
|
||||
if (f.geometry?.type !== 'Point' || !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite) || Math.abs(c[0]) > 180 || Math.abs(c[1]) > 85 || !Number.isFinite(p.radiusM) || p.radiusM < 300 || p.radiusM > 10000 || !/^[A-Z]{2}$/.test(p.country || '') || typeof p.source !== 'string' || !p.source.trim() || !Number.isFinite(Date.parse(p.updatedAt)) || !Number.isFinite(Date.parse(p.expiresAt)) || Date.parse(p.expiresAt) <= Date.parse(p.updatedAt)) throw new Error(`Zone ${i + 1} invalide : coordonnées, pays, rayon (300–10 000 m), source et dates requis.`);
|
||||
const id = String(p.id ?? i).slice(0, 100);
|
||||
if (ids.has(id)) throw new Error('Identifiants de zones en double.'); ids.add(id);
|
||||
return { id, lat: c[1], lon: c[0], radiusM: p.radiusM, country: p.country, name: String(p.name || 'Zone de vigilance').slice(0, 120), source: p.source.slice(0, 150), updatedAt: p.updatedAt, expiresAt: p.expiresAt };
|
||||
});
|
||||
}
|
||||
export function nearbyZones(zones, position, country, now = Date.now()) {
|
||||
if (!position || position.source !== 'gps' || !Number.isFinite(position.accuracy) || position.accuracy > 100 || !Number.isFinite(position.timestamp) || Math.abs(now - position.timestamp) > 15000) return [];
|
||||
return zones.filter(z => z.country === country && Date.parse(z.updatedAt) <= now && Date.parse(z.expiresAt) > now)
|
||||
.map(z => ({ ...z, remaining: Math.max(0, distance(position, z) - z.radiusM) }))
|
||||
.filter(z => z.remaining <= 400).sort((a, b) => a.remaining - b.remaining);
|
||||
}
|
||||
export function instruction(step) {
|
||||
const m = step.maneuver || {}, ways = { left: 'à gauche', right: 'à droite', 'slight left': 'légèrement à gauche', 'slight right': 'légèrement à droite', 'sharp left': 'franchement à gauche', 'sharp right': 'franchement à droite', straight: 'tout droit', uturn: 'en faisant demi-tour' };
|
||||
if (m.type === 'arrive') return 'Vous arrivez à destination';
|
||||
if (m.type === 'depart') return `Départ${step.name ? ` sur ${step.name}` : ''}`;
|
||||
if (['roundabout', 'rotary'].includes(m.type)) return `Au rond-point, ${m.exit ? `prenez la sortie ${m.exit}` : 'suivez l’itinéraire'}`;
|
||||
return `${m.type === 'merge' ? 'Insérez-vous' : m.type === 'fork' ? 'Restez' : 'Continuez'} ${ways[m.modifier] || 'sur l’itinéraire'}${step.name ? ` sur ${step.name}` : ''}`;
|
||||
}
|
||||
// Nearest segment projection gives progress even on long rural road segments.
|
||||
export function routeProgress(coords, position) {
|
||||
let best = { away: Infinity, travelled: 0 }, total = 0;
|
||||
const scale = Math.cos(position.lat * Math.PI / 180);
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
const a = { lon: coords[i - 1][0], lat: coords[i - 1][1] }, b = { lon: coords[i][0], lat: coords[i][1] };
|
||||
const dx = (b.lon - a.lon) * scale, dy = b.lat - a.lat;
|
||||
const fraction = Math.max(0, Math.min(1, (((position.lon - a.lon) * scale) * dx + (position.lat - a.lat) * dy) / (dx * dx + dy * dy || 1)));
|
||||
const point = { lon: a.lon + fraction * (b.lon - a.lon), lat: a.lat + fraction * (b.lat - a.lat) };
|
||||
const length = distance(a, b), away = distance(position, point);
|
||||
if (away < best.away) best = { away, travelled: total + fraction * length };
|
||||
total += length;
|
||||
}
|
||||
return { ...best, total, fraction: total ? best.travelled / total : 0 };
|
||||
}
|
||||
+73
-15
@@ -3,49 +3,107 @@
|
||||
<head>
|
||||
<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">♫ 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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* The accent is the only configurable color; surfaces derive from it. */
|
||||
body.light{--bg:color-mix(in srgb,var(--accent) 3%,#f2f2ee);--surface2:color-mix(in srgb,var(--accent) 4%,#f5f5f2);--border:color-mix(in srgb,var(--accent) 9%,#deded8)}
|
||||
.rail,body.light .rail{background:color-mix(in srgb,var(--accent) 4%,var(--bg))}
|
||||
.brand span,.gear>span,.text-link span{color:var(--accent)}
|
||||
.clock-card{background:color-mix(in srgb,var(--accent) 13%,#14201e)}
|
||||
.sun{background:var(--accent);box-shadow:0 0 80px color-mix(in srgb,var(--accent) 20%,transparent)}
|
||||
.mountain{background:color-mix(in srgb,var(--accent) 22%,#23312d)}
|
||||
.mountain.two{background:color-mix(in srgb,var(--accent) 8%,#12221e)}
|
||||
.road{background:color-mix(in srgb,var(--accent) 45%,#48534c)}
|
||||
.album-art{background:linear-gradient(140deg,color-mix(in srgb,var(--accent) 70%,#849484),color-mix(in srgb,var(--accent) 12%,#243a33))}
|
||||
.record:after{border-color:color-mix(in srgb,var(--accent) 45%,#314436)}
|
||||
.tiny-dot{box-shadow:0 0 12px color-mix(in srgb,var(--accent) 25%,transparent)}
|
||||
body.light .rail nav a.active{color:var(--ink)}
|
||||
.theme-grid{display:grid;grid-template-columns:1fr 1fr;gap:15px}input[type=color]{width:100%;height:46px;padding:4px;border:1px solid var(--border);border-radius:10px;background:var(--surface2)}
|
||||
.weather-actions{display:flex;align-items:center;gap:9px;margin:0 0 10px;flex-wrap:wrap}.weather-actions button{font-size:10px;padding:6px 10px}.weather-actions a{font-size:10px;color:var(--accent)}.weather-credit{font-size:8px;color:var(--muted);margin-top:8px}.weather-main strong #weather-temperature{color:var(--text)}.weather-card{min-height:280px}.weather-card #weather-status{font-size:10px;min-height:26px;margin-bottom:8px}
|
||||
.journey-grid{display:grid;grid-template-columns:minmax(0,1fr) 285px;gap:16px}.map-column{min-width:0}#map-canvas{height:420px;min-height:200px;border-radius:17px;border:1px solid var(--border);background:var(--surface2);z-index:0;isolation:isolate}.journey-panel{padding:18px;overflow:auto;max-height:680px}.journey-panel label{font-size:11px;display:block;margin:12px 0 7px}.journey-panel input:not([type=checkbox]):not([type=file]),.journey-panel select{width:100%;min-width:0;min-height:44px;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);background:var(--surface2)}.search-line{display:flex;gap:6px}.search-line input{flex:1}.search-line button{font-size:23px;padding:5px 14px}#place-results button{text-align:left;width:100%;margin-top:8px;font-size:11px;overflow-wrap:anywhere}#place-results p{font-size:11px;color:var(--muted)}.map-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.map-actions button,.map-actions label{font-size:11px}.journey-panel p,#map-status{font-size:11px;color:var(--muted);line-height:1.5}.journey-panel details{padding:14px 0;border-top:1px solid var(--border);margin-top:10px}.journey-panel summary{font-size:12px;cursor:pointer;min-height:34px;padding-top:8px}.provider-credit a{color:var(--accent)}#route-steps{padding-left:20px;font-size:11px;color:var(--muted)}#route-steps li{padding:8px 0}#route-steps li.current{color:var(--accent)}#guidance{padding:15px;margin-top:10px}#guidance strong{font-size:18px}#guidance p{font-size:11px}.journey-panel label.check-label{display:flex;align-items:center;gap:10px;min-height:36px}.check-label input{accent-color:var(--accent)}#zone-alert{position:fixed;z-index:1500;bottom:22px;left:50%;transform:translateX(-50%);background:#fbd499;color:#30200d;border:2px solid #af6d16;box-shadow:var(--shadow);border-radius:14px;padding:18px 65px 18px 22px;max-width:calc(100vw - 28px);width:520px}#zone-alert strong,#zone-alert span{display:block}#zone-alert span{font-size:12px;margin-top:6px}#zone-alert button{position:absolute;right:10px;top:10px;background:#fff4;color:#30200d;border-color:#af6d16}
|
||||
.leaflet-container{font-family:inherit;color:#20312a}.leaflet-container .leaflet-control-attribution{font-size:10px}.leaflet-container .leaflet-control-attribution a{color:#22557b}.leaflet-control-zoom a{min-width:40px;min-height:40px;line-height:40px!important}.leaflet-tooltip{font-size:12px}.leaflet-control-attribution{max-width:100%;white-space:normal}.rail nav{gap:8px}.rail nav a{min-height:65px}
|
||||
@media(max-width:1100px){.journey-grid{grid-template-columns:minmax(0,1fr) 250px}#map-canvas{height:340px}.journey-panel{padding:14px}}
|
||||
@media(max-height:650px) and (min-width:760px){.weather-card{min-height:260px}.weather-card .weather-main{margin:4px 0}.weather-card #weather-status{min-height:0}.weather-actions{margin-bottom:6px}.weather-credit{margin-top:5px}#map-canvas{height:270px}.journey-panel{max-height:480px}.rail nav a{min-height:56px}.rail nav{gap:4px}}
|
||||
@media(max-height:520px) and (min-width:760px){.rail nav a{min-height:48px;gap:4px}.rail nav .icon{font-size:21px}.weather-card{min-height:220px}.weather-actions{gap:5px}.weather-actions button{font-size:9px;padding:5px}.weather-card #weather-status{display:block}.weather-credit{font-size:7px}#map-canvas{height:230px}.journey-panel{max-height:420px}}
|
||||
@media(max-width:759px){.journey-grid{grid-template-columns:1fr}.journey-panel{max-height:none}#map-canvas{height:320px}.map-actions button{padding:8px}.weather-card{min-height:290px}.theme-grid{gap:10px}.rail nav a{min-height:60px}.leaflet-container .leaflet-control-attribution{font-size:9px}}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { distance, instruction, nearbyZones, routeProgress, validateZones } from './geo.js';
|
||||
|
||||
export function initJourney({ storage, toast }) {
|
||||
const $ = s => document.querySelector(s);
|
||||
let position = null, watch = null, map = null, marker = null, routeLayer = null, targetMarker = null;
|
||||
let destination = null, route = null, routeGeneration = 0, searchGeneration = 0, weatherGeneration = 0;
|
||||
let weatherAt = 0, weatherRetryAt = 0, weatherPosition = null, weatherBusy = false, mapLoading = null, zones = [], audioContext;
|
||||
let lastSpoken = '', lastZone = '', lastZoneTime = 0;
|
||||
const fmtDistance = m => m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;
|
||||
const readJSON = key => { try { return JSON.parse(storage.get(key, 'null')); } catch { return null; } };
|
||||
async function api(path, params = {}) {
|
||||
const r = await fetch(`/api/${path}?${new URLSearchParams(params)}`, { signal: AbortSignal.timeout(12000) });
|
||||
const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Service indisponible'); return d;
|
||||
}
|
||||
function weatherLabel(code) {
|
||||
if (code === 0) return ['☀', 'Ciel dégagé'];
|
||||
if (code <= 3) return ['⛅', 'Nuages et éclaircies'];
|
||||
if (code <= 48) return ['≋', 'Brouillard'];
|
||||
if (code >= 95) return ['ϟ', 'Orages'];
|
||||
if ([71,73,75,77,85,86].includes(code)) return ['❄', 'Neige'];
|
||||
return ['☂', 'Pluie ou bruine'];
|
||||
}
|
||||
function clearWeather(message) {
|
||||
['#weather-temperature','#weather-min','#weather-max','#weather-wind'].forEach(id => $(id).textContent = '—');
|
||||
$('#weather-source').textContent = 'INDISPONIBLE'; $('#weather-status').textContent = message;
|
||||
$('#weather-title').textContent = 'Météo indisponible'; $('#weather-icon').textContent = '◌';
|
||||
}
|
||||
async function weather(force = false) {
|
||||
if (!position) return;
|
||||
if (!force && (weatherBusy || Date.now() < weatherRetryAt || (Date.now() - weatherAt < 15 * 60000 && weatherPosition && distance(position, weatherPosition) < 5000))) return;
|
||||
const id = ++weatherGeneration, at = { ...position }; weatherBusy = true;
|
||||
if (force || !weatherPosition || distance(at, weatherPosition) >= 5000) clearWeather('Actualisation…');
|
||||
$('#weather-source').textContent = 'CHARGEMENT';
|
||||
try {
|
||||
const d = await api('weather', { lat: at.lat, lon: at.lon });
|
||||
if (id !== weatherGeneration) return;
|
||||
if (!position || distance(at, position) > 5000) { clearWeather('Position modifiée · nouvelle météo en attente'); weatherAt = 0; return; }
|
||||
const [icon, label] = weatherLabel(d.code);
|
||||
$('#weather-icon').textContent = icon; $('#weather-title').textContent = label;
|
||||
$('#weather-temperature').textContent = Math.round(d.temperature);
|
||||
$('#weather-min').textContent = `${Math.round(d.min)}°`; $('#weather-max').textContent = `${Math.round(d.max)}°`;
|
||||
$('#weather-wind').textContent = `${Math.round(d.wind)} km/h`;
|
||||
$('#weather-source').textContent = 'OPEN-METEO';
|
||||
$('#weather-status').textContent = `${at.label} · modèle météo ${d.observedAt?.slice(11,16) || ''} (heure du lieu)`;
|
||||
weatherAt = Date.now(); weatherRetryAt = 0; weatherPosition = at;
|
||||
} catch (error) { if (id === weatherGeneration) { clearWeather(error.message); weatherAt = 0; weatherRetryAt = Date.now() + 60000; } }
|
||||
finally { if (id === weatherGeneration) weatherBusy = false; }
|
||||
}
|
||||
$('#weather-refresh').onclick = () => { if (!position) toast('Choisissez un lieu ou activez le GPS.'); else weather(true); };
|
||||
|
||||
async function ensureMap() {
|
||||
if (map) { map.invalidateSize(); return map; }
|
||||
if (mapLoading) return mapLoading;
|
||||
mapLoading = (async () => {
|
||||
if (!window.L) await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script'); script.src = '/vendor/leaflet/leaflet.js';
|
||||
script.onload = resolve; script.onerror = () => { script.remove(); reject(new Error('Bibliothèque cartographique indisponible')); }; document.head.append(script);
|
||||
});
|
||||
const config = await api('map-config');
|
||||
map = window.L.map('map-canvas', { zoomControl: true }).setView(position ? [position.lat, position.lon] : [46.6, 2.4], position ? 13 : 6);
|
||||
const attribution = document.createElement('span'); attribution.textContent = config.tileAttribution;
|
||||
const tiles = window.L.tileLayer(config.tileUrl, { maxZoom: 19, attribution: attribution.innerHTML + ' · <a href="https://www.openstreetmap.org/copyright">Licence</a>' }).addTo(map);
|
||||
tiles.on('tileerror', () => $('#map-status').textContent = 'Fond de carte indisponible : vérifiez la connexion.');
|
||||
tiles.on('load', () => { $('#map-status').textContent = 'Carte © OpenStreetMap · Internet requis'; });
|
||||
updateMarker(); drawRoute(); return map;
|
||||
})().catch(error => { $('#map-status').textContent = error.message; mapLoading = null; return null; });
|
||||
return mapLoading;
|
||||
}
|
||||
function updateMarker() {
|
||||
if (!map) return;
|
||||
if (marker) { marker.remove(); marker = null; }
|
||||
if (position) {
|
||||
marker = window.L.circleMarker([position.lat, position.lon], { radius: 8, color: '#fff', weight: 3, fillColor: getComputedStyle(document.body).getPropertyValue('--accent').trim(), fillOpacity: 1 }).addTo(map);
|
||||
const label = document.createElement('span'); label.textContent = position.source === 'gps' ? `GPS ± ${Math.round(position.accuracy)} m` : 'Départ manuel'; marker.bindTooltip(label);
|
||||
}
|
||||
}
|
||||
function drawRoute() {
|
||||
if (!map) return;
|
||||
if (routeLayer) { routeLayer.remove(); routeLayer = null; }
|
||||
if (targetMarker) { targetMarker.remove(); targetMarker = null; }
|
||||
if (route) routeLayer = window.L.geoJSON(route.geometry, { style: { color: getComputedStyle(document.body).getPropertyValue('--accent').trim(), weight: 6 } }).addTo(map);
|
||||
if (destination) targetMarker = window.L.circleMarker([destination.lat, destination.lon], { radius: 8, color: '#1d3530', fillColor: '#fff', fillOpacity: 1 }).addTo(map);
|
||||
}
|
||||
document.addEventListener('caria-view', e => { if (e.detail === 'map') ensureMap(); });
|
||||
document.addEventListener('caria-layout', () => { if(map) requestAnimationFrame(()=>map.invalidateSize()); });
|
||||
document.addEventListener('caria-theme', () => { updateMarker(); drawRoute(); });
|
||||
$('#map-center').onclick = async () => { const m = await ensureMap(); if (m && position) m.setView([position.lat, position.lon], 15); else if (!position) toast('Définissez d’abord un départ.'); };
|
||||
|
||||
function stopGPS() {
|
||||
gpsGeneration++;
|
||||
if (watch !== null) navigator.geolocation.clearWatch(watch);
|
||||
watch = null; $('#gps-stop').hidden = true;
|
||||
}
|
||||
function setPosition(value) {
|
||||
position = value;
|
||||
$('#position-status').textContent = value.source === 'gps' ? `GPS actif · précision ± ${Math.round(value.accuracy)} m` : `Départ manuel : ${value.label}`;
|
||||
updateMarker(); weather(); guidance(); checkAlerts();
|
||||
}
|
||||
function manual(value) {
|
||||
stopGPS(); clearRoute();
|
||||
setPosition({ ...value, source: 'manual', timestamp: Date.now() });
|
||||
storage.set('position', JSON.stringify({ lat: value.lat, lon: value.lon, label: value.label }));
|
||||
weather(true);
|
||||
if (map) map.setView([value.lat, value.lon], 13);
|
||||
$('#place-use').value = 'destination';
|
||||
}
|
||||
let gpsGeneration = 0;
|
||||
function locate() {
|
||||
if (!window.isSecureContext || !navigator.geolocation) { toast('GPS : ouvrez CARIA en HTTPS ou sur localhost. Le départ manuel reste disponible.'); return; }
|
||||
stopGPS(); const generation = ++gpsGeneration;
|
||||
$('#position-status').textContent = 'Recherche de la position GPS…';
|
||||
watch = navigator.geolocation.watchPosition(p => {
|
||||
if (generation !== gpsGeneration) return;
|
||||
const first = position?.source !== 'gps';
|
||||
setPosition({ lat: p.coords.latitude, lon: p.coords.longitude, accuracy: p.coords.accuracy, timestamp: p.timestamp, source: 'gps', label: 'Position GPS' });
|
||||
$('#gps-stop').hidden = false;
|
||||
if (map && first) map.setView([position.lat, position.lon], 15);
|
||||
}, error => {
|
||||
if (generation !== gpsGeneration) return;
|
||||
$('#position-status').textContent = error.code === 1 ? 'GPS refusé. Choisissez un départ manuel.' : 'GPS indisponible. Choisissez un départ manuel ou réessayez.';
|
||||
if (position?.source === 'gps') position.timestamp = 0;
|
||||
guidance(); checkAlerts();
|
||||
if (error.code === 1) stopGPS();
|
||||
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 });
|
||||
$('#gps-stop').hidden = false;
|
||||
}
|
||||
document.querySelectorAll('[data-locate]').forEach(button => button.onclick = locate);
|
||||
$('#gps-stop').onclick = () => {
|
||||
++gpsGeneration; stopGPS(); position = null; updateMarker();
|
||||
$('#position-status').textContent = 'GPS arrêté · choisissez un nouveau départ';
|
||||
weatherGeneration++; weatherBusy = false; clearWeather('GPS arrêté · choisissez un lieu'); guidance(); checkAlerts();
|
||||
};
|
||||
$('#coordinates-form').onsubmit = e => {
|
||||
e.preventDefault(); const lat = Number($('#latitude').value), lon = Number($('#longitude').value);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 85 && Math.abs(lon) <= 180) manual({ lat, lon, label: `${lat.toFixed(3)}, ${lon.toFixed(3)}` });
|
||||
};
|
||||
$('#place-form').onsubmit = async e => {
|
||||
e.preventDefault(); const id = ++searchGeneration, use = $('#place-use').value;
|
||||
const results = $('#place-results'); results.textContent = 'Recherche…';
|
||||
try {
|
||||
const places = await api('search', { q: $('#place-query').value.trim() });
|
||||
if (id !== searchGeneration) return;
|
||||
results.replaceChildren(); if (!places.length) results.textContent = 'Aucun lieu trouvé. Précisez la ville ou utilisez les coordonnées.';
|
||||
for (const place of places) {
|
||||
const button = document.createElement('button'); button.textContent = place.label;
|
||||
button.onclick = () => {
|
||||
results.replaceChildren();
|
||||
if (use === 'origin') manual(place);
|
||||
else if (!position) toast('Définissez un départ avant la destination.');
|
||||
else { destination = place; document.dispatchEvent(new CustomEvent('caria-destination', { detail: place })); calculateRoute(); }
|
||||
}; results.append(button);
|
||||
}
|
||||
} catch (error) { if (id === searchGeneration) results.textContent = error.message; }
|
||||
};
|
||||
|
||||
function clearRoute() {
|
||||
window.speechSynthesis?.cancel();
|
||||
routeGeneration++; route = null; destination = null; lastSpoken = '';
|
||||
document.dispatchEvent(new CustomEvent('caria-destination', { detail: null }));
|
||||
$('#guidance').hidden = true; $('#route-steps').replaceChildren(); drawRoute();
|
||||
}
|
||||
$('#route-clear').onclick = clearRoute;
|
||||
$('#route-recalculate').onclick = () => calculateRoute();
|
||||
async function calculateRoute() {
|
||||
if (!position || !destination) return toast('Choisissez un départ et une destination.');
|
||||
const id = ++routeGeneration, origin = { ...position }, target = { ...destination };
|
||||
route = null; drawRoute(); $('#route-steps').replaceChildren(); $('#guidance').hidden = false;
|
||||
$('#next-instruction').textContent = 'Calcul de l’itinéraire…'; $('#route-summary').textContent = target.label;
|
||||
try {
|
||||
const data = await api('route', { fromLat: origin.lat, fromLon: origin.lon, toLat: target.lat, toLon: target.lon });
|
||||
if (id !== routeGeneration) return;
|
||||
if (!position || distance(origin, position) > 200) throw new Error('Le départ a changé. Recalculez le trajet.');
|
||||
route = data; lastSpoken = ''; drawRoute();
|
||||
if (map && routeLayer) map.fitBounds(routeLayer.getBounds(), { padding: [25, 25] });
|
||||
for (const step of route.steps) {
|
||||
const li = document.createElement('li'); li.textContent = `${instruction(step)} · ${fmtDistance(step.distance)}`; $('#route-steps').append(li);
|
||||
}
|
||||
guidance();
|
||||
} catch (error) { if (id === routeGeneration) { $('#next-instruction').textContent = error.message; $('#route-summary').textContent = 'Itinéraire non disponible'; } }
|
||||
}
|
||||
function speak(text) {
|
||||
if (!('speechSynthesis' in window)) return;
|
||||
const utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'fr-FR'; window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
$('#voice').onchange = () => { if ($('#voice').checked) { if ('speechSynthesis' in window) speak('Guidage vocal activé'); else toast('La synthèse vocale n’est pas disponible.'); } else window.speechSynthesis?.cancel(); };
|
||||
function guidance() {
|
||||
if (!route) return;
|
||||
const freshGPS = position?.source === 'gps' && Math.abs(Date.now() - position.timestamp) < 15000 && position.accuracy <= 100;
|
||||
let progress = 0, index = 0;
|
||||
if (freshGPS) {
|
||||
const p = routeProgress(route.geometry.coordinates, position);
|
||||
if (p.away > 100) { $('#next-instruction').textContent = 'Hors itinéraire · recalculez le trajet'; return; }
|
||||
progress = p.fraction * route.distance;
|
||||
let cumulative = 0;
|
||||
while (index < route.steps.length - 1 && cumulative + route.steps[index].distance < progress + 30) cumulative += route.steps[index++].distance;
|
||||
}
|
||||
const next = Math.min(index + 1, route.steps.length - 1);
|
||||
const text = instruction(route.steps[next]);
|
||||
$('#next-instruction').textContent = freshGPS ? text : position?.source === 'manual' ? 'Aperçu du trajet · activez le GPS pour le guidage' : 'Guidage en pause · GPS absent ou imprécis';
|
||||
const remaining = Math.max(0, route.distance - progress);
|
||||
$('#route-summary').textContent = `${fmtDistance(remaining)} · environ ${Math.max(1, Math.round(route.duration * (route.distance ? remaining / route.distance : 0) / 60))} min · ${destination.label} · sans trafic temps réel`;
|
||||
[...$('#route-steps').children].forEach((li, i) => li.classList.toggle('current', i === next));
|
||||
if (freshGPS && $('#voice').checked && lastSpoken !== `${next}:${text}`) { lastSpoken = `${next}:${text}`; speak(text); }
|
||||
}
|
||||
|
||||
function zoneStatus() {
|
||||
const expired = zones.filter(z => Date.parse(z.expiresAt) <= Date.now()).length;
|
||||
$('#zones-status').textContent = zones.length ? `${zones.length} zones importées · ${expired} expirée(s). Source : ${[...new Set(zones.map(z => z.source))].join(', ')}` : 'Aucune zone chargée · aucune couverture radar';
|
||||
}
|
||||
const savedZones = readJSON('zones');
|
||||
if (savedZones) { try { zones = validateZones(savedZones); } catch { /* Invalid persisted data is ignored. */ } }
|
||||
zoneStatus();
|
||||
$('#zones-file').onchange = async e => {
|
||||
const file = e.target.files[0]; if (!file) return;
|
||||
try {
|
||||
if (file.size > 1500000) throw new Error('Fichier trop volumineux (1,5 Mo maximum).');
|
||||
const data = JSON.parse(await file.text()), validated = validateZones(data);
|
||||
if (!storage.set('zones', JSON.stringify(data))) throw new Error('Stockage local indisponible.');
|
||||
zones = validated; zoneStatus(); checkAlerts(); toast('Zones importées. Choisissez le pays puis activez les alertes.');
|
||||
} catch (error) { toast(error.message); }
|
||||
e.target.value = '';
|
||||
};
|
||||
$('#zones-clear').onclick = () => { zones = []; storage.set('zones', 'null'); $('#alerts-enabled').checked = false; zoneStatus(); checkAlerts(); };
|
||||
function sound() {
|
||||
if (!audioContext) return;
|
||||
const oscillator = audioContext.createOscillator(), gain = audioContext.createGain();
|
||||
oscillator.connect(gain); gain.connect(audioContext.destination); oscillator.frequency.value = 660;
|
||||
gain.gain.setValueAtTime(.12, audioContext.currentTime); gain.gain.exponentialRampToValueAtTime(.001, audioContext.currentTime + .35);
|
||||
oscillator.start(); oscillator.stop(audioContext.currentTime + .36);
|
||||
}
|
||||
$('#alert-sound').onchange = async () => {
|
||||
if ($('#alert-sound').checked) try { audioContext ||= new (window.AudioContext || window.webkitAudioContext)(); await audioContext.resume(); sound(); } catch { toast('Son indisponible dans ce navigateur.'); }
|
||||
};
|
||||
function showAlert(title, description) { $('#zone-alert-title').textContent = title; $('#zone-alert-description').textContent = description; $('#zone-alert').hidden = false; }
|
||||
let testUntil = 0, dismissedZone = '';
|
||||
$('#alert-test').onclick = () => { testUntil = Date.now() + 6000; showAlert('TEST · Zone fictive', 'Exemple d’avertissement, sans lien avec un radar ou votre position.'); if ($('#alert-sound').checked) sound(); };
|
||||
$('#zone-alert-close').onclick = () => { testUntil = 0; dismissedZone = lastZone; $('#zone-alert').hidden = true; };
|
||||
function checkAlerts() {
|
||||
if (Date.now() < testUntil) return;
|
||||
const candidates = $('#alerts-enabled').checked && $('#alert-country').value ? nearbyZones(zones, position, $('#alert-country').value) : [];
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) { $('#zone-alert').hidden = true; dismissedZone = ''; return; }
|
||||
const newAlert = candidate.id !== lastZone || Date.now() - lastZoneTime > 120000;
|
||||
if (newAlert || (dismissedZone !== candidate.id && $('#zone-alert').hidden)) {
|
||||
lastZone = candidate.id; lastZoneTime = Date.now();
|
||||
showAlert(candidate.remaining ? 'Zone de vigilance à proximité' : 'Zone de vigilance', `${candidate.name} · ${candidate.country} · source : ${candidate.source}. Proximité géographique, sens de circulation non vérifié.`);
|
||||
if (newAlert && $('#alert-sound').checked) sound();
|
||||
}
|
||||
}
|
||||
$('#alerts-enabled').onchange = () => { lastZone = ''; if ($('#alerts-enabled').checked && (!zones.length || !$('#alert-country').value)) { $('#alerts-enabled').checked = false; toast('Importez des zones et choisissez le pays d’utilisation.'); } else if ($('#alerts-enabled').checked && position?.source !== 'gps') toast('Alertes en attente d’un GPS précis.'); checkAlerts(); };
|
||||
$('#alert-country').onchange = () => { $('#alerts-enabled').checked = false; lastZone = ''; checkAlerts(); toast('Pays modifié : réactivez les alertes après vérification des règles locales.'); };
|
||||
setInterval(() => {
|
||||
guidance(); checkAlerts();
|
||||
if (position?.source === 'gps' && Date.now() - position.timestamp > 15000) $('#position-status').textContent = 'Position GPS ancienne · guidage et alertes en pause';
|
||||
}, 3000);
|
||||
setInterval(() => weather(), 60000);
|
||||
window.addEventListener('pagehide', () => { stopGPS(); window.speechSynthesis?.cancel(); });
|
||||
const saved = readJSON('position');
|
||||
if (saved && Number.isFinite(saved.lat) && Number.isFinite(saved.lon) && Math.abs(saved.lat) <= 85 && Math.abs(saved.lon) <= 180) setPosition({ ...saved, label: String(saved.label || 'Lieu enregistré'), source: 'manual', timestamp: Date.now() });
|
||||
if (location.hash === '#map') ensureMap();
|
||||
}
|
||||
+2
-1
@@ -5,11 +5,12 @@ server {
|
||||
index index.html;
|
||||
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
@@ -0,0 +1,24 @@
|
||||
export const accents = { green: '#d2ef92', blue: '#8ac7ff', red: '#ff9e99' };
|
||||
export function palette(color, light = false) {
|
||||
color = /^#[0-9a-f]{6}$/i.test(color) ? color : accents.green;
|
||||
const rgb = [1, 3, 5].map(i => parseInt(color.slice(i, i + 2), 16));
|
||||
const luminance = c => { const n = c / 255; return n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4; };
|
||||
const lum = values => values.map(luminance).reduce((n, v, i) => n + v * [.2126, .7152, .0722][i], 0);
|
||||
// Maintain readable accent text on either background, even for arbitrary colors.
|
||||
for (let i = 0; i < 40 && (light ? lum(rgb) > .17 : lum(rgb) < .36); i++) {
|
||||
rgb.forEach((n, j) => rgb[j] = Math.round(light ? n * .9 : n + (255 - n) * .12));
|
||||
}
|
||||
return { accent: `#${rgb.map(n => n.toString(16).padStart(2, '0')).join('')}`, ink: light ? '#ffffff' : '#101313' };
|
||||
}
|
||||
export function applyTheme(storage) {
|
||||
const light = storage.get('theme') === 'light';
|
||||
const color = accents[storage.get('accent', 'green')] || storage.get('customColor', accents.green);
|
||||
const values = palette(color, light);
|
||||
document.body.classList.toggle('light', light);
|
||||
document.body.classList.toggle('digital-dials', storage.get('dialStyle') === 'digital');
|
||||
document.body.classList.toggle('luminous-dials', storage.get('dialStyle') === 'luminous');
|
||||
document.querySelectorAll('.gauge > svg').forEach(svg => svg.setAttribute('viewBox', '12 10 276 340'));
|
||||
document.body.style.setProperty('--accent', values.accent);
|
||||
document.body.style.setProperty('--ink', values.ink);
|
||||
document.dispatchEvent(new Event('caria-theme'));
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||
Copyright (c) 2010-2011, CloudMade
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user