Gemelo digital

Dashboard de gemelo digital: combina una escena 3D con KPI en vivo

Construye un dashboard web de gemelo digital en el que una escena Three.js, tarjetas de KPI, un gráfico de tendencia y una lista de alarmas comparten una conexión MQTT y un único almacén de estado, con navegación al hacer clic y gestión de datos obsoletos.

Dashboard de gemelo digital con una escena 3D de fábrica junto a tarjetas de KPI, un gráfico de tendencia de temperatura y una lista de alarmas
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Un dashboard de gemelo digital coloca lado a lado una escena 3D en vivo y las cifras que importan, para que los operarios vean tanto dónde ocurre algo como cuánto. En este tutorial construirás uno en una sola página web: una escena Three.js con un horno y una cinta transportadora, tarjetas de KPI de producción y temperatura, un gráfico de tendencia continuo y una lista de alarmas, todo alimentado por una única conexión MQTT a broker.connectmq.com, el servidor MQTT público gratuito de ConnectMQ para pruebas y prototipos. Al hacer clic en una tarjeta de KPI, la cámara vuela hasta la máquina correspondiente.

Por qué combinar 3D y KPI

Una vista 3D por sí sola es estupenda para orientarse, pero mala para leer con precisión; nadie distingue 812 grados de 845 por el tono de naranja. Un panel de KPI por sí solo es preciso, pero abstracto; una lista de cincuenta variables no le dice a un nuevo jefe de turno dónde está el problema. Un dashboard de gemelo digital combinado da a cada elemento una función clara:

Elemento Función Responde a
Escena 3D Contexto espacial, estado de un vistazo ¿Dónde está? ¿Qué máquina?
Tarjetas de KPI Valores actuales exactos frente a objetivos ¿Cuánto? ¿Está dentro del rango?
Gráfico de tendencia Historial reciente ¿Va a mejor o a peor?
Lista de alarmas Eventos que requieren acción ¿Qué tengo que hacer ahora?
Indicador de conexión Confianza en los datos ¿Esto está en vivo?

La regla de diseño más importante es que todos ellos lean de un único almacén de estado alimentado por una única conexión MQTT. Si la escena 3D y las tarjetas de KPI abren cada una su propia conexión, pueden discrepar durante un instante, y un dashboard que se contradice pierde rápidamente la confianza de los operarios. Para entender cómo encajan los gemelos visuales en el concepto general, consulta la guía de tecnología de gemelo digital.

Topics y el simulador

El dashboard usa dos activos bajo un prefijo único (sustituye dash-7f3a por el tuyo):

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 en Node.js genera los tres. Instala MQTT.js con npm install mqtt, guárdalo como sim.mjs y ejecuta 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 telemetría se publica como retenida para que el dashboard se rellene de inmediato al cargar. Los eventos de alarma no se retienen aquí porque son transiciones, no estado; un sistema de producción normalmente publicaría además un resumen retenido de "alarmas activas".

Paso 1: la maquetación del dashboard

La página usa un grid CSS: la escena 3D ocupa la gran zona de la izquierda, las tarjetas de KPI y el gráfico se apilan a la derecha y la lista de alarmas recorre la parte inferior. Guárdala como index.html y sírvela con 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 apila los paneles en pantallas estrechas, así que el mismo dashboard funciona en una tablet en planta.

Paso 2: un almacén, muchas vistas

Crea app.js junto al archivo HTML. El objeto store guarda los últimos valores junto con sus marcas de tiempo y avisa a los suscriptores cuando algo cambia. El manejador MQTT escribe en el store; la escena 3D, las tarjetas, el gráfico y la lista de alarmas solo leen de él.

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import mqtt from "mqtt";

const P = "connectmq/demo/dash-7f3a";

// ---------- Almacén de estado ----------
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);
  }
});

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

// Clic en una tarjeta de KPI para volar hasta su activo
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);
});

// ---------- Tarjetas de KPI ----------
const rateSamples = []; // [timestamp, count] de los ú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 tendencia ----------
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 alarmas ----------
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;
  }));
}

// ---------- Conexiones entre piezas ----------
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();
});

// Tarjetas y gráfico se refrescan con un temporizador: fluido, barato y los estados obsoletos aparecen aunque no lleguen mensajes
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);

Ejecuta el simulador y abre la página. El horno brilla según la temperatura, el paquete avanza a la velocidad de la cinta, los bordes de los KPI se vuelven ámbar o rojos cuando los valores se desvían y el gráfico de tendencia avanza una vez por segundo. Cuando el horno simulado se pasa de temperatura, aparece una alarma abajo; haz clic en ella y la cámara se desliza hasta el horno.

Decisiones de diseño detrás del dashboard de gemelo digital

Paneles controlados por temporizador, 3D por fotograma. La escena 3D funciona a la frecuencia de refresco de la pantalla porque el movimiento debe ser fluido. Los textos y los gráficos se actualizan una vez por segundo, lo que resulta más cómodo para la vista y para la CPU. Actualizar elementos del DOM en cada fotograma es una causa habitual de dashboards lentos.

Los datos obsoletos son un estado de primera clase. Cada valor lleva una marca de tiempo. Las tarjetas se atenúan y la cinta se vuelve gris oscuro cuando dejan de llegar datos, de modo que un gateway averiado nunca parezca una máquina sana y tranquila. Los umbrales deben reflejar con qué frecuencia publica cada dispositivo.

