Un tableau de bord de jumeau numérique place côte à côte une scène 3D en direct et les chiffres qui comptent, pour que les opérateurs voient à la fois où quelque chose se passe et dans quelle mesure. Dans ce tutoriel, vous allez en construire un sous forme d'une seule page web : une scène Three.js avec un four et un convoyeur, des cartes KPI pour le débit et la température, une courbe de tendance glissante et une liste d'alarmes, le tout alimenté par une seule connexion MQTT au serveur MQTT public gratuit broker.connectmq.com. Un clic sur une carte KPI déplace la caméra vers la machine correspondante.
Pourquoi associer 3D et KPI
Une vue 3D seule est idéale pour s'orienter, mais médiocre pour une lecture précise : personne ne distingue 812 degrés de 845 à la nuance d'orange. Un panneau de KPI seul est précis mais abstrait : une liste de cinquante variables n'indique pas à un nouveau chef d'équipe où se situe le problème. Un tableau de bord de jumeau numérique combiné attribue à chaque élément un rôle clair :
| Élément | Rôle | Répond à |
|---|---|---|
| Scène 3D | Contexte spatial, état en un coup d'œil | Où ? Quelle machine ? |
| Cartes KPI | Valeurs actuelles exactes par rapport aux objectifs | Combien ? Est-ce dans la plage ? |
| Courbe de tendance | Historique récent | Cela s'améliore ou se dégrade ? |
| Liste d'alarmes | Événements qui demandent une action | Que dois-je faire maintenant ? |
| Indicateur de connexion | Confiance dans les données | Est-ce bien en direct ? |
La règle de conception la plus importante : tous ces éléments lisent un seul store d'état alimenté par une seule connexion MQTT. Si la scène 3D et les cartes KPI ouvrent chacune leur propre connexion, elles peuvent se contredire un instant, et un tableau de bord qui se contredit perd vite la confiance des opérateurs. Pour comprendre comment les jumeaux visuels s'inscrivent dans le concept plus large, consultez le guide de la technologie du jumeau numérique.
Topics et simulateur
Le tableau de bord utilise deux équipements sous un préfixe unique (remplacez dash-7f3a par le vôtre) :
| Topic | Payload |
|---|---|
connectmq/demo/dash-7f3a/furnace1/telemetry |
{"temperature":842.5,"setpoint":850} |
connectmq/demo/dash-7f3a/conv1/telemetry |
{"speed":0.8,"count":10234,"running":true} |
connectmq/demo/dash-7f3a/alarms |
{"id":"F1-HI","asset":"furnace1","text":"Furnace over temperature","active":true} |
Ce simulateur Node.js produit les trois. Installez MQTT.js avec npm install mqtt, enregistrez le fichier sous sim.mjs et lancez node sim.mjs.
import mqtt from "mqtt";
const P = "connectmq/demo/dash-7f3a";
const c = mqtt.connect("mqtts://broker.connectmq.com:8883", { clientId: "dash-sim-7f3a", protocolVersion: 5 });
let temp = 820, count = 10000, t = 0, alarmActive = false;
c.on("connect", () => {
console.log("simulator connected");
setInterval(() => {
t++;
temp += (850 - temp) * 0.05 + (Math.random() - 0.5) * 6 + (t % 120 > 100 ? 4 : 0);
const running = t % 90 < 80;
if (running) count += Math.random() < 0.6 ? 1 : 0;
c.publish(`${P}/furnace1/telemetry`, JSON.stringify({ temperature: +temp.toFixed(1), setpoint: 850 }), { retain: true });
c.publish(`${P}/conv1/telemetry`, JSON.stringify({ speed: running ? 0.8 : 0, count, running }), { retain: true });
const shouldAlarm = temp > 870;
if (shouldAlarm !== alarmActive) {
alarmActive = shouldAlarm;
c.publish(`${P}/alarms`, JSON.stringify({
id: "F1-HI", asset: "furnace1", text: "Furnace over temperature", active: alarmActive, ts: Date.now(),
}), { qos: 1 });
}
}, 1000);
});
La télémétrie est retenue pour que le tableau de bord se remplisse immédiatement au chargement. Les événements d'alarme ne sont pas retenus ici, car ce sont des transitions et non un état ; un système de production publierait généralement aussi un récapitulatif retenu des « alarmes actives ».
Étape 1 : la mise en page du tableau de bord
La page utilise une grille CSS : la scène 3D occupe la grande zone de gauche, les cartes KPI et le graphique s'empilent à droite, et la liste d'alarmes court en bas. Enregistrez sous index.html et servez avec npx serve ..
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Digital Twin Dashboard</title>
<style>
:root { --bg: #0b1220; --panel: #131c2e; --text: #e5e7eb; --muted: #94a3b8; --ok: #22c55e; --warn: #f59e0b; --bad: #ef4444; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px system-ui, sans-serif; }
.grid { display: grid; height: 100vh; gap: 10px; padding: 10px;
grid-template-columns: 1fr 340px; grid-template-rows: 1fr 160px;
grid-template-areas: "scene side" "alarms side"; }
#scene { grid-area: scene; background: var(--panel); border-radius: 8px; overflow: hidden; position: relative; }
#side { grid-area: side; display: flex; flex-direction: column; gap: 10px; }
#alarms { grid-area: alarms; background: var(--panel); border-radius: 8px; padding: 10px; overflow: auto; }
.card { background: var(--panel); border-radius: 8px; padding: 12px; cursor: pointer; border-left: 4px solid var(--muted); }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.card .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
.card.ok { border-color: var(--ok); } .card.warn { border-color: var(--warn); } .card.bad { border-color: var(--bad); }
.stale .value { opacity: .4; }
#conn { position: absolute; top: 10px; right: 10px; padding: 4px 8px; border-radius: 4px; background: #000a; }
@media (max-width: 800px) {
.grid { grid-template-columns: 1fr; grid-template-rows: 50vh auto 200px; grid-template-areas: "scene" "side" "alarms"; height: auto; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"mqtt": "https://unpkg.com/mqtt@5.10.1/dist/mqtt.esm.js"
} }
</script>
</head>
<body>
<div class="grid">
<div id="scene"><span id="conn">Connecting...</span></div>
<div id="side">
<div class="card" id="kpi-temp" data-asset="furnace1"><div class="label">Furnace temperature</div><div class="value">-</div></div>
<div class="card" id="kpi-rate" data-asset="conv1"><div class="label">Throughput (parts/h)</div><div class="value">-</div></div>
<div class="card" id="kpi-state" data-asset="conv1"><div class="label">Conveyor</div><div class="value">-</div></div>
<div class="card" style="cursor:default;flex:1"><canvas id="trend"></canvas></div>
</div>
<div id="alarms"><strong>Alarms</strong><ul id="alarm-list"></ul></div>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
La media query empile les panneaux sur les écrans étroits : le même tableau de bord fonctionne ainsi sur une tablette dans l'atelier.
Étape 2 : un store, plusieurs vues
Créez app.js à côté du fichier HTML. L'objet store contient les dernières valeurs avec leurs horodatages et prévient les abonnés quand quelque chose change. Le handler MQTT écrit dans le store ; la scène 3D, les cartes, le graphique et la liste d'alarmes ne font que le lire.
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import mqtt from "mqtt";
const P = "connectmq/demo/dash-7f3a";
// ---------- Store ----------
const store = {
furnace1: null, conv1: null, alarms: new Map(), at: {}, listeners: new Set(),
set(key, value) { this[key] = value; this.at[key] = Date.now(); this.listeners.forEach((fn) => fn(key)); },
isStale(key, ms = 5000) { return !this.at[key] || Date.now() - this.at[key] > ms; },
};
// ---------- MQTT ----------
const conn = document.getElementById("conn");
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "dash-" + crypto.randomUUID().slice(0, 8),
protocolVersion: 5,
reconnectPeriod: 2000,
});
client.on("connect", () => { conn.textContent = "Live"; client.subscribe(`${P}/#`, { qos: 1 }); });
client.on("close", () => (conn.textContent = "Disconnected"));
client.on("message", (topic, payload) => {
let d;
try { d = JSON.parse(new TextDecoder().decode(payload)); } catch { return; }
const key = topic.slice(P.length + 1).split("/")[0];
if (key === "alarms") {
d.active ? store.alarms.set(d.id, d) : store.alarms.delete(d.id);
store.set("alarms", store.alarms);
} else if (key === "furnace1" || key === "conv1") {
store.set(key, d);
}
});
// ---------- Scène 3D ----------
const host = document.getElementById("scene");
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
host.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x131c2e);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(6, 6, 10);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 1.3), new THREE.GridHelper(20, 20, 0x334155, 0x1e293b));
const furnaceMat = new THREE.MeshStandardMaterial({ color: 0x3f3f46, emissive: 0xff5a1f, emissiveIntensity: 0 });
const furnace = new THREE.Mesh(new THREE.BoxGeometry(2.5, 2.5, 2.5), furnaceMat);
furnace.position.set(-3, 1.25, 0);
const convMat = new THREE.MeshStandardMaterial({ color: 0x64748b });
const conveyor = new THREE.Mesh(new THREE.BoxGeometry(6, 0.3, 1.2), convMat);
conveyor.position.set(2.5, 0.8, 0);
const parcel = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.4, 0.5), new THREE.MeshStandardMaterial({ color: 0xc08a4a }));
parcel.position.set(0, 1.15, 0);
scene.add(furnace, conveyor, parcel);
const assets = { furnace1: furnace, conv1: conveyor };
function resize() {
const { clientWidth: w, clientHeight: h } = host;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(host);
// Cliquer sur une carte KPI pour voler jusqu'à son équipement
let flyTarget = null;
document.querySelectorAll(".card[data-asset]").forEach((card) =>
card.addEventListener("click", () => (flyTarget = assets[card.dataset.asset].position.clone())));
const clock = new THREE.Clock();
let glow = 0;
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
const f = store.furnace1, c = store.conv1;
const targetGlow = f && !store.isStale("furnace1") ? THREE.MathUtils.clamp((f.temperature - 600) / 300, 0, 1) : 0;
glow += (targetGlow - glow) * (1 - Math.exp(-dt * 3));
furnaceMat.emissiveIntensity = glow * 1.5;
const speed = c && !store.isStale("conv1") ? c.speed : 0;
parcel.position.x = ((parcel.position.x + speed * dt + 0.5) % 6) - 0.5;
convMat.color.setHex(store.isStale("conv1") ? 0x374151 : c?.running ? 0x64748b : 0xf59e0b);
if (flyTarget) {
controls.target.lerp(flyTarget, 1 - Math.exp(-dt * 4));
if (controls.target.distanceTo(flyTarget) < 0.01) flyTarget = null;
}
controls.update();
renderer.render(scene, camera);
});
// ---------- Cartes KPI ----------
const rateSamples = []; // [horodatage, compteur] sur les 10 dernières minutes
function setCard(id, text, level, stale) {
const el = document.getElementById(id);
el.querySelector(".value").textContent = text;
el.className = `card ${level}${stale ? " stale" : ""}`;
}
function renderCards() {
const f = store.furnace1, c = store.conv1;
if (f) {
const dev = Math.abs(f.temperature - f.setpoint);
setCard("kpi-temp", `${f.temperature.toFixed(0)} C`, dev < 15 ? "ok" : dev < 30 ? "warn" : "bad", store.isStale("furnace1"));
}
if (c) {
const first = rateSamples[0], last = rateSamples[rateSamples.length - 1];
const perHour = first && last && last[0] > first[0] ? ((last[1] - first[1]) / (last[0] - first[0])) * 3_600_000 : 0;
setCard("kpi-rate", Math.round(perHour).toLocaleString(), perHour > 1500 ? "ok" : "warn", store.isStale("conv1"));
setCard("kpi-state", c.running ? "Running" : "Stopped", c.running ? "ok" : "warn", store.isStale("conv1"));
}
}
// ---------- Courbe de tendance ----------
const chart = new Chart(document.getElementById("trend"), {
type: "line",
data: { labels: [], datasets: [
{ label: "Temperature", data: [], borderColor: "#f97316", pointRadius: 0, tension: 0.3 },
{ label: "Setpoint", data: [], borderColor: "#94a3b8", borderDash: [4, 4], pointRadius: 0 },
] },
options: { animation: false, maintainAspectRatio: false, scales: { x: { display: false } },
plugins: { legend: { labels: { color: "#e5e7eb" } } } },
});
// ---------- Liste d'alarmes ----------
function renderAlarms() {
const ul = document.getElementById("alarm-list");
ul.replaceChildren(...[...store.alarms.values()].map((a) => {
const li = document.createElement("li");
li.textContent = `${new Date(a.ts).toLocaleTimeString()} ${a.text}`;
li.style.cursor = "pointer";
li.onclick = () => (flyTarget = assets[a.asset]?.position.clone() ?? null);
return li;
}));
}
// ---------- Câblage ----------
store.listeners.add((key) => {
if (key === "conv1") {
rateSamples.push([Date.now(), store.conv1.count]);
while (rateSamples.length && Date.now() - rateSamples[0][0] > 600_000) rateSamples.shift();
}
if (key === "alarms") renderAlarms();
});
// Cartes et graphique rafraîchis par minuterie : fluide, peu coûteux, et les états obsolètes apparaissent même sans message
setInterval(() => {
renderCards();
const f = store.furnace1;
if (!f) return;
const d = chart.data;
d.labels.push("");
d.datasets[0].data.push(f.temperature);
d.datasets[1].data.push(f.setpoint);
if (d.labels.length > 120) { d.labels.shift(); d.datasets.forEach((s) => s.data.shift()); }
chart.update("none");
}, 1000);
Lancez le simulateur et ouvrez la page. Le four rougeoie selon la température, le colis avance à la vitesse du convoyeur, les bordures des KPI passent à l'orange ou au rouge quand les valeurs dérivent, et la courbe de tendance défile chaque seconde. Quand le four simulé dépasse sa consigne, une alarme apparaît en bas ; cliquez dessus et la caméra glisse jusqu'au four.
Les choix de conception derrière le tableau de bord de jumeau numérique
Panneaux pilotés par minuterie, 3D pilotée par image. La scène 3D tourne à la fréquence de rafraîchissement de l'écran, car le mouvement doit être fluide. Le texte et les graphiques se mettent à jour une fois par seconde, ce qui ménage les yeux comme le processeur. Mettre à jour des éléments DOM à chaque image est une cause fréquente de tableaux de bord poussifs.
Les données obsolètes sont un état à part entière. Chaque valeur porte un horodatage. Les cartes s'estompent et le convoyeur passe au gris foncé quand les données s'arrêtent : une passerelle en panne ne ressemble jamais à une machine saine et calme. Les seuils doivent refléter la fréquence de publication de chaque appareil.
Des KPI dérivés calculés à partir de compteurs bruts. Le simulateur publie un compteur de pièces cumulé, et le tableau de bord en déduit un débit sur une fenêtre glissante de dix minutes. Publier des compteurs bruts est plus robuste que publier des débits, car un message manqué ne fausse pas le résultat. Pour les indicateurs à l'échelle de l'usine comme le TRS (OEE), calculez-les une seule fois dans un backend ou un flux afin que chaque écran affiche le même chiffre ; consultez créer un tableau de bord OEE dans Node-RED pour cette approche.
La navigation relie les deux mondes. Un clic sur une carte KPI ou une alarme déplace la caméra vers l'équipement. C'est cette petite interaction qui transforme deux panneaux sur une même page en un seul outil.
Check-list avant de l'afficher dans l'atelier
- Indiquez les unités sur chaque carte et gardez-les cohérentes avec le système source.
- Définissez et affichez des objectifs ou des limites, pas seulement des valeurs brutes.
- Affichez l'état de la connexion de manière visible et testez ce qui se passe quand le réseau tombe.
- Utilisez des messages retenus pour qu'un écran fraîchement ouvert soit complet en moins d'une seconde ; les messages MQTT retenus pour les jumeaux numériques en détaille le fonctionnement.
- Testez sur l'écran réel : les grands écrans muraux demandent des polices plus grandes ; les tablettes ont besoin de la mise en page empilée.
- Gardez une liste d'alarmes courte et exploitable ; une liste interminable d'événements informatifs apprend aux gens à l'ignorer.
Dépannage
La zone 3D est vide ou écrasée. Le renderer se dimensionne à partir de la cellule de la grille via un ResizeObserver. Si la cellule n'a pas de hauteur, par exemple parce qu'un parent n'a pas height: 100vh, le canvas tombe à zéro.
Les cartes KPI restent sur « - ». Les clés du store proviennent du premier niveau de topic après le préfixe. Si vos appareils publient sous un autre préfixe ou un autre nom d'équipement, le handler les ignore ; journalisez topic dans le handler de messages pour comparer.
Le graphique ralentit avec le temps. Vérifiez que le plafond de points est en place et que les animations sont désactivées ; un graphique qui conserve chaque échantillon finira par bloquer la page.
Le débit affiche zéro pendant les premières minutes. Le calcul du débit exige au moins deux échantillons espacés dans le temps. Affichez un indicateur provisoire jusqu'à ce que la fenêtre soit remplie, ou initialisez-la à partir d'un topic récapitulatif retenu.
Passer à davantage d'équipements
Ajoutez des équipements en suivant le même schéma : une clé de store par équipement, une liaison entre cette clé et l'objet 3D, et une définition de carte. Au-delà de quelques machines, générez les liaisons de la scène à partir d'une configuration, comme décrit dans lier des données IoT à un modèle 3D et créer un jumeau numérique 3D web. Le tutoriel jumeau numérique de convoyeur présente un convoyeur plus détaillé que vous pouvez intégrer à cette mise en page.
Configuration de production
Pour une vraie usine, connectez le tableau de bord à un broker privé avec identifiants, TLS et isolation au lieu du serveur MQTT public gratuit, réservé aux tests et aux prototypes. Un broker MQTT managé comme ConnectMQ fournit un WebSocket sécurisé sur le port 8084 avec un certificat reconnu ; ajoutez à mqtt.connect le username, le password et le clientId délivrés par le portail (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID). Le tableau de bord du portail, sa vue des appareils par niveau ISA-95 et ses règles d'alerte à seuil complètent un tableau de bord de jumeau sur mesure : utilisez le portail pour la supervision du broker et votre jumeau pour la vue opérateur.
Démarrer avec ConnectMQ
La démo web de jumeau numérique 3D de ConnectMQ présente une scène d'usine avec des données en direct via WebSocket sécurisé ; ce tutoriel vous donne les briques pour construire votre propre tableau de bord de jumeau numérique autour d'une scène de ce type. Créez un broker gratuit sur app.connectmq.com avec le plan Serverless, sans carte bancaire, et faites pointer le tableau de bord vers lui. Pas encore prêt ? Continuez à prototyper sur le serveur MQTT public gratuit.
Questions fréquentes
Un tableau de bord de jumeau numérique doit-il calculer les KPI dans le navigateur ?
Les KPI simples et locaux, comme un débit glissant, peuvent être calculés dans le navigateur. Ceux qui doivent correspondre aux rapports, comme le TRS ou les totaux par poste, doivent être calculés une seule fois en amont et publiés sur un topic pour que tous les écrans concordent.
À quelle fréquence les cartes KPI doivent-elles se mettre à jour ?
Environ une fois par seconde est un bon réglage par défaut. L'affichage paraît vivant sans scintiller, et les contrôles de données obsolètes s'exécutent même quand aucun message n'arrive.
Peut-on utiliser une autre bibliothèque de graphiques que Chart.js ?
Oui. Toute bibliothèque capable d'ajouter des points efficacement convient. Désactivez les animations pour les données en flux et plafonnez le nombre de points pour stabiliser la mémoire.
Comment garder la scène 3D et les KPI cohérents ?
Alimentez les deux avec une seule connexion MQTT et un seul store d'état. Les vues lisent le store ; seul le handler MQTT y écrit.



