feat(data-only): dashboard web + carte monde
Pipeline web data-only complet : Cote feeds, OSC target :57124 ajoute au profil data-only.toml pour que le bridge.py diffuse vers : SC (57121) + oF (57123) + Web (57124). Cote web (data_only_viz/web/) : - server.js : Express :3211 + WS broadcast + osc UDP :57124 parse les paths /data/<feed>/<sub> en JSON et diffuse - public/dashboard.html : grille de cards live avec sparklines SVG vanilla (USGS, SWPC Kp/wind/Bz/X-ray, Blitz, OpenSky, Bluesky, meteo, air, ISS, volcans, social_buzz, grid) - public/map.html : Leaflet dark fullscreen avec markers ephemeres (60s TTL) pour seismes/foudre/avions/volcans + marker ISS persistant qui suit la position Cote launcher : - ProcessManager : startDataWeb / stopDataWeb + openDataDashboard / openDataMap, port :3211 hardcode, script attendu a metalVizDir/web/server.js - MenuBarContent : nouvelle row 'Dashboard data-only' en mode .dataOnly et .bodyMesh + boutons Dashboard/Carte monde quand le serveur tourne - stopAll inclut dataWebProc
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
Generated
+1273
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "av-live-data-only-web",
|
||||
"version": "0.1.0",
|
||||
"description": "Dashboard live + carte mondiale pour le mode data-only d'AV-Live.",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"osc": "^2.4.4",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0a0a14;
|
||||
color: #d7d7e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", system-ui,
|
||||
sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(180deg, #15152a 0%, #0e0e1f 100%);
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
}
|
||||
header .pill {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 60, 130, 0.18);
|
||||
color: #ff5e95;
|
||||
border: 1px solid rgba(255, 60, 130, 0.4);
|
||||
}
|
||||
header nav { margin-left: auto; }
|
||||
header nav a {
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
margin-left: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
header nav a:hover { color: #fff; }
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
height: calc(100vh - 65px);
|
||||
overflow: auto;
|
||||
}
|
||||
.card {
|
||||
background: #14142a;
|
||||
border: 1px solid #25254a;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 120px;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #8a8aa8;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card .sub {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card.alert { border-color: #ff5e95; }
|
||||
.card.alert .value { color: #ff5e95; }
|
||||
.card.warn .value { color: #ffd84a; }
|
||||
.card.green .value { color: #6fe9b3; }
|
||||
.card .spark {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
}
|
||||
.card.wide { grid-column: span 2; }
|
||||
.card.tall { grid-row: span 2; }
|
||||
.feed-pill {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #1e1e3a;
|
||||
color: #8a8aa8;
|
||||
margin-right: 4px;
|
||||
}
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 6px 16px;
|
||||
background: rgba(10, 10, 20, 0.9);
|
||||
border-top: 1px solid #25254a;
|
||||
font-size: 11px;
|
||||
color: #6a6a8a;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>AV-Live · Data-only Dashboard</title>
|
||||
<link rel="stylesheet" href="css/dashboard.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>AV-Live · Data-only</h1>
|
||||
<span class="pill" id="conn">connexion…</span>
|
||||
<span class="pill" id="rate" style="background:rgba(110,233,179,.15);color:#6fe9b3;border-color:rgba(110,233,179,.4);">0 msg/s</span>
|
||||
<nav>
|
||||
<a href="/dashboard.html">Dashboard</a>
|
||||
<a href="/map.html">Carte monde</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="grid"></main>
|
||||
<footer id="log">en attente du bridge OSC :57124…</footer>
|
||||
<script type="module" src="js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,244 @@
|
||||
// Dashboard live : OSC -> WS -> cards + sparklines.
|
||||
// Aucune dependance externe. Tous les graphes en SVG vanilla.
|
||||
|
||||
const grid = document.getElementById("grid");
|
||||
const connPill = document.getElementById("conn");
|
||||
const ratePill = document.getElementById("rate");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
// ------------------- WebSocket connection -------------------
|
||||
let ws = null;
|
||||
let msgCount = 0;
|
||||
function connect() {
|
||||
const url = (location.protocol === "https:" ? "wss://" : "ws://")
|
||||
+ location.host;
|
||||
ws = new WebSocket(url);
|
||||
ws.onopen = () => {
|
||||
connPill.textContent = "connecte";
|
||||
connPill.style.background = "rgba(110,233,179,.18)";
|
||||
connPill.style.color = "#6fe9b3";
|
||||
connPill.style.borderColor = "rgba(110,233,179,.4)";
|
||||
};
|
||||
ws.onclose = () => {
|
||||
connPill.textContent = "deconnecte";
|
||||
connPill.style.background = "rgba(255,94,149,.18)";
|
||||
connPill.style.color = "#ff5e95";
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
msgCount++;
|
||||
try { handleMessage(JSON.parse(ev.data)); }
|
||||
catch (e) { console.warn("parse error", e); }
|
||||
};
|
||||
}
|
||||
connect();
|
||||
|
||||
// Rate counter
|
||||
setInterval(() => {
|
||||
ratePill.textContent = `${msgCount} msg/s`;
|
||||
msgCount = 0;
|
||||
}, 1000);
|
||||
|
||||
// ------------------- Card registry --------------------------
|
||||
const cards = new Map(); // id -> {el, valueEl, subEl, sparkEl, history}
|
||||
function ensureCard(id, opts = {}) {
|
||||
let c = cards.get(id);
|
||||
if (c) return c;
|
||||
const el = document.createElement("div");
|
||||
el.className = "card";
|
||||
if (opts.wide) el.classList.add("wide");
|
||||
if (opts.alert) el.classList.add("alert");
|
||||
if (opts.warn) el.classList.add("warn");
|
||||
if (opts.green) el.classList.add("green");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = opts.title || id;
|
||||
const valueEl = document.createElement("div");
|
||||
valueEl.className = "value";
|
||||
valueEl.textContent = "—";
|
||||
const subEl = document.createElement("div");
|
||||
subEl.className = "sub";
|
||||
const sparkEl = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg", "svg");
|
||||
sparkEl.setAttribute("class", "spark");
|
||||
sparkEl.setAttribute("preserveAspectRatio", "none");
|
||||
sparkEl.setAttribute("viewBox", "0 0 100 30");
|
||||
el.append(title, valueEl, subEl, sparkEl);
|
||||
grid.appendChild(el);
|
||||
c = { el, valueEl, subEl, sparkEl, history: [], order: opts.order ?? 99 };
|
||||
cards.set(id, c);
|
||||
// Sort children by order
|
||||
[...grid.children]
|
||||
.sort((a, b) => Number(a.dataset.order || 99) - Number(b.dataset.order || 99));
|
||||
el.dataset.order = c.order;
|
||||
return c;
|
||||
}
|
||||
|
||||
function pushHistory(card, v, maxLen = 64) {
|
||||
card.history.push(v);
|
||||
if (card.history.length > maxLen) card.history.shift();
|
||||
}
|
||||
|
||||
function drawSpark(card, color = "#ff5e95") {
|
||||
const hist = card.history;
|
||||
if (hist.length < 2) return;
|
||||
const min = Math.min(...hist);
|
||||
const max = Math.max(...hist);
|
||||
const range = (max - min) || 1;
|
||||
const stepX = 100 / (hist.length - 1);
|
||||
const points = hist.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const y = 28 - ((v - min) / range) * 26;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
card.sparkEl.innerHTML = `<polyline fill="none" stroke="${color}" stroke-width="1.5" points="${points}" />`;
|
||||
}
|
||||
|
||||
function fmt(v, digits = 2) {
|
||||
return (typeof v === "number" ? v.toFixed(digits) : "—");
|
||||
}
|
||||
|
||||
// ------------------- Message handlers -----------------------
|
||||
const handlers = {
|
||||
usgs: (m) => {
|
||||
if (m.sub === "event") {
|
||||
const [, , mag] = m.args;
|
||||
const c = ensureCard("usgs", { title: "USGS · seisme", order: 1, alert: mag >= 5 });
|
||||
c.valueEl.textContent = `M${fmt(mag, 1)}`;
|
||||
c.subEl.textContent = `dernier event • ${new Date(m.t).toLocaleTimeString()}`;
|
||||
pushHistory(c, Number(mag) || 0);
|
||||
drawSpark(c);
|
||||
}
|
||||
},
|
||||
swpc: (m) => {
|
||||
if (m.sub === "kp") {
|
||||
const c = ensureCard("kp", { title: "SWPC · Kp index", order: 5,
|
||||
alert: m.args[0] >= 6, warn: m.args[0] >= 4 });
|
||||
c.valueEl.textContent = fmt(m.args[0], 1);
|
||||
c.subEl.textContent = "geomagnetique 0..9";
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c);
|
||||
} else if (m.sub === "wind") {
|
||||
const c = ensureCard("wind", { title: "SWPC · vent solaire", order: 6 });
|
||||
c.valueEl.textContent = `${fmt(m.args[0], 0)} km/s`;
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c, "#6fe9b3");
|
||||
} else if (m.sub === "bz") {
|
||||
const c = ensureCard("bz", { title: "SWPC · Bz IMF", order: 7,
|
||||
alert: m.args[0] <= -10 });
|
||||
c.valueEl.textContent = `${fmt(m.args[0], 1)} nT`;
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c);
|
||||
} else if (m.sub === "xray") {
|
||||
const c = ensureCard("xray", { title: "SWPC · X-ray flux", order: 8 });
|
||||
c.valueEl.textContent = m.args[0].toExponential(2);
|
||||
pushHistory(c, Math.log10(Math.max(m.args[0], 1e-10)));
|
||||
drawSpark(c, "#ffd84a");
|
||||
}
|
||||
},
|
||||
blitzortung: (m) => {
|
||||
if (m.sub === "strike") {
|
||||
const c = ensureCard("blitz", { title: "Foudre · global", order: 2 });
|
||||
c.valueEl.textContent = `${(c.history.length + 1)}`;
|
||||
c.subEl.textContent = `dernier • ${new Date(m.t).toLocaleTimeString()}`;
|
||||
pushHistory(c, c.history.length + 1, 128);
|
||||
drawSpark(c, "#ffd84a");
|
||||
}
|
||||
},
|
||||
opensky: (m) => {
|
||||
if (m.sub === "count") {
|
||||
const c = ensureCard("opensky", { title: "OpenSky · avions", order: 9 });
|
||||
c.valueEl.textContent = String(m.args[0] | 0);
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c, "#6fe9b3");
|
||||
}
|
||||
},
|
||||
bluesky: (m) => {
|
||||
if (m.sub === "post") {
|
||||
const c = ensureCard("bsky", { title: "Bluesky · firehose", order: 10 });
|
||||
pushHistory(c, c.history.length + 1, 80);
|
||||
c.valueEl.textContent = `${c.history.length}`;
|
||||
c.subEl.textContent = "posts (window)";
|
||||
drawSpark(c, "#6fa6ff");
|
||||
}
|
||||
},
|
||||
openmeteo: (m) => {
|
||||
if (m.sub === "now") {
|
||||
const [t, hum, wspd, , press, rain] = m.args;
|
||||
const c = ensureCard("meteo", { title: "Meteo locale", order: 11, wide: true });
|
||||
c.valueEl.textContent = `${fmt(t, 1)}°C / ${fmt(hum, 0)}% HR`;
|
||||
c.subEl.textContent = `vent ${fmt(wspd, 1)} m/s · ${fmt(press, 0)} hPa · pluie ${fmt(rain, 1)} mm/h`;
|
||||
pushHistory(c, t);
|
||||
drawSpark(c, "#6fa6ff");
|
||||
}
|
||||
},
|
||||
openaq: (m) => {
|
||||
if (m.sub === "now") {
|
||||
const [pm25, pm10, no2, o3] = m.args;
|
||||
const c = ensureCard("air", { title: "Qualite air (μg/m³)", order: 12,
|
||||
wide: true, alert: pm25 > 35, warn: pm25 > 15 });
|
||||
c.valueEl.textContent = `PM2.5 ${fmt(pm25, 0)}`;
|
||||
c.subEl.textContent = `PM10 ${fmt(pm10, 0)} · NO₂ ${fmt(no2, 0)} · O₃ ${fmt(o3, 0)}`;
|
||||
pushHistory(c, pm25);
|
||||
drawSpark(c);
|
||||
}
|
||||
},
|
||||
iss: (m) => {
|
||||
if (m.sub === "pos") {
|
||||
const [lat, lon, alt, vel] = m.args;
|
||||
const c = ensureCard("iss", { title: "ISS · position", order: 13, wide: true });
|
||||
c.valueEl.textContent = `${fmt(lat, 1)}°, ${fmt(lon, 1)}°`;
|
||||
c.subEl.textContent = `alt ${fmt(alt, 0)} km · vel ${fmt(vel, 0)} km/h`;
|
||||
} else if (m.sub === "pass") {
|
||||
const c = ensureCard("iss", { title: "ISS · passage !", order: 13, wide: true, green: true });
|
||||
c.subEl.textContent = `dans le radius • dist ${fmt(m.args[1], 0)} km`;
|
||||
}
|
||||
},
|
||||
volcano: (m) => {
|
||||
if (m.sub === "active") {
|
||||
const c = ensureCard("volcano", { title: "Volcans actifs", order: 14,
|
||||
alert: m.args[0] >= 20 });
|
||||
c.valueEl.textContent = String(m.args[0] | 0);
|
||||
c.subEl.textContent = "eruptions 7 derniers jours";
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c);
|
||||
} else if (m.sub === "eruption") {
|
||||
const c = ensureCard("volcano", { title: "Volcans actifs", order: 14 });
|
||||
c.subEl.textContent = `nouvelle eruption • ${m.args[3] || "region inconnue"}`;
|
||||
}
|
||||
},
|
||||
social_buzz: (m) => {
|
||||
if (m.sub === "pulse") {
|
||||
const c = ensureCard("social", { title: "Pulse social", order: 15 });
|
||||
c.valueEl.textContent = fmt(m.args[0] * 100, 0) + " %";
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c, "#ff5e95");
|
||||
} else if (m.sub === "reddit") {
|
||||
const c = ensureCard("reddit", { title: "Reddit /r/all hot", order: 16 });
|
||||
c.valueEl.textContent = fmt(m.args[0], 0);
|
||||
c.subEl.textContent = `score moyen · ${fmt(m.args[1], 0)} comments`;
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c, "#ff8838");
|
||||
} else if (m.sub === "hn") {
|
||||
const c = ensureCard("hn", { title: "HackerNews top", order: 17 });
|
||||
c.valueEl.textContent = fmt(m.args[0], 0);
|
||||
c.subEl.textContent = `score moyen · ${fmt(m.args[1], 0)} comments`;
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c, "#ff8838");
|
||||
}
|
||||
},
|
||||
netzfrequenz: (m) => {
|
||||
if (m.sub === "freq") {
|
||||
const c = ensureCard("grid", { title: "Reseau EU · 50 Hz", order: 18 });
|
||||
c.valueEl.textContent = `${fmt(m.args[0], 3)} Hz`;
|
||||
c.subEl.textContent = `delta ${fmt((m.args[0] - 50) * 1000, 1)} mHz`;
|
||||
pushHistory(c, m.args[0]);
|
||||
drawSpark(c);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function handleMessage(msg) {
|
||||
logEl.textContent = `${new Date(msg.t).toLocaleTimeString()} /data/${msg.feed}/${msg.sub} ${JSON.stringify(msg.args)}`;
|
||||
const h = handlers[msg.feed];
|
||||
if (h) h(msg);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Carte mondiale fullscreen : seismes, foudre, avions, ISS, volcans.
|
||||
// Markers ephemeres (fade out apres TTL_MS) + ISS persistant.
|
||||
|
||||
const map = L.map("map", { zoomControl: false, attributionControl: false })
|
||||
.setView([20, 0], 2);
|
||||
L.tileLayer(
|
||||
"https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png",
|
||||
{ maxZoom: 12 }
|
||||
).addTo(map);
|
||||
|
||||
const connEl = document.getElementById("conn");
|
||||
const countsEl = document.getElementById("counts");
|
||||
let total = 0;
|
||||
|
||||
// ------------------- WebSocket --------------------------------
|
||||
function connect() {
|
||||
const url = (location.protocol === "https:" ? "wss://" : "ws://")
|
||||
+ location.host;
|
||||
const ws = new WebSocket(url);
|
||||
ws.onopen = () => connEl.textContent = "connecte";
|
||||
ws.onclose = () => {
|
||||
connEl.textContent = "deconnecte";
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try { handleMessage(JSON.parse(ev.data)); }
|
||||
catch (e) { console.warn(e); }
|
||||
};
|
||||
}
|
||||
connect();
|
||||
|
||||
// ------------------- Marker helpers ---------------------------
|
||||
const TTL_MS = 60_000; // markers visibles 60s
|
||||
|
||||
function ephemeralMarker(lat, lon, kind, label) {
|
||||
const icon = L.divIcon({
|
||||
className: "",
|
||||
iconSize: [10, 10],
|
||||
html: `<div class="${kind}" style="width:10px;height:10px"></div>`,
|
||||
});
|
||||
const m = L.marker([lat, lon], { icon }).addTo(map);
|
||||
if (label) m.bindTooltip(label, { direction: "top" });
|
||||
setTimeout(() => map.removeLayer(m), TTL_MS);
|
||||
total += 1;
|
||||
countsEl.textContent = `${total} events`;
|
||||
return m;
|
||||
}
|
||||
|
||||
// ISS : un seul marker persistant qui bouge
|
||||
let issMarker = null;
|
||||
function updateIss(lat, lon) {
|
||||
const icon = L.divIcon({
|
||||
className: "",
|
||||
iconSize: [16, 16],
|
||||
html: `<div class="iss" style="width:16px;height:16px"></div>`,
|
||||
});
|
||||
if (issMarker) issMarker.setLatLng([lat, lon]);
|
||||
else issMarker = L.marker([lat, lon], { icon })
|
||||
.addTo(map)
|
||||
.bindTooltip("ISS", { permanent: true, direction: "top",
|
||||
offset: [0, -8] });
|
||||
}
|
||||
|
||||
// ------------------- Handlers ---------------------------------
|
||||
const handlers = {
|
||||
usgs: (m) => {
|
||||
if (m.sub !== "event") return;
|
||||
const [lat, lon, mag] = m.args;
|
||||
ephemeralMarker(lat, lon, "quake", `M${mag.toFixed(1)} seisme`);
|
||||
},
|
||||
blitzortung: (m) => {
|
||||
if (m.sub !== "strike") return;
|
||||
const [lat, lon] = m.args;
|
||||
ephemeralMarker(lat, lon, "strike", "foudre");
|
||||
},
|
||||
opensky: (m) => {
|
||||
if (m.sub !== "state") return;
|
||||
const [, , lat, lon] = m.args;
|
||||
if (lat && lon) ephemeralMarker(lat, lon, "plane", "vol");
|
||||
},
|
||||
iss: (m) => {
|
||||
if (m.sub === "pos") updateIss(m.args[0], m.args[1]);
|
||||
},
|
||||
volcano: (m) => {
|
||||
if (m.sub !== "eruption") return;
|
||||
const [lat, lon, vei, region] = m.args;
|
||||
ephemeralMarker(lat, lon, "volcano",
|
||||
`volcan ${region} (VEI ${vei})`);
|
||||
},
|
||||
};
|
||||
|
||||
function handleMessage(msg) {
|
||||
const h = handlers[msg.feed];
|
||||
if (h) h(msg);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>AV-Live · Carte monde</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #000; }
|
||||
#map { width: 100vw; height: 100vh; background: #050516; }
|
||||
.hud {
|
||||
position: fixed; top: 14px; left: 14px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
font-family: -apple-system, system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
z-index: 999;
|
||||
}
|
||||
.hud b { color: #ff5e95; }
|
||||
.hud a { color: #aaa; margin-left: 12px; text-decoration: none; }
|
||||
.leaflet-container { background: #050516 !important; }
|
||||
/* Custom marker styles */
|
||||
.quake { background: #ff5e95; border-radius: 50%; }
|
||||
.strike { background: #ffd84a; border-radius: 50%; }
|
||||
.plane { background: #6fe9b3; border-radius: 50%; }
|
||||
.iss { background: #6fa6ff; border-radius: 50%; border: 2px solid #fff; }
|
||||
.volcano { background: #ff8838; border-radius: 50%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<div class="hud">
|
||||
<b id="conn">connexion…</b>
|
||||
· <span id="counts">0 events</span>
|
||||
<a href="/dashboard.html">Dashboard</a>
|
||||
</div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script type="module" src="js/map.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
// =====================================================================
|
||||
// data_only_viz / web / server.js
|
||||
//
|
||||
// Bridge OSC -> WebSocket pour le mode data-only d'AV-Live.
|
||||
//
|
||||
// data_feeds bridge.py
|
||||
// | OSC UDP :57124
|
||||
// v
|
||||
// server.js (Express :3211 + WS)
|
||||
// | JSON via WebSocket
|
||||
// v
|
||||
// browser : dashboard.html / map.html
|
||||
//
|
||||
// Conversion : chaque message OSC /data/<feed>/<sub> args... devient
|
||||
// { feed, sub, args, t }
|
||||
// diffuse a tous les clients WS connectes.
|
||||
// =====================================================================
|
||||
import express from "express";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import osc from "osc";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3211", 10);
|
||||
const OSC_PORT_IN = parseInt(process.env.OSC_PORT_IN ?? "57124", 10);
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(join(__dirname, "public")));
|
||||
app.get("/", (_req, res) => res.redirect("/dashboard.html"));
|
||||
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
// Ring buffer for late-joiners : ~500 last messages
|
||||
const HISTORY_MAX = 500;
|
||||
const history = [];
|
||||
function record(msg) {
|
||||
history.push(msg);
|
||||
if (history.length > HISTORY_MAX) history.shift();
|
||||
}
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
// Replay recent history so a new dashboard sees the latest values
|
||||
for (const msg of history.slice(-100)) {
|
||||
try { ws.send(JSON.stringify(msg)); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
function broadcast(msg) {
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const c of wss.clients) {
|
||||
if (c.readyState === 1) {
|
||||
try { c.send(payload); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OSC UDP listener
|
||||
const udp = new osc.UDPPort({
|
||||
localAddress: "127.0.0.1",
|
||||
localPort: OSC_PORT_IN,
|
||||
metadata: false,
|
||||
});
|
||||
|
||||
udp.on("message", (m) => {
|
||||
// Path format : /data/<feed>/<sub>
|
||||
const parts = (m.address || "").split("/").filter(Boolean);
|
||||
if (parts.length < 2 || parts[0] !== "data") return;
|
||||
const feed = parts[1];
|
||||
const sub = parts.slice(2).join("/") || "msg";
|
||||
const msg = {
|
||||
t: Date.now(),
|
||||
feed,
|
||||
sub,
|
||||
args: Array.isArray(m.args) ? m.args : [],
|
||||
};
|
||||
record(msg);
|
||||
broadcast(msg);
|
||||
});
|
||||
|
||||
udp.on("error", (e) => console.error("OSC error:", e));
|
||||
udp.open();
|
||||
console.log(`OSC listening on udp :${OSC_PORT_IN}`);
|
||||
|
||||
httpServer.listen(HTTP_PORT, () => {
|
||||
console.log(`Data-only web on http://127.0.0.1:${HTTP_PORT}/`);
|
||||
console.log(` Dashboard : http://127.0.0.1:${HTTP_PORT}/dashboard.html`);
|
||||
console.log(` Map : http://127.0.0.1:${HTTP_PORT}/map.html`);
|
||||
});
|
||||
@@ -118,6 +118,31 @@ struct MenuBarContent: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Dashboard web data-only (Express :3211, dashboard + carte
|
||||
// monde live OSC). Visible en dataOnly + bodyMesh.
|
||||
if processManager.mode == .dataOnly
|
||||
|| processManager.mode == .bodyMesh {
|
||||
ProcessRow(
|
||||
title: "Dashboard data-only",
|
||||
subtitle: "Express :\(processManager.dataWebPort) — dashboard + carte monde",
|
||||
isRunning: processManager.dataWebRunning,
|
||||
start: processManager.startDataWeb,
|
||||
stop: processManager.stopDataWeb
|
||||
)
|
||||
if processManager.dataWebRunning {
|
||||
HStack {
|
||||
Button(action: processManager.openDataDashboard) {
|
||||
Label("Dashboard", systemImage: "gauge")
|
||||
}
|
||||
Button(action: processManager.openDataMap) {
|
||||
Label("Carte monde", systemImage: "globe")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if processManager.mode == .full {
|
||||
HStack {
|
||||
Button(action: processManager.openBrowser) {
|
||||
|
||||
@@ -291,6 +291,73 @@ final class ProcessManager: ObservableObject {
|
||||
|
||||
private var webWantsRestart = false
|
||||
|
||||
// ----------------- Web data-only dashboard ------------------------
|
||||
|
||||
private var dataWebProc: Process?
|
||||
@Published var dataWebRunning = false
|
||||
let dataWebPort: Int = 3211
|
||||
|
||||
/// Lance le dashboard web data-only (Express + WS bridge sur :3211,
|
||||
/// ecoute OSC UDP :57124). Necessite node + le dossier
|
||||
/// `data_only_viz/web/`.
|
||||
func startDataWeb() {
|
||||
guard dataWebProc == nil else { return }
|
||||
guard FileManager.default.isExecutableFile(atPath: nodePath) else {
|
||||
append(source: "launcher", text: "node not found at \(nodePath)")
|
||||
return
|
||||
}
|
||||
let script = URL(fileURLWithPath: metalVizDir)
|
||||
.appendingPathComponent("web/server.js").path
|
||||
guard FileManager.default.fileExists(atPath: script) else {
|
||||
append(source: "launcher",
|
||||
text: "data-only web server.js manquant a \(script)")
|
||||
return
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: nodePath)
|
||||
p.arguments = [script]
|
||||
p.currentDirectoryURL = URL(fileURLWithPath: script)
|
||||
.deletingLastPathComponent()
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
|
||||
+ ":/usr/local/bin:/opt/homebrew/bin"
|
||||
env["HTTP_PORT"] = String(dataWebPort)
|
||||
p.environment = env
|
||||
attach(process: p, label: "dataweb")
|
||||
do {
|
||||
try p.run()
|
||||
dataWebProc = p
|
||||
DispatchQueue.main.async { self.dataWebRunning = true }
|
||||
p.terminationHandler = { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.dataWebProc = nil
|
||||
self?.dataWebRunning = false
|
||||
}
|
||||
}
|
||||
append(source: "launcher",
|
||||
text: "started data-only web on :\(dataWebPort)")
|
||||
} catch {
|
||||
append(source: "launcher",
|
||||
text: "data-only web start failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stopDataWeb() {
|
||||
dataWebProc?.terminate()
|
||||
}
|
||||
|
||||
func openDataDashboard() {
|
||||
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/dashboard.html") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func openDataMap() {
|
||||
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/map.html") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func restartWeb() {
|
||||
guard webProc != nil else { startWeb(); return }
|
||||
append(source: "launcher", text: "restarting web server…")
|
||||
@@ -737,6 +804,7 @@ final class ProcessManager: ObservableObject {
|
||||
dataFeedsProc?.terminate()
|
||||
metalVizProc?.terminate()
|
||||
bodyAppProc?.terminate()
|
||||
dataWebProc?.terminate()
|
||||
}
|
||||
|
||||
func clearLogs() {
|
||||
|
||||
Reference in New Issue
Block a user