// ============================================
// MQTT QoS Playground
// Interactive Tool for IoT Messaging Education
// ============================================
{
// IEEE Color Palette
const colors = {
navy: "#2C3E50",
teal: "#16A085",
orange: "#E67E22",
gray: "#7F8C8D",
lightGray: "#ECF0F1",
white: "#FFFFFF",
red: "#E74C3C",
green: "#27AE60",
purple: "#9B59B6",
yellow: "#F1C40F",
blue: "#3498DB",
darkGray: "#34495E",
lightBlue: "#85C1E9",
lightGreen: "#82E0AA",
lightRed: "#F5B7B1",
lightOrange: "#FAD7A0",
darkTeal: "#0E6655"
};
// QoS Level Definitions
const qosLevels = {
0: {
name: "QoS 0",
subtitle: "At Most Once",
description: "Fire and forget - no acknowledgment, fastest but may lose messages",
color: colors.teal,
steps: ["PUBLISH"],
guarantee: "Message may be lost, no duplicates",
useCase: "Telemetry data sent frequently (temperature every 10s)"
},
1: {
name: "QoS 1",
subtitle: "At Least Once",
description: "Guaranteed delivery with acknowledgment, may have duplicates",
color: colors.orange,
steps: ["PUBLISH", "PUBACK"],
guarantee: "Message delivered at least once, may have duplicates",
useCase: "Alerts, notifications, status changes"
},
2: {
name: "QoS 2",
subtitle: "Exactly Once",
description: "Four-way handshake ensures exactly one delivery, slowest",
color: colors.purple,
steps: ["PUBLISH", "PUBREC", "PUBREL", "PUBCOMP"],
guarantee: "Message delivered exactly once, no duplicates",
useCase: "Critical commands (unlock door, payment transactions)"
}
};
// Pre-built Scenarios
const scenarios = {
perfect: {
name: "Perfect Network",
description: "Ideal conditions - no packet loss, low latency, stable connection",
packetLoss: 0,
latency: 50,
connectionDropRate: 0
},
lossy: {
name: "Lossy Network",
description: "High packet loss simulating wireless interference or congestion",
packetLoss: 30,
latency: 150,
connectionDropRate: 5
},
intermittent: {
name: "Intermittent Connection",
description: "Unstable connection with frequent drops (mobile/satellite)",
packetLoss: 15,
latency: 300,
connectionDropRate: 25
},
highLatency: {
name: "High Latency",
description: "Satellite or long-distance connection with high delay",
packetLoss: 5,
latency: 800,
connectionDropRate: 3
},
stressed: {
name: "Stressed Network",
description: "Extreme conditions - heavy packet loss and connection issues",
packetLoss: 50,
latency: 400,
connectionDropRate: 40
}
};
// State management
let state = {
selectedQoS: 1,
comparisonMode: false,
packetLoss: 20,
latency: 100,
connectionDropRate: 10,
messageId: 1,
isAnimating: false,
retryTimeout: 2000,
maxRetries: 3
};
// Statistics for each QoS level
let stats = {
0: { sent: 0, delivered: 0, lost: 0, duplicates: 0, inFlight: 0 },
1: { sent: 0, delivered: 0, lost: 0, duplicates: 0, retries: 0, inFlight: 0 },
2: { sent: 0, delivered: 0, lost: 0, duplicates: 0, retries: 0, inFlight: 0 }
};
// Message log for tracking
let messageLog = [];
// Random with seed for reproducibility
function random() {
return Math.random();
}
// Simulate packet transmission with loss
function simulatePacket(successCallback, failCallback) {
const lost = random() * 100 < state.packetLoss;
const dropped = random() * 100 < state.connectionDropRate;
if (dropped) {
return { success: false, reason: "connection_drop" };
}
if (lost) {
return { success: false, reason: "packet_loss" };
}
return { success: true };
}
// Create main container
const container = d3.create("div")
.style("font-family", "system-ui, -apple-system, sans-serif")
.style("max-width", "1200px")
.style("margin", "0 auto");
// Header
const header = container.append("div")
.style("background", `linear-gradient(135deg, ${colors.navy} 0%, ${colors.darkTeal} 100%)`)
.style("border-radius", "12px 12px 0 0")
.style("padding", "20px")
.style("color", colors.white);
header.append("h3")
.style("margin", "0 0 8px 0")
.style("font-size", "22px")
.text("MQTT QoS Playground");
header.append("p")
.style("margin", "0")
.style("opacity", "0.9")
.style("font-size", "14px")
.text("Simulate message delivery under different network conditions and compare QoS levels");
// Scenario and Mode Selection Panel
const controlPanel = container.append("div")
.style("background", colors.lightGray)
.style("padding", "15px 20px")
.style("display", "flex")
.style("flex-wrap", "wrap")
.style("gap", "15px")
.style("align-items", "center")
.style("justify-content", "space-between");
// Scenario buttons
const scenarioGroup = controlPanel.append("div")
.style("display", "flex")
.style("gap", "8px")
.style("flex-wrap", "wrap")
.style("align-items", "center");
scenarioGroup.append("span")
.style("font-weight", "bold")
.style("color", colors.navy)
.style("font-size", "13px")
.style("margin-right", "5px")
.text("Scenarios:");
Object.entries(scenarios).forEach(([key, scenario]) => {
scenarioGroup.append("button")
.attr("class", `scenario-btn scenario-${key}`)
.text(scenario.name)
.style("padding", "8px 12px")
.style("background", colors.white)
.style("border", `2px solid ${colors.teal}`)
.style("border-radius", "6px")
.style("cursor", "pointer")
.style("font-size", "11px")
.style("color", colors.navy)
.style("font-weight", "bold")
.style("transition", "all 0.2s")
.attr("title", scenario.description)
.on("mouseover", function() {
d3.select(this).style("background", colors.teal).style("color", colors.white);
})
.on("mouseout", function() {
d3.select(this).style("background", colors.white).style("color", colors.navy);
})
.on("click", () => loadScenario(key));
});
// Comparison mode toggle
const modeGroup = controlPanel.append("div")
.style("display", "flex")
.style("align-items", "center")
.style("gap", "10px");
modeGroup.append("span")
.style("font-size", "13px")
.style("color", colors.navy)
.style("font-weight", "bold")
.text("Comparison Mode:");
const compToggle = modeGroup.append("label")
.style("position", "relative")
.style("display", "inline-block")
.style("width", "50px")
.style("height", "26px")
.style("cursor", "pointer");
const toggleInput = compToggle.append("input")
.attr("type", "checkbox")
.style("opacity", "0")
.style("width", "0")
.style("height", "0")
.on("change", function() {
state.comparisonMode = this.checked;
renderMainContent();
});
compToggle.append("span")
.attr("class", "toggle-slider")
.style("position", "absolute")
.style("top", "0")
.style("left", "0")
.style("right", "0")
.style("bottom", "0")
.style("background", colors.gray)
.style("border-radius", "26px")
.style("transition", "0.3s");
// Main content area
const mainContent = container.append("div")
.attr("class", "main-content")
.style("display", "grid")
.style("gap", "15px")
.style("padding", "15px")
.style("background", colors.white);
// Network conditions panel
const networkPanel = container.append("div")
.style("background", colors.navy)
.style("padding", "20px")
.style("color", colors.white);
const networkTitle = networkPanel.append("div")
.style("display", "flex")
.style("justify-content", "space-between")
.style("align-items", "center")
.style("margin-bottom", "15px");
networkTitle.append("h4")
.style("margin", "0")
.style("font-size", "16px")
.text("Network Conditions");
const resetBtn = networkTitle.append("button")
.text("Reset Stats")
.style("padding", "6px 12px")
.style("background", colors.orange)
.style("border", "none")
.style("border-radius", "4px")
.style("color", colors.white)
.style("cursor", "pointer")
.style("font-size", "11px")
.style("font-weight", "bold")
.on("click", resetStats);
const slidersGrid = networkPanel.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(auto-fit, minmax(300px, 1fr))")
.style("gap", "20px");
// Packet Loss Slider
const packetLossGroup = slidersGrid.append("div");
const packetLossLabel = packetLossGroup.append("div")
.style("display", "flex")
.style("justify-content", "space-between")
.style("margin-bottom", "8px");
packetLossLabel.append("span")
.style("font-size", "13px")
.text("Packet Loss");
packetLossLabel.append("span")
.attr("class", "packet-loss-value")
.style("font-weight", "bold")
.style("color", colors.orange)
.text(`${state.packetLoss}%`);
const packetLossSlider = packetLossGroup.append("input")
.attr("type", "range")
.attr("min", "0")
.attr("max", "80")
.attr("value", state.packetLoss)
.style("width", "100%")
.style("height", "8px")
.style("cursor", "pointer")
.on("input", function() {
state.packetLoss = parseInt(this.value);
container.select(".packet-loss-value").text(`${state.packetLoss}%`);
});
// Latency Slider
const latencyGroup = slidersGrid.append("div");
const latencyLabel = latencyGroup.append("div")
.style("display", "flex")
.style("justify-content", "space-between")
.style("margin-bottom", "8px");
latencyLabel.append("span")
.style("font-size", "13px")
.text("Network Latency");
latencyLabel.append("span")
.attr("class", "latency-value")
.style("font-weight", "bold")
.style("color", colors.teal)
.text(`${state.latency}ms`);
const latencySlider = latencyGroup.append("input")
.attr("type", "range")
.attr("min", "10")
.attr("max", "1000")
.attr("value", state.latency)
.style("width", "100%")
.style("height", "8px")
.style("cursor", "pointer")
.on("input", function() {
state.latency = parseInt(this.value);
container.select(".latency-value").text(`${state.latency}ms`);
});
// Connection Drop Rate Slider
const dropGroup = slidersGrid.append("div");
const dropLabel = dropGroup.append("div")
.style("display", "flex")
.style("justify-content", "space-between")
.style("margin-bottom", "8px");
dropLabel.append("span")
.style("font-size", "13px")
.text("Connection Drop Rate");
dropLabel.append("span")
.attr("class", "drop-value")
.style("font-weight", "bold")
.style("color", colors.red)
.text(`${state.connectionDropRate}%`);
const dropSlider = dropGroup.append("input")
.attr("type", "range")
.attr("min", "0")
.attr("max", "60")
.attr("value", state.connectionDropRate)
.style("width", "100%")
.style("height", "8px")
.style("cursor", "pointer")
.on("input", function() {
state.connectionDropRate = parseInt(this.value);
container.select(".drop-value").text(`${state.connectionDropRate}%`);
});
// Statistics Panel
const statsPanel = container.append("div")
.attr("class", "stats-panel")
.style("background", colors.lightGray)
.style("padding", "20px")
.style("border-radius", "0 0 12px 12px");
// Helper function to render QoS panel
function renderQoSPanel(qosLevel, panelContainer, width = "100%") {
const qos = qosLevels[qosLevel];
const panel = panelContainer.append("div")
.attr("class", `qos-panel qos-panel-${qosLevel}`)
.style("background", colors.white)
.style("border", `3px solid ${qos.color}`)
.style("border-radius", "12px")
.style("overflow", "hidden")
.style("width", width);
// Panel header
const panelHeader = panel.append("div")
.style("background", qos.color)
.style("color", colors.white)
.style("padding", "12px 15px")
.style("display", "flex")
.style("justify-content", "space-between")
.style("align-items", "center");
const headerLeft = panelHeader.append("div");
headerLeft.append("div")
.style("font-weight", "bold")
.style("font-size", "16px")
.text(qos.name);
headerLeft.append("div")
.style("font-size", "12px")
.style("opacity", "0.9")
.text(qos.subtitle);
// Send button in header
const sendBtn = panelHeader.append("button")
.attr("class", `send-btn-${qosLevel}`)
.text("Send Message")
.style("padding", "8px 16px")
.style("background", colors.white)
.style("color", qos.color)
.style("border", "none")
.style("border-radius", "6px")
.style("cursor", "pointer")
.style("font-weight", "bold")
.style("font-size", "12px")
.style("transition", "all 0.2s")
.on("mouseover", function() {
d3.select(this).style("background", colors.lightGray);
})
.on("mouseout", function() {
d3.select(this).style("background", colors.white);
})
.on("click", () => sendMessage(qosLevel));
// Description
panel.append("div")
.style("padding", "10px 15px")
.style("background", colors.lightGray)
.style("font-size", "12px")
.style("color", colors.darkGray)
.text(qos.description);
// Flow diagram area
const flowArea = panel.append("div")
.attr("class", `flow-area-${qosLevel}`)
.style("padding", "15px")
.style("min-height", "200px")
.style("position", "relative");
// SVG for flow visualization
const svgWidth = state.comparisonMode ? 320 : 600;
const svg = flowArea.append("svg")
.attr("class", `flow-svg-${qosLevel}`)
.attr("viewBox", `0 0 ${svgWidth} 200`)
.attr("width", "100%")
.style("display", "block");
// Draw static elements
drawFlowDiagram(svg, qosLevel, svgWidth);
// Message log area
const logArea = panel.append("div")
.attr("class", `log-area-${qosLevel}`)
.style("max-height", "100px")
.style("overflow-y", "auto")
.style("padding", "10px 15px")
.style("border-top", `1px solid ${colors.lightGray}`)
.style("font-size", "11px")
.style("font-family", "monospace")
.style("background", "#f8f9fa");
logArea.append("div")
.style("color", colors.gray)
.style("font-style", "italic")
.text("Message log will appear here...");
// Stats display
const panelStats = panel.append("div")
.attr("class", `panel-stats-${qosLevel}`)
.style("display", "grid")
.style("grid-template-columns", "repeat(4, 1fr)")
.style("gap", "10px")
.style("padding", "15px")
.style("background", colors.lightGray);
const statItems = [
{ label: "Sent", key: "sent", color: colors.blue },
{ label: "Delivered", key: "delivered", color: colors.green },
{ label: "Lost", key: "lost", color: colors.red },
{ label: qosLevel === 0 ? "N/A" : "Duplicates", key: "duplicates", color: colors.orange }
];
statItems.forEach(item => {
const statBox = panelStats.append("div")
.style("text-align", "center");
statBox.append("div")
.attr("class", `stat-${item.key}-${qosLevel}`)
.style("font-size", "24px")
.style("font-weight", "bold")
.style("color", item.color)
.text(item.key === "duplicates" && qosLevel === 0 ? "-" : stats[qosLevel][item.key]);
statBox.append("div")
.style("font-size", "11px")
.style("color", colors.gray)
.text(item.label);
});
return panel;
}
// Draw flow diagram for a QoS level
function drawFlowDiagram(svg, qosLevel, width) {
const qos = qosLevels[qosLevel];
const height = 200;
const centerY = height / 2;
// Client and Broker boxes
const clientX = 50;
const brokerX = width - 50;
const boxWidth = 60;
const boxHeight = 40;
// Client
svg.append("rect")
.attr("x", clientX - boxWidth/2)
.attr("y", centerY - boxHeight/2)
.attr("width", boxWidth)
.attr("height", boxHeight)
.attr("fill", colors.teal)
.attr("rx", 6);
svg.append("text")
.attr("x", clientX)
.attr("y", centerY + 4)
.attr("text-anchor", "middle")
.attr("fill", colors.white)
.attr("font-size", "11px")
.attr("font-weight", "bold")
.text("Client");
// Broker
svg.append("rect")
.attr("x", brokerX - boxWidth/2)
.attr("y", centerY - boxHeight/2)
.attr("width", boxWidth)
.attr("height", boxHeight)
.attr("fill", colors.navy)
.attr("rx", 6);
svg.append("text")
.attr("x", brokerX)
.attr("y", centerY + 4)
.attr("text-anchor", "middle")
.attr("fill", colors.white)
.attr("font-size", "11px")
.attr("font-weight", "bold")
.text("Broker");
// Connection line
svg.append("line")
.attr("x1", clientX + boxWidth/2)
.attr("y1", centerY)
.attr("x2", brokerX - boxWidth/2)
.attr("y2", centerY)
.attr("stroke", colors.lightGray)
.attr("stroke-width", 2)
.attr("stroke-dasharray", "4,4");
// Step labels on the side
const steps = qos.steps;
const stepY = 25;
steps.forEach((step, i) => {
const isClientToBroker = i % 2 === 0;
const yPos = stepY + i * 25;
svg.append("text")
.attr("class", `step-label-${qosLevel}-${i}`)
.attr("x", width / 2)
.attr("y", yPos)
.attr("text-anchor", "middle")
.attr("fill", colors.gray)
.attr("font-size", "10px")
.attr("opacity", 0.5)
.text(`${i + 1}. ${step} ${isClientToBroker ? "→" : "←"}`);
});
// Animation group
svg.append("g").attr("class", `animation-group-${qosLevel}`);
}
// Animate message flow
function animateQoSFlow(qosLevel, success, failedStep = -1) {
const svg = container.select(`.flow-svg-${qosLevel}`);
const animGroup = svg.select(`.animation-group-${qosLevel}`);
animGroup.selectAll("*").remove();
const qos = qosLevels[qosLevel];
const steps = qos.steps;
const width = state.comparisonMode ? 320 : 600;
const height = 200;
const centerY = height / 2;
const clientX = 50;
const brokerX = width - 50;
const baseDelay = Math.min(state.latency * 2, 400);
let currentDelay = 0;
steps.forEach((step, i) => {
const isClientToBroker = i % 2 === 0;
const startX = isClientToBroker ? clientX + 30 : brokerX - 30;
const endX = isClientToBroker ? brokerX - 30 : clientX + 30;
const yOffset = -60 + i * 30;
// Highlight step label
svg.select(`.step-label-${qosLevel}-${i}`)
.transition()
.delay(currentDelay)
.duration(200)
.attr("opacity", 1)
.attr("fill", qos.color)
.attr("font-weight", "bold");
// Create message packet
const packet = animGroup.append("g")
.attr("transform", `translate(${startX}, ${centerY + yOffset})`)
.style("opacity", 0);
packet.append("rect")
.attr("x", -25)
.attr("y", -12)
.attr("width", 50)
.attr("height", 24)
.attr("fill", i <= failedStep && failedStep !== -1 && i === failedStep ? colors.red : qos.color)
.attr("rx", 4);
packet.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", colors.white)
.attr("font-size", "9px")
.attr("font-weight", "bold")
.text(step);
// Animate packet
if (i <= failedStep || failedStep === -1 || i < failedStep) {
const shouldFail = failedStep === i;
packet.transition()
.delay(currentDelay)
.duration(100)
.style("opacity", 1)
.transition()
.duration(baseDelay)
.attr("transform", `translate(${shouldFail ? (startX + endX) / 2 : endX}, ${centerY + yOffset})`)
.on("end", function() {
if (shouldFail) {
// Show X for failure
packet.select("rect").attr("fill", colors.red);
packet.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("dx", "30")
.attr("fill", colors.red)
.attr("font-size", "16px")
.attr("font-weight", "bold")
.text("X");
packet.transition()
.delay(500)
.duration(300)
.style("opacity", 0);
} else if (i === steps.length - 1 && success) {
// Success indication
packet.transition()
.delay(200)
.duration(300)
.style("opacity", 0);
}
});
}
currentDelay += baseDelay + 150;
});
// Show success or failure indicator
setTimeout(() => {
const indicator = animGroup.append("g")
.attr("transform", `translate(${width/2}, ${height - 25})`);
indicator.append("circle")
.attr("r", 15)
.attr("fill", success ? colors.green : colors.red);
indicator.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", colors.white)
.attr("font-size", "14px")
.attr("font-weight", "bold")
.text(success ? "OK" : "X");
indicator.append("text")
.attr("y", 30)
.attr("text-anchor", "middle")
.attr("fill", success ? colors.green : colors.red)
.attr("font-size", "10px")
.attr("font-weight", "bold")
.text(success ? "Delivered" : "Failed");
// Fade out after showing
indicator.transition()
.delay(1500)
.duration(500)
.style("opacity", 0)
.on("end", () => {
animGroup.selectAll("*").remove();
// Reset step labels
qos.steps.forEach((_, i) => {
svg.select(`.step-label-${qosLevel}-${i}`)
.transition()
.duration(200)
.attr("opacity", 0.5)
.attr("fill", colors.gray)
.attr("font-weight", "normal");
});
});
}, currentDelay + 200);
}
// Send message with QoS simulation
function sendMessage(qosLevel) {
if (state.isAnimating) return;
const qos = qosLevels[qosLevel];
const msgId = state.messageId++;
const timestamp = new Date().toLocaleTimeString();
stats[qosLevel].sent++;
updateStats();
// Simulate QoS behavior
let success = true;
let failedStep = -1;
let isDuplicate = false;
let logMessage = "";
if (qosLevel === 0) {
// QoS 0: Fire and forget
const result = simulatePacket();
if (!result.success) {
success = false;
failedStep = 0;
stats[qosLevel].lost++;
logMessage = `[${timestamp}] MSG#${msgId}: PUBLISH failed (${result.reason})`;
} else {
stats[qosLevel].delivered++;
logMessage = `[${timestamp}] MSG#${msgId}: PUBLISH delivered (no ACK required)`;
}
} else if (qosLevel === 1) {
// QoS 1: At least once (PUBLISH + PUBACK)
const publishResult = simulatePacket();
if (!publishResult.success) {
// PUBLISH lost - will retry
stats[qosLevel].retries++;
// Simulate retry
const retryResult = simulatePacket();
if (!retryResult.success) {
success = false;
failedStep = 0;
stats[qosLevel].lost++;
logMessage = `[${timestamp}] MSG#${msgId}: PUBLISH failed after retry`;
} else {
// Retry succeeded
success = true;
stats[qosLevel].delivered++;
logMessage = `[${timestamp}] MSG#${msgId}: PUBLISH delivered after retry`;
}
} else {
// PUBLISH succeeded, check PUBACK
const pubackResult = simulatePacket();
if (!pubackResult.success) {
// PUBACK lost - client retries (may cause duplicate)
stats[qosLevel].retries++;
isDuplicate = random() > 0.5; // 50% chance broker got it
if (isDuplicate) {
stats[qosLevel].duplicates++;
stats[qosLevel].delivered++;
logMessage = `[${timestamp}] MSG#${msgId}: Delivered (PUBACK lost, caused duplicate)`;
} else {
stats[qosLevel].delivered++;
logMessage = `[${timestamp}] MSG#${msgId}: PUBLISH + PUBACK complete`;
}
} else {
stats[qosLevel].delivered++;
logMessage = `[${timestamp}] MSG#${msgId}: PUBLISH + PUBACK complete`;
}
}
} else if (qosLevel === 2) {
// QoS 2: Exactly once (PUBLISH, PUBREC, PUBREL, PUBCOMP)
const steps = ["PUBLISH", "PUBREC", "PUBREL", "PUBCOMP"];
for (let i = 0; i < steps.length; i++) {
const result = simulatePacket();
if (!result.success) {
// Retry this step
stats[qosLevel].retries++;
const retryResult = simulatePacket();
if (!retryResult.success) {
success = false;
failedStep = i;
stats[qosLevel].lost++;
logMessage = `[${timestamp}] MSG#${msgId}: ${steps[i]} failed after retry`;
break;
}
}
}
if (success) {
stats[qosLevel].delivered++;
logMessage = `[${timestamp}] MSG#${msgId}: Full 4-way handshake complete (exactly once)`;
}
}
// Update log
const logArea = container.select(`.log-area-${qosLevel}`);
const currentLog = logArea.html();
if (currentLog.includes("will appear here")) {
logArea.html("");
}
logArea.insert("div", ":first-child")
.style("padding", "3px 0")
.style("border-bottom", `1px solid ${colors.lightGray}`)
.style("color", success ? colors.green : colors.red)
.text(logMessage);
// Keep only last 10 messages
const logEntries = logArea.selectAll("div").nodes();
if (logEntries.length > 10) {
d3.select(logEntries[logEntries.length - 1]).remove();
}
// Animate the flow
animateQoSFlow(qosLevel, success, failedStep);
updateStats();
}
// Update statistics display
function updateStats() {
[0, 1, 2].forEach(qos => {
container.select(`.stat-sent-${qos}`).text(stats[qos].sent);
container.select(`.stat-delivered-${qos}`).text(stats[qos].delivered);
container.select(`.stat-lost-${qos}`).text(stats[qos].lost);
if (qos !== 0) {
container.select(`.stat-duplicates-${qos}`).text(stats[qos].duplicates);
}
});
// Update comparison stats panel
renderComparisonStats();
}
// Reset statistics
function resetStats() {
stats = {
0: { sent: 0, delivered: 0, lost: 0, duplicates: 0, inFlight: 0 },
1: { sent: 0, delivered: 0, lost: 0, duplicates: 0, retries: 0, inFlight: 0 },
2: { sent: 0, delivered: 0, lost: 0, duplicates: 0, retries: 0, inFlight: 0 }
};
state.messageId = 1;
// Clear logs
[0, 1, 2].forEach(qos => {
const logArea = container.select(`.log-area-${qos}`);
if (!logArea.empty()) {
logArea.html("")
.append("div")
.style("color", colors.gray)
.style("font-style", "italic")
.text("Message log will appear here...");
}
});
updateStats();
}
// Load scenario
function loadScenario(scenarioKey) {
const scenario = scenarios[scenarioKey];
if (!scenario) return;
state.packetLoss = scenario.packetLoss;
state.latency = scenario.latency;
state.connectionDropRate = scenario.connectionDropRate;
// Update sliders
container.select(".packet-loss-value").text(`${state.packetLoss}%`);
container.select(".latency-value").text(`${state.latency}ms`);
container.select(".drop-value").text(`${state.connectionDropRate}%`);
packetLossSlider.property("value", state.packetLoss);
latencySlider.property("value", state.latency);
dropSlider.property("value", state.connectionDropRate);
}
// Render comparison stats
function renderComparisonStats() {
const statsContent = statsPanel.select(".stats-content");
if (statsContent.empty()) return;
statsContent.selectAll("*").remove();
const grid = statsContent.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(4, 1fr)")
.style("gap", "15px");
// Header
grid.append("div")
.style("font-weight", "bold")
.style("color", colors.navy)
.text("Metric");
[0, 1, 2].forEach(qos => {
grid.append("div")
.style("font-weight", "bold")
.style("color", qosLevels[qos].color)
.style("text-align", "center")
.text(`QoS ${qos}`);
});
// Rows
const metrics = [
{ label: "Messages Sent", key: "sent" },
{ label: "Delivered", key: "delivered" },
{ label: "Lost", key: "lost" },
{ label: "Duplicates", key: "duplicates" },
{ label: "Success Rate", key: "rate" }
];
metrics.forEach(metric => {
grid.append("div")
.style("font-size", "13px")
.style("color", colors.darkGray)
.text(metric.label);
[0, 1, 2].forEach(qos => {
let value;
if (metric.key === "rate") {
value = stats[qos].sent > 0
? `${Math.round((stats[qos].delivered / stats[qos].sent) * 100)}%`
: "-";
} else if (metric.key === "duplicates" && qos === 0) {
value = "-";
} else {
value = stats[qos][metric.key];
}
grid.append("div")
.style("text-align", "center")
.style("font-weight", "bold")
.style("color", metric.key === "lost" ? colors.red :
metric.key === "delivered" ? colors.green :
metric.key === "duplicates" ? colors.orange : colors.navy)
.text(value);
});
});
}
// Main content renderer
function renderMainContent() {
mainContent.selectAll("*").remove();
if (state.comparisonMode) {
// Three-column comparison layout
mainContent.style("grid-template-columns", "1fr 1fr 1fr");
[0, 1, 2].forEach(qos => {
renderQoSPanel(qos, mainContent);
});
// Stats panel shows comparison
statsPanel.selectAll("*").remove();
statsPanel.append("h4")
.style("margin", "0 0 15px 0")
.style("color", colors.navy)
.text("Comparison Statistics");
statsPanel.append("div").attr("class", "stats-content");
renderComparisonStats();
} else {
// Single QoS with tabs
mainContent.style("grid-template-columns", "1fr");
// Tab navigation
const tabNav = mainContent.append("div")
.style("display", "flex")
.style("gap", "5px")
.style("margin-bottom", "10px");
[0, 1, 2].forEach(qos => {
const qosInfo = qosLevels[qos];
tabNav.append("button")
.attr("class", `tab-btn tab-btn-${qos}`)
.style("flex", "1")
.style("padding", "15px")
.style("background", state.selectedQoS === qos ? qosInfo.color : colors.lightGray)
.style("color", state.selectedQoS === qos ? colors.white : colors.darkGray)
.style("border", "none")
.style("border-radius", "8px 8px 0 0")
.style("cursor", "pointer")
.style("font-weight", "bold")
.style("font-size", "14px")
.style("transition", "all 0.2s")
.html(`${qosInfo.name}<br><span style="font-size:11px;font-weight:normal">${qosInfo.subtitle}</span>`)
.on("click", () => {
state.selectedQoS = qos;
renderMainContent();
});
});
// Single panel content
const contentArea = mainContent.append("div");
renderQoSPanel(state.selectedQoS, contentArea, "100%");
// Stats panel shows selected QoS details
statsPanel.selectAll("*").remove();
const qos = qosLevels[state.selectedQoS];
statsPanel.append("h4")
.style("margin", "0 0 15px 0")
.style("color", colors.navy)
.html(`${qos.name} Details`);
const detailsGrid = statsPanel.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(auto-fit, minmax(200px, 1fr))")
.style("gap", "15px");
// Handshake steps
const stepsCard = detailsGrid.append("div")
.style("background", colors.white)
.style("padding", "15px")
.style("border-radius", "8px")
.style("border-left", `4px solid ${qos.color}`);
stepsCard.append("div")
.style("font-weight", "bold")
.style("color", colors.navy)
.style("margin-bottom", "10px")
.text("Handshake Steps");
qos.steps.forEach((step, i) => {
stepsCard.append("div")
.style("display", "flex")
.style("align-items", "center")
.style("gap", "8px")
.style("padding", "5px 0")
.html(`
<span style="background:${qos.color};color:white;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:bold">${i + 1}</span>
<span style="font-size:13px">${step}</span>
<span style="font-size:11px;color:${colors.gray}">${i % 2 === 0 ? "Client → Broker" : "Broker → Client"}</span>
`);
});
// Guarantee card
const guaranteeCard = detailsGrid.append("div")
.style("background", colors.white)
.style("padding", "15px")
.style("border-radius", "8px")
.style("border-left", `4px solid ${colors.green}`);
guaranteeCard.append("div")
.style("font-weight", "bold")
.style("color", colors.navy)
.style("margin-bottom", "10px")
.text("Delivery Guarantee");
guaranteeCard.append("div")
.style("font-size", "14px")
.style("color", colors.darkGray)
.text(qos.guarantee);
// Use case card
const useCaseCard = detailsGrid.append("div")
.style("background", colors.white)
.style("padding", "15px")
.style("border-radius", "8px")
.style("border-left", `4px solid ${colors.orange}`);
useCaseCard.append("div")
.style("font-weight", "bold")
.style("color", colors.navy)
.style("margin-bottom", "10px")
.text("Typical Use Case");
useCaseCard.append("div")
.style("font-size", "14px")
.style("color", colors.darkGray)
.text(qos.useCase);
}
}
// Export Panel
const exportPanel = container.append("div")
.style("background", colors.lightGray)
.style("padding", "15px 20px")
.style("border-radius", "0 0 12px 12px")
.style("display", "flex")
.style("gap", "15px")
.style("align-items", "center")
.style("justify-content", "flex-end")
.style("border-top", `2px solid ${colors.gray}`);
exportPanel.append("span")
.style("font-size", "13px")
.style("color", colors.navy)
.style("font-weight", "bold")
.style("margin-right", "auto")
.text("Export Configuration:");
// Export JSON button
const exportJsonBtn = exportPanel.append("button")
.text("Download JSON")
.style("padding", "10px 20px")
.style("background", colors.teal)
.style("color", colors.white)
.style("border", "none")
.style("border-radius", "6px")
.style("cursor", "pointer")
.style("font-size", "13px")
.style("font-weight", "bold")
.style("transition", "all 0.2s")
.on("mouseover", function() {
d3.select(this).style("background", colors.darkTeal);
})
.on("mouseout", function() {
d3.select(this).style("background", colors.teal);
})
.on("click", () => {
const exportConfig = {
exportedAt: new Date().toISOString(),
tool: "MQTT QoS Playground",
configuration: {
selectedQoS: state.selectedQoS,
comparisonMode: state.comparisonMode,
networkConditions: {
packetLoss: state.packetLoss,
latency: state.latency,
connectionDropRate: state.connectionDropRate
},
messageSettings: {
retryTimeout: state.retryTimeout,
maxRetries: state.maxRetries
}
},
statistics: stats,
qosLevelInfo: qosLevels
};
const blob = new Blob([JSON.stringify(exportConfig, null, 2)], {type: "application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "qos-playground-config.json";
a.click();
URL.revokeObjectURL(url);
});
// Copy to clipboard button
const copyBtn = exportPanel.append("button")
.text("Copy to Clipboard")
.style("padding", "10px 20px")
.style("background", colors.navy)
.style("color", colors.white)
.style("border", "none")
.style("border-radius", "6px")
.style("cursor", "pointer")
.style("font-size", "13px")
.style("font-weight", "bold")
.style("transition", "all 0.2s")
.on("mouseover", function() {
d3.select(this).style("background", colors.darkGray);
})
.on("mouseout", function() {
d3.select(this).style("background", colors.navy);
})
.on("click", function() {
const exportConfig = {
exportedAt: new Date().toISOString(),
tool: "MQTT QoS Playground",
configuration: {
selectedQoS: state.selectedQoS,
comparisonMode: state.comparisonMode,
networkConditions: {
packetLoss: state.packetLoss,
latency: state.latency,
connectionDropRate: state.connectionDropRate
},
messageSettings: {
retryTimeout: state.retryTimeout,
maxRetries: state.maxRetries
}
},
statistics: stats,
qosLevelInfo: qosLevels
};
navigator.clipboard.writeText(JSON.stringify(exportConfig, null, 2)).then(() => {
const btn = d3.select(this);
btn.text("Copied!").style("background", colors.green);
setTimeout(() => {
btn.text("Copy to Clipboard").style("background", colors.navy);
}, 2000);
});
});
// Initial render
renderMainContent();
return container.node();
}