A digital twin dashboard puts a live 3D scene and the numbers that matter side by side, so operators see both where something is happening and how much. In this tutorial you will build one as a single web page: a Three.js scene of a furnace and a conveyor, KPI cards for throughput and temperature, a rolling trend chart and an alarm list, all fed by one MQTT connection to broker.connectmq.com. Clicking a KPI card flies the camera to the matching machine.
Why Combine 3D and KPIs
A 3D view alone is great for orientation but poor for precise reading; nobody can tell 812 degrees from 845 by the shade of orange. A KPI panel alone is precise but abstract; a list of fifty tags does not tell a new shift lead where the problem is. A combined digital twin dashboard gives each element a clear job:
| Element | Job | Answers |
|---|---|---|
| 3D scene | Spatial context, state at a glance | Where is it? Which machine? |
| KPI cards | Exact current values against targets | How much? Is it in range? |
| Trend chart | Recent history | Is it getting better or worse? |
| Alarm list | Events that need action | What do I need to do now? |
| Connection indicator | Trust in the data | Is this live? |
The most important design rule is that all of these read from one state store fed by one MQTT connection. If the 3D scene and the KPI cards each open their own connection, they can disagree for a moment, and a dashboard that contradicts itself loses operator trust quickly. For background on how visual twins fit into the bigger concept, see the digital twin technology guide.
Topics and the Simulator
The dashboard uses two assets under a unique prefix (replace dash-7f3a with your own):
| 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} |
This Node.js simulator produces all three. Install MQTT.js with npm install mqtt, save as sim.mjs and run 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);
});
Telemetry is retained so the dashboard fills in immediately on load. Alarm events are not retained here because they are transitions, not state; a production system would usually also publish a retained "active alarms" summary.
Step 1: The Dashboard Layout
The page uses a CSS grid: the 3D scene takes the large left area, KPI cards and the chart stack on the right, and the alarm list runs along the bottom. Save as index.html and serve it with 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>
The media query stacks the panels on narrow screens, so the same dashboard works on a tablet on the shop floor.
Step 2: One Store, Many Views
Create app.js next to the HTML file. The store object holds the latest values plus timestamps and notifies subscribers when something changes. The MQTT handler writes to the store; the 3D scene, cards, chart and alarm list only read from it.
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 scene ----------
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);
// Click a KPI card to fly to its 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 cards ----------
const rateSamples = []; // [timestamp, count] over the last 10 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"));
}
}
// ---------- Trend chart ----------
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" } } } },
});
// ---------- Alarm list ----------
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;
}));
}
// ---------- Wiring ----------
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();
});
// Cards and chart refresh on a timer: smooth, cheap and stale states appear even without messages
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);
Run the simulator and open the page. The furnace glows according to temperature, the parcel travels at conveyor speed, the KPI borders turn amber or red when values drift, and the trend chart scrolls once per second. When the simulated furnace overshoots, an alarm appears at the bottom; click it and the camera glides to the furnace.
Design Decisions Behind the Digital Twin Dashboard
Timer-driven panels, frame-driven 3D. The 3D scene runs at the display refresh rate because motion must be smooth. Text and charts update once per second, which is easier on the eyes and on the CPU. Updating DOM elements on every frame is a common cause of sluggish dashboards.
Stale data is a first-class state. Each value carries a timestamp. Cards fade and the conveyor turns dark grey when data stops, so a broken gateway never looks like a healthy, quiet machine. Thresholds should reflect how often each device publishes.
Derived KPIs computed from raw counters. The simulator publishes a cumulative part count, and the dashboard derives a rate over a rolling ten-minute window. Publishing raw counters is more robust than publishing rates, because a missed message does not corrupt the result. For plant-wide metrics such as OEE, compute them once in a backend or flow so every screen shows the same number; see building an OEE dashboard in Node-RED for that approach.
Navigation links the two worlds. Clicking a KPI card or alarm moves the camera to the asset. That small interaction is what turns two panels on one page into a single tool.
Checklist Before You Show It on the Shop Floor
- Label units on every card and keep them consistent with the source system.
- Define and display targets or limits, not just raw values.
- Show connection state prominently and test what happens when the network drops.
- Use retained messages so a freshly opened screen is complete within a second; retained MQTT for digital twins explains the details.
- Test on the actual screen: large wall displays need bigger fonts; tablets need the stacked layout.
- Keep the alarm list short and actionable; an endless list of informational events trains people to ignore it.
Troubleshooting
The 3D area is blank or squashed. The renderer sizes itself from the grid cell through a ResizeObserver. If the cell has no height, for example because a parent lacks height: 100vh, the canvas collapses to zero.
KPI cards stay at "-". The store keys come from the first topic level after the prefix. If your devices publish under a different prefix or asset name, the handler ignores them; log topic in the message handler to compare.
The chart grows slower over time. Make sure the point cap is in place and animations are disabled; a chart that keeps every sample will eventually stall the page.
Throughput shows zero for the first minutes. The rate needs at least two samples spread over time. Show a placeholder until the window has filled, or seed it from a retained summary topic.
Scaling to More Assets
Add assets by following the same pattern: a store key per asset, a binding from that key to the 3D object, and a card definition. For more than a handful of machines, generate scene bindings from configuration, as described in binding IoT data to a 3D model and building a web based 3D digital twin. The conveyor digital twin tutorial shows a more detailed conveyor you can drop into this layout.
Production Setup
For a real plant, connect the dashboard to a private broker with credentials, TLS and isolation instead of the public test area. A managed MQTT broker such as ConnectMQ provides secure WebSocket on port 8084 with a trusted certificate; add username, password and the issued clientId from the portal (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID) to mqtt.connect. The portal's own dashboard, devices view by ISA-95 level and threshold alert rules complement a custom twin dashboard: use the portal for broker-level monitoring and your twin for the operator view.
Get Started with ConnectMQ
ConnectMQ's 3D digital twin web demo shows a factory scene with live data over secure WebSocket; this tutorial gives you the building blocks to create your own digital twin dashboard around a scene like that. Create a free broker at app.connectmq.com on the Serverless plan, with no credit card required, and point the dashboard at it. Not ready yet? Keep prototyping on the free public MQTT broker.
Frequently Asked Questions
Should a digital twin dashboard compute KPIs in the browser?
Simple, local KPIs such as a rolling rate are fine in the browser. KPIs that must match reports, such as OEE or shift totals, should be computed once upstream and published to a topic so every screen agrees.
How often should KPI cards update?
About once per second is a good default. It feels live without flicker, and stale-data checks still run when no messages arrive.
Can I use a charting library other than Chart.js?
Yes. Any library that can append points efficiently works. Disable animations for streaming data and cap the number of points to keep memory flat.
How do I keep the 3D scene and the KPIs consistent?
Feed both from one MQTT connection and one state store. Views read from the store; only the MQTT handler writes to it.



