Gêmeo digital

Dashboard de gêmeo digital: combinando uma cena 3D com KPIs ao vivo

Crie um dashboard web de gêmeo digital em que uma cena Three.js, cards de KPI, um gráfico de tendência e uma lista de alarmes compartilham uma única conexão MQTT e um único store de estado, com navegação por clique e tratamento de dados desatualizados.

Dashboard de gêmeo digital com uma cena 3D de fábrica ao lado de cards de KPI, um gráfico de tendência de temperatura e uma lista de alarmes
Rode isso no seu próprio broker MQTTTenha um broker MQTT privado e gerenciado no plano Free e comece com exemplos de código prontos.
Teste grátis Exemplos de código Sem cartão de crédito

Um dashboard de gêmeo digital coloca lado a lado uma cena 3D ao vivo e os números que importam, para que os operadores vejam tanto onde algo está acontecendo quanto em que medida. Neste tutorial, você vai construir um em uma única página web: uma cena Three.js com um forno e uma esteira, cards de KPI para produção e temperatura, um gráfico de tendência contínuo e uma lista de alarmes, tudo alimentado por uma única conexão MQTT com o servidor MQTT público broker.connectmq.com, adequado para testes e protótipos, não para dados de produção. Clicar em um card de KPI leva a câmera até a máquina correspondente.

Por que combinar 3D e KPIs

Uma visualização 3D sozinha é ótima para orientação, mas ruim para leitura precisa; ninguém distingue 812 graus de 845 pelo tom de laranja. Um painel de KPIs sozinho é preciso, mas abstrato; uma lista de cinquenta tags não diz a um novo líder de turno onde está o problema. Um dashboard de gêmeo digital combinado dá a cada elemento uma função clara:

Elemento Função Responde
Cena 3D Contexto espacial, estado de relance Onde está? Qual máquina?
Cards de KPI Valores atuais exatos em relação às metas Quanto? Está dentro da faixa?
Gráfico de tendência Histórico recente Está melhorando ou piorando?
Lista de alarmes Eventos que exigem ação O que preciso fazer agora?
Indicador de conexão Confiança nos dados Isto está ao vivo?

A regra de design mais importante é que todos eles leiam de um único store de estado alimentado por uma única conexão MQTT. Se a cena 3D e os cards de KPI abrirem cada um sua própria conexão, eles podem divergir por um instante, e um dashboard que se contradiz perde rapidamente a confiança do operador. Para entender como os gêmeos visuais se encaixam no conceito mais amplo, consulte o guia de gêmeo digital indicado na introdução.

Topics e o simulador

O dashboard usa dois ativos sob um prefixo único (substitua dash-7f3a pelo seu):

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}

Este simulador em Node.js produz os três. Instale o MQTT.js com npm install mqtt, salve como sim.mjs e execute 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);
});

A telemetria é publicada como retained para que o dashboard seja preenchido imediatamente ao carregar. Os eventos de alarme não são retained aqui porque são transições, não estado; um sistema em produção normalmente também publicaria um resumo retained de "alarmes ativos".

Passo 1: o layout do dashboard

A página usa um grid CSS: a cena 3D ocupa a grande área à esquerda, os cards de KPI e o gráfico ficam empilhados à direita, e a lista de alarmes corre ao longo da parte inferior. Salve como index.html e sirva com 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>

A media query empilha os painéis em telas estreitas, de modo que o mesmo dashboard funcione em um tablet no chão de fábrica.

Passo 2: um store, várias visualizações

Crie app.js ao lado do arquivo HTML. O objeto store guarda os valores mais recentes com seus timestamps e notifica os assinantes quando algo muda. O handler MQTT escreve no store; a cena 3D, os cards, o gráfico e a lista de alarmes apenas leem dele.

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);
  }
});

// ---------- Cena 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);

// Clique em um card de KPI para voar até o ativo correspondente
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);
});

// ---------- Cards de KPI ----------
const rateSamples = []; // [timestamp, contagem] nos últimos 10 minutos
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"));
  }
}

// ---------- Gráfico de tendência ----------
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" } } } },
});

// ---------- Lista de 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;
  }));
}

// ---------- Ligações ----------
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 e gráfico atualizam por timer: suave, barato, e estados desatualizados aparecem mesmo sem mensagens
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);

Execute o simulador e abra a página. O forno brilha de acordo com a temperatura, a caixa avança na velocidade da esteira, as bordas dos KPIs ficam âmbar ou vermelhas quando os valores se desviam e o gráfico de tendência rola uma vez por segundo. Quando o forno simulado ultrapassa o limite, um alarme aparece na parte inferior; clique nele e a câmera desliza até o forno.

Decisões de design por trás do dashboard de gêmeo digital

Painéis atualizados por timer, 3D atualizado por quadro. A cena 3D roda na taxa de atualização da tela porque o movimento precisa ser suave. Textos e gráficos atualizam uma vez por segundo, o que é mais confortável para os olhos e para a CPU. Atualizar elementos do DOM a cada quadro é uma causa comum de dashboards lentos.

Dados desatualizados são um estado de primeira classe. Cada valor carrega um timestamp. Os cards esmaecem e a esteira fica cinza-escura quando os dados param, então um gateway com defeito nunca parece uma máquina saudável e silenciosa. Os limites devem refletir a frequência com que cada dispositivo publica.

