Aggiunta stili CSS per Kiosk, struttura HTML per la Mappa e Riferimenti ai Sensori
• Creato un nuovo file CSS per gli stili del chiosco (kiosk) con variabili, stili per le schede (card) e animazioni. • Aggiunto un file HTML per l'interfaccia della mappa utilizzando Mapbox, inclusi gli stili e il JavaScript per le funzionalità della mappa. • Introdotto un file JSON per i riferimenti ai sensori, definendo percorsi ed elementi per i dati di temperatura, vento, onde, posizione, batteria, motore e sistema. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
336
plugin/tools/kiosk/canvas.js
Normal file
336
plugin/tools/kiosk/canvas.js
Normal file
@@ -0,0 +1,336 @@
|
||||
const COLS = 24, ROWS = 18;
|
||||
const SNAP = 0.5;
|
||||
const SNAP_MAG = 0.3;
|
||||
const MIN_GW = 2, MIN_GH = 1.5;
|
||||
const MAX_GW = 20, MAX_GH = 16;
|
||||
const DEF_GW = 6, DEF_GH = 5;
|
||||
|
||||
const canvasEl = document.getElementById('canvas');
|
||||
const tooltipEl = document.getElementById('tooltip');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const cardCountEl = document.getElementById('cardCount');
|
||||
const unitBadge = document.getElementById('unitBadge');
|
||||
const modalOvl = document.getElementById('modalOverlay');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const importArea = document.getElementById('importArea');
|
||||
const modalApply = document.getElementById('modalApply');
|
||||
const toastEl = document.getElementById('toast');
|
||||
|
||||
let cards = [], cardIdCounter = 0, selectedCard = null, zCounter = 1;
|
||||
let snapGuidesH = [], snapGuidesV = [];
|
||||
let editMode = false;
|
||||
|
||||
const uw = () => canvasEl.clientWidth / COLS;
|
||||
const uh = () => canvasEl.clientHeight / ROWS;
|
||||
const gSnap = v => Math.round(v / SNAP) * SNAP;
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
function screenToGrid(cx, cy) {
|
||||
const r = canvasEl.getBoundingClientRect();
|
||||
return { gx: (cx - r.left) / uw(), gy: (cy - r.top) / uh() };
|
||||
}
|
||||
|
||||
function renderCard(c) {
|
||||
const u = uw(), h = uh();
|
||||
c.el.style.left = (c.gx * u) + 'px';
|
||||
c.el.style.top = (c.gy * h) + 'px';
|
||||
c.el.style.width = (c.gw * u) + 'px';
|
||||
c.el.style.height = (c.gh * h) + 'px';
|
||||
|
||||
if (editMode) c.el.classList.add('editable');
|
||||
else c.el.classList.remove('editable', 'selected');
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
cards.forEach(renderCard);
|
||||
unitBadge.textContent = `1u = ${Math.round(uw())}px`;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const n = cards.length;
|
||||
cardCountEl.textContent = `${n} card${n !== 1 ? 's' : ''}`;
|
||||
emptyState.classList.toggle('hidden', n > 0);
|
||||
}
|
||||
|
||||
// Responsive re-render
|
||||
let rafId = null;
|
||||
window.addEventListener('resize', () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(renderAll);
|
||||
});
|
||||
|
||||
// Toast
|
||||
let toastT = null;
|
||||
function toast(msg) {
|
||||
toastEl.textContent = msg;
|
||||
toastEl.classList.add('show');
|
||||
clearTimeout(toastT);
|
||||
toastT = setTimeout(() => toastEl.classList.remove('show'), 2200);
|
||||
}
|
||||
|
||||
// Guides
|
||||
function ensureGuides() {
|
||||
if (snapGuidesH.length) return;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
let g = document.createElement('div'); g.className = 'guide snap-guide horizontal'; canvasEl.appendChild(g); snapGuidesH.push(g);
|
||||
g = document.createElement('div'); g.className = 'guide snap-guide vertical'; canvasEl.appendChild(g); snapGuidesV.push(g);
|
||||
}
|
||||
}
|
||||
function hideGuides() {
|
||||
snapGuidesH.forEach(g => g.classList.remove('visible')); snapGuidesV.forEach(g => g.classList.remove('visible'));
|
||||
}
|
||||
function showGuide(type, gp, idx = 0) {
|
||||
if (type === 'h' && idx < snapGuidesH.length) { snapGuidesH[idx].style.top = (gp * uh()) + 'px'; snapGuidesH[idx].classList.add('visible'); }
|
||||
else if (type === 'v' && idx < snapGuidesV.length) { snapGuidesV[idx].style.left = (gp * uw()) + 'px'; snapGuidesV[idx].classList.add('visible'); }
|
||||
}
|
||||
|
||||
// Magnetic snap
|
||||
function magSnap(el, gx, gy, gw, gh) {
|
||||
let sx = gx, sy = gy, gH = [], gV = [];
|
||||
const others = cards.filter(c => c.el !== el);
|
||||
|
||||
let bH = SNAP_MAG + 1;
|
||||
for (const o of others)
|
||||
for (const [f, t] of [[gy, o.gy], [gy, o.gy + o.gh], [gy + gh, o.gy], [gy + gh, o.gy + o.gh]]) {
|
||||
const d = Math.abs(f - t);
|
||||
if (d < bH) { bH = d; sy = gy + (t - f); gH = [t]; }
|
||||
}
|
||||
|
||||
let bV = SNAP_MAG + 1;
|
||||
for (const o of others)
|
||||
for (const [f, t] of [[gx, o.gx], [gx, o.gx + o.gw], [gx + gw, o.gx], [gx + gw, o.gx + o.gw]]) {
|
||||
const d = Math.abs(f - t);
|
||||
if (d < bV) { bV = d; sx = gx + (t - f); gV = [t]; }
|
||||
}
|
||||
|
||||
if (bH > SNAP_MAG) sy = gSnap(gy);
|
||||
if (bV > SNAP_MAG) sx = gSnap(gx);
|
||||
return { gx: sx, gy: sy, guidesH: gH, guidesV: gV };
|
||||
}
|
||||
|
||||
// Signal K data handling
|
||||
function updateData(path, value) {
|
||||
cards.filter(c => c.path === path).forEach(c => {
|
||||
const body = c.el.querySelector('.card-body');
|
||||
if (body) {
|
||||
let displayVal = value;
|
||||
if (path.includes('speed')) displayVal = (value * 1.94384).toFixed(1) + ' kn';
|
||||
else if (path.includes('depth')) displayVal = value.toFixed(1) + ' m';
|
||||
body.textContent = displayVal;
|
||||
}
|
||||
});
|
||||
}
|
||||
window.updateKioskData = updateData;
|
||||
|
||||
// Create card
|
||||
function createCard(gx, gy, gw = DEF_GW, gh = DEF_GH, forceId = null, gz = null, type = 'widget', path = null) {
|
||||
const id = forceId || (++cardIdCounter);
|
||||
if (forceId && forceId >= cardIdCounter) cardIdCounter = forceId;
|
||||
if (!forceId && id > cardIdCounter) cardIdCounter = id;
|
||||
|
||||
const skPaths = window.skPaths || [];
|
||||
const finalPath = path || (type === 'widget' && skPaths.length ? skPaths[(id - 1) % skPaths.length] : null);
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card spawning' + (editMode ? ' editable' : '');
|
||||
el.dataset.id = id;
|
||||
el.dataset.type = type;
|
||||
const z = gz || (++zCounter);
|
||||
el.style.zIndex = z;
|
||||
if (gz && gz >= zCounter) zCounter = gz;
|
||||
|
||||
let headerHtml = `<span class="card-label">${type === 'map' ? 'Mappa' : (finalPath ? finalPath.split('.').pop() : 'Widget')}</span>`;
|
||||
|
||||
// Suggerimento menu path se widget
|
||||
if (type === 'widget') {
|
||||
let menuHtml = `<div class="path-menu">`;
|
||||
skPaths.forEach(p => {
|
||||
menuHtml += `<div class="path-option" data-path="${p}">${p.split('.').pop()}</div>`;
|
||||
});
|
||||
menuHtml += `</div>`;
|
||||
headerHtml = `<div class="label-wrapper">${headerHtml}${menuHtml}</div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="card-header">
|
||||
${headerHtml}
|
||||
<button class="card-close" title="Rimuovi">ELIMINA</button>
|
||||
</div>
|
||||
<div class="card-body"></div>
|
||||
<div class="rh corner nw"></div><div class="rh corner ne"></div>
|
||||
<div class="rh corner se"></div><div class="rh corner sw"></div>
|
||||
<div class="rh edge n"></div><div class="rh edge s"></div>
|
||||
<div class="rh edge e"></div><div class="rh edge w"></div>`;
|
||||
|
||||
canvasEl.appendChild(el);
|
||||
const c = { id, el, gx, gy, gw, gh, type, path: finalPath };
|
||||
cards.push(c);
|
||||
renderCard(c);
|
||||
|
||||
if (type === 'map') {
|
||||
const mapDiv = document.createElement('div');
|
||||
mapDiv.id = `map-container-${id}`;
|
||||
mapDiv.className = 'card-map-canvas';
|
||||
el.querySelector('.card-body').appendChild(mapDiv);
|
||||
if (window.initMapInstance) window.initMapInstance(mapDiv.id);
|
||||
} else {
|
||||
updateBody(c);
|
||||
// Listener per il cambio path
|
||||
el.querySelectorAll('.path-option').forEach(opt => {
|
||||
opt.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
c.path = opt.dataset.path;
|
||||
el.querySelector('.card-label').textContent = c.path.split('.').pop();
|
||||
toast(`Path aggiornato: ${c.path}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
el.addEventListener('animationend', () => el.classList.remove('spawning'), { once: true });
|
||||
el.querySelector('.card-close').addEventListener('click', ev => { ev.stopPropagation(); removeCard(c); });
|
||||
el.addEventListener('mousedown', () => { if (editMode) selectCard(c); });
|
||||
|
||||
setupDrag(c);
|
||||
setupResize(c);
|
||||
updateCount();
|
||||
return c;
|
||||
}
|
||||
|
||||
function removeCard(c) {
|
||||
c.el.classList.add('removing');
|
||||
c.el.addEventListener('animationend', () => {
|
||||
c.el.remove();
|
||||
cards = cards.filter(x => x.id !== c.id);
|
||||
if (selectedCard === c) selectedCard = null;
|
||||
updateCount();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function selectCard(c) {
|
||||
if (selectedCard?.el) selectedCard.el.classList.remove('selected');
|
||||
selectedCard = c; c.el.classList.add('selected'); c.el.style.zIndex = ++zCounter;
|
||||
}
|
||||
|
||||
function updateBody(c) {
|
||||
if (c.type === 'map') {
|
||||
if (window.resizeMapInstance) window.resizeMapInstance();
|
||||
} else {
|
||||
const b = c.el.querySelector('.card-body');
|
||||
if (b && !b.textContent.trim()) {
|
||||
b.textContent = `${c.gw.toFixed(1)} × ${c.gh.toFixed(1)} u`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag
|
||||
function setupDrag(c) {
|
||||
c.el.addEventListener('mousedown', e => {
|
||||
if (!editMode) return;
|
||||
if (e.target.classList.contains('rh') || e.target.classList.contains('card-close')) return;
|
||||
e.preventDefault(); ensureGuides(); c.el.classList.add('dragging');
|
||||
const start = screenToGrid(e.clientX, e.clientY);
|
||||
const oGx = c.gx, oGy = c.gy;
|
||||
|
||||
const onMove = ev => {
|
||||
const now = screenToGrid(ev.clientX, ev.clientY);
|
||||
let nx = oGx + (now.gx - start.gx), ny = oGy + (now.gy - start.gy);
|
||||
nx = clamp(nx, 0, COLS - c.gw); ny = clamp(ny, 0, ROWS - c.gh);
|
||||
const s = magSnap(c.el, nx, ny, c.gw, c.gh);
|
||||
c.gx = clamp(s.gx, 0, COLS - c.gw); c.gy = clamp(s.gy, 0, ROWS - c.gh);
|
||||
hideGuides();
|
||||
s.guidesH.forEach((p, i) => showGuide('h', p, i));
|
||||
s.guidesV.forEach((p, i) => showGuide('v', p, i));
|
||||
renderCard(c);
|
||||
tooltipEl.textContent = `${c.gx.toFixed(1)}, ${c.gy.toFixed(1)}`;
|
||||
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
|
||||
tooltipEl.classList.add('visible');
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
c.el.classList.remove('dragging'); hideGuides(); tooltipEl.classList.remove('visible');
|
||||
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
|
||||
if (c.type === 'map' && window.resizeMapInstance) window.resizeMapInstance();
|
||||
};
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// Resize
|
||||
function setupResize(c) {
|
||||
c.el.querySelectorAll('.rh').forEach(h => {
|
||||
h.addEventListener('mousedown', e => {
|
||||
if (!editMode) return;
|
||||
e.preventDefault(); e.stopPropagation(); ensureGuides();
|
||||
c.el.classList.add('resizing'); selectCard(c);
|
||||
const start = screenToGrid(e.clientX, e.clientY);
|
||||
const oGx = c.gx, oGy = c.gy, oGw = c.gw, oGh = c.gh;
|
||||
const isN = h.classList.contains('nw') || h.classList.contains('ne') || h.classList.contains('n');
|
||||
const isS = h.classList.contains('sw') || h.classList.contains('se') || h.classList.contains('s');
|
||||
const isW = h.classList.contains('nw') || h.classList.contains('sw') || h.classList.contains('w');
|
||||
const isE = h.classList.contains('ne') || h.classList.contains('se') || h.classList.contains('e');
|
||||
|
||||
const onMove = ev => {
|
||||
const now = screenToGrid(ev.clientX, ev.clientY);
|
||||
const dx = now.gx - start.gx, dy = now.gy - start.gy;
|
||||
let nw = oGw, nh = oGh, nx = oGx, ny = oGy;
|
||||
if (isE) nw = oGw + dx; if (isS) nh = oGh + dy;
|
||||
if (isW) { nw = oGw - dx; nx = oGx + dx; }
|
||||
if (isN) { nh = oGh - dy; ny = oGy + dy; }
|
||||
nw = gSnap(clamp(nw, MIN_GW, MAX_GW)); nh = gSnap(clamp(nh, MIN_GH, MAX_GH));
|
||||
if (isW) nx = oGx + oGw - nw; if (isN) ny = oGy + oGh - nh;
|
||||
nx = gSnap(clamp(nx, 0, COLS - nw)); ny = gSnap(clamp(ny, 0, ROWS - nh));
|
||||
c.gx = nx; c.gy = ny; c.gw = nw; c.gh = nh;
|
||||
renderCard(c); updateBody(c);
|
||||
tooltipEl.textContent = `${nw.toFixed(1)} × ${nh.toFixed(1)} u`;
|
||||
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
|
||||
tooltipEl.classList.add('visible');
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
c.el.classList.remove('resizing'); tooltipEl.classList.remove('visible'); hideGuides();
|
||||
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// EXPORT / IMPORT
|
||||
// ═══════════════════════════════════════════════════════
|
||||
function exportConfig() {
|
||||
return JSON.stringify({
|
||||
canvas: { cols: COLS, rows: ROWS },
|
||||
cards: cards.map(c => ({
|
||||
id: c.id, type: c.type,
|
||||
dimensions: {
|
||||
x: Math.round(c.gx * 100) / 100, y: Math.round(c.gy * 100) / 100,
|
||||
z: parseInt(c.el.style.zIndex) || 1,
|
||||
width: Math.round(c.gw * 100) / 100, height: Math.round(c.gh * 100) / 100
|
||||
}
|
||||
}))
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function importConfig(json) {
|
||||
let data;
|
||||
try { data = JSON.parse(json); } catch { toast('JSON non valido'); return false; }
|
||||
if (!data.cards || !Array.isArray(data.cards)) { toast('Formato non valido: serve "cards" array'); return false; }
|
||||
|
||||
cards.forEach(c => c.el.remove());
|
||||
cards = []; selectedCard = null; cardIdCounter = 0; zCounter = 1;
|
||||
|
||||
for (const entry of data.cards) {
|
||||
const d = entry.dimensions || {};
|
||||
createCard(
|
||||
clamp(d.x ?? 0, 0, COLS - MIN_GW), clamp(d.y ?? 0, 0, ROWS - MIN_GH),
|
||||
clamp(d.width ?? DEF_GW, MIN_GW, MAX_GW), clamp(d.height ?? DEF_GH, MIN_GH, MAX_GH),
|
||||
entry.id ?? null, d.z ?? 1, entry.type ?? 'widget'
|
||||
);
|
||||
}
|
||||
updateCount(); renderAll();
|
||||
toast(`Importate ${data.cards.length} card`);
|
||||
return true;
|
||||
}
|
||||
|
||||
updateCount(); renderAll();
|
||||
90
plugin/tools/kiosk/control-socket.js
Normal file
90
plugin/tools/kiosk/control-socket.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Kiosk plugin page bootstrap:
|
||||
* 1) legge config dal <meta> iniettato dal server del plugin
|
||||
* 2) carica template attivo via API
|
||||
* 3) apre WS locale SignalK per i valori live
|
||||
* 4) apre WS "leggero" al realtime server per comandi dalla console
|
||||
*/
|
||||
(async function () {
|
||||
function cfg(name, fallback) {
|
||||
const m = document.querySelector(`meta[name="${name}"]`);
|
||||
return (m && m.content) || fallback;
|
||||
}
|
||||
|
||||
const apiUrl = cfg('api-url', 'https://api.mebboat.it');
|
||||
const realtimeUrl = cfg('realtime-url', 'https://realtime.mebboat.it');
|
||||
const realtimeWsUrl = cfg('realtime-ws-url', 'wss://realtime.mebboat.it');
|
||||
const sensorCode = cfg('sensor-code', '');
|
||||
const sensorName = cfg('sensor-name', '');
|
||||
|
||||
window.kiosk.init({ apiUrl, sensorCode, sensorName });
|
||||
|
||||
// 1) template iniziale
|
||||
await window.kiosk.loadTemplate();
|
||||
|
||||
// 2) WS locale SignalK
|
||||
try {
|
||||
const skWs = new WebSocket(`ws://${location.host}/signalk/v1/stream?subscribe=all`);
|
||||
skWs.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||||
for (const u of msg.updates || []) for (const v of u.values || []) {
|
||||
window.kiosk.updateValue(v.path, v.value);
|
||||
}
|
||||
};
|
||||
skWs.onclose = () => setTimeout(() => location.reload(), 5000);
|
||||
} catch (e) { console.error('[kiosk] signalk ws error', e); }
|
||||
|
||||
// 3) WS controllo verso realtime server
|
||||
if (!sensorCode || !sensorName) { window.kiosk.setStatus('no sensor config', true); return; }
|
||||
|
||||
let controlWs = null;
|
||||
let reconnectTm = null;
|
||||
|
||||
async function fetchSocketToken() {
|
||||
const r = await fetch(`${realtimeUrl}/connect`, {
|
||||
method: 'POST', headers: { 'Content-Type':'application/json' },
|
||||
body: JSON.stringify({ name: sensorName, code: sensorCode })
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const j = await r.json();
|
||||
return j.s === 'ok' ? j.t : null;
|
||||
}
|
||||
|
||||
async function connectControl() {
|
||||
clearTimeout(reconnectTm);
|
||||
const token = await fetchSocketToken();
|
||||
if (!token) { reconnectTm = setTimeout(connectControl, 5000); return; }
|
||||
const url = `${realtimeWsUrl}/kiosk?role=device&sensor=${encodeURIComponent(sensorName)}&token=${encodeURIComponent(token)}`;
|
||||
controlWs = new WebSocket(url);
|
||||
controlWs.onopen = () => {
|
||||
controlWs.send(JSON.stringify({ t:'hello', templateId: window.kiosk.currentTemplateId() }));
|
||||
};
|
||||
controlWs.onmessage = async (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||
let ok = true, err = null;
|
||||
try {
|
||||
switch (m.t) {
|
||||
case 'patch_box': ok = window.kiosk.patchBox(m.boxId, m.patch || {}); break;
|
||||
case 'add_box': ok = window.kiosk.addBox(m.box); break;
|
||||
case 'remove_box': ok = window.kiosk.removeBox(m.boxId); break;
|
||||
case 'load_template': {
|
||||
const tpl = await window.kiosk.loadTemplate(m.templateId);
|
||||
ok = !!tpl;
|
||||
break;
|
||||
}
|
||||
case 'apply_inline': ok = window.kiosk.applyInline(m.content); break;
|
||||
case 'persist': ok = true; break; // no-op locale, la persistenza è server-side
|
||||
case 'reload': location.reload(); return;
|
||||
default: ok = false; err = 'unknown cmd';
|
||||
}
|
||||
} catch (e) { ok = false; err = e.message; }
|
||||
if (m.cmdId) controlWs.send(JSON.stringify({ t:'ack', cmdId: m.cmdId, ok, err }));
|
||||
};
|
||||
controlWs.onclose = () => { reconnectTm = setTimeout(connectControl, 5000); };
|
||||
controlWs.onerror = () => { try { controlWs.close(); } catch {} };
|
||||
|
||||
setInterval(() => { if (controlWs && controlWs.readyState === 1) controlWs.send(JSON.stringify({ t:'heartbeat' })); }, 25000);
|
||||
}
|
||||
|
||||
connectControl();
|
||||
})();
|
||||
100
plugin/tools/kiosk/core.js
Normal file
100
plugin/tools/kiosk/core.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const paths = [
|
||||
"navigation.speedOverGround",
|
||||
"environment.depth.belowTransducer",
|
||||
]
|
||||
window.skPaths = paths;
|
||||
|
||||
mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
|
||||
|
||||
let map = null;
|
||||
let boatMark = null;
|
||||
let followBoat = true;
|
||||
|
||||
window.initMapInstance = (containerId) => {
|
||||
map = new mapboxgl.Map({
|
||||
container: containerId,
|
||||
style: {
|
||||
"version": 8,
|
||||
"sources": {
|
||||
"osm": { "type": "raster", "tiles": ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], "tileSize": 256 },
|
||||
"openseamap": { "type": "raster", "tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], "tileSize": 256 }
|
||||
},
|
||||
"layers": [
|
||||
{ "id": "osm-layer", "type": "raster", "source": "osm", "minzoom": 0, "maxzoom": 22 },
|
||||
{ "id": "openseamap-layer", "type": "raster", "source": "openseamap", "minzoom": 0, "maxzoom": 18 }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
map.on('dragstart', () => {
|
||||
followBoat = false;
|
||||
});
|
||||
|
||||
boatMark = new mapboxgl.Marker({ color: 'red' })
|
||||
.setLngLat([9, 9])
|
||||
.addTo(map);
|
||||
|
||||
map.on('load', () => {
|
||||
// Area Protetta mock
|
||||
map.addSource('area-protetta', {
|
||||
'type': 'geojson',
|
||||
'data': {
|
||||
'type': 'Feature',
|
||||
'geometry': {
|
||||
'type': 'Polygon',
|
||||
'coordinates': [[[9.05, 45.05], [9.15, 45.05], [9.15, 45.15], [9.05, 45.15], [9.05, 45.05]]]
|
||||
}
|
||||
}
|
||||
});
|
||||
map.addLayer({
|
||||
'id': 'area-layer',
|
||||
'type': 'fill',
|
||||
'source': 'area-protetta',
|
||||
'paint': { 'fill-color': '#0080ff', 'fill-opacity': 0.3 }
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window.resizeMapInstance = () => {
|
||||
if (map) map.resize();
|
||||
};
|
||||
|
||||
function movePosition(lng, lat) {
|
||||
if (!followBoat || !map) return;
|
||||
map.flyTo({ center: [lng, lat], zoom: 14, speed: 1.2 });
|
||||
}
|
||||
|
||||
const host = window.location.host;
|
||||
const connection = `ws://${host}/signalk/v1/stream?subscribe=all`;
|
||||
const ws = new WebSocket(connection);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.updates) {
|
||||
msg.updates.forEach(update => {
|
||||
if (update.values) {
|
||||
update.values.forEach(v => {
|
||||
// Aggiorna le card nel dashboard tramite canvas.js
|
||||
if (window.updateKioskData) {
|
||||
window.updateKioskData(v.path, v.value);
|
||||
}
|
||||
|
||||
if (v.path === "navigation.position" && boatMark) {
|
||||
const lng = v.value.longitude;
|
||||
const lat = v.value.latitude;
|
||||
boatMark.setLngLat([lng, lat]);
|
||||
movePosition(lng, lat);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => console.error("Errore WebSocket:", err);
|
||||
ws.onclose = () => {
|
||||
console.log("WebSocket chiuso. Riconnessione tra 5s...");
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
};
|
||||
|
||||
// style: 'mapbox://styles/sesee3/cmn9767jg003l01qsbpmace1t/draft'
|
||||
26
plugin/tools/kiosk/dashboard.html
Normal file
26
plugin/tools/kiosk/dashboard.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kiosk Dashboard</title>
|
||||
|
||||
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script>
|
||||
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Main Grid Canvas -->
|
||||
<div id="canvas" class="canvas">
|
||||
<div id="emptyState" class="empty-state">Caricamento dei tile in corso</div>
|
||||
</div>
|
||||
|
||||
<!-- UI Feedback & Overlays -->
|
||||
<div id="tooltip" class="tooltip"></div>
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="canvas.js"></script>
|
||||
<script src="core.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
plugin/tools/kiosk/fonts/atkinson-bold.ttf
Normal file
BIN
plugin/tools/kiosk/fonts/atkinson-bold.ttf
Normal file
Binary file not shown.
BIN
plugin/tools/kiosk/fonts/atkinson-regular.ttf
Normal file
BIN
plugin/tools/kiosk/fonts/atkinson-regular.ttf
Normal file
Binary file not shown.
24
plugin/tools/kiosk/kiosk.html
Normal file
24
plugin/tools/kiosk/kiosk.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kiosk</title>
|
||||
<style>
|
||||
html,body { margin:0; padding:0; height:100%; background:#0b1220; color:#fff; font-family:-apple-system,sans-serif; overflow:hidden; }
|
||||
#canvas { position:relative; width:100vw; height:100vh; }
|
||||
.box { position:absolute; border-radius:10px; padding:14px; box-sizing:border-box; display:flex; flex-direction:column; overflow:hidden; transition: background .25s, color .25s, left .25s, top .25s, width .25s, height .25s; }
|
||||
.box .title { font-size:.9rem; opacity:.65; letter-spacing:.06em; text-transform:uppercase; }
|
||||
.box .val { flex:1; display:flex; align-items:center; justify-content:center; font-weight:800; font-variant-numeric: tabular-nums; line-height:1; }
|
||||
.box .unit { opacity:.7; font-size:.6em; margin-left:.25em; }
|
||||
#statusChip { position:fixed; right:10px; bottom:10px; background:#111827aa; padding:4px 8px; border-radius:999px; font-size:11px; opacity:.6; }
|
||||
#statusChip.err { background:#dc2626cc; opacity:1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="canvas"></div>
|
||||
<div id="statusChip">boot…</div>
|
||||
<script src="template-loader.js"></script>
|
||||
<script src="control-socket.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
472
plugin/tools/kiosk/style.css
Normal file
472
plugin/tools/kiosk/style.css
Normal file
@@ -0,0 +1,472 @@
|
||||
:root {
|
||||
--card-bg: #101415;
|
||||
--card-border: #2a2d2e;
|
||||
--card-border-active: #3a9bff;
|
||||
--danger: #ff4d4d;
|
||||
--success: #34d399;
|
||||
--grid-dot: rgba(255, 255, 255, 0.04);
|
||||
--snap-line: rgba(50, 152, 255, 0.25);
|
||||
--cols: 24;
|
||||
--rows: 18
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'hyperlegible';
|
||||
src: url('./fonts/atkinson-regular.ttf');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'hyperlegible';
|
||||
src: url('./fonts/atkinson-bold.ttf');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'hyperlegible', sans-serif;
|
||||
background-color: black;
|
||||
color: white
|
||||
}
|
||||
|
||||
.data-card {
|
||||
border: 1px solid #ccc;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/** CAMVAS!!! */
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background:
|
||||
radial-gradient(circle 1px, #ffffff2c 0.8px, transparent 0.4px);
|
||||
background-size: calc(100% / var(--cols)) calc(100% / var(--rows));
|
||||
}
|
||||
|
||||
/* CARDS */
|
||||
|
||||
.card {
|
||||
position: absolute;
|
||||
background-color: #101415;
|
||||
border: 2px dashed var(--card-border);
|
||||
border-radius: 15px;
|
||||
cursor: grab;
|
||||
transition: box-shadow 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94), border-color 0.25s ease;
|
||||
will-change: left, top, width, height;
|
||||
overflow: visible;
|
||||
/* Necessario per vedere i manigli di resize fuori bordo se necessario */
|
||||
}
|
||||
|
||||
/* Stili Header Card */
|
||||
.card-header {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Path Picker Menu ────────────────────────────── */
|
||||
.label-wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.path-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: -8px;
|
||||
background: rgba(26, 30, 31, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--card-border-active);
|
||||
border-radius: 8px;
|
||||
z-index: 2000;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
padding: 5px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card.editable .label-wrapper:hover .path-menu {
|
||||
display: block;
|
||||
animation: spawnIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.path-option {
|
||||
padding: 10px 15px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.path-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.path-option:hover {
|
||||
background: var(--card-border-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 0, 0, 0.404);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.card-close:hover {
|
||||
color: var(--danger);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.card:not(.editable) .card-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px;
|
||||
font-size: 70px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
height: calc(100% - 33px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
@keyframes cardSpawn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.card.spawning {
|
||||
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes cardRemove {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
}
|
||||
|
||||
.card.removing {
|
||||
animation: cardRemove 0.2s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Stili per le classi dinamiche delle card */
|
||||
.card.selected {
|
||||
border-color: var(--card-border-active);
|
||||
box-shadow: 0 0 15px rgba(50, 152, 255, 0.5);
|
||||
}
|
||||
|
||||
.card.dragging,
|
||||
.card.resizing {
|
||||
cursor: grabbing;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Stili per gli elementi aggiunti da canvas.js */
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.tooltip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #aaa;
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.empty-state.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.unit-badge {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
/* Regola in base all'altezza della toolbar */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-top: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-content textarea {
|
||||
width: calc(100% - 20px);
|
||||
min-height: 200px;
|
||||
background-color: #2a2d2e;
|
||||
border: 1px solid #3a3d3e;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
white-space: nowrap;
|
||||
background-color: rgba(255, 255, 255, 0.103);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-actions button.primary {
|
||||
background-color: #4da8ff;
|
||||
}
|
||||
|
||||
.modal-actions button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ── Edit Mode & Animations ──────────────────────── */
|
||||
@keyframes cardSpawn {
|
||||
0% { opacity: 0; transform: scale(0.92); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes cardRemove {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.88); }
|
||||
}
|
||||
|
||||
.card.spawning {
|
||||
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.card.removing {
|
||||
animation: cardRemove 0.2s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Canvas state during editing */
|
||||
.canvas.edit-active {
|
||||
outline: 2px dashed rgba(58, 155, 255, 0.3);
|
||||
outline-offset: -10px;
|
||||
background-color: rgba(58, 155, 255, 0.02);
|
||||
}
|
||||
|
||||
.card.editable {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.card.editable:not(.selected) {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Hide handlers when not editing */
|
||||
.card:not(.editable) .rh {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card:not(.editable) {
|
||||
cursor: default;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Rimuovi padding se la card contiene la mappa per farla aderire ai bordi */
|
||||
.card[data-type="map"] .card-body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-map-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0 0 15px 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Fix per Mapbox canvas che a volte non prende il 100% se il container è flex */
|
||||
.card-map-canvas .mapboxgl-canvas-container,
|
||||
.card-map-canvas .mapboxgl-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.toolbar button.primary {
|
||||
background-color: var(--card-border-active) !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Global Edit Mode Overrides ──────────────────── */
|
||||
body.edit-mode {
|
||||
background-color: #0a0e0f;
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
body.edit-mode .toolbar {
|
||||
background: rgba(58, 155, 255, 0.15) !important;
|
||||
backdrop-filter: blur(25px);
|
||||
border: 2px dashed var(--card-border-active) !important;
|
||||
box-shadow: 0 0 20px rgba(58, 155, 255, 0.2);
|
||||
}
|
||||
|
||||
body.edit-mode .toolbar p#cardCount {
|
||||
color: var(--card-border-active);
|
||||
}
|
||||
|
||||
@keyframes editPulse {
|
||||
0% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
body.edit-mode .canvas.edit-active::after {
|
||||
content: "DASHBOARD EDITING";
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--card-border-active);
|
||||
letter-spacing: 2px;
|
||||
animation: editPulse 2s infinite ease-in-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
166
plugin/tools/kiosk/template-loader.js
Normal file
166
plugin/tools/kiosk/template-loader.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Kiosk template loader + renderer (display-only).
|
||||
* Espone window.kiosk con: loadTemplate, applyInline, patchBox, addBox, removeBox, updateValue.
|
||||
*/
|
||||
(function () {
|
||||
const COLS = 24, ROWS = 18;
|
||||
const canvasEl = document.getElementById('canvas');
|
||||
const chip = document.getElementById('statusChip');
|
||||
|
||||
const state = {
|
||||
template: null,
|
||||
boxes: [], // array con .el attaccato
|
||||
byPath: new Map(), // path → Set<box>
|
||||
sensorCode: null,
|
||||
sensorName: null,
|
||||
apiUrl: null,
|
||||
};
|
||||
|
||||
function setStatus(msg, err) {
|
||||
chip.textContent = msg;
|
||||
chip.classList.toggle('err', !!err);
|
||||
}
|
||||
|
||||
function indexPaths() {
|
||||
state.byPath.clear();
|
||||
for (const b of state.boxes) {
|
||||
if (!b.path) continue;
|
||||
if (!state.byPath.has(b.path)) state.byPath.set(b.path, new Set());
|
||||
state.byPath.get(b.path).add(b);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBox(b) {
|
||||
const uw = canvasEl.clientWidth / COLS;
|
||||
const uh = canvasEl.clientHeight / ROWS;
|
||||
b.el.style.left = (b.x * uw) + 'px';
|
||||
b.el.style.top = (b.y * uh) + 'px';
|
||||
b.el.style.width = (b.w * uw) + 'px';
|
||||
b.el.style.height = (b.h * uh) + 'px';
|
||||
b.el.style.background = b.color || '#1e293b';
|
||||
b.el.style.color = b.textColor || '#fff';
|
||||
const titleEl = b.el.querySelector('.title');
|
||||
const valEl = b.el.querySelector('.val');
|
||||
titleEl.textContent = b.title || (b.path ? b.path.split('.').pop() : '');
|
||||
// adatta font-size al box
|
||||
valEl.style.fontSize = Math.min(b.w * uw, b.h * uh) * 0.35 + 'px';
|
||||
if (b._lastVal !== undefined) renderValue(b, b._lastVal);
|
||||
else valEl.innerHTML = '<span style="opacity:.4">—</span>';
|
||||
}
|
||||
|
||||
function renderValue(b, value) {
|
||||
const valEl = b.el.querySelector('.val');
|
||||
if (value == null || (typeof value === 'object' && !('longitude' in value))) {
|
||||
valEl.innerHTML = '<span style="opacity:.4">—</span>';
|
||||
return;
|
||||
}
|
||||
let v = value;
|
||||
if (typeof v === 'number') {
|
||||
const mul = typeof b.multiplier === 'number' ? b.multiplier : 1;
|
||||
const dec = typeof b.decimals === 'number' ? b.decimals : 1;
|
||||
v = (v * mul).toFixed(dec);
|
||||
} else if (v && typeof v === 'object' && 'longitude' in v) {
|
||||
v = v.latitude.toFixed(3) + ', ' + v.longitude.toFixed(3);
|
||||
}
|
||||
valEl.innerHTML = String(v) + (b.unit ? `<span class="unit">${b.unit}</span>` : '');
|
||||
}
|
||||
|
||||
function createBoxEl() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'box';
|
||||
el.innerHTML = '<div class="title"></div><div class="val"></div>';
|
||||
canvasEl.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
for (const b of state.boxes) b.el.remove();
|
||||
state.boxes = [];
|
||||
state.byPath.clear();
|
||||
}
|
||||
|
||||
function applyContent(content) {
|
||||
clearAll();
|
||||
if (content?.background) document.body.style.background = content.background;
|
||||
for (const raw of content?.boxes || []) {
|
||||
const b = { ...raw, el: createBoxEl() };
|
||||
state.boxes.push(b);
|
||||
renderBox(b);
|
||||
}
|
||||
indexPaths();
|
||||
}
|
||||
|
||||
async function loadTemplate(templateId) {
|
||||
try {
|
||||
const url = templateId
|
||||
? `${state.apiUrl}/kiosk/templates/${templateId}`
|
||||
: `${state.apiUrl}/kiosk/template/active`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) { setStatus('no template (' + r.status + ')', true); return null; }
|
||||
const tpl = await r.json();
|
||||
state.template = tpl;
|
||||
applyContent(tpl.content);
|
||||
setStatus('template ' + tpl.name);
|
||||
return tpl;
|
||||
} catch (e) {
|
||||
setStatus('fetch error', true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function patchBox(boxId, patch) {
|
||||
const b = state.boxes.find(x => x.id === boxId);
|
||||
if (!b) return false;
|
||||
Object.assign(b, patch);
|
||||
indexPaths();
|
||||
renderBox(b);
|
||||
return true;
|
||||
}
|
||||
|
||||
function addBox(boxDef) {
|
||||
const b = { ...boxDef, el: createBoxEl() };
|
||||
state.boxes.push(b);
|
||||
renderBox(b);
|
||||
indexPaths();
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeBox(boxId) {
|
||||
const i = state.boxes.findIndex(b => b.id === boxId);
|
||||
if (i < 0) return false;
|
||||
state.boxes[i].el.remove();
|
||||
state.boxes.splice(i, 1);
|
||||
indexPaths();
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyInline(content) {
|
||||
applyContent(content);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateValue(path, value) {
|
||||
const set = state.byPath.get(path);
|
||||
if (!set) return;
|
||||
for (const b of set) {
|
||||
b._lastVal = value;
|
||||
renderValue(b, value);
|
||||
}
|
||||
}
|
||||
|
||||
function init({ apiUrl, sensorCode, sensorName }) {
|
||||
state.apiUrl = apiUrl;
|
||||
state.sensorCode = sensorCode;
|
||||
state.sensorName = sensorName;
|
||||
}
|
||||
|
||||
function currentTemplateId() { return state.template?.id || null; }
|
||||
|
||||
let resizeRaf;
|
||||
window.addEventListener('resize', () => {
|
||||
cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(() => state.boxes.forEach(renderBox));
|
||||
});
|
||||
|
||||
window.kiosk = { init, loadTemplate, patchBox, addBox, removeBox, applyInline, updateValue, currentTemplateId, setStatus };
|
||||
})();
|
||||
Reference in New Issue
Block a user