Files
signalk-plugin/plugin/tools/kiosk/tile-renderer.js
Giuseppe Raffa c2c1598226 feat: Implement rulesets and layout management for kiosk plugin
- Added rulesets manager to handle various data types and updates via HTTP and WebSocket.
- Introduced layout store for managing kiosk layouts with caching and server synchronization.
- Enhanced dashboard and data routes to support new layout and ruleset features.
- Updated kiosk HTML and JavaScript to utilize new layout rendering and data binding.
- Removed obsolete map route and integrated map functionality into the new tile renderer.
- Improved Telegram commands to reflect changes in data structure and logging.
- Refactored weather fetching intervals to prevent multiple instances.
- Added SSE stream for real-time layout updates in the kiosk.
2026-05-12 10:17:54 +02:00

189 lines
8.8 KiB
JavaScript

/**
* tile-renderer.js — registra renderer per ogni tipo di tile.
*
* Ogni renderer espone:
* mount(el, tile) → setup DOM iniziale
* bind(el, tile, binder) → sottoscrizione ai path → ritorna unbind()
*
* Tipi supportati: value | gauge | map
*/
(function () {
const renderers = {};
function register(type, r) { renderers[type] = r; }
function get(type) { return renderers[type]; }
function format(value, { decimals = 1 } = {}) {
if (value == null || Number.isNaN(value)) return '—';
if (typeof value === 'number') {
const factor = Math.pow(10, decimals);
return (Math.round(value * factor) / factor).toString();
}
return String(value);
}
// ============================== VALUE ==================================
register('value', {
mount(el, tile) {
const d = tile.display || {};
el.innerHTML = `
<div class="title">${escapeHtml(d.title || tile.source?.path || '')}</div>
<div class="val" data-val>
<span data-num>—</span>
${d.unit ? `<span class="unit">${escapeHtml(d.unit)}</span>` : ''}
</div>`;
const val = el.querySelector('[data-val]');
if (d.font) val.style.fontFamily = d.font;
if (d.fontSize) val.style.fontSize = d.fontSize;
if (d.color) val.style.color = d.color;
if (d.bg) el.style.background = d.bg;
},
bind(el, tile, binder) {
const num = el.querySelector('[data-num]');
const off = binder.subscribe(tile.source.path, (v) => {
num.textContent = format(v, { decimals: tile.display?.decimals ?? 1 });
el.classList.remove('stale');
});
const staleTimer = setInterval(() => {
const l = binder.getLatest(tile.source.path);
if (!l && !el.classList.contains('stale')) el.classList.add('stale');
}, 5000);
return () => { off(); clearInterval(staleTimer); };
}
});
// ============================== GAUGE ==================================
// SVG gauge semplice (arco 270°, lancetta). Range = tile.range = [min, max].
register('gauge', {
mount(el, tile) {
const d = tile.display || {};
el.innerHTML = `
<div class="title">${escapeHtml(d.title || tile.source?.path || '')}</div>
<svg viewBox="0 0 200 140" preserveAspectRatio="xMidYMid meet">
<path d="M 25 120 A 80 80 0 1 1 175 120" fill="none" stroke="#1f2937" stroke-width="14" stroke-linecap="round"/>
<path d="M 25 120 A 80 80 0 1 1 175 120" fill="none" stroke="${escapeHtml(d.color || '#22d3ee')}" stroke-width="14" stroke-linecap="round" data-arc/>
<line x1="100" y1="120" x2="100" y2="55" stroke="${escapeHtml(d.color || '#fff')}" stroke-width="3" stroke-linecap="round" data-needle transform="rotate(0 100 120)"/>
<circle cx="100" cy="120" r="6" fill="${escapeHtml(d.color || '#fff')}"/>
<text x="100" y="100" text-anchor="middle" font-size="22" font-weight="700" fill="#fff" data-num>—</text>
<text x="100" y="118" text-anchor="middle" font-size="9" opacity=".5" fill="#fff">${escapeHtml(d.unit || '')}</text>
</svg>`;
if (d.bg) el.style.background = d.bg;
},
bind(el, tile, binder) {
const [min, max] = tile.range || [0, 100];
const arc = el.querySelector('[data-arc]');
const needle = el.querySelector('[data-needle]');
const num = el.querySelector('[data-num]');
// pre-compute arc length (perimetro semicerchio R=80)
const totalLen = 376.99; // ~ 80 * Math.PI * 1.5 (270deg)
arc.setAttribute('stroke-dasharray', `${totalLen} ${totalLen}`);
const off = binder.subscribe(tile.source.path, (v) => {
const num_v = (v == null || Number.isNaN(+v)) ? null : Number(v);
num.textContent = format(num_v, { decimals: tile.display?.decimals ?? 1 });
if (num_v == null) return;
const ratio = Math.max(0, Math.min(1, (num_v - min) / (max - min)));
arc.setAttribute('stroke-dashoffset', String(totalLen * (1 - ratio)));
// needle: da -135° a +135°
const deg = -135 + 270 * ratio;
needle.setAttribute('transform', `rotate(${deg} 100 120)`);
el.classList.remove('stale');
});
return off;
}
});
// ============================== MAP ====================================
let mapboxLoadingPromise = null;
function loadMapbox() {
if (window.mapboxgl) return Promise.resolve();
if (mapboxLoadingPromise) return mapboxLoadingPromise;
mapboxLoadingPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
return mapboxLoadingPromise;
}
register('map', {
mount(el, tile) {
const d = tile.display || {};
el.innerHTML = `
<div class="title">${escapeHtml(d.title || 'Mappa')}</div>
<div class="map-host" data-map></div>
<div class="overlays" data-overlays></div>`;
if (d.bg) el.style.background = d.bg;
},
bind(el, tile, binder) {
const mapHost = el.querySelector('[data-map]');
const overlays = el.querySelector('[data-overlays]');
const extras = Array.isArray(tile.extras) ? tile.extras : [];
// pre-popola overlay
const extraEls = extras.map((ex, i) => {
const span = document.createElement('span');
span.dataset.idx = i;
span.innerHTML = `<b>${escapeHtml(ex.label || ex.path)}</b>: <span data-ex>—</span> ${escapeHtml(ex.unit || '')}`;
overlays.appendChild(span);
return span.querySelector('[data-ex]');
});
let map = null, marker = null;
const mapboxKey = document.querySelector('meta[name="mapbox-key"]')?.content || '';
const offs = [];
loadMapbox().then(() => {
if (!window.mapboxgl) return;
window.mapboxgl.accessToken = mapboxKey;
map = new window.mapboxgl.Map({
container: mapHost,
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:'sea-layer', type:'raster', source:'openseamap', minzoom:0, maxzoom:18 },
],
},
center: [0, 0], zoom: 2,
});
marker = new window.mapboxgl.Marker({ color: tile.display?.color || '#ef4444' })
.setLngLat([0, 0]).addTo(map);
}).catch(err => console.warn('[KIOSK|MAP] mapbox load failed:', err));
let curLat = null, curLon = null;
const update = () => {
if (curLat == null || curLon == null || !map || !marker) return;
marker.setLngLat([curLon, curLat]);
map.flyTo({ center: [curLon, curLat], zoom: Math.max(13, map.getZoom()) });
};
offs.push(binder.subscribe(tile.source.latPath, v => { curLat = Number(v); update(); }));
offs.push(binder.subscribe(tile.source.lonPath, v => { curLon = Number(v); update(); }));
// extras
extras.forEach((ex, i) => {
offs.push(binder.subscribe(ex.path, v => {
extraEls[i].textContent = format(v, { decimals: ex.decimals ?? 1 });
}));
});
return () => {
offs.forEach(o => o && o());
if (map) { try { map.remove(); } catch (_) {} map = null; }
};
}
});
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
window.tileRenderer = { register, get };
})();