KPIs derivados calculados a partir de contadores brutos. O simulador publica uma contagem acumulada de peças, e o dashboard deriva uma taxa em uma janela móvel de dez minutos. Publicar contadores brutos é mais robusto do que publicar taxas, porque uma mensagem perdida não corrompe o resultado. Para métricas de toda a planta, como o OEE, calcule-as uma única vez em um backend ou fluxo, para que todas as telas mostrem o mesmo número; veja como construir um dashboard de OEE no Node-RED para essa abordagem.

A navegação conecta os dois mundos. Clicar em um card de KPI ou em um alarme move a câmera até o ativo. Essa pequena interação é o que transforma dois painéis em uma página em uma única ferramenta.

Checklist antes de levar ao chão de fábrica

  1. Identifique as unidades em cada card e mantenha-as consistentes com o sistema de origem.
  2. Defina e exiba metas ou limites, não apenas valores brutos.
  3. Mostre o estado da conexão com destaque e teste o que acontece quando a rede cai.
  4. Use retained messages para que uma tela recém-aberta fique completa em menos de um segundo; MQTT retained para gêmeos digitais explica os detalhes.
  5. Teste na tela real: grandes painéis de parede precisam de fontes maiores; tablets precisam do layout empilhado.
  6. Mantenha a lista de alarmes curta e acionável; uma lista interminável de eventos informativos ensina as pessoas a ignorá-la.

Solução de problemas

A área 3D está em branco ou achatada. O renderer se dimensiona a partir da célula do grid por meio de um ResizeObserver. Se a célula não tiver altura, por exemplo porque um elemento pai não tem height: 100vh, o canvas colapsa para zero.

Os cards de KPI ficam em "-". As chaves do store vêm do primeiro nível do topic após o prefixo. Se seus dispositivos publicam sob outro prefixo ou nome de ativo, o handler os ignora; registre topic no handler de mensagens para comparar.

O gráfico fica mais lento com o tempo. Garanta que o limite de pontos esteja ativo e que as animações estejam desativadas; um gráfico que guarda todas as amostras acabará travando a página.

A produção mostra zero nos primeiros minutos. A taxa precisa de pelo menos duas amostras distribuídas no tempo. Mostre um placeholder até a janela ser preenchida ou inicialize-a a partir de um topic de resumo retained.

Escalando para mais ativos

Adicione ativos seguindo o mesmo padrão: uma chave no store por ativo, um vínculo dessa chave com o objeto 3D e uma definição de card. Para mais do que algumas máquinas, gere os vínculos da cena a partir de configuração, como descrito em vincular dados IoT a um modelo 3D e como construir um gêmeo digital 3D baseado na web. O tutorial de gêmeo digital de esteira transportadora mostra uma esteira mais detalhada que você pode encaixar neste layout.

Configuração para produção

Em uma planta real, como uma linha de tratamento térmico em Joinville, conecte o dashboard a um broker privado com credenciais, TLS e isolamento, em vez do servidor MQTT público. Um broker MQTT gerenciado como a ConnectMQ oferece WebSocket seguro na porta 8084 com um certificado confiável; adicione a mqtt.connect o username, a password e o clientId emitido pelo portal (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID). O próprio dashboard do portal, a visualização de dispositivos por nível ISA-95 e as regras de alerta por limite complementam um dashboard de gêmeo personalizado: use o portal para o monitoramento do broker e o seu gêmeo para a visão do operador.

Comece a usar a ConnectMQ

A demo web de gêmeo digital 3D da ConnectMQ mostra uma cena de fábrica com dados ao vivo via WebSocket seguro; este tutorial oferece os blocos de construção para criar seu próprio dashboard de gêmeo digital em torno de uma cena como essa. Crie um broker gratuito em app.connectmq.com no plano Serverless, sem necessidade de cartão de crédito, e aponte o dashboard para ele. Ainda não está pronto? Continue prototipando no servidor MQTT público (gratuito).

Perguntas frequentes

Um dashboard de gêmeo digital deve calcular KPIs no navegador?

KPIs simples e locais, como uma taxa em janela móvel, podem ser calculados no navegador. KPIs que precisam bater com relatórios, como OEE ou totais por turno, devem ser calculados uma única vez a montante e publicados em um topic, para que todas as telas concordem.

Com que frequência os cards de KPI devem ser atualizados?

Cerca de uma vez por segundo é um bom padrão. Parece ao vivo sem tremulação, e as verificações de dados desatualizados continuam rodando quando nenhuma mensagem chega.

Posso usar outra biblioteca de gráficos além do Chart.js?

Sim. Qualquer biblioteca capaz de adicionar pontos de forma eficiente funciona. Desative as animações para dados em streaming e limite o número de pontos para manter o uso de memória estável.

Como manter a cena 3D e os KPIs consistentes?

Alimente ambos a partir de uma única conexão MQTT e de um único store de estado. As visualizações leem do store; somente o handler MQTT escreve nele.

Artigos relacionados

Todos os artigos →

Seu broker MQTT privado está a poucos cliques de distância

Crie uma conta, receba as credenciais do seu broker e conecte seu primeiro dispositivo — sem servidores para instalar, atualizar ou escalar.