Ein Digital-Twin-Dashboard stellt eine Live-3D-Szene und die entscheidenden Kennzahlen nebeneinander, sodass Bediener sowohl sehen, wo etwas passiert, als auch wie viel. In diesem Tutorial bauen Sie es als einzelne Webseite: eine Three.js-Szene mit einem Ofen und einem Förderband, KPI-Karten für Durchsatz und Temperatur, ein mitlaufendes Trenddiagramm und eine Alarmliste, alle gespeist von einer MQTT-Verbindung zu broker.connectmq.com. Ein Klick auf eine KPI-Karte fährt die Kamera zur passenden Maschine.
Warum 3D und KPIs kombinieren
Eine 3D-Ansicht allein eignet sich hervorragend zur Orientierung, aber schlecht zum genauen Ablesen; niemand kann 812 Grad von 845 Grad am Orangeton unterscheiden. Ein KPI-Panel allein ist präzise, aber abstrakt; eine Liste von fünfzig Tags sagt einem neuen Schichtleiter nicht, wo das Problem liegt. Ein kombiniertes Digital-Twin-Dashboard gibt jedem Element eine klare Aufgabe:
| Element | Aufgabe | Beantwortet |
|---|---|---|
| 3D-Szene | Räumlicher Kontext, Zustand auf einen Blick | Wo ist es? Welche Maschine? |
| KPI-Karten | Exakte aktuelle Werte im Vergleich zu Zielwerten | Wie viel? Liegt es im Bereich? |
| Trenddiagramm | Jüngster Verlauf | Wird es besser oder schlechter? |
| Alarmliste | Ereignisse, die Handeln erfordern | Was muss ich jetzt tun? |
| Verbindungsanzeige | Vertrauen in die Daten | Ist das live? |
Die wichtigste Designregel lautet: Alle diese Elemente lesen aus einem State Store, der von einer MQTT-Verbindung gespeist wird. Öffnen 3D-Szene und KPI-Karten jeweils eine eigene Verbindung, können sie kurzzeitig voneinander abweichen, und ein Dashboard, das sich selbst widerspricht, verliert schnell das Vertrauen der Bediener. Wie visuelle Zwillinge in das größere Konzept passen, erklärt der Leitfaden zur Digital-Twin-Technologie.
Topics und der Simulator
Das Dashboard nutzt zwei Assets unter einem eindeutigen Präfix (ersetzen Sie dash-7f3a durch Ihr eigenes):
| 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} |
Dieser Node.js-Simulator erzeugt alle drei. Installieren Sie MQTT.js mit npm install mqtt, speichern Sie die Datei als sim.mjs und starten Sie 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);
});
Die Telemetrie ist retained, damit sich das Dashboard beim Laden sofort füllt. Alarmereignisse sind hier nicht retained, weil sie Übergänge und keinen Zustand darstellen; ein Produktivsystem würde meist zusätzlich eine retained Zusammenfassung der „aktiven Alarme“ veröffentlichen.
Schritt 1: Das Dashboard-Layout
Die Seite nutzt ein CSS-Grid: Die 3D-Szene belegt den großen linken Bereich, KPI-Karten und Diagramm stapeln sich rechts, und die Alarmliste verläuft unten. Speichern Sie die Datei als index.html und stellen Sie sie mit npx serve . bereit.
<!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>
Die Media Query stapelt die Panels auf schmalen Bildschirmen, sodass dasselbe Dashboard auch auf einem Tablet in der Fertigung funktioniert.
Schritt 2: Ein Store, viele Ansichten
Legen Sie app.js neben der HTML-Datei an. Das Objekt store hält die neuesten Werte samt Zeitstempeln und benachrichtigt Abonnenten bei Änderungen. Der MQTT-Handler schreibt in den Store; 3D-Szene, Karten, Diagramm und Alarmliste lesen nur daraus.
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);
}
});
// ---------- 3D-Szene ----------
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);
// Klick auf eine KPI-Karte fährt die Kamera zum zugehörigen Asset
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);
});
// ---------- KPI-Karten ----------
const rateSamples = []; // [Zeitstempel, Zähler] der letzten 10 Minuten
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"));
}
}
// ---------- Trenddiagramm ----------
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" } } } },
});
// ---------- Alarmliste ----------
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;
}));
}
// ---------- Verdrahtung ----------
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();
});
// Karten und Diagramm per Timer aktualisieren: flüssig, sparsam, und veraltete Zustände erscheinen auch ohne Nachrichten
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);
Starten Sie den Simulator und öffnen Sie die Seite. Der Ofen glüht entsprechend der Temperatur, das Paket fährt mit Bandgeschwindigkeit, die KPI-Rahmen werden gelb oder rot, wenn Werte abdriften, und das Trenddiagramm scrollt einmal pro Sekunde weiter. Überschwingt der simulierte Ofen, erscheint unten ein Alarm; klicken Sie darauf, und die Kamera gleitet zum Ofen.
Designentscheidungen hinter dem Digital-Twin-Dashboard
Panels per Timer, 3D per Frame. Die 3D-Szene läuft mit der Bildwiederholrate des Displays, weil Bewegung flüssig sein muss. Text und Diagramme werden einmal pro Sekunde aktualisiert, was Augen und CPU schont. DOM-Elemente in jedem Frame zu aktualisieren, ist eine häufige Ursache für träge Dashboards.
Veraltete Daten sind ein vollwertiger Zustand. Jeder Wert trägt einen Zeitstempel. Karten verblassen und das Förderband wird dunkelgrau, wenn keine Daten mehr kommen, sodass ein defektes Gateway nie wie eine gesunde, ruhige Maschine aussieht. Die Schwellwerte sollten widerspiegeln, wie oft jedes Gerät veröffentlicht.
Abgeleitete KPIs aus Rohzählern. Der Simulator veröffentlicht einen kumulativen Stückzähler, und das Dashboard leitet daraus eine Rate über ein gleitendes Zehn-Minuten-Fenster ab. Rohzähler zu veröffentlichen ist robuster als Raten, weil eine verpasste Nachricht das Ergebnis nicht verfälscht. Werksweite Kennzahlen wie OEE berechnen Sie einmal in einem Backend oder Flow, damit jeder Bildschirm dieselbe Zahl zeigt; siehe ein OEE-Dashboard in Node-RED bauen.
Navigation verbindet beide Welten. Ein Klick auf eine KPI-Karte oder einen Alarm bewegt die Kamera zum Asset. Diese kleine Interaktion macht aus zwei Panels auf einer Seite ein einziges Werkzeug.
Checkliste, bevor Sie es in der Fertigung zeigen
- Beschriften Sie auf jeder Karte die Einheiten und halten Sie sie konsistent mit dem Quellsystem.
- Definieren und zeigen Sie Zielwerte oder Grenzen, nicht nur Rohwerte.
- Zeigen Sie den Verbindungsstatus deutlich an und testen Sie, was bei einem Netzwerkausfall passiert.
- Verwenden Sie Retained Messages, damit ein frisch geöffneter Bildschirm innerhalb einer Sekunde vollständig ist; Details erklärt Retained MQTT für Digital Twins.
- Testen Sie auf dem tatsächlichen Bildschirm: Große Wanddisplays brauchen größere Schriften, Tablets das gestapelte Layout.
- Halten Sie die Alarmliste kurz und handlungsrelevant; eine endlose Liste informativer Ereignisse gewöhnt Menschen daran, sie zu ignorieren.
Fehlerbehebung
Der 3D-Bereich ist leer oder gestaucht. Der Renderer bezieht seine Größe über einen ResizeObserver aus der Grid-Zelle. Hat die Zelle keine Höhe, etwa weil einem Elternelement height: 100vh fehlt, schrumpft die Canvas auf null.
KPI-Karten bleiben bei „-“. Die Store-Keys stammen aus der ersten Topic-Ebene nach dem Präfix. Veröffentlichen Ihre Geräte unter einem anderen Präfix oder Asset-Namen, ignoriert der Handler sie; protokollieren Sie topic im Message Handler, um zu vergleichen.
Das Diagramm wird mit der Zeit langsamer. Stellen Sie sicher, dass die Punktbegrenzung greift und Animationen deaktiviert sind; ein Diagramm, das jeden Messwert behält, legt die Seite irgendwann lahm.
Der Durchsatz zeigt in den ersten Minuten null. Die Rate braucht mindestens zwei zeitlich auseinanderliegende Messwerte. Zeigen Sie einen Platzhalter, bis sich das Fenster gefüllt hat, oder initialisieren Sie es aus einem retained Zusammenfassungs-Topic.
Auf mehr Assets skalieren
Fügen Sie Assets nach demselben Muster hinzu: ein Store-Key pro Asset, ein Binding von diesem Key zum 3D-Objekt und eine Kartendefinition. Im realen Werk stammen die Assets meist aus einem Unified Namespace (UNS), dessen Topic-Struktur der ISA-95-Hierarchie folgt, etwa acme/werk-stuttgart/haertenerei/linie-1/ofen-1/telemetry. Das Dashboard abonniert dann den Ast seiner Linie und bildet den Store-Key aus dem Asset-Segment statt aus der ersten Ebene nach dem Präfix; Szene und Karten bleiben unverändert. Bei mehr als einer Handvoll Maschinen generieren Sie die Szenen-Bindings aus einer Konfiguration, wie in IoT-Daten an ein 3D-Modell binden und einen webbasierten 3D Digital Twin bauen beschrieben. Das Tutorial zum Förderband-Digital-Twin zeigt ein detaillierteres Förderband, das Sie in dieses Layout einsetzen können.
Produktions-Setup
Für ein echtes Werk verbinden Sie das Dashboard statt mit dem öffentlichen MQTT-Broker, der nur für Tests und Prototypen gedacht ist, mit einem privaten Broker mit Zugangsdaten, TLS und Isolation. Ein verwalteter MQTT-Broker wie ConnectMQ bietet Secure WebSocket auf Port 8084 mit vertrauenswürdigem Zertifikat; fügen Sie username, password und die im Portal ausgegebene clientId (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID) zu mqtt.connect hinzu. Das Dashboard des Portals, die Geräteansicht nach ISA-95-Ebene (also entlang der Struktur Ihres UNS) und die Schwellwert-Alarmregeln ergänzen ein individuelles Zwillings-Dashboard: Nutzen Sie das Portal für das Monitoring auf Broker-Ebene und Ihren Zwilling für die Bedieneransicht.
Starten Sie mit ConnectMQ
Die 3D-Digital-Twin-Webdemo von ConnectMQ zeigt eine Fabrikszene mit Live-Daten über Secure WebSocket; dieses Tutorial liefert Ihnen die Bausteine, um rund um eine solche Szene Ihr eigenes Digital-Twin-Dashboard zu erstellen. Erstellen Sie einen kostenlosen Broker unter app.connectmq.com im Serverless-Plan, ohne Kreditkarte, und richten Sie das Dashboard darauf aus. Noch nicht so weit? Prototypisieren Sie weiter auf dem kostenlosen öffentlichen MQTT-Broker.
Häufig gestellte Fragen
Sollte ein Digital-Twin-Dashboard KPIs im Browser berechnen?
Einfache, lokale KPIs wie eine gleitende Rate sind im Browser in Ordnung. KPIs, die mit Berichten übereinstimmen müssen, etwa OEE oder Schichtsummen, sollten einmal vorgelagert berechnet und auf einem Topic veröffentlicht werden, damit alle Bildschirme übereinstimmen.
Wie oft sollten KPI-Karten aktualisiert werden?
Etwa einmal pro Sekunde ist ein guter Standardwert. Das wirkt live, ohne zu flackern, und die Prüfungen auf veraltete Daten laufen auch dann, wenn keine Nachrichten eintreffen.
Kann ich eine andere Diagrammbibliothek als Chart.js verwenden?
Ja. Jede Bibliothek, die Punkte effizient anhängen kann, funktioniert. Deaktivieren Sie Animationen für Streaming-Daten und begrenzen Sie die Anzahl der Punkte, damit der Speicherverbrauch konstant bleibt.
Wie halte ich 3D-Szene und KPIs konsistent?
Speisen Sie beide aus einer MQTT-Verbindung und einem State Store. Ansichten lesen aus dem Store; nur der MQTT-Handler schreibt hinein.



