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.
This commit is contained in:
@@ -4,7 +4,7 @@ const paths = [
|
||||
]
|
||||
window.skPaths = paths;
|
||||
|
||||
mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
|
||||
mapboxgl.accessToken = document.querySelector('meta[name="mapbox-key"]')?.content || '';
|
||||
|
||||
let map = null;
|
||||
let boatMark = null;
|
||||
|
||||
72
plugin/tools/kiosk/data-binder.js
Normal file
72
plugin/tools/kiosk/data-binder.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* data-binder.js — apre una singola sottoscrizione SignalK delta locale
|
||||
* e smista i valori ai tile per path.
|
||||
*
|
||||
* Espone window.dataBinder con:
|
||||
* subscribe(path, fn) → unsubscribe()
|
||||
* getLatest(path) → ultimo valore visto (o null)
|
||||
* onConnState(fn) → notifica connesso/disconnesso
|
||||
*/
|
||||
(function () {
|
||||
const subs = new Map(); // path -> Set<fn>
|
||||
const latest = new Map(); // path -> { value, ts }
|
||||
const stateListeners = new Set();
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
|
||||
function notify(path, value) {
|
||||
latest.set(path, { value, ts: Date.now() });
|
||||
const ss = subs.get(path);
|
||||
if (!ss) return;
|
||||
for (const fn of ss) { try { fn(value); } catch (e) { console.warn(e); } }
|
||||
}
|
||||
|
||||
function notifyState(s) { for (const fn of stateListeners) { try { fn(s); } catch (_) {} } }
|
||||
|
||||
function connect() {
|
||||
const url = `ws://${location.host}/signalk/v1/stream?subscribe=all`;
|
||||
try { ws = new WebSocket(url); }
|
||||
catch (err) { console.error('[BINDER] WS create:', err); scheduleReconnect(); return; }
|
||||
|
||||
ws.onopen = () => { notifyState({ connected: true }); console.log('[BINDER] WS connected'); };
|
||||
ws.onerror = (e) => console.warn('[BINDER] WS error', e);
|
||||
ws.onclose = () => { notifyState({ connected: false }); scheduleReconnect(); };
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||
if (!msg.updates) return;
|
||||
for (const u of msg.updates) {
|
||||
if (!u.values) continue;
|
||||
for (const v of u.values) {
|
||||
if (!v.path) continue;
|
||||
notify(v.path, v.value);
|
||||
// espandi position in lat/lon per chi si iscrive a quelli
|
||||
if (v.path === 'navigation.position' && v.value && typeof v.value === 'object') {
|
||||
if (v.value.latitude != null) notify('navigation.position.latitude', v.value.latitude);
|
||||
if (v.value.longitude != null) notify('navigation.position.longitude', v.value.longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 3000);
|
||||
}
|
||||
|
||||
function subscribe(path, fn) {
|
||||
if (!subs.has(path)) subs.set(path, new Set());
|
||||
subs.get(path).add(fn);
|
||||
const last = latest.get(path);
|
||||
if (last) { try { fn(last.value); } catch (_) {} }
|
||||
return () => subs.get(path)?.delete(fn);
|
||||
}
|
||||
|
||||
function getLatest(path) { return latest.get(path)?.value ?? null; }
|
||||
function onConnState(fn) { stateListeners.add(fn); return () => stateListeners.delete(fn); }
|
||||
|
||||
connect();
|
||||
|
||||
window.dataBinder = { subscribe, getLatest, onConnState };
|
||||
})();
|
||||
@@ -3,22 +3,41 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kiosk</title>
|
||||
<title>MEB Kiosk</title>
|
||||
<style>
|
||||
html,body { margin:0; padding:0; height:100%; background:#0b1220; color:#fff; font-family:-apple-system,sans-serif; overflow:hidden; }
|
||||
#canvas { position:relative; width:100vw; height:100vh; }
|
||||
.box { position:absolute; border-radius:10px; padding:14px; box-sizing:border-box; display:flex; flex-direction:column; overflow:hidden; transition: background .25s, color .25s, left .25s, top .25s, width .25s, height .25s; }
|
||||
.box .title { font-size:.9rem; opacity:.65; letter-spacing:.06em; text-transform:uppercase; }
|
||||
.box .val { flex:1; display:flex; align-items:center; justify-content:center; font-weight:800; font-variant-numeric: tabular-nums; line-height:1; }
|
||||
.box .unit { opacity:.7; font-size:.6em; margin-left:.25em; }
|
||||
#statusChip { position:fixed; right:10px; bottom:10px; background:#111827aa; padding:4px 8px; border-radius:999px; font-size:11px; opacity:.6; }
|
||||
html,body { margin:0; padding:0; height:100%; background:#0b1220; color:#fff;
|
||||
font-family:-apple-system,Segoe UI,sans-serif; overflow:hidden; }
|
||||
#grid { position:relative; width:100vw; height:100vh; display:grid;
|
||||
grid-template-columns: repeat(var(--cols, 12), 1fr);
|
||||
grid-template-rows: repeat(var(--rows, 8), 1fr);
|
||||
gap: var(--gap, 4px); padding: var(--gap, 4px); box-sizing:border-box; }
|
||||
.tile { border-radius:10px; padding:14px; box-sizing:border-box; display:flex;
|
||||
flex-direction:column; overflow:hidden; background:#111827;
|
||||
transition: background .25s, color .25s; }
|
||||
.tile .title { font-size:.85rem; opacity:.65; letter-spacing:.06em;
|
||||
text-transform:uppercase; margin-bottom:6px; }
|
||||
.tile .val { flex:1; display:flex; align-items:center; justify-content:center;
|
||||
font-weight:800; font-variant-numeric: tabular-nums; line-height:1; }
|
||||
.tile .val .unit { opacity:.55; font-size:.5em; margin-left:.3em; }
|
||||
.tile .stale { opacity:.45; }
|
||||
.tile.map .map-host { flex:1; border-radius:6px; overflow:hidden; }
|
||||
.tile.map .overlays { display:flex; flex-wrap:wrap; gap:8px 14px; padding-top:6px;
|
||||
font-size:.8rem; opacity:.85; }
|
||||
.tile.gauge svg { width:100%; height:100%; max-height:100%; }
|
||||
#statusChip { position:fixed; right:10px; bottom:10px; background:#111827cc;
|
||||
padding:4px 8px; border-radius:999px; font-size:11px; opacity:.7; }
|
||||
#statusChip.err { background:#dc2626cc; opacity:1; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="canvas"></div>
|
||||
<div id="grid"></div>
|
||||
<div id="statusChip">boot…</div>
|
||||
<script src="template-loader.js"></script>
|
||||
<script src="control-socket.js"></script>
|
||||
|
||||
<!-- Mapbox GL (caricato lazy solo se serve un tile di tipo "map") -->
|
||||
|
||||
<script src="data-binder.js" defer></script>
|
||||
<script src="tile-renderer.js" defer></script>
|
||||
<script src="layout-client.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
98
plugin/tools/kiosk/layout-client.js
Normal file
98
plugin/tools/kiosk/layout-client.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* layout-client.js — apre l'EventSource /meb/kiosk/stream del plugin, riceve
|
||||
* il layout corrente (e i successivi update), e ridisegna la griglia.
|
||||
*
|
||||
* Il plugin NON edita: si limita a mostrare. La logica di binding ai dati
|
||||
* SignalK (local stream) e' in data-binder.js; il render dei tile e' in
|
||||
* tile-renderer.js.
|
||||
*/
|
||||
(function () {
|
||||
const grid = document.getElementById('grid');
|
||||
const chip = document.getElementById('statusChip');
|
||||
|
||||
let activeUnbinds = []; // funzioni di unbind per i tile correnti
|
||||
let currentLayout = null;
|
||||
|
||||
function setStatus(text, isErr = false) {
|
||||
chip.textContent = text;
|
||||
chip.classList.toggle('err', isErr);
|
||||
}
|
||||
|
||||
function clearGrid() {
|
||||
for (const off of activeUnbinds) { try { off(); } catch (_) {} }
|
||||
activeUnbinds = [];
|
||||
grid.innerHTML = '';
|
||||
}
|
||||
|
||||
function applyLayout(layoutRow) {
|
||||
const content = layoutRow?.content;
|
||||
if (!content || !Array.isArray(content.tiles)) {
|
||||
console.warn('[KIOSK] layout vuoto/non valido');
|
||||
return;
|
||||
}
|
||||
clearGrid();
|
||||
currentLayout = layoutRow;
|
||||
|
||||
// grid CSS variables
|
||||
grid.style.setProperty('--cols', String(content.grid?.cols ?? 12));
|
||||
grid.style.setProperty('--rows', String(content.grid?.rows ?? 8));
|
||||
grid.style.setProperty('--gap', String(content.grid?.gap ?? 4) + 'px');
|
||||
|
||||
// theme (opzionale)
|
||||
if (content.theme === 'light') {
|
||||
document.body.style.background = '#f4f5f7';
|
||||
document.body.style.color = '#111';
|
||||
} else {
|
||||
document.body.style.background = '#0b1220';
|
||||
document.body.style.color = '#fff';
|
||||
}
|
||||
|
||||
for (const tile of content.tiles) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `tile tile-${tile.type}`;
|
||||
el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
|
||||
el.style.gridRow = `${tile.y + 1} / span ${tile.h}`;
|
||||
grid.appendChild(el);
|
||||
|
||||
const r = window.tileRenderer.get(tile.type);
|
||||
if (!r) { el.innerHTML = `<div class="title">tipo sconosciuto: ${tile.type}</div>`; continue; }
|
||||
try {
|
||||
r.mount(el, tile);
|
||||
const off = r.bind(el, tile, window.dataBinder);
|
||||
if (off) activeUnbinds.push(off);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] render', tile.id, err);
|
||||
el.innerHTML = `<div class="title">errore render ${tile.id}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(`v${layoutRow.version || '?'} · ${content.tiles.length} tiles`);
|
||||
}
|
||||
|
||||
// ====== Stream del plugin (SSE) ======
|
||||
function openStream() {
|
||||
const es = new EventSource('/meb/kiosk/stream');
|
||||
es.addEventListener('layout', (ev) => {
|
||||
try { applyLayout(JSON.parse(ev.data)); }
|
||||
catch (err) { console.warn('[KIOSK] layout parse:', err); }
|
||||
});
|
||||
es.onerror = () => setStatus('stream disconnesso', true);
|
||||
es.onopen = () => setStatus('stream ok');
|
||||
}
|
||||
|
||||
// status connessione SignalK
|
||||
if (window.dataBinder) {
|
||||
window.dataBinder.onConnState(({ connected }) => {
|
||||
if (!connected) setStatus('signalk disconnesso', true);
|
||||
else if (currentLayout) setStatus(`v${currentLayout.version || '?'} · live`);
|
||||
});
|
||||
}
|
||||
|
||||
// bootstrap: prima fetch one-shot del layout (per non aspettare il primo evento SSE),
|
||||
// poi apri lo stream per gli update successivi
|
||||
fetch('/meb/kiosk/layout')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(l => { if (l) applyLayout(l); })
|
||||
.catch(() => {})
|
||||
.finally(openStream);
|
||||
})();
|
||||
80
plugin/tools/kiosk/server-layout-store.js
Normal file
80
plugin/tools/kiosk/server-layout-store.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Store del kiosk layout lato plugin.
|
||||
*
|
||||
* - cache su disco: data/kiosk-layout.json
|
||||
* - bootstrap HTTP: GET {API_URL}/kiosklayouts/sensor/<SENSOR_ID>/active
|
||||
* - apply push WS: invocato da socket.js quando arriva _t:'kiosk_layout_update'
|
||||
* - SSE stream: espone un emitter consumato dalla route /meb/kiosk/stream
|
||||
*
|
||||
* Il plugin NON sceglie il layout: lo riceve dal server e lo mostra.
|
||||
*/
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const configManager = require('../../config/configManager');
|
||||
|
||||
const cacheFile = path.join(__dirname, '../../../data/kiosk-layout.json');
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
let current = null;
|
||||
|
||||
async function ensureDir() {
|
||||
try { await fs.mkdir(path.dirname(cacheFile), { recursive: true }); } catch {}
|
||||
}
|
||||
|
||||
async function loadCache() {
|
||||
try {
|
||||
const raw = await fs.readFile(cacheFile, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function saveCache() {
|
||||
if (!current) return;
|
||||
try { await ensureDir(); await fs.writeFile(cacheFile, JSON.stringify(current, null, 2)); }
|
||||
catch (err) { console.warn('[KIOSK|STORE] save cache failed:', err.message); }
|
||||
}
|
||||
|
||||
async function init() {
|
||||
current = await loadCache();
|
||||
if (current) {
|
||||
console.log(`[KIOSK|STORE] cache caricata v=${current.version || '?'} (${current.content?.tiles?.length || 0} tiles)`);
|
||||
} else {
|
||||
console.log('[KIOSK|STORE] nessun layout in cache');
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapFromServer() {
|
||||
const API_URL = process.env.API_URL;
|
||||
if (!API_URL) { console.warn('[KIOSK|STORE] API_URL non configurato'); return; }
|
||||
const sensorId = configManager.getSensorName() || process.env.SENSOR_ID;
|
||||
if (!sensorId) { console.warn('[KIOSK|STORE] SENSOR_ID non configurato'); return; }
|
||||
try {
|
||||
const r = await fetch(`${API_URL}/kiosklayouts/sensor/${encodeURIComponent(sensorId)}/active`, {
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
if (!r.ok) { if (r.status !== 404) console.warn('[KIOSK|STORE] bootstrap', r.status); return; }
|
||||
const layout = await r.json();
|
||||
await applyRemote(layout);
|
||||
} catch (err) {
|
||||
console.warn('[KIOSK|STORE] bootstrap err:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applica un layout (dedup per id+version), salva cache, emette 'update'.
|
||||
*/
|
||||
async function applyRemote(layout) {
|
||||
if (!layout || !layout.content) return false;
|
||||
if (current && current.id === layout.id && current.version === layout.version) return false;
|
||||
current = layout;
|
||||
await saveCache();
|
||||
console.log(`[KIOSK|STORE] applicato v=${layout.version || '?'} (${layout.content.tiles?.length || 0} tiles)`);
|
||||
emitter.emit('update', current);
|
||||
return true;
|
||||
}
|
||||
|
||||
function get() { return current; }
|
||||
function onUpdate(fn) { emitter.on('update', fn); return () => emitter.off('update', fn); }
|
||||
|
||||
module.exports = { init, bootstrapFromServer, applyRemote, get, onUpdate };
|
||||
188
plugin/tools/kiosk/tile-renderer.js
Normal file
188
plugin/tools/kiosk/tile-renderer.js
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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 =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
window.tileRenderer = { register, get };
|
||||
})();
|
||||
Reference in New Issue
Block a user