KPI derivados calculados a partir de contadores en bruto. El simulador publica un recuento acumulado de piezas y el dashboard deriva una tasa sobre una ventana móvil de diez minutos. Publicar contadores en bruto es más robusto que publicar tasas, porque un mensaje perdido no corrompe el resultado. Para métricas de toda la planta como el OEE, calcúlalas una sola vez en un backend o en un flujo para que todas las pantallas muestren la misma cifra; consulta crear un dashboard de OEE en Node-RED para ese enfoque.

La navegación une los dos mundos. Hacer clic en una tarjeta de KPI o en una alarma mueve la cámara hasta el activo. Esa pequeña interacción es lo que convierte dos paneles en una página en una sola herramienta.

Lista de comprobación antes de mostrarlo en planta

  1. Indica las unidades en cada tarjeta y mantenlas coherentes con el sistema de origen.
  2. Define y muestra objetivos o límites, no solo valores en bruto.
  3. Muestra el estado de la conexión de forma destacada y prueba qué ocurre cuando cae la red.
  4. Usa retained messages para que una pantalla recién abierta esté completa en un segundo; retained MQTT para gemelos digitales explica los detalles.
  5. Prueba en la pantalla real: las grandes pantallas murales necesitan fuentes más grandes; las tablets necesitan la maquetación apilada.
  6. Mantén la lista de alarmas corta y accionable; una lista interminable de eventos informativos acostumbra a la gente a ignorarla.

Resolución de problemas

La zona 3D aparece en blanco o aplastada. El renderer toma su tamaño de la celda del grid mediante un ResizeObserver. Si la celda no tiene altura, por ejemplo porque a un elemento padre le falta height: 100vh, el canvas se reduce a cero.

Las tarjetas de KPI se quedan en "-". Las claves del store salen del primer nivel de topic tras el prefijo. Si tus dispositivos publican bajo otro prefijo u otro nombre de activo, el manejador los ignora; registra topic en el manejador de mensajes para compararlos.

El gráfico se vuelve más lento con el tiempo. Asegúrate de que el límite de puntos está activo y de que las animaciones están desactivadas; un gráfico que conserva todas las muestras acabará bloqueando la página.

La producción muestra cero durante los primeros minutos. La tasa necesita al menos dos muestras separadas en el tiempo. Muestra un marcador de posición hasta que la ventana se haya llenado, o inicialízala desde un topic de resumen retenido.

Escalar a más activos

Añade activos siguiendo el mismo patrón: una clave del store por activo, una vinculación de esa clave al objeto 3D y una definición de tarjeta. Para más de unas pocas máquinas, genera las vinculaciones de la escena a partir de configuración, como se describe en vincular datos IoT a un modelo 3D y en la guía para crear un gemelo digital 3D en la web. El tutorial de gemelo digital de cinta transportadora muestra una cinta más detallada que puedes incorporar a esta maquetación.

Configuración para producción

Para una planta real, conecta el dashboard a un broker privado con credenciales, TLS y aislamiento en lugar de al servidor público, que no está pensado para datos de producción. Un broker MQTT gestionado como ConnectMQ ofrece WebSocket seguro en el puerto 8084 con un certificado de confianza; añade a mqtt.connect el username, la password y el clientId emitido en el portal (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID). El dashboard propio del portal, la vista de dispositivos por nivel ISA-95 y las reglas de alerta por umbral complementan un dashboard de gemelo a medida: usa el portal para la monitorización a nivel de broker y tu gemelo para la vista del operario.

Empieza con ConnectMQ

La demo web de gemelo digital 3D de ConnectMQ muestra una escena de fábrica con datos en vivo por WebSocket seguro; este tutorial te da las piezas para crear tu propio dashboard de gemelo digital en torno a una escena así. Crea un broker gratuito en app.connectmq.com con el plan Serverless, sin tarjeta de crédito, y apunta el dashboard hacia él. ¿Aún no estás listo? Sigue prototipando en el servidor MQTT público gratuito, broker.connectmq.com.

Preguntas frecuentes

¿Debe un dashboard de gemelo digital calcular los KPI en el navegador?

Los KPI sencillos y locales, como una tasa móvil, están bien en el navegador. Los KPI que deben coincidir con los informes, como el OEE o los totales por turno, deben calcularse una sola vez aguas arriba y publicarse en un topic para que todas las pantallas coincidan.

¿Con qué frecuencia deben actualizarse las tarjetas de KPI?

Una vez por segundo aproximadamente es un buen valor por defecto. Se percibe en vivo sin parpadeos, y las comprobaciones de datos obsoletos siguen funcionando cuando no llegan mensajes.

¿Puedo usar otra librería de gráficos distinta de Chart.js?

Sí. Sirve cualquier librería capaz de añadir puntos de forma eficiente. Desactiva las animaciones para datos en streaming y limita el número de puntos para mantener estable el uso de memoria.

¿Cómo mantengo coherentes la escena 3D y los KPI?

Aliméntalos desde una única conexión MQTT y un único almacén de estado. Las vistas leen del almacén; solo el manejador MQTT escribe en él.

Artículos relacionados

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.