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:
Giuseppe Raffa
2026-04-23 16:19:11 +02:00
parent 41f33ce181
commit bb8d267cd4
85 changed files with 4293 additions and 5083 deletions

View 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();

View 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
View 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'

View 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>

Binary file not shown.

Binary file not shown.

View 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>

View 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;
}

View 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 };
})();