update kiosk

This commit is contained in:
2026-10-21 12:24:12 +02:00
parent 5444ea943a
commit d6d424cae8
3 changed files with 320 additions and 9 deletions
+14 -1
View File
@@ -22,6 +22,7 @@ class Kiosk
'andamento' => 'Andamento 14 giorni',
'fermi' => 'Fermi della settimana',
'coda' => 'Lavoro in arrivo',
'meteo' => 'Meteo e previsioni',
'sicurezza' => 'Giorni senza infortuni',
'obiettivi' => 'Obiettivi',
'comunicazioni' => 'Comunicazioni',
@@ -51,7 +52,8 @@ class Kiosk
const DEFAULTS = [
'company' => 'Zibo',
'slide_seconds' => '15',
'slides' => 'linee,oggi,andamento,fermi,coda,sicurezza,obiettivi,comunicazioni,benvenuti,turni,menu',
'slide_durations' => '',
'slides' => 'linee,oggi,andamento,fermi,coda,meteo,sicurezza,obiettivi,comunicazioni,benvenuti,turni,menu',
'scarto_target' => '3',
'last_incident' => '',
'record_days' => '',
@@ -91,6 +93,16 @@ class Kiosk
return $t;
}
/** Secondi per tipo di slide (vuoto = durata predefinita). */
public static function durations(array $s)
{
$out = [];
foreach ((array)json_decode((string)$s['slide_durations'], true) as $k => $v) {
if (isset(self::SLIDES[$k]) && (int)$v > 0) $out[$k] = max(4, min(300, (int)$v));
}
return $out;
}
/* ===================== DATE ===================== */
private static function tz()
@@ -366,6 +378,7 @@ class Kiosk
'settings' => [
'slide_seconds' => max(6, min(120, (int)$s['slide_seconds'] ?: 15)),
'slides' => array_values(array_filter(explode(',', $s['slides']))),
'durations' => (object)self::durations($s),
'scarto_target' => (float)str_replace(',', '.', $s['scarto_target']),
'city' => $s['city'],
'lat' => $s['lat'],
+277 -5
View File
@@ -972,6 +972,173 @@ if (($_GET['a'] ?? '') === 'data') {
margin-right: .8rem;
}
/* meteo */
.wxs {
flex: 1;
display: grid;
grid-template-columns: 34rem minmax(0, 1fr);
gap: 1.4rem;
min-height: 0;
}
.wnow {
padding: 2rem 2.2rem;
display: flex;
flex-direction: column;
background: radial-gradient(600px 300px at 30% 20%, rgba(252, 211, 77, .14), transparent 70%), var(--panel);
}
.wbig {
display: flex;
align-items: center;
gap: 1.4rem;
margin-top: 1rem;
}
.wbig .wi {
font-size: 8rem;
line-height: 1;
}
.wbig span {
font-size: 8.5rem;
font-weight: 900;
line-height: 1;
}
.wdesc {
font-size: 2.2rem;
font-weight: 800;
margin-top: .8rem;
}
.wmeta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem 1.4rem;
margin-top: auto;
padding-top: 1.4rem;
}
.wmeta span {
display: block;
font-size: 1rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--label);
}
.wmeta b {
font-size: 1.9rem;
font-weight: 900;
}
.wright {
display: flex;
flex-direction: column;
gap: 1.4rem;
min-height: 0;
}
.whours {
padding: 1.4rem 1.8rem;
}
.hrow {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: .8rem;
margin-top: 1rem;
}
.hc {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
}
.hc .t {
font-size: 1.3rem;
font-weight: 800;
color: var(--label);
}
.hc .wi {
font-size: 2.8rem;
}
.hc b {
font-size: 2.2rem;
font-weight: 900;
}
.hc .r,
.dc .dr {
font-size: 1.2rem;
font-weight: 800;
color: var(--muted);
}
.hi {
color: #60a5fa !important;
}
.mid {
color: #93c5fd !important;
}
.wdays {
flex: 1;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1.2rem;
min-height: 0;
}
.dc {
min-width: 0;
padding: 1.4rem 1rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .4rem;
}
.dc .dn {
white-space: nowrap;
font-size: 1.7rem;
font-weight: 900;
}
.dc .wi {
font-size: 4rem;
margin: .4rem 0;
}
.dc .dt b {
font-size: 2.6rem;
font-weight: 900;
}
.dc .dt span {
font-size: 1.8rem;
font-weight: 800;
color: var(--muted);
margin-left: .6rem;
}
.dc .dd {
font-size: 1.1rem;
font-weight: 700;
color: var(--label);
min-height: 2.8rem;
}
.empty {
margin: auto;
color: var(--muted);
@@ -1101,6 +1268,12 @@ if (($_GET['a'] ?? '') === 'data') {
i: 'bi-list-ol',
f: slideQueue
});
if (has('meteo') && WX && WX.daily && WX.hourly) S.push({
k: 'meteo',
t: 'Meteo' + (D.settings.city ? ' · ' + D.settings.city : ''),
i: 'bi-cloud-sun-fill',
f: slideWeather
});
if (has('sicurezza') && D.safety) S.push({
k: 'sicurezza',
t: 'Sicurezza',
@@ -1152,7 +1325,8 @@ if (($_GET['a'] ?? '') === 'data') {
return setTimeout(next, 10000);
}
show(seq[pos]);
const sec = D.settings.slide_seconds * (seq[pos].k === 'comunicazioni' || seq[pos].k === 'turni' || seq[pos].k === 'menu' ? 1.3 : 1);
// durata impostata per questo tipo di slide, altrimenti quella predefinita (un po' di più per i testi)
const sec = (D.settings.durations || {})[seq[pos].k] || D.settings.slide_seconds * (seq[pos].k === 'comunicazioni' || seq[pos].k === 'turni' || seq[pos].k === 'menu' ? 1.3 : 1);
const p = $('#prog');
p.style.transition = 'none';
p.style.width = '0';
@@ -1461,18 +1635,116 @@ if (($_GET['a'] ?? '') === 'data') {
setInterval(weather, 30 * 60000);
}
// meteo: ora, prossime ore e prossimi giorni (open-meteo, gratuito e senza chiave)
let WX = null;
const WX_TXT = {
0: 'Sereno',
1: 'Quasi sereno',
2: 'Parzialmente nuvoloso',
3: 'Coperto',
45: 'Nebbia',
48: 'Nebbia con brina',
51: 'Pioviggine leggera',
53: 'Pioviggine',
55: 'Pioviggine intensa',
56: 'Pioviggine gelata',
57: 'Pioviggine gelata',
61: 'Pioggia debole',
63: 'Pioggia',
65: 'Pioggia forte',
66: 'Pioggia gelata',
67: 'Pioggia gelata forte',
71: 'Neve debole',
73: 'Neve',
75: 'Neve forte',
77: 'Nevischio',
80: 'Rovesci leggeri',
81: 'Rovesci',
82: 'Rovesci violenti',
85: 'Rovesci di neve',
86: 'Rovesci di neve forti',
95: 'Temporale',
96: 'Temporale con grandine',
99: 'Temporale con grandine'
};
function wxIcon(w, day = true) {
w = +w;
if (w === 0) return day ? ['bi-sun-fill', '#fcd34d'] : ['bi-moon-stars-fill', '#c7d2fe'];
if (w <= 2) return day ? ['bi-cloud-sun-fill', '#fcd34d'] : ['bi-cloud-moon-fill', '#c7d2fe'];
if (w === 3) return ['bi-cloud-fill', '#cbd5e1'];
if (w <= 48) return ['bi-cloud-fog2-fill', '#94a3b8'];
if (w <= 57) return ['bi-cloud-drizzle-fill', '#7dd3fc'];
if (w <= 67) return ['bi-cloud-rain-fill', '#60a5fa'];
if (w <= 77) return ['bi-snow', '#e0f2fe'];
if (w <= 82) return ['bi-cloud-rain-heavy-fill', '#3b82f6'];
if (w <= 86) return ['bi-cloud-snow-fill', '#e0f2fe'];
return ['bi-cloud-lightning-rain-fill', '#a78bfa'];
}
const ico = (w, day, cls = '') => {
const [i, c] = wxIcon(w, day);
return `<i class="bi ${i} wi ${cls}" style="color:${c}"></i>`;
};
function weather() {
const s = D.settings;
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${encodeURIComponent(s.lat)}&longitude=${encodeURIComponent(s.lon)}&current=temperature_2m,weather_code&timezone=auto`)
const q = `latitude=${encodeURIComponent(s.lat)}&longitude=${encodeURIComponent(s.lon)}&timezone=Europe%2FRome&forecast_days=7` +
'&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day' +
'&hourly=temperature_2m,weather_code,precipitation_probability,is_day' +
'&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset';
fetch('https://api.open-meteo.com/v1/forecast?' + q)
.then(r => r.json()).then(r => {
const c = r.current;
if (!c) return;
const w = +c.weather_code;
const ic = w === 0 ? 'bi-sun-fill' : w <= 2 ? 'bi-cloud-sun-fill' : w === 3 ? 'bi-cloud-fill' : w <= 48 ? 'bi-cloud-fog2-fill' : w <= 67 ? 'bi-cloud-rain-fill' : w <= 77 ? 'bi-snow' : w <= 82 ? 'bi-cloud-rain-heavy-fill' : 'bi-cloud-lightning-rain-fill';
$('#wx').innerHTML = `<i class="bi ${ic}"></i><div>${Math.round(c.temperature_2m)}°<small>${esc(s.city || '')}</small></div>`;
WX = r;
$('#wx').innerHTML = `${ico(c.weather_code, c.is_day !== 0)}<div>${Math.round(c.temperature_2m)}°<small>${esc(s.city || '')}</small></div>`;
}).catch(() => {});
}
function slideWeather() {
const c = WX.current,
d = WX.daily,
h = WX.hourly;
const hhmm = t => String(t).slice(11, 16);
// prossime 8 ore a partire dall'ora attuale
const nowH = String(c.time).slice(0, 13);
let i0 = h.time.findIndex(t => t.slice(0, 13) >= nowH);
if (i0 < 0) i0 = 0;
const hours = [];
for (let i = i0 + 1; i < h.time.length && hours.length < 8; i++) hours.push(i);
const GG = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
const days = d.time.map((t, i) => i).slice(1, 7);
const rainCls = p => p >= 60 ? 'hi' : (p >= 30 ? 'mid' : '');
return `<div class="wxs">
<div class="panel wnow">
<div class="lbl">Adesso${D.settings.city ? ' a ' + esc(D.settings.city) : ''}</div>
<div class="wbig">${ico(c.weather_code, c.is_day !== 0)}<span>${Math.round(c.temperature_2m)}°</span></div>
<div class="wdesc">${esc(WX_TXT[c.weather_code] || '')}</div>
<div class="wmeta">
<div><span>Percepita</span><b>${Math.round(c.apparent_temperature)}°</b></div>
<div><span>Min / max</span><b>${Math.round(d.temperature_2m_min[0])}° / ${Math.round(d.temperature_2m_max[0])}°</b></div>
<div><span>Umidità</span><b>${Math.round(c.relative_humidity_2m)}%</b></div>
<div><span>Vento</span><b>${Math.round(c.wind_speed_10m)} km/h</b></div>
<div><span>Pioggia oggi</span><b>${d.precipitation_probability_max[0] ?? 0}%</b></div>
<div><span>Alba · tramonto</span><b>${hhmm(d.sunrise[0])} · ${hhmm(d.sunset[0])}</b></div>
</div>
</div>
<div class="wright">
<div class="panel whours">
<div class="lbl">Prossime ore</div>
<div class="hrow">${hours.map(i => `<div class="hc"><span class="t">${hhmm(h.time[i])}</span>${ico(h.weather_code[i], h.is_day[i] !== 0)}<b>${Math.round(h.temperature_2m[i])}°</b><span class="r ${rainCls(h.precipitation_probability[i])}"><i class="bi bi-droplet-fill"></i> ${h.precipitation_probability[i] ?? 0}%</span></div>`).join('')}</div>
</div>
<div class="wdays">${days.map(i => {
const dt = new Date(d.time[i] + 'T12:00:00');
return `<div class="panel dc"><div class="dn">${i === 1 ? 'Domani' : GG[dt.getDay()] + ' ' + dt.getDate()}</div>${ico(d.weather_code[i], true)}
<div class="dt"><b>${Math.round(d.temperature_2m_max[i])}°</b><span>${Math.round(d.temperature_2m_min[i])}°</span></div>
<div class="dd">${esc(WX_TXT[d.weather_code[i]] || '')}</div>
<div class="dr ${rainCls(d.precipitation_probability_max[i])}"><i class="bi bi-droplet-fill"></i> ${d.precipitation_probability_max[i] ?? 0}%</div></div>`;
}).join('')}</div>
</div>
</div>`;
}
/* ---------- avvio ---------- */
tick();
load().then(() => next());
+29 -3
View File
@@ -121,6 +121,7 @@ if ($action !== '') {
'company' => mb_substr(trim((string)($_POST['company'] ?? '')), 0, 60),
'slide_seconds' => (string)max(6, min(120, (int)($_POST['slide_seconds'] ?? 15))),
'slides' => implode(',', $slides),
'slide_durations' => json_encode((object)array_filter(array_map('intval', array_intersect_key((array)($_POST['dur'] ?? []), Kiosk::SLIDES)), fn($v) => $v > 0)),
'scarto_target' => (string)max(0, (float)str_replace(',', '.', (string)($_POST['scarto_target'] ?? 3))),
'city' => mb_substr(trim((string)($_POST['city'] ?? '')), 0, 60),
'lat' => is_numeric($_POST['lat'] ?? '') ? (string)(float)$_POST['lat'] : '',
@@ -507,6 +508,23 @@ if ($action !== '') {
line-height: 1;
}
.sl-row {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
border-bottom: 1px dashed var(--line);
}
.sl-row .form-check {
flex: 1;
}
.sl-sec {
width: 92px;
flex-shrink: 0;
}
.slide-list .form-check {
padding: 6px 10px 6px 2.6em;
border-radius: 8px;
@@ -627,7 +645,7 @@ if ($action !== '') {
<h6><i class="fa-solid fa-sliders text-secondary"></i> Impostazioni</h6>
<div class="row g-2">
<div class="col-7"><label class="form-label small fw-bold mb-1">Nome azienda</label><input class="form-control" name="company" id="cCompany"></div>
<div class="col-5"><label class="form-label small fw-bold mb-1">Secondi per slide</label><input type="number" min="6" max="120" class="form-control" name="slide_seconds" id="cSec"></div>
<div class="col-5"><label class="form-label small fw-bold mb-1">Secondi predefiniti</label><input type="number" min="6" max="120" class="form-control" name="slide_seconds" id="cSec"></div>
<div class="col-7"><label class="form-label small fw-bold mb-1">Obiettivo scarto %</label><input type="number" step="0.1" min="0" class="form-control" name="scarto_target" id="cScarto"></div>
<div class="col-12"><label class="form-label small fw-bold mb-1">Città per il meteo</label>
<div class="input-group"><input class="form-control" name="city" id="cCity" placeholder="Es. Lecco"><button type="button" class="btn btn-outline-secondary" id="btnGeo">Trova</button></div>
@@ -635,7 +653,8 @@ if ($action !== '') {
<div class="hint" id="geoTxt"></div>
</div>
</div>
<label class="form-label small fw-bold mt-3 mb-1">Slide da mostrare</label>
<label class="form-label small fw-bold mt-3 mb-1">Slide da mostrare e durata</label>
<div class="hint mb-1">Secondi vuoti = durata predefinita. Le slide con più contenuti (es. 3 comunicazioni) usano la durata per ciascuno.</div>
<div class="slide-list" id="slideList"></div>
<button class="btn btn-primary btn-sm mt-2"><i class="fa-solid fa-check me-1"></i> Salva</button>
</form>
@@ -1005,7 +1024,14 @@ if ($action !== '') {
$('#cLon').val(s.lon);
$('#geoTxt').text(s.lat ? `Posizione: ${s.lat}, ${s.lon}` : 'Scrivi la città e premi "Trova" per attivare il meteo');
const on = s.slides.split(',');
$('#slideList').html(Object.entries(L.slides).map(([k, v]) => `<div class="form-check form-switch"><input class="form-check-input" type="checkbox" name="slides[]" value="${k}" id="sl_${k}" ${on.includes(k) ? 'checked' : ''}><label class="form-check-label" for="sl_${k}">${esc(v)}</label></div>`).join(''));
let dur = {};
try {
dur = JSON.parse(s.slide_durations || '{}') || {};
} catch (e) {}
$('#slideList').html(Object.entries(L.slides).map(([k, v]) => `<div class="sl-row">
<div class="form-check form-switch mb-0"><input class="form-check-input" type="checkbox" name="slides[]" value="${k}" id="sl_${k}" ${on.includes(k) ? 'checked' : ''}><label class="form-check-label" for="sl_${k}">${esc(v)}</label></div>
<div class="input-group input-group-sm sl-sec"><input type="number" min="4" max="300" class="form-control" name="dur[${k}]" value="${dur[k] || ''}" placeholder="${k === 'comunicazioni' || k === 'turni' || k === 'menu' ? Math.round((+s.slide_seconds || 15) * 1.3) : (+s.slide_seconds || 15)}"><span class="input-group-text">s</span></div>
</div>`).join(''));
}
// sicurezza e impostazioni si salvano insieme (stessa tabella)