Add initial KioskCore and API endpoint for data analysis
- Created a new CSS file for kiosk styles, defining variables, typography, and layout for cards and toolbars. - Implemented new routes for data anlaysis page
This commit is contained in:
@@ -96,6 +96,9 @@ app.use('/params', paramsRoutes)
|
|||||||
const settingsRoutes = require('./routes/settings')
|
const settingsRoutes = require('./routes/settings')
|
||||||
app.use('/settings', settingsRoutes)
|
app.use('/settings', settingsRoutes)
|
||||||
|
|
||||||
|
const sessionsRoutes = require('./routes/sessions')
|
||||||
|
app.use('/sessions', sessionsRoutes)
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Started on port ${PORT}`);
|
console.log(`Started on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
88
api/src/routes/sessions.js
Normal file
88
api/src/routes/sessions.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { query: dbQuery } = require('../storage/postgres');
|
||||||
|
const { querySessionHistory, exportSessionCSV } = require('../storage/influx');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/history
|
||||||
|
* Lista tutte le sessioni di registrazione storiche da PostgreSQL (sessiondataref).
|
||||||
|
*/
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT * FROM sessiondataref ORDER BY created_at DESC LIMIT 100`,
|
||||||
|
[],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] history error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:sensorId/data?session=sXXXX&from=ISO&to=ISO
|
||||||
|
* Restituisce i dati storici di una sessione come JSON (righe pivotate da InfluxDB).
|
||||||
|
*/
|
||||||
|
router.get('/:sensorId/data', async (req, res) => {
|
||||||
|
const { sensorId } = req.params;
|
||||||
|
const { session, from, to } = req.query;
|
||||||
|
|
||||||
|
if (!session) return res.status(400).json({ error: 'session param required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let since = from || null;
|
||||||
|
if (!since) {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[session],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
since = result.rows[0]?.created_at?.toISOString() || '-30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = to ? new Date(parseInt(to)).toISOString() : null;
|
||||||
|
const rows = await querySessionHistory(sensorId, session, since, until);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] data error:', err.message);
|
||||||
|
res.status(500).json({ error: 'internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /sessions/:sensorId/csv?session=sXXXX&from=ms&to=ms
|
||||||
|
* Esporta i dati di una sessione come CSV (supporta intervallo opzionale).
|
||||||
|
*/
|
||||||
|
router.get('/:sensorId/csv', async (req, res) => {
|
||||||
|
const { sensorId } = req.params;
|
||||||
|
const { session, from, to } = req.query;
|
||||||
|
|
||||||
|
if (!session) return res.status(400).json({ error: 'session param required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let since = from ? new Date(parseInt(from)).toISOString() : null;
|
||||||
|
if (!since) {
|
||||||
|
const result = await dbQuery(
|
||||||
|
`SELECT created_at FROM sessiondataref WHERE session_id = $1`,
|
||||||
|
[session],
|
||||||
|
'sensors'
|
||||||
|
);
|
||||||
|
since = result.rows[0]?.created_at?.toISOString() || '-30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = to ? new Date(parseInt(to)).toISOString() : null;
|
||||||
|
const csv = await exportSessionCSV(sensorId, session, since, until);
|
||||||
|
|
||||||
|
if (!csv) return res.status(404).json({ error: 'No data found for this session' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="session_${session}_${sensorId}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sessions] csv error:', err.message);
|
||||||
|
res.status(500).json({ error: 'CSV export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -60,6 +60,64 @@ async function query(bucket, relativeTime, measurement, sensor, field) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionBucket = process.env.INFLX_BUCKET || 'logs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query storica per una sessione di registrazione.
|
||||||
|
* @param {string} sensor - nome sensore
|
||||||
|
* @param {string} session - session_id (tag InfluxDB)
|
||||||
|
* @param {string} since - ISO timestamp o duration (es. "-30d")
|
||||||
|
* @param {string|null} until - ISO timestamp fine (opzionale)
|
||||||
|
* @returns {Promise<Array<Object>>}
|
||||||
|
*/
|
||||||
|
async function querySessionHistory(sensor, session, since, until = null) {
|
||||||
|
const rangeStr = until ? `start: ${since}, stop: ${until}` : `start: ${since}`;
|
||||||
|
const fluxQuery = `
|
||||||
|
from(bucket: "${sessionBucket}")
|
||||||
|
|> range(${rangeStr})
|
||||||
|
|> filter(fn: (r) => r._measurement == "logs")
|
||||||
|
|> filter(fn: (r) => r.sensor == "${sensor}")
|
||||||
|
|> filter(fn: (r) => r.session == "${session}")
|
||||||
|
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||||
|
|> sort(columns: ["_time"])
|
||||||
|
`;
|
||||||
|
const rows = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
querying.queryRows(fluxQuery, {
|
||||||
|
next(row, tableMeta) { rows.push(tableMeta.toObject(row)); },
|
||||||
|
error: reject,
|
||||||
|
complete() { resolve(rows); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esporta i dati di una sessione come stringa CSV.
|
||||||
|
* @param {string} sensor
|
||||||
|
* @param {string} session
|
||||||
|
* @param {string} since
|
||||||
|
* @param {string|null} until
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function exportSessionCSV(sensor, session, since, until = null) {
|
||||||
|
const rows = await querySessionHistory(sensor, session, since, until);
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
|
||||||
|
const fieldNames = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (!metaKeys.has(key) && key !== '_time') fieldNames.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fields = Array.from(fieldNames).sort();
|
||||||
|
const header = ['timestamp', ...fields].join(',');
|
||||||
|
const csvRows = rows.map(row => {
|
||||||
|
const values = fields.map(f => { const v = row[f]; return (v == null) ? '' : v; });
|
||||||
|
return [row._time || '', ...values].join(',');
|
||||||
|
});
|
||||||
|
return header + '\n' + csvRows.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
async function checkInflux() {
|
async function checkInflux() {
|
||||||
try {
|
try {
|
||||||
const result = await querying.collectRows(
|
const result = await querying.collectRows(
|
||||||
@@ -78,9 +136,11 @@ async function checkInflux() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
write:append,
|
write: append,
|
||||||
writeBatch,
|
writeBatch,
|
||||||
query,
|
query,
|
||||||
|
querySessionHistory,
|
||||||
|
exportSessionCSV,
|
||||||
checkInflux
|
checkInflux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ app.get('/rulesets', renderPage('rulesets', {
|
|||||||
apiUrl: process.env.API_URL || 'http://localhost:3003'
|
apiUrl: process.env.API_URL || 'http://localhost:3003'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get('/sessions', renderPage('sessions', {
|
||||||
|
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||||
|
mapboxToken: process.env.MAPBOX_TOKEN || ''
|
||||||
|
}));
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Started on port ${PORT}`);
|
console.log(`Started on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
558
console/src/pages/kioskedit.html
Normal file
558
console/src/pages/kioskedit.html
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
<!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="../static/styles/kiosk.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- Main Grid Canvas -->
|
||||||
|
<div id="canvas" class="canvas">
|
||||||
|
<div id="emptyState" class="empty-state">Trascina o aggiungi una card per iniziare</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UI Feedback & Overlays -->
|
||||||
|
<div id="tooltip" class="tooltip"></div>
|
||||||
|
<div id="unitBadge" class="unit-badge">1u = 0px</div>
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<div id="modalOverlay" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modalTitle">Configurazione</h2>
|
||||||
|
<textarea id="importArea" placeholder="JSON..."></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="modalCancel">Annulla</button>
|
||||||
|
<button id="modalApply" class="primary">Applica</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<p id="cardCount">0 cards</p>
|
||||||
|
|
||||||
|
<button id="editBtn">Edit</button>
|
||||||
|
<button id="addCardBtn" title="Aggiungi Widget">+</button>
|
||||||
|
<button id="addMapBtn" title="Aggiungi Mappa">Map</button>
|
||||||
|
|
||||||
|
<button id="importBtn">Import</button>
|
||||||
|
<button id="exportBtn">Export</button>
|
||||||
|
|
||||||
|
<button id="clearBtn">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="canvas.js"></script>
|
||||||
|
<script src="core.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolbar ─────────────────────────────────────────
|
||||||
|
document.getElementById('editBtn').addEventListener('click', (e) => {
|
||||||
|
editMode = !editMode;
|
||||||
|
e.target.classList.toggle('primary', editMode);
|
||||||
|
canvasEl.classList.toggle('edit-active', editMode);
|
||||||
|
document.body.classList.toggle('edit-mode', editMode);
|
||||||
|
renderAll();
|
||||||
|
toast(editMode ? 'Edit Mode Attiva' : 'Edit Mode Disattiva');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addCardBtn').addEventListener('click', () => {
|
||||||
|
const off = (cards.length % 8) * SNAP * 2;
|
||||||
|
createCard(gSnap(1 + off), gSnap(1 + off));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addMapBtn').addEventListener('click', () => {
|
||||||
|
createCard(gSnap(4), gSnap(4), 10, 8, null, null, 'map');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exportBtn').addEventListener('click', () => {
|
||||||
|
const json = exportConfig();
|
||||||
|
modalTitle.textContent = 'Esporta configurazione JSON';
|
||||||
|
importArea.value = json; importArea.readOnly = true;
|
||||||
|
modalApply.textContent = 'Copia';
|
||||||
|
modalOvl.classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('importBtn').addEventListener('click', () => {
|
||||||
|
modalTitle.textContent = 'Importa configurazione JSON';
|
||||||
|
importArea.value = ''; importArea.readOnly = false;
|
||||||
|
modalApply.textContent = 'Applica';
|
||||||
|
modalOvl.classList.add('open');
|
||||||
|
setTimeout(() => importArea.focus(), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modalCancel').addEventListener('click', () => modalOvl.classList.remove('open'));
|
||||||
|
|
||||||
|
modalApply.addEventListener('click', () => {
|
||||||
|
if (modalTitle.textContent.includes('Esporta')) {
|
||||||
|
navigator.clipboard.writeText(importArea.value).then(() => toast('Copiato')).catch(() => toast('Errore copia'));
|
||||||
|
modalOvl.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
if (importConfig(importArea.value)) modalOvl.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modalOvl.addEventListener('click', e => { if (e.target === modalOvl) modalOvl.classList.remove('open'); });
|
||||||
|
|
||||||
|
document.getElementById('clearBtn').addEventListener('click', () => {
|
||||||
|
[...cards].forEach((c, i) => setTimeout(() => removeCard(c), i * 40));
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasEl.addEventListener('mousedown', e => {
|
||||||
|
if (e.target === canvasEl && selectedCard) { selectedCard.el.classList.remove('selected'); selectedCard = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') { modalOvl.classList.remove('open'); return; }
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedCard) {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
removeCard(selectedCard);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCount(); renderAll();
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</html>
|
||||||
922
console/src/pages/sessions.html
Normal file
922
console/src/pages/sessions.html
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="/static/styles/style.css">
|
||||||
|
<link href="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.css" rel="stylesheet">
|
||||||
|
<script src="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<style>
|
||||||
|
/* === Layout === */
|
||||||
|
html, body { height: 100%; overflow: hidden; background: var(--surface); }
|
||||||
|
.page-wrap { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
|
||||||
|
/* === Header === */
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--header-bg); border-bottom: 1px solid var(--header-border);
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
flex-shrink: 0; z-index: 100;
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.header-left h1 { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
#changeSessionBtn { display: none; padding: 8px 16px; font-size: 13px; }
|
||||||
|
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||||
|
#sessionNameDisplay { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); display: none; }
|
||||||
|
#sessionMetaDisplay { font-size: 0.75rem; color: var(--text-secondary); display: none; }
|
||||||
|
|
||||||
|
/* === Session Popup Overlay === */
|
||||||
|
.session-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 2000;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
background: rgba(15,23,42,0.4);
|
||||||
|
}
|
||||||
|
.session-popup {
|
||||||
|
background: var(--header-bg, #fff); border: 1px solid var(--header-border);
|
||||||
|
border-radius: 20px; padding: 28px; width: 860px; max-width: 95vw;
|
||||||
|
max-height: 82vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.popup-head { margin-bottom: 16px; }
|
||||||
|
.popup-head h2 { font-size: 1.15rem; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.popup-head p { font-size: 0.82rem; color: var(--text-secondary); }
|
||||||
|
.popup-filters {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.popup-filters input {
|
||||||
|
flex: 1; padding: 9px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--header-border); background: var(--surface);
|
||||||
|
font-family: inherit; font-size: 13px; color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.popup-filters input:focus { border-color: var(--accent-color); }
|
||||||
|
.popup-filters select {
|
||||||
|
padding: 9px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--header-border); background: var(--surface);
|
||||||
|
font-family: inherit; font-size: 13px; color: var(--text-primary);
|
||||||
|
outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.session-grid {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 12px; padding-right: 4px;
|
||||||
|
}
|
||||||
|
.sess-card {
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface); padding: 16px; cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.sess-card:hover {
|
||||||
|
border-color: var(--accent-color); transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(37,99,235,0.12);
|
||||||
|
}
|
||||||
|
.sess-card-name { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.sess-card-id { font-size: 0.72rem; color: var(--text-tertiary); font-family: monospace; }
|
||||||
|
.sess-card-sensor { font-size: 0.78rem; color: var(--text-secondary); }
|
||||||
|
.sess-card-dates { font-size: 0.75rem; color: var(--text-secondary); }
|
||||||
|
.sess-card-duration { font-size: 0.78rem; font-weight: 600; color: var(--accent-color); }
|
||||||
|
.sess-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||||
|
.sess-tag {
|
||||||
|
font-size: 0.68rem; padding: 2px 8px; border-radius: 20px;
|
||||||
|
background: var(--accent-light); color: var(--accent-color);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
}
|
||||||
|
.sess-empty { grid-column: 1/-1; text-align: center; color: var(--text-secondary); padding: 40px; }
|
||||||
|
|
||||||
|
/* === Detail Panel === */
|
||||||
|
.detail-panel {
|
||||||
|
flex: 1; display: none; flex-direction: column;
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.detail-panel.visible { display: flex; }
|
||||||
|
|
||||||
|
/* === Map === */
|
||||||
|
.map-section {
|
||||||
|
flex-shrink: 0; height: 280px; position: relative; border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
#sessionMap { width: 100%; height: 100%; }
|
||||||
|
.no-gps-msg {
|
||||||
|
display: none; position: absolute; inset: 0; align-items: center; justify-content: center;
|
||||||
|
background: var(--surface); color: var(--text-secondary); font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Data section (scrollable) === */
|
||||||
|
.data-section {
|
||||||
|
flex: 1; overflow-y: auto; padding: 16px 24px 140px;
|
||||||
|
}
|
||||||
|
.data-controls {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center;
|
||||||
|
}
|
||||||
|
.search-field {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--header-bg); border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg); padding: 8px 14px; flex: 1; min-width: 140px;
|
||||||
|
}
|
||||||
|
.search-field input {
|
||||||
|
border: none; background: transparent; font-family: inherit; font-size: 13px;
|
||||||
|
color: var(--text-primary); outline: none; width: 100%;
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
display: flex; gap: 4px; background: var(--header-bg);
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.filter button { padding: 6px 12px; border-radius: 10px; font-size: 12px; border: none; }
|
||||||
|
.filter button.active {
|
||||||
|
background: var(--accent-color); color: #fff; border-color: transparent;
|
||||||
|
}
|
||||||
|
.data-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Data Cards === */
|
||||||
|
.data-card {
|
||||||
|
border: 1px solid var(--header-border); border-radius: var(--radius-lg);
|
||||||
|
background: white; padding: 14px; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.card-top { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||||
|
.card-info h4 { font-size: 0.78rem; color: var(--text-secondary); font-weight: 600; }
|
||||||
|
.card-action-btn {
|
||||||
|
padding: 4px; border-radius: 6px; border: none; background: transparent;
|
||||||
|
color: var(--text-secondary); cursor: pointer; opacity: 0.6; transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.card-action-btn:hover { opacity: 1; background: var(--accent-light); color: var(--accent-color); }
|
||||||
|
.card-body { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.card-values { display: flex; align-items: baseline; gap: 4px; }
|
||||||
|
.card-main-val { font-size: 1.4rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.card-unit { font-size: 0.75rem; color: var(--text-secondary); }
|
||||||
|
.card-mini-chart { height: 44px; position: relative; }
|
||||||
|
.card-mini-chart canvas { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* === Expanded Chart === */
|
||||||
|
.expanded-chart-container {
|
||||||
|
display: none; position: fixed; top: 72px; left: 24px; right: 24px; bottom: 110px;
|
||||||
|
background: white; border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg); z-index: 500;
|
||||||
|
flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.expanded-chart-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 18px; border-bottom: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
.expanded-chart-header h3 { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.close-expanded-btn {
|
||||||
|
padding: 4px 10px; border-radius: 8px; border: none; font-size: 18px;
|
||||||
|
background: transparent; cursor: pointer; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.expanded-chart-body { flex: 1; padding: 16px; position: relative; }
|
||||||
|
.expanded-chart-body canvas { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* === Timeline Bar === */
|
||||||
|
.timeline-bar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
||||||
|
background: var(--header-bg); border-top: 1px solid var(--header-border);
|
||||||
|
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||||
|
display: none; align-items: center; gap: 16px;
|
||||||
|
padding: 12px 20px; min-height: 84px; flex-direction: column;
|
||||||
|
}
|
||||||
|
.timeline-bar.visible { display: flex; }
|
||||||
|
.tl-row { display: flex; align-items: center; gap: 12px; width: 100%; }
|
||||||
|
.tl-track-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.tl-track {
|
||||||
|
position: relative; height: 6px; border-radius: 3px;
|
||||||
|
background: #334155; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.tl-fill-inner {
|
||||||
|
position: absolute; top: 0; height: 100%; background: var(--accent-color);
|
||||||
|
border-radius: 3px; pointer-events: none;
|
||||||
|
}
|
||||||
|
.tl-handle {
|
||||||
|
position: absolute; top: 50%; width: 18px; height: 18px;
|
||||||
|
background: white; border: 2px solid var(--accent-color);
|
||||||
|
border-radius: 50%; transform: translate(-50%, -50%);
|
||||||
|
cursor: grab; box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
transition: box-shadow 0.15s; z-index: 2;
|
||||||
|
}
|
||||||
|
.tl-handle:active { cursor: grabbing; box-shadow: 0 3px 10px rgba(37,99,235,0.35); }
|
||||||
|
.tl-handle.hidden { display: none; }
|
||||||
|
.tl-label-wrap {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.tl-label-start, .tl-label-end { font-size: 0.68rem; color: var(--text-secondary); }
|
||||||
|
.tl-label-current { font-size: 0.72rem; font-weight: 700; color: var(--accent-color); }
|
||||||
|
.tl-btn { padding: 8px 16px; font-size: 12px; flex-shrink: 0; }
|
||||||
|
#restrictBtn.active { background: var(--accent-color); color: white; border-color: transparent; }
|
||||||
|
.tl-loading { font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* === Loading overlay === */
|
||||||
|
.loading-overlay {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 1500;
|
||||||
|
background: rgba(248,250,252,0.85); backdrop-filter: blur(4px);
|
||||||
|
align-items: center; justify-content: center; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.loading-overlay.visible { display: flex; }
|
||||||
|
.loading-spinner {
|
||||||
|
width: 36px; height: 36px; border: 3px solid var(--accent-border);
|
||||||
|
border-top-color: var(--accent-color); border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loading-text { font-size: 0.9rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* === Toast === */
|
||||||
|
#dl-toast {
|
||||||
|
position: fixed; bottom: 96px; right: 24px;
|
||||||
|
background: rgba(255,255,255,0.95); padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-lg); border: 1px solid var(--header-border);
|
||||||
|
box-shadow: var(--shadow-md); color: var(--text-primary);
|
||||||
|
font-size: 13px; font-weight: 600; z-index: 9999;
|
||||||
|
backdrop-filter: blur(10px); transform: translateY(120px); opacity: 0;
|
||||||
|
transition: all 0.4s cubic-bezier(0.8,0,0.2,1); pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Map secondary bar === */
|
||||||
|
.map-bar {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||||
|
background: rgba(15,23,42,0.6); backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.map-bar .filter button { color: #cbd5e1; }
|
||||||
|
.map-bar .filter button.active { background: #3b82f6; color: #fff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page-wrap">
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-text" id="loadingText">Caricamento dati sessione...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session picker popup -->
|
||||||
|
<div class="session-overlay" id="sessionOverlay">
|
||||||
|
<div class="session-popup">
|
||||||
|
<div class="popup-head">
|
||||||
|
<h2>Seleziona una sessione</h2>
|
||||||
|
<p>Scegli una sessione di registrazione da analizzare</p>
|
||||||
|
</div>
|
||||||
|
<div class="popup-filters">
|
||||||
|
<input type="text" id="popupSearch" placeholder="Cerca per nome o sensore...">
|
||||||
|
<select id="popupSensorFilter"><option value="">Tutti i sensori</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="session-grid" id="sessionGrid">
|
||||||
|
<div class="sess-empty">Caricamento sessioni...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button id="changeSessionBtn">← Cambia sessione</button>
|
||||||
|
<h1>Sessioni</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span id="sessionNameDisplay"></span>
|
||||||
|
<span id="sessionMetaDisplay"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail panel -->
|
||||||
|
<div class="detail-panel" id="detailPanel">
|
||||||
|
|
||||||
|
<!-- Map -->
|
||||||
|
<div class="map-section" id="mapSection">
|
||||||
|
<div id="sessionMap"></div>
|
||||||
|
<div class="no-gps-msg" id="noGpsMsg">Nessun dato GPS per questa sessione</div>
|
||||||
|
<div class="map-bar" id="mapBar" style="display:none">
|
||||||
|
<div class="filter" style="background:transparent;border:none;">
|
||||||
|
<button class="active" data-zoom="10" style="font-size:11px;padding:4px 10px;">10x</button>
|
||||||
|
<button data-zoom="5" style="font-size:11px;padding:4px 10px;">5x</button>
|
||||||
|
<button data-zoom="1" style="font-size:11px;padding:4px 10px;">1x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data section -->
|
||||||
|
<div class="data-section" id="dataSection">
|
||||||
|
<div class="data-controls">
|
||||||
|
<div class="search-field">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 12 12" fill="none"><path d="M11.0835 11.0834L8.57516 8.57504M9.91683 5.25004C9.91683 7.82737 7.82749 9.91671 5.25016 9.91671C2.67283 9.91671 0.583496 7.82737 0.583496 5.25004C0.583496 2.67271 2.67283 0.583374 5.25016 0.583374C7.82749 0.583374 9.91683 2.67271 9.91683 5.25004Z" stroke="var(--text-secondary)" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<input type="text" id="dataSearch" placeholder="Cerca campo...">
|
||||||
|
</div>
|
||||||
|
<div class="filter" id="catFilter">
|
||||||
|
<button class="active" data-cat="all">Tutto</button>
|
||||||
|
<button data-cat="weather">Meteo</button>
|
||||||
|
<button data-cat="navigation">Navigazione</button>
|
||||||
|
<button data-cat="engine">Motore</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-grid" id="dataGrid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded chart -->
|
||||||
|
<div class="expanded-chart-container" id="expandedChartContainer">
|
||||||
|
<div class="expanded-chart-header">
|
||||||
|
<h3 id="expChartTitle">Dettaglio</h3>
|
||||||
|
<button class="close-expanded-btn" id="closeExpBtn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="expanded-chart-body">
|
||||||
|
<canvas id="expandedChartCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline bar -->
|
||||||
|
<div class="timeline-bar" id="timelineBar">
|
||||||
|
<div class="tl-row">
|
||||||
|
<button class="tl-btn" id="restrictBtn">Restringi</button>
|
||||||
|
<div class="tl-track-wrap">
|
||||||
|
<div class="tl-track" id="tlTrack">
|
||||||
|
<div class="tl-fill-inner" id="tlFill"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleLeft" style="left:0%"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleSingle" style="left:100%"></div>
|
||||||
|
<div class="tl-handle" id="tlHandleRight" style="left:100%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-label-wrap">
|
||||||
|
<span class="tl-label-start" id="tlLabelStart"></span>
|
||||||
|
<span class="tl-label-current" id="tlLabelCurrent"></span>
|
||||||
|
<span class="tl-label-end" id="tlLabelEnd"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="tl-btn" id="downloadBtn">Scarica</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dl-toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Config (injected by Nunjucks) ---
|
||||||
|
const API_URL = '{{ apiUrl }}';
|
||||||
|
mapboxgl.accessToken = '{{ mapboxToken }}';
|
||||||
|
|
||||||
|
// --- FIELD_DEFS (same as live.html) ---
|
||||||
|
const FIELD_DEFS = {
|
||||||
|
'meb.forecasts.temperature': { name: 'Temperatura', unit: '°C', category: 'weather' },
|
||||||
|
'meb.forecast.wind.speed': { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
||||||
|
'meb.forecast.wind.direction': { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
||||||
|
'meb.forecast.wind.gusts': { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
||||||
|
'meb.forecast.humidity': { name: 'Umidita', unit: '%', category: 'weather' },
|
||||||
|
'meb.forecast.pressure': { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
||||||
|
'meb.forecast.precipitation': { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
||||||
|
'meb.forecast.rain': { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
||||||
|
'meb.forecast.cloudCover': { name: 'Copertura Nuvole', unit: '%', category: 'weather' },
|
||||||
|
'meb.forecast.precipitationProbability': { name: 'Prob. Precipitazioni', unit: '%', category: 'weather' },
|
||||||
|
'meb.waves.height': { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
||||||
|
'meb.waves.direction': { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
||||||
|
'meb.waves.period': { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
||||||
|
'meb.waves.peakPeriod': { name: 'Periodo Picco', unit: 's', category: 'weather' },
|
||||||
|
'meb.waves.currentVelocity': { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
||||||
|
'meb.waves.currentDirection': { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
||||||
|
'navigation.position.latitude': { name: 'Latitudine', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.position.longitude': { name: 'Longitudine', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.headingTrue': { name: 'Heading', unit: '°', category: 'navigation' },
|
||||||
|
'navigation.speedOverGround': { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
||||||
|
'navigation.courseOverGroundTrue': { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
||||||
|
'electrical.batteries.service.Voltage': { name: 'Batteria Serv. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.service.current': { name: 'Batteria Serv. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.service.stateOfCharge': { name: 'Batteria Serv. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.Voltage': { name: 'Batteria Traz. V', unit: 'V', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.current': { name: 'Batteria Traz. A', unit: 'A', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.stateOfCharge': { name: 'Batteria Traz. SoC', unit: '%', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.temperature': { name: 'Batteria Traz. Temp', unit: '°C', category: 'engine' },
|
||||||
|
'electrical.batteries.traction.power': { name: 'Batteria Traz. W', unit: 'W', category: 'engine' },
|
||||||
|
'propulsion.0.revolutions': { name: 'Giri Motore', unit: 'RPM', category: 'engine' },
|
||||||
|
'system.uptime': { name: 'Uptime', unit: 's', category: 'engine' },
|
||||||
|
};
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'rgba(59,130,246,1)', 'rgba(16,185,129,1)', 'rgba(245,158,11,1)',
|
||||||
|
'rgba(239,68,68,1)', 'rgba(139,92,246,1)', 'rgba(236,72,153,1)',
|
||||||
|
];
|
||||||
|
const TICK_COLOR = '#94a3b8';
|
||||||
|
const GRID_COLOR = 'rgba(148,163,184,0.08)';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let sessionRows = []; // [{_time, field: val, ...}]
|
||||||
|
let sessionTimes = []; // ms timestamps (sorted)
|
||||||
|
let positionData = []; // [{ts, lat, lon}]
|
||||||
|
let fieldColorMap = {};
|
||||||
|
let colorIdx = 0;
|
||||||
|
let miniCharts = {};
|
||||||
|
let expChart = null;
|
||||||
|
let expActiveField = null;
|
||||||
|
let currentSensorId = null;
|
||||||
|
let currentSessionId = null;
|
||||||
|
let currentSessionMeta = null;
|
||||||
|
let tStart = 0, tEnd = 0;
|
||||||
|
let currentT = 0;
|
||||||
|
let restrictMode = false;
|
||||||
|
let restrictStart = 0, restrictEnd = 0;
|
||||||
|
let activeCategory = 'all';
|
||||||
|
let searchQuery = '';
|
||||||
|
let mapbox = null;
|
||||||
|
let mapDot = null;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
function fmtDuration(ms) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s/60)}m ${s%60}s`;
|
||||||
|
return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
|
||||||
|
}
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('it-IT', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||||
|
}
|
||||||
|
function fmtTime(ms) {
|
||||||
|
return new Date(ms).toLocaleTimeString('it-IT', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||||||
|
}
|
||||||
|
function getFieldColor(key) {
|
||||||
|
if (!fieldColorMap[key]) {
|
||||||
|
fieldColorMap[key] = CHART_COLORS[colorIdx % CHART_COLORS.length];
|
||||||
|
colorIdx++;
|
||||||
|
}
|
||||||
|
return fieldColorMap[key];
|
||||||
|
}
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.getElementById('dl-toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.style.transform = 'translateY(0)'; t.style.opacity = '1';
|
||||||
|
setTimeout(() => { t.style.transform = 'translateY(120px)'; t.style.opacity = '0'; }, 4500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search: nearest index in sessionTimes to ts
|
||||||
|
function nearestIdx(ts) {
|
||||||
|
if (!sessionTimes.length) return -1;
|
||||||
|
let lo = 0, hi = sessionTimes.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (sessionTimes[mid] < ts) lo = mid + 1; else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0 && Math.abs(sessionTimes[lo-1]-ts) < Math.abs(sessionTimes[lo]-ts)) return lo - 1;
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load sessions list ---
|
||||||
|
async function loadSessionsList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/sessions/history`);
|
||||||
|
const sessions = await res.json();
|
||||||
|
renderSessionGrid(sessions);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('sessionGrid').innerHTML = '<div class="sess-empty">Errore nel caricamento sessioni.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allSessions = [];
|
||||||
|
function renderSessionGrid(sessions) {
|
||||||
|
allSessions = sessions;
|
||||||
|
const sensors = [...new Set(sessions.map(s => s.sensor_name).filter(Boolean))];
|
||||||
|
const selEl = document.getElementById('popupSensorFilter');
|
||||||
|
selEl.innerHTML = '<option value="">Tutti i sensori</option>';
|
||||||
|
sensors.forEach(s => { const o = document.createElement('option'); o.value = s; o.textContent = s; selEl.appendChild(o); });
|
||||||
|
filterSessionGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSessionGrid() {
|
||||||
|
const q = document.getElementById('popupSearch').value.toLowerCase();
|
||||||
|
const sensor = document.getElementById('popupSensorFilter').value;
|
||||||
|
const filtered = allSessions.filter(s => {
|
||||||
|
const name = (s.name || s.session_id || '').toLowerCase();
|
||||||
|
const sname = (s.sensor_name || '').toLowerCase();
|
||||||
|
const matchQ = !q || name.includes(q) || sname.includes(q);
|
||||||
|
const matchSensor = !sensor || s.sensor_name === sensor;
|
||||||
|
return matchQ && matchSensor;
|
||||||
|
});
|
||||||
|
const grid = document.getElementById('sessionGrid');
|
||||||
|
if (!filtered.length) { grid.innerHTML = '<div class="sess-empty">Nessuna sessione trovata.</div>'; return; }
|
||||||
|
grid.innerHTML = '';
|
||||||
|
filtered.forEach(s => {
|
||||||
|
const start = s.created_at ? new Date(s.created_at).getTime() : null;
|
||||||
|
const end = s.disconnected_at ? new Date(s.disconnected_at).getTime() : null;
|
||||||
|
const dur = start && end ? fmtDuration(end - start) : (start ? 'In corso' : '—');
|
||||||
|
const tags = Array.isArray(s.tags) ? s.tags : [];
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'sess-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="sess-card-name">${s.name || s.session_id || '—'}</div>
|
||||||
|
<div class="sess-card-id">${s.session_id || ''}</div>
|
||||||
|
<div class="sess-card-sensor">${s.sensor_name || '—'}</div>
|
||||||
|
<div class="sess-card-dates">${fmtDate(s.created_at)}</div>
|
||||||
|
${end ? `<div class="sess-card-dates">${fmtDate(s.disconnected_at)}</div>` : ''}
|
||||||
|
<div class="sess-card-duration">${dur}</div>
|
||||||
|
${tags.length ? `<div class="sess-tags">${tags.map(t=>`<span class="sess-tag">${t}</span>`).join('')}</div>` : ''}
|
||||||
|
`;
|
||||||
|
card.onclick = () => selectSession(s);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('popupSearch').oninput = filterSessionGrid;
|
||||||
|
document.getElementById('popupSensorFilter').onchange = filterSessionGrid;
|
||||||
|
|
||||||
|
// --- Select session ---
|
||||||
|
async function selectSession(meta) {
|
||||||
|
currentSensorId = meta.sensor_name;
|
||||||
|
currentSessionId = meta.session_id;
|
||||||
|
currentSessionMeta = meta;
|
||||||
|
document.getElementById('sessionOverlay').style.display = 'none';
|
||||||
|
document.getElementById('changeSessionBtn').style.display = '';
|
||||||
|
document.getElementById('sessionNameDisplay').style.display = '';
|
||||||
|
document.getElementById('sessionMetaDisplay').style.display = '';
|
||||||
|
document.getElementById('sessionNameDisplay').textContent = meta.name || meta.session_id;
|
||||||
|
document.getElementById('sessionMetaDisplay').textContent = `${meta.sensor_name || ''} • ${meta.session_id}`;
|
||||||
|
document.getElementById('detailPanel').classList.add('visible');
|
||||||
|
document.getElementById('timelineBar').classList.add('visible');
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
sessionRows = []; sessionTimes = []; positionData = [];
|
||||||
|
fieldColorMap = {}; colorIdx = 0;
|
||||||
|
Object.values(miniCharts).forEach(c => c.destroy());
|
||||||
|
miniCharts = {};
|
||||||
|
if (expChart) { expChart.destroy(); expChart = null; expActiveField = null; }
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'none';
|
||||||
|
document.getElementById('dataGrid').innerHTML = '';
|
||||||
|
|
||||||
|
await loadSessionData(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionData(meta) {
|
||||||
|
document.getElementById('loadingText').textContent = 'Caricamento dati sessione...';
|
||||||
|
document.getElementById('loadingOverlay').classList.add('visible');
|
||||||
|
try {
|
||||||
|
const from = meta.created_at ? new Date(meta.created_at).toISOString() : null;
|
||||||
|
const params = new URLSearchParams({ session: meta.session_id });
|
||||||
|
if (from) params.set('from', from);
|
||||||
|
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(meta.sensor_name)}/data?${params}`);
|
||||||
|
sessionRows = await res.json();
|
||||||
|
|
||||||
|
if (!sessionRows.length) { showToast('Nessun dato trovato per questa sessione'); return; }
|
||||||
|
|
||||||
|
sessionTimes = sessionRows.map(r => new Date(r._time).getTime());
|
||||||
|
tStart = sessionTimes[0]; tEnd = sessionTimes[sessionTimes.length - 1];
|
||||||
|
currentT = tEnd;
|
||||||
|
restrictStart = tStart; restrictEnd = tEnd;
|
||||||
|
|
||||||
|
positionData = sessionRows
|
||||||
|
.map((r, i) => ({ ts: sessionTimes[i], lat: r['navigation.position.latitude'], lon: r['navigation.position.longitude'] }))
|
||||||
|
.filter(p => p.lat != null && p.lon != null);
|
||||||
|
|
||||||
|
buildGrid();
|
||||||
|
initMap();
|
||||||
|
initTimeline();
|
||||||
|
updateGrid(currentT);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Errore nel caricamento dei dati');
|
||||||
|
} finally {
|
||||||
|
document.getElementById('loadingOverlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grid ---
|
||||||
|
function getFieldsFromRows() {
|
||||||
|
const meta = new Set(['result', 'table', '_start', '_stop', '_measurement', '_time', 'sensor', 'session', '']);
|
||||||
|
const fields = new Set();
|
||||||
|
sessionRows.forEach(r => Object.keys(r).forEach(k => { if (!meta.has(k)) fields.add(k); }));
|
||||||
|
return [...fields];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGrid() {
|
||||||
|
const grid = document.getElementById('dataGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const fields = getFieldsFromRows();
|
||||||
|
fields.forEach(key => {
|
||||||
|
const def = FIELD_DEFS[key] || { name: key, unit: '', category: 'engine' };
|
||||||
|
const numericVals = sessionRows.map(r => r[key]).filter(v => typeof v === 'number');
|
||||||
|
if (!numericVals.length) return;
|
||||||
|
const col = getFieldColor(key);
|
||||||
|
const bgCol = col.replace(', 1)', ', 0.12)');
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'data-card';
|
||||||
|
card.dataset.key = key;
|
||||||
|
card.dataset.category = def.category;
|
||||||
|
card.dataset.name = def.name.toLowerCase();
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="card-info"><h4>${def.name}</h4></div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="card-action-btn enlarge-btn" title="Espandi">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-values">
|
||||||
|
<span class="card-main-val">—</span>
|
||||||
|
<span class="card-unit">${def.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-mini-chart"><canvas id="mini-${CSS.escape(key)}"></canvas></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.querySelector('.enlarge-btn').onclick = e => { e.stopPropagation(); openExpandedChart(key); };
|
||||||
|
grid.appendChild(card);
|
||||||
|
|
||||||
|
const ctx = document.getElementById(`mini-${CSS.escape(key)}`).getContext('2d');
|
||||||
|
miniCharts[key] = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: sessionRows.map(() => ''),
|
||||||
|
datasets: [{ data: sessionRows.map(r => r[key] ?? null), borderColor: col, backgroundColor: bgCol, fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 }]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||||
|
scales: {
|
||||||
|
x: { type: 'category', display: false },
|
||||||
|
y: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGrid(ts) {
|
||||||
|
const idx = nearestIdx(ts);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const row = sessionRows[idx];
|
||||||
|
document.querySelectorAll('.data-card').forEach(card => {
|
||||||
|
const key = card.dataset.key;
|
||||||
|
const v = row[key];
|
||||||
|
const el = card.querySelector('.card-main-val');
|
||||||
|
if (typeof v === 'number') el.textContent = v.toFixed(2);
|
||||||
|
else el.textContent = v != null ? String(v) : '—';
|
||||||
|
});
|
||||||
|
if (expChart && expActiveField) updateExpandedChartLine(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
document.querySelectorAll('.data-card').forEach(c => {
|
||||||
|
const matchCat = activeCategory === 'all' || c.dataset.category === activeCategory;
|
||||||
|
const matchStr = !searchQuery || c.dataset.name.includes(searchQuery);
|
||||||
|
c.style.display = matchCat && matchStr ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dataSearch').oninput = e => { searchQuery = e.target.value.toLowerCase(); applyFilters(); };
|
||||||
|
document.querySelectorAll('#catFilter button').forEach(b => {
|
||||||
|
b.onclick = () => {
|
||||||
|
document.querySelectorAll('#catFilter button').forEach(x => x.classList.remove('active'));
|
||||||
|
b.classList.add('active'); activeCategory = b.dataset.cat; applyFilters();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Expanded Chart ---
|
||||||
|
function openExpandedChart(key) {
|
||||||
|
expActiveField = key;
|
||||||
|
const def = FIELD_DEFS[key] || { name: key, unit: '' };
|
||||||
|
document.getElementById('expChartTitle').textContent = `Dettaglio: ${def.name}`;
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'flex';
|
||||||
|
const col = getFieldColor(key);
|
||||||
|
if (!expChart) {
|
||||||
|
const ctx = document.getElementById('expandedChartCanvas').getContext('2d');
|
||||||
|
expChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [
|
||||||
|
{ label: def.name, data: [], borderColor: col, backgroundColor: col.replace(',1)',',0.12)'), fill: false, tension: 0.3, pointRadius: 0, borderWidth: 2 },
|
||||||
|
{ type: 'line', data: [], borderColor: 'rgba(239,68,68,0.8)', borderWidth: 1, borderDash: [3,3], pointRadius: 0, fill: false }
|
||||||
|
]},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
scales: {
|
||||||
|
x: { type: 'category', ticks: { maxTicksLimit: 8, color: TICK_COLOR, font: { size: 10 } }, grid: { display: false } },
|
||||||
|
y: { ticks: { color: TICK_COLOR, font: { size: 10 }, maxTicksLimit: 5 }, grid: { color: GRID_COLOR } }
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.parsed.y?.toFixed(3)} ${def.unit}` } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const data = sessionRows.map(r => r[key] ?? null);
|
||||||
|
const labels = sessionTimes.map(t => fmtTime(t));
|
||||||
|
expChart.data.labels = labels;
|
||||||
|
expChart.data.datasets[0].data = data;
|
||||||
|
expChart.data.datasets[0].borderColor = col;
|
||||||
|
expChart.data.datasets[0].backgroundColor = col.replace(',1)',',0.12)');
|
||||||
|
expChart.update('none');
|
||||||
|
updateExpandedChartLine(nearestIdx(currentT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpandedChartLine(idx) {
|
||||||
|
if (!expChart) return;
|
||||||
|
const key = expActiveField;
|
||||||
|
const nulls = sessionRows.map(() => null);
|
||||||
|
if (idx >= 0) nulls[idx] = sessionRows[idx][key];
|
||||||
|
expChart.data.datasets[1].data = nulls;
|
||||||
|
expChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('closeExpBtn').onclick = () => {
|
||||||
|
document.getElementById('expandedChartContainer').style.display = 'none';
|
||||||
|
expActiveField = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Mapbox ---
|
||||||
|
function initMap() {
|
||||||
|
if (mapbox) { mapbox.remove(); mapbox = null; mapDot = null; }
|
||||||
|
document.getElementById('sessionMap').innerHTML = '';
|
||||||
|
|
||||||
|
if (!positionData.length) {
|
||||||
|
document.getElementById('noGpsMsg').style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('noGpsMsg').style.display = 'none';
|
||||||
|
|
||||||
|
mapbox = new mapboxgl.Map({
|
||||||
|
container: 'sessionMap',
|
||||||
|
style: 'mapbox://styles/mapbox/dark-v11',
|
||||||
|
center: [positionData[0].lon, positionData[0].lat],
|
||||||
|
zoom: 12
|
||||||
|
});
|
||||||
|
|
||||||
|
mapbox.on('load', () => {
|
||||||
|
const coordinates = positionData.map(p => [p.lon, p.lat]);
|
||||||
|
|
||||||
|
mapbox.addSource('route', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'LineString', coordinates } }
|
||||||
|
});
|
||||||
|
mapbox.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': '#3b82f6', 'line-width': 3 } });
|
||||||
|
|
||||||
|
// Wider hit area for hover
|
||||||
|
mapbox.addLayer({ id: 'route-hover', type: 'line', source: 'route', paint: { 'line-color': 'transparent', 'line-width': 16 } });
|
||||||
|
|
||||||
|
mapbox.addSource('dot', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [positionData[positionData.length-1].lon, positionData[positionData.length-1].lat] } }
|
||||||
|
});
|
||||||
|
mapbox.addLayer({ id: 'dot', type: 'circle', source: 'dot', paint: { 'circle-radius': 8, 'circle-color': '#fff', 'circle-stroke-color': '#3b82f6', 'circle-stroke-width': 3 } });
|
||||||
|
|
||||||
|
mapbox.fitBounds(coordinates.reduce((b, c) => b.extend(c), new mapboxgl.LngLatBounds(coordinates[0], coordinates[0])), { padding: 30 });
|
||||||
|
|
||||||
|
mapbox.on('mousemove', 'route-hover', e => {
|
||||||
|
if (!e.features.length) return;
|
||||||
|
const pt = e.lngLat;
|
||||||
|
let nearest = positionData[0], minD = Infinity;
|
||||||
|
positionData.forEach(p => {
|
||||||
|
const d = Math.hypot(p.lon - pt.lng, p.lat - pt.lat);
|
||||||
|
if (d < minD) { minD = d; nearest = p; }
|
||||||
|
});
|
||||||
|
seekTo(nearest.ts);
|
||||||
|
});
|
||||||
|
mapbox.on('mouseleave', 'route-hover', () => {});
|
||||||
|
|
||||||
|
document.getElementById('mapBar').style.display = 'flex';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMapDot(ts) {
|
||||||
|
if (!mapbox || !positionData.length) return;
|
||||||
|
const idx = nearestIdx(ts);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const row = sessionRows[idx];
|
||||||
|
const lat = row['navigation.position.latitude'];
|
||||||
|
const lon = row['navigation.position.longitude'];
|
||||||
|
if (lat == null || lon == null) return;
|
||||||
|
mapbox.getSource('dot')?.setData({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Timeline ---
|
||||||
|
function initTimeline() {
|
||||||
|
document.getElementById('tlLabelStart').textContent = fmtTime(tStart);
|
||||||
|
document.getElementById('tlLabelEnd').textContent = fmtTime(tEnd);
|
||||||
|
setTimelineMode(false);
|
||||||
|
setHandlePos('single', 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHandlePos(which, frac) {
|
||||||
|
const el = which === 'single' ? document.getElementById('tlHandleSingle')
|
||||||
|
: which === 'left' ? document.getElementById('tlHandleLeft')
|
||||||
|
: document.getElementById('tlHandleRight');
|
||||||
|
el.style.left = `${(frac * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posToFrac(px) {
|
||||||
|
const track = document.getElementById('tlTrack');
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
return Math.max(0, Math.min(1, (px - rect.left) / rect.width));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fracToTs(frac) { return tStart + frac * (tEnd - tStart); }
|
||||||
|
function tsToFrac(ts) { return (tEnd === tStart) ? 0 : (ts - tStart) / (tEnd - tStart); }
|
||||||
|
|
||||||
|
function updateFill() {
|
||||||
|
const fill = document.getElementById('tlFill');
|
||||||
|
if (!restrictMode) {
|
||||||
|
const f = tsToFrac(currentT);
|
||||||
|
fill.style.left = '0%';
|
||||||
|
fill.style.width = `${f * 100}%`;
|
||||||
|
} else {
|
||||||
|
const fl = tsToFrac(restrictStart);
|
||||||
|
const fr = tsToFrac(restrictEnd);
|
||||||
|
fill.style.left = `${fl * 100}%`;
|
||||||
|
fill.style.width = `${(fr - fl) * 100}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimelineMode(restrict) {
|
||||||
|
restrictMode = restrict;
|
||||||
|
document.getElementById('restrictBtn').classList.toggle('active', restrict);
|
||||||
|
document.getElementById('tlHandleSingle').classList.toggle('hidden', restrict);
|
||||||
|
document.getElementById('tlHandleLeft').classList.toggle('hidden', !restrict);
|
||||||
|
document.getElementById('tlHandleRight').classList.toggle('hidden', !restrict);
|
||||||
|
if (restrict) {
|
||||||
|
restrictStart = tStart; restrictEnd = tEnd;
|
||||||
|
setHandlePos('left', 0); setHandlePos('right', 1);
|
||||||
|
}
|
||||||
|
updateFill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekTo(ts) {
|
||||||
|
currentT = Math.max(tStart, Math.min(tEnd, ts));
|
||||||
|
setHandlePos('single', tsToFrac(currentT));
|
||||||
|
document.getElementById('tlLabelCurrent').textContent = fmtTime(currentT);
|
||||||
|
updateFill();
|
||||||
|
updateGrid(currentT);
|
||||||
|
updateMapDot(currentT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag logic
|
||||||
|
let dragging = null;
|
||||||
|
function startDrag(id, e) {
|
||||||
|
dragging = id;
|
||||||
|
e.preventDefault();
|
||||||
|
const onMove = ev => {
|
||||||
|
const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
|
||||||
|
const frac = posToFrac(clientX);
|
||||||
|
const ts = fracToTs(frac);
|
||||||
|
if (id === 'single') {
|
||||||
|
seekTo(ts);
|
||||||
|
} else if (id === 'left') {
|
||||||
|
restrictStart = Math.max(tStart, Math.min(restrictEnd - 1000, ts));
|
||||||
|
setHandlePos('left', tsToFrac(restrictStart));
|
||||||
|
updateFill();
|
||||||
|
} else if (id === 'right') {
|
||||||
|
restrictEnd = Math.min(tEnd, Math.max(restrictStart + 1000, ts));
|
||||||
|
setHandlePos('right', tsToFrac(restrictEnd));
|
||||||
|
updateFill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUp = () => { dragging = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tlHandleSingle').addEventListener('mousedown', e => startDrag('single', e));
|
||||||
|
document.getElementById('tlHandleLeft').addEventListener('mousedown', e => startDrag('left', e));
|
||||||
|
document.getElementById('tlHandleRight').addEventListener('mousedown', e => startDrag('right', e));
|
||||||
|
|
||||||
|
document.getElementById('tlTrack').addEventListener('click', e => {
|
||||||
|
if (dragging) return;
|
||||||
|
const frac = posToFrac(e.clientX);
|
||||||
|
if (!restrictMode) seekTo(fracToTs(frac));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('restrictBtn').onclick = () => setTimelineMode(!restrictMode);
|
||||||
|
|
||||||
|
// --- Download ---
|
||||||
|
document.getElementById('downloadBtn').onclick = async () => {
|
||||||
|
if (!currentSensorId || !currentSessionId) return;
|
||||||
|
const btn = document.getElementById('downloadBtn');
|
||||||
|
const orig = btn.textContent; btn.textContent = '...';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ session: currentSessionId, from: String(tStart) });
|
||||||
|
if (restrictMode) params.set('to', String(restrictEnd));
|
||||||
|
const res = await fetch(`${API_URL}/sessions/${encodeURIComponent(currentSensorId)}/csv?${params}`);
|
||||||
|
if (!res.ok) { showToast('Errore durante il download'); return; }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url;
|
||||||
|
a.download = `session_${currentSessionId}_${currentSensorId}.csv`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const sizeStr = blob.size < 1024 ? `${blob.size} B` : blob.size < 1048576 ? `${(blob.size/1024).toFixed(1)} KB` : `${(blob.size/1048576).toFixed(1)} MB`;
|
||||||
|
const text = await blob.text();
|
||||||
|
const rows = Math.max(0, text.split('\n').length - 2);
|
||||||
|
const dur = restrictMode ? fmtDuration(restrictEnd - restrictStart) : fmtDuration(tEnd - tStart);
|
||||||
|
showToast(`${rows} righe • ${sizeStr} • ${dur}`);
|
||||||
|
} catch { showToast('Errore durante il download'); }
|
||||||
|
finally { btn.textContent = orig; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Change session ---
|
||||||
|
document.getElementById('changeSessionBtn').onclick = () => {
|
||||||
|
document.getElementById('sessionOverlay').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
document.addEventListener('DOMContentLoaded', loadSessionsList);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
console/src/static/fonts/atkinson-bold.ttf
Normal file
BIN
console/src/static/fonts/atkinson-bold.ttf
Normal file
Binary file not shown.
BIN
console/src/static/fonts/atkinson-regular.ttf
Normal file
BIN
console/src/static/fonts/atkinson-regular.ttf
Normal file
Binary file not shown.
634
console/src/static/styles/kiosk.css
Normal file
634
console/src/static/styles/kiosk.css
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
background: rgba(16, 20, 21, 0.88);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 20px;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.toolbar button.primary {
|
||||||
|
background-color: rgba(255, 255, 255, 0.103);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary:hover {
|
||||||
|
background: #4da8ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** CAMVAS!!! */
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 70%;
|
||||||
|
position: absolute;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 1px, #393b3c 0.8px, transparent 0.8px);
|
||||||
|
background-size: calc(100% / var(--cols)) calc(100% / var(--rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Snap guides ───────────────────────────────────── */
|
||||||
|
.guide {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--snap-line);
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.horizontal {
|
||||||
|
height: 1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide.vertical {
|
||||||
|
width: 1px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RESIZE HANDLERS (.rh) */
|
||||||
|
.rh {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
border-radius: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.corner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--card-border-active);
|
||||||
|
border: 2px solid #101415;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.edge {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner alignment */
|
||||||
|
.rh.nw {
|
||||||
|
top: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.ne {
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.se {
|
||||||
|
bottom: -6px;
|
||||||
|
right: -6px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.sw {
|
||||||
|
bottom: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edge alignment */
|
||||||
|
.rh.n {
|
||||||
|
top: -4px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.s {
|
||||||
|
bottom: -4px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.e {
|
||||||
|
right: -4px;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 8px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh.w {
|
||||||
|
left: -4px;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 8px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(.selected) .rh.corner {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.selected .rh.corner {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
12
realtime/src/routes/kiosk.js
Normal file
12
realtime/src/routes/kiosk.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const db = require('../store/db');
|
||||||
|
|
||||||
|
// Endpoint per ricevere dati dal kiosk
|
||||||
|
router.post('/data', async (req, res) => {
|
||||||
|
const { session_id, sensor_code, value, timestamp } = req.body;
|
||||||
|
if (!session_id || !sensor_code || value === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -11,8 +11,9 @@ const baseConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dbs = {
|
const dbs = {
|
||||||
data: { name: process.env.DATA_DB || 'data' },
|
data: { name: 'data' },
|
||||||
sensors: { name: process.env.SENSORS_DB || 'sensors' }
|
sensors: { name: 'sensors' },
|
||||||
|
kiosk: { name: 'kiosk' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const pools = {};
|
const pools = {};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const client = new InfluxDB({
|
|||||||
token: process.env.INFLX_TOKEN,
|
token: process.env.INFLX_TOKEN,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bucket = process.env.INFLX_BUCKET || 'sensors';
|
const bucket = process.env.INFLX_BUCKET || 'logs';
|
||||||
const org = process.env.INFLX_ORG;
|
const org = process.env.INFLX_ORG;
|
||||||
|
|
||||||
const writeApi = client.getWriteApi(org, bucket, 'ms', {
|
const writeApi = client.getWriteApi(org, bucket, 'ms', {
|
||||||
|
|||||||
Reference in New Issue
Block a user