/**
* 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 = `
${escapeHtml(d.title || tile.source?.path || '')}
—
${d.unit ? `${escapeHtml(d.unit)}` : ''}
`;
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 = `
${escapeHtml(d.title || tile.source?.path || '')}
`;
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 = `
${escapeHtml(d.title || 'Mappa')}
`;
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 = `${escapeHtml(ex.label || ex.path)}: — ${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 =>
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
window.tileRenderer = { register, get };
})();