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:
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* dataHub.js - Cache centralizzata dei dati del plugin.
|
||||
*
|
||||
* Tutti i moduli (realtime, telegram, ecc.) leggono da qui.
|
||||
* I dati vengono scritti da:
|
||||
* - realtime/core.js → updateSensorData() (ogni 500ms)
|
||||
* - index.cjs → updateWeatherData() (ogni 5min)
|
||||
*
|
||||
* Nessuna duplicazione: i dati vengono raccolti UNA volta e condivisi.
|
||||
*/
|
||||
|
||||
let latestSensorData = null;
|
||||
let latestWeatherData = { forecast: null, sea: null };
|
||||
let lastSensorUpdate = 0;
|
||||
let lastWeatherUpdate = 0;
|
||||
|
||||
/**
|
||||
* Aggiorna lo snapshot dei dati sensore.
|
||||
* Chiamato da sendData() in core.js ogni 500ms.
|
||||
* @param {Object} data - Dati sensore flat (es. { wind_direction: 180, temperature: 22.5 })
|
||||
*/
|
||||
function updateSensorData(data) {
|
||||
latestSensorData = data ? { ...data } : null;
|
||||
lastSensorUpdate = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna lo snapshot dei dati meteo.
|
||||
* Chiamato da fetchAndPublishWeather() in index.cjs.
|
||||
* @param {Object|null} forecast - Dati previsioni (temperatura, vento, ecc.)
|
||||
* @param {Object|null} sea - Dati condizioni marine (onde, ecc.)
|
||||
*/
|
||||
function updateWeatherData(forecast, sea) {
|
||||
latestWeatherData = {
|
||||
forecast: forecast || null,
|
||||
sea: sea || null
|
||||
};
|
||||
lastWeatherUpdate = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge l'ultimo snapshot dei dati sensore.
|
||||
* @returns {Object|null} Dati sensore o null se non ancora disponibili
|
||||
*/
|
||||
function getSensorData() {
|
||||
return latestSensorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge l'ultimo snapshot dei dati meteo.
|
||||
* @returns {{ forecast: Object|null, sea: Object|null }}
|
||||
*/
|
||||
function getWeatherData() {
|
||||
return latestWeatherData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge tutti i dati disponibili (sensori + meteo) con timestamps.
|
||||
* @returns {{ sensors: Object|null, weather: Object, timestamps: Object }}
|
||||
*/
|
||||
function getAllData() {
|
||||
return {
|
||||
sensors: latestSensorData,
|
||||
weather: latestWeatherData,
|
||||
timestamps: {
|
||||
sensorUpdate: lastSensorUpdate,
|
||||
weatherUpdate: lastWeatherUpdate
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
updateSensorData,
|
||||
updateWeatherData,
|
||||
getSensorData,
|
||||
getWeatherData,
|
||||
getAllData
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
const SERVICES = {
|
||||
api: 'https://api.mebboat.it/health',
|
||||
storage: 'https://storage.mebboat.it/health',
|
||||
auth: 'https://auth.mebboat.it/health',
|
||||
realtime: 'https://realtime.mebboat.it/health',
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the health of a single service.
|
||||
* @param {string} url - Health endpoint URL
|
||||
* @returns {Promise<{ok: boolean, status: string}>}
|
||||
*/
|
||||
async function checkService(url) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json" }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
const isOk = data?.status === "ok";
|
||||
return { ok: isOk, status: isOk ? 'online' : 'offline' };
|
||||
} catch (err) {
|
||||
return { ok: false, status: `error: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all MEB cloud services in parallel.
|
||||
* @returns {Promise<Object>} Map of service name -> health result
|
||||
*/
|
||||
async function checkAllServices() {
|
||||
const entries = Object.entries(SERVICES);
|
||||
const results = await Promise.all(
|
||||
entries.map(async ([name, url]) => {
|
||||
const result = await checkService(url);
|
||||
return [name, result];
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERVICES,
|
||||
checkService,
|
||||
checkAllServices
|
||||
};
|
||||
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 };
|
||||
})();
|
||||
264
plugin/tools/map/map.html
Normal file
264
plugin/tools/map/map.html
Normal file
@@ -0,0 +1,264 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Mappa SignalK</title>
|
||||
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js"></script>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
transform-origin: center center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: rgb(0, 0, 0);
|
||||
background-color: rgba(233, 233, 233, 0.412);
|
||||
border: 1px solid rgba(233, 233, 233, 0.412);
|
||||
padding: 12px 18px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.25;
|
||||
min-width: 270px;
|
||||
z-index: 999;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="map"></div>
|
||||
<div id="infoBox" class="info">
|
||||
Caricamento dati...
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// MAPBOX TOKEN
|
||||
mapboxgl.accessToken =
|
||||
"pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ";
|
||||
|
||||
// Centro predefinito
|
||||
const defaultCenter = [9.19, 44.41];
|
||||
|
||||
// MAPPA
|
||||
const map = new mapboxgl.Map({
|
||||
container: "map",
|
||||
style: "mapbox://styles/mapbox/streets-v12",
|
||||
center: defaultCenter,
|
||||
zoom: 15
|
||||
});
|
||||
|
||||
// FUNZIONE CALCOLO DESTINAZIONE VETTORE
|
||||
function destinationPoint([lon, lat], bearingDeg, distanceMeters) {
|
||||
const R = 6371000;
|
||||
const brng = bearingDeg * Math.PI / 180;
|
||||
const d = distanceMeters;
|
||||
|
||||
const lat1 = lat * Math.PI / 180;
|
||||
const lon1 = lon * Math.PI / 180;
|
||||
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(d / R) +
|
||||
Math.cos(lat1) * Math.sin(d / R) * Math.cos(brng)
|
||||
);
|
||||
|
||||
const lon2 = lon1 + Math.atan2(
|
||||
Math.sin(brng) * Math.sin(d / R) * Math.cos(lat1),
|
||||
Math.cos(d / R) - Math.sin(lat1) * Math.sin(lat2)
|
||||
);
|
||||
|
||||
return [ lon2 * 180/Math.PI, lat2 * 180/Math.PI ];
|
||||
}
|
||||
|
||||
// QUANDO LA MAPPA È PRONTA
|
||||
map.on("load", () => {
|
||||
map.addSource("waveVector", {
|
||||
type: "geojson",
|
||||
data: { type: "Feature", geometry: { type: "LineString", coordinates: [defaultCenter, defaultCenter] }}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "waveVectorLine",
|
||||
type: "line",
|
||||
source: "waveVector",
|
||||
paint: { "line-color": "#0000ff", "line-width": 6 }
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "waveLabelText",
|
||||
type: "symbol",
|
||||
source: "waveVector",
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"text-field": "Direzione Onde",
|
||||
"text-size": 30,
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#0000ff",
|
||||
"text-halo-color": "white",
|
||||
"text-halo-width": 4
|
||||
}
|
||||
});
|
||||
|
||||
map.addSource("windVector", {
|
||||
type: "geojson",
|
||||
data: { type: "Feature", geometry: { type: "LineString", coordinates: [defaultCenter, defaultCenter] }}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "windVectorLine",
|
||||
type: "line",
|
||||
source: "windVector",
|
||||
paint: { "line-color": "lime", "line-width": 6 }
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "windLabelText",
|
||||
type: "symbol",
|
||||
source: "windVector",
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"text-field": "Direzione Vento",
|
||||
"text-size": 30,
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "lime",
|
||||
"text-halo-color": "black",
|
||||
"text-halo-width": 3
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// MARKER BARCA
|
||||
const boatEl = document.createElement("div");
|
||||
boatEl.className = "icon";
|
||||
boatEl.innerHTML = "⛵";
|
||||
boatEl.style.fontSize = "60px";
|
||||
const boatMarker = new mapboxgl.Marker({ element: boatEl }).setLngLat(defaultCenter).addTo(map);
|
||||
|
||||
// FRECCE
|
||||
const arrowEl = document.createElement("div");
|
||||
arrowEl.className = "icon";
|
||||
arrowEl.innerHTML = `<svg width="80" height="80" viewBox="0 0 100 100"><g id="arrowGroup" transform="rotate(0 50 50)"><polygon points="50,0 90,100 50,75 10,100" fill="blue" /></g></svg>`;
|
||||
const arrowMarker = new mapboxgl.Marker({ element: arrowEl }).setLngLat(defaultCenter).addTo(map);
|
||||
|
||||
const windEl = document.createElement("div");
|
||||
windEl.className = "icon";
|
||||
windEl.innerHTML = `<svg width="80" height="80" viewBox="0 0 100 100"><g id="windArrowGroup" transform="rotate(0 50 50)"><polygon points="50,0 90,100 50,75 10,100" fill="lime" /></g></svg>`;
|
||||
const windMarker = new mapboxgl.Marker({ element: windEl }).setLngLat(defaultCenter).addTo(map);
|
||||
|
||||
// SIGNALK VARIABILI
|
||||
let position=null, waveDir=null, waveHeight=null, wavePeriod=null, windDir=null, windSpeed=null, temperature=null;
|
||||
|
||||
// WebSocket SK
|
||||
const ws = new WebSocket(`ws://${location.host}/signalk/v1/stream?subscribe=all`);
|
||||
|
||||
ws.onmessage = msg => {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (!data.updates) return;
|
||||
|
||||
data.updates.forEach(u => {
|
||||
u.values?.forEach(v => {
|
||||
|
||||
if (v.path === "navigation.position") position = [v.value.longitude, v.value.latitude];
|
||||
|
||||
if (v.path === "meb.waves.direction") waveDir = v.value;
|
||||
if (v.path === "meb.waves.height") waveHeight = v.value;
|
||||
if (v.path === "meb.waves.period") wavePeriod = v.value;
|
||||
|
||||
if (v.path === "meb.wind.direction") windDir = v.value;
|
||||
if (v.path === "environment.wind.speedTrue") windSpeed = v.value;
|
||||
|
||||
if (v.path === "environment.outside.temperature") temperature = v.value;
|
||||
});
|
||||
});
|
||||
|
||||
updateMap();
|
||||
};
|
||||
|
||||
// UPDATE MAP
|
||||
function updateMap() {
|
||||
if (!position) return;
|
||||
|
||||
boatMarker.setLngLat(position);
|
||||
|
||||
if (waveDir !== null) {
|
||||
const endPoint = destinationPoint(position, waveDir, 1000);
|
||||
map.getSource("waveVector").setData({
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: [position, endPoint] }
|
||||
});
|
||||
arrowMarker.setLngLat(endPoint);
|
||||
document.getElementById("arrowGroup").setAttribute("transform", `rotate(${waveDir} 50 50)`);
|
||||
}
|
||||
|
||||
if (windDir !== null) {
|
||||
const windHeading = (windDir + 180) % 360;
|
||||
const windEndPoint = destinationPoint(position, windHeading, 1000);
|
||||
map.getSource("windVector").setData({
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: [position, windEndPoint] }
|
||||
});
|
||||
windMarker.setLngLat(windEndPoint);
|
||||
document.getElementById("windArrowGroup").setAttribute("transform", `rotate(${windHeading} 50 50)`);
|
||||
}
|
||||
|
||||
updateInfoBox();
|
||||
map.easeTo({ center: position });
|
||||
}
|
||||
|
||||
// UPDATE INFO BOX
|
||||
function updateInfoBox() {
|
||||
const box = document.getElementById("infoBox");
|
||||
if (!position) return;
|
||||
|
||||
const lat = position[1].toFixed(5);
|
||||
const lon = position[0].toFixed(5);
|
||||
|
||||
const waveDeg = waveDir !== null ? `${waveDir.toFixed(0)}°` : "Need Request";
|
||||
const heightM = waveHeight !== null ? `${waveHeight.toFixed(2)} m` : "Need Request";
|
||||
const periodS = wavePeriod !== null ? `${wavePeriod.toFixed(1)} s` : "Need Request";
|
||||
|
||||
const windDeg = windDir !== null ? `${windDir.toFixed(0)}°` : "Need Request";
|
||||
const windKMH = windSpeed !== null ? `${(windSpeed*3.6).toFixed(1)} km/h` : "Need Request";
|
||||
const tempC = temperature !== null ? `${(temperature - 273.15).toFixed(1)} °C` : "Need Request";
|
||||
box.innerHTML = `
|
||||
<b>Direzione Onde:</b> ${waveDeg}<br>
|
||||
<b>Altezza Onda:</b> ${heightM}<br>
|
||||
<b>Periodo Onda:</b> ${periodS}<br>
|
||||
<b>Direzione Vento:</b> ${windDeg}<br>
|
||||
<b>Intensità Vento:</b> ${windKMH}<br>
|
||||
<b>Temperatura:</b> ${tempC}<br>
|
||||
<b>Lat:</b> ${lat} | <b>Lon:</b> ${lon}
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* publisher.js - Pubblica dati su SignalK
|
||||
*/
|
||||
|
||||
/**
|
||||
* Genera valori SignalK da un oggetto dati
|
||||
* @param {Object} data - Dati da convertire
|
||||
* @param {string} prefix - Prefisso per i path SignalK
|
||||
* @returns {Array} Array di valori SignalK
|
||||
*/
|
||||
function generateValues(data, prefix = "") {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = [];
|
||||
|
||||
function traverse(obj, pathParts) {
|
||||
for (const key in obj) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
||||
|
||||
const val = obj[key];
|
||||
if (val === undefined || val === null) continue;
|
||||
|
||||
const newPath = pathParts.length > 0 ? [...pathParts, key] : [key];
|
||||
|
||||
if (typeof val === "object" && !Array.isArray(val)) {
|
||||
traverse(val, newPath);
|
||||
} else if (!Array.isArray(val)) {
|
||||
// Ignora array, pubblica solo valori primitivi
|
||||
values.push({
|
||||
path: newPath.join("."),
|
||||
value: val,
|
||||
meta: { displayName: key },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initialPath = prefix ? [prefix] : [];
|
||||
traverse(data, initialPath);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pubblica dati meteo su SignalK
|
||||
* @param {Object} app - Istanza app SignalK
|
||||
* @param {Object} weatherData - Dati meteo da pubblicare
|
||||
* @param {Object} settings - Impostazioni plugin
|
||||
*/
|
||||
function publishWeatherData(app, weatherData, settings) {
|
||||
if (!app || !weatherData) {
|
||||
console.warn('[Publisher] App o dati non disponibili');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = generateValues(weatherData);
|
||||
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app.handleMessage("meb", {
|
||||
updates: [{ values }],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Publisher] Errore pubblicazione:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publish: publishWeatherData };
|
||||
Reference in New Issue
Block a user