Dijital İkiz

Dijital İkiz Paneli: 3D Sahneyi Canlı KPI'larla Birleştirmek

Bir Three.js sahnesinin, KPI kartlarının, trend grafiğinin ve alarm listesinin tek bir MQTT bağlantısını ve tek bir durum deposunu paylaştığı, tıklayarak odaklanma ve bayat veri yönetimi içeren bir web dijital ikiz paneli oluşturun.

KPI kartları, sıcaklık trend grafiği ve alarm listesinin yanında 3D fabrika sahnesi bulunan dijital ikiz paneli
Bunu kendi MQTT broker’ınızda çalıştırınFree planla özel, yönetilen bir MQTT broker alın ve hazır kod örnekleriyle başlayın.
Ücretsiz deneyin Kod örnekleri Kart gerekmez

Bir dijital ikiz paneli, canlı bir 3D sahneyi ve önemli sayıları yan yana koyar; böylece operatörler hem bir şeyin nerede olduğunu hem de ne kadar olduğunu görür. Bu eğitimde tek bir web sayfası olarak böyle bir panel oluşturacaksınız: bir fırın ve bir konveyörden oluşan Three.js sahnesi, verim ve sıcaklık için KPI kartları, kayan bir trend grafiği ve bir alarm listesi; hepsi broker.connectmq.com adresine yapılan tek bir MQTT bağlantısıyla beslenir. Bir KPI kartına tıklamak kamerayı ilgili makineye uçurur.

3D ve KPI'ları Neden Birleştirmeli?

Tek başına bir 3D görünüm yön bulmak için harikadır ama hassas okuma için zayıftır; kimse 812 dereceyi 845 dereceden turuncunun tonuna bakarak ayırt edemez. Tek başına bir KPI paneli hassastır ama soyuttur; elli etiketlik bir liste yeni bir vardiya amirine sorunun nerede olduğunu söylemez. Birleşik bir dijital ikiz paneli her öğeye net bir görev verir:

Öğe Görev Yanıtladığı soru
3D sahne Mekânsal bağlam, tek bakışta durum Nerede? Hangi makine?
KPI kartları Hedeflere göre kesin anlık değerler Ne kadar? Aralıkta mı?
Trend grafiği Yakın geçmiş Daha mı iyiye gidiyor, daha mı kötüye?
Alarm listesi Müdahale gerektiren olaylar Şimdi ne yapmam gerekiyor?
Bağlantı göstergesi Veriye güven Bu canlı mı?

En önemli tasarım kuralı, bunların hepsinin tek bir MQTT bağlantısıyla beslenen tek bir durum deposundan okumasıdır. 3D sahne ve KPI kartları her biri kendi bağlantısını açarsa bir an için birbirleriyle çelişebilirler ve kendi kendisiyle çelişen bir panel operatörün güvenini hızla kaybeder. Görsel ikizlerin daha büyük kavrama nasıl oturduğuna dair arka plan için dijital ikiz teknolojisi rehberine bakın.

Topic'ler ve Simülatör

Panel, benzersiz bir prefix altında iki varlık kullanır (dash-7f3a yerine kendi değerinizi kullanın):

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}

Bu Node.js simülatörü üçünü de üretir. MQTT.js'i npm install mqtt ile kurun, dosyayı sim.mjs olarak kaydedin ve node sim.mjs ile çalıştırın.

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

Telemetri retained olarak yayınlanır, böylece panel yüklenir yüklenmez dolar. Alarm olayları burada retained değildir, çünkü bunlar durum değil geçişlerdir; bir üretim sistemi genellikle ayrıca retained bir "aktif alarmlar" özeti de yayınlar.

Adım 1: Panel Yerleşimi

Sayfa bir CSS grid kullanır: 3D sahne soldaki geniş alanı kaplar, KPI kartları ve grafik sağda üst üste dizilir, alarm listesi ise alt kısım boyunca uzanır. index.html olarak kaydedin ve npx serve . ile sunun.

<!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>

Media query dar ekranlarda panelleri alt alta dizer; böylece aynı panel sahadaki bir tablette de çalışır.

Adım 2: Tek Depo, Birden Çok Görünüm

HTML dosyasının yanına app.js dosyasını oluşturun. store nesnesi en son değerleri zaman damgalarıyla birlikte tutar ve bir şey değiştiğinde abonelerini bilgilendirir. MQTT handler depoya yazar; 3D sahne, kartlar, grafik ve alarm listesi yalnızca ondan okur.

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

Simülatörü çalıştırın ve sayfayı açın. Fırın sıcaklığa göre parlar, koli konveyör hızında ilerler, değerler saptığında KPI kenarlıkları turuncu veya kırmızıya döner ve trend grafiği saniyede bir kayar. Simüle edilen fırın hedefi aştığında altta bir alarm belirir; üzerine tıklayın, kamera fırına doğru süzülür.

Dijital İkiz Panelinin Ardındaki Tasarım Kararları

Zamanlayıcıyla güncellenen paneller, kareyle güncellenen 3D. Hareketin akıcı olması gerektiği için 3D sahne ekran yenileme hızında çalışır. Metinler ve grafikler saniyede bir güncellenir; bu hem gözü hem de CPU'yu daha az yorar. DOM öğelerini her karede güncellemek, ağır çalışan panellerin yaygın bir nedenidir.

Bayat veri birinci sınıf bir durumdur. Her değer bir zaman damgası taşır. Veri kesildiğinde kartlar soluklaşır ve konveyör koyu griye döner; böylece bozuk bir gateway asla sağlıklı, sessiz bir makine gibi görünmez. Eşikler, her cihazın ne sıklıkta yayın yaptığını yansıtmalıdır.

Ham sayaçlardan hesaplanan türetilmiş KPI'lar. Simülatör kümülatif bir parça sayısı yayınlar ve panel bundan kayan on dakikalık bir pencere üzerinden bir hız türetir. Ham sayaç yayınlamak, hız yayınlamaktan daha sağlamdır; çünkü kaçırılan bir mesaj sonucu bozmaz. OEE gibi tesis genelindeki metrikleri, her ekranın aynı sayıyı göstermesi için bir backend'de veya akışta bir kez hesaplayın; bu yaklaşım için Node-RED'de OEE paneli oluşturma yazımıza bakın.

Gezinme iki dünyayı birbirine bağlar. Bir KPI kartına veya alarma tıklamak kamerayı ilgili varlığa taşır. Bir sayfadaki iki paneli tek bir araca dönüştüren şey bu küçük etkileşimdir.

Sahada Göstermeden Önce Kontrol Listesi

  1. Her kartta birimleri belirtin ve kaynak sistemle tutarlı tutun.
  2. Yalnızca ham değerleri değil, hedefleri veya limitleri de tanımlayıp gösterin.
  3. Bağlantı durumunu belirgin şekilde gösterin ve ağ koptuğunda ne olduğunu test edin.
  4. Yeni açılan bir ekranın bir saniye içinde eksiksiz olması için retained mesajlar kullanın; ayrıntıları dijital ikizler için retained MQTT yazımız açıklıyor.
  5. Gerçek ekranda test edin: büyük duvar ekranları daha büyük yazı tipleri, tabletler ise alt alta yerleşim ister.
  6. Alarm listesini kısa ve eyleme dönük tutun; bitmeyen bir bilgilendirme olayları listesi, insanlara onu görmezden gelmeyi öğretir.

Sorun Giderme

3D alan boş ya da basık görünüyor. Renderer boyutunu bir ResizeObserver aracılığıyla grid hücresinden alır. Hücrenin yüksekliği yoksa, örneğin bir üst öğede height: 100vh eksikse, canvas sıfıra çöker.

KPI kartları "-" olarak kalıyor. Depo anahtarları, prefix'ten sonraki ilk topic seviyesinden gelir. Cihazlarınız farklı bir prefix veya varlık adı altında yayın yapıyorsa handler onları yok sayar; karşılaştırmak için mesaj handler'ında topic değerini loglayın.

Grafik zamanla yavaşlıyor. Nokta sınırının uygulandığından ve animasyonların kapalı olduğundan emin olun; her örneği saklayan bir grafik eninde sonunda sayfayı kilitler.

Verim ilk dakikalarda sıfır gösteriyor. Hızın hesaplanması için zamana yayılmış en az iki örnek gerekir. Pencere dolana kadar bir yer tutucu gösterin veya değeri retained bir özet topic'inden başlatın.

Daha Fazla Varlığa Ölçeklendirme

Varlıkları aynı kalıbı izleyerek ekleyin: varlık başına bir depo anahtarı, bu anahtardan 3D nesneye bir bağlama ve bir kart tanımı. Birkaç makineden fazlası için sahne bağlamalarını, IoT verisini bir 3D modele bağlama ve web tabanlı 3D dijital ikiz oluşturma yazılarında anlatıldığı gibi konfigürasyondan üretin. Konveyör dijital ikizi eğitimi, bu yerleşime ekleyebileceğiniz daha ayrıntılı bir konveyör gösteriyor.

Üretim Kurulumu

Gerçek bir tesis için paneli public test alanı yerine kimlik bilgileri, TLS ve izolasyon sunan özel bir broker'a bağlayın. ConnectMQ gibi bir yönetilen MQTT broker, güvenilir bir sertifikayla 8084 portunda güvenli WebSocket sunar; portaldan aldığınız username, password ve verilen clientId değerlerini (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID) mqtt.connect çağrısına ekleyin. Portalın kendi paneli, ISA-95 seviyesine göre cihaz görünümü ve eşik uyarı kuralları özel bir ikiz panelini tamamlar: broker seviyesinde izleme için portalı, operatör görünümü için ise ikizinizi kullanın.

ConnectMQ ile Başlayın

ConnectMQ'nun 3D dijital ikiz web demosu, güvenli WebSocket üzerinden canlı veri alan bir fabrika sahnesi gösterir; bu eğitim ise böyle bir sahnenin etrafında kendi dijital ikiz panelinizi oluşturmanız için yapı taşlarını sunar. Kredi kartı gerekmeden Serverless planında app.connectmq.com adresinden ücretsiz bir broker oluşturun ve paneli ona yönlendirin. Henüz hazır değil misiniz? Ücretsiz public MQTT broker üzerinde prototip geliştirmeye devam edin.

Sık Sorulan Sorular

Bir dijital ikiz paneli KPI'ları tarayıcıda mı hesaplamalı?

Kayan bir hız gibi basit, yerel KPI'lar tarayıcıda hesaplanabilir. OEE veya vardiya toplamları gibi raporlarla eşleşmesi gereken KPI'lar ise yukarı akışta bir kez hesaplanmalı ve her ekranın aynı fikirde olması için bir topic'e yayınlanmalıdır.

KPI kartları ne sıklıkta güncellenmeli?

Saniyede yaklaşık bir kez iyi bir varsayılandır. Titreme olmadan canlı hissettirir ve mesaj gelmediğinde bile bayat veri kontrolleri çalışmaya devam eder.

Chart.js dışında bir grafik kütüphanesi kullanabilir miyim?

Evet. Noktaları verimli şekilde ekleyebilen her kütüphane çalışır. Akış verisi için animasyonları kapatın ve belleği sabit tutmak için nokta sayısını sınırlayın.

3D sahne ile KPI'ları nasıl tutarlı tutarım?

İkisini de tek bir MQTT bağlantısından ve tek bir durum deposundan besleyin. Görünümler depodan okur; depoya yalnızca MQTT handler yazar.

İlgili yazılar

Tüm yazılar →

Özel MQTT broker’ınız birkaç tık uzağınızda

Hesabınızı oluşturun, broker kimlik bilgilerinizi alın ve ilk cihazınızı bağlayın — kurulacak, yamalanacak ya da ölçeklendirilecek sunucu yok.