// ============================================
// Pub/Sub Flow Simulator
// 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",
darkTeal: "#0E6655"
};
// State management
let publishers = [
{ id: 1, name: "Temp Sensor", topic: "home/livingroom/temperature" },
{ id: 2, name: "Humidity Sensor", topic: "home/livingroom/humidity" }
];
let subscribers = [
{ id: 1, name: "Dashboard", filter: "home/livingroom/+" },
{ id: 2, name: "Logger", filter: "home/#" }
];
let nextPubId = 3;
let nextSubId = 3;
let selectedPublisher = publishers[0];
let messageContent = "25.5";
let lastMatchResults = null;
let animationInProgress = false;
// Pre-built scenarios
const scenarios = {
basic: {
name: "Basic Matching",
description: "Simple exact topic matching between publishers and subscribers",
publishers: [
{ id: 1, name: "Kitchen Temp", topic: "kitchen/temperature" },
{ id: 2, name: "Kitchen Humidity", topic: "kitchen/humidity" }
],
subscribers: [
{ id: 1, name: "Temp Monitor", filter: "kitchen/temperature" },
{ id: 2, name: "Humidity Monitor", filter: "kitchen/humidity" }
]
},
wildcards: {
name: "Wildcard Demo",
description: "Demonstrates + (single-level) and # (multi-level) wildcards",
publishers: [
{ id: 1, name: "Sensor A", topic: "sensors/floor1/room1/temp" },
{ id: 2, name: "Sensor B", topic: "sensors/floor1/room2/temp" },
{ id: 3, name: "Sensor C", topic: "sensors/floor2/room1/humidity" }
],
subscribers: [
{ id: 1, name: "Floor 1 Temps", filter: "sensors/floor1/+/temp" },
{ id: 2, name: "All Sensors", filter: "sensors/#" },
{ id: 3, name: "Room 1 Only", filter: "sensors/+/room1/+" }
]
},
multilevel: {
name: "Multi-Level Hierarchy",
description: "Complex topic hierarchy with multiple levels and mixed wildcards",
publishers: [
{ id: 1, name: "Device 1", topic: "building/floor1/office/desk1/light" },
{ id: 2, name: "Device 2", topic: "building/floor1/office/desk2/temp" },
{ id: 3, name: "Device 3", topic: "building/floor2/lab/bench1/pressure" },
{ id: 4, name: "Device 4", topic: "building/floor1/lobby/status" }
],
subscribers: [
{ id: 1, name: "Floor 1 Office", filter: "building/floor1/office/#" },
{ id: 2, name: "All Floors", filter: "building/+/+/+/+" },
{ id: 3, name: "Building Wide", filter: "building/#" },
{ id: 4, name: "Status Only", filter: "+/+/+/status" }
]
}
};
// Topic matching function (MQTT wildcards)
function topicMatches(topic, filter) {
const topicParts = topic.split('/');
const filterParts = filter.split('/');
let ti = 0, fi = 0;
while (fi < filterParts.length) {
const fp = filterParts[fi];
if (fp === '#') {
// # matches everything from here to the end
return { matches: true, reason: `'#' matches remaining levels: '${topicParts.slice(ti).join('/')}'` };
}
if (ti >= topicParts.length) {
return { matches: false, reason: `Filter has more levels than topic (missing: '${filterParts.slice(fi).join('/')}')` };
}
if (fp === '+') {
// + matches exactly one level
ti++;
fi++;
continue;
}
if (fp !== topicParts[ti]) {
return { matches: false, reason: `Level mismatch: '${fp}' != '${topicParts[ti]}'` };
}
ti++;
fi++;
}
if (ti < topicParts.length) {
return { matches: false, reason: `Topic has extra levels: '${topicParts.slice(ti).join('/')}'` };
}
return { matches: true, reason: "Exact match on all levels" };
}
// Create main container
const container = d3.create("div")
.style("font-family", "system-ui, -apple-system, sans-serif")
.style("max-width", "1100px")
.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", "15px 20px")
.style("color", colors.white);
header.append("h3")
.style("margin", "0 0 5px 0")
.style("font-size", "20px")
.text("MQTT Pub/Sub Message Flow Simulator");
header.append("p")
.style("margin", "0")
.style("opacity", "0.9")
.style("font-size", "13px")
.text("Visualize how messages route from publishers through a broker to matching subscribers");
// Scenario selector
const scenarioPanel = container.append("div")
.style("background", colors.lightGray)
.style("padding", "12px 20px")
.style("display", "flex")
.style("flex-wrap", "wrap")
.style("gap", "10px")
.style("align-items", "center");
scenarioPanel.append("span")
.style("font-weight", "bold")
.style("color", colors.navy)
.style("font-size", "13px")
.text("Pre-built Scenarios:");
Object.entries(scenarios).forEach(([key, scenario]) => {
scenarioPanel.append("button")
.text(scenario.name)
.style("padding", "8px 14px")
.style("background", colors.white)
.style("border", `2px solid ${colors.teal}`)
.style("border-radius", "6px")
.style("cursor", "pointer")
.style("font-size", "12px")
.style("color", colors.navy)
.style("font-weight", "bold")
.style("transition", "all 0.2s")
.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));
});
// Main content area - 3 columns
const mainArea = container.append("div")
.style("display", "grid")
.style("grid-template-columns", "220px 1fr 220px")
.style("gap", "15px")
.style("padding", "15px")
.style("background", colors.white);
// === LEFT COLUMN: Publishers ===
const pubColumn = mainArea.append("div")
.style("display", "flex")
.style("flex-direction", "column")
.style("gap", "10px");
pubColumn.append("div")
.style("font-weight", "bold")
.style("color", colors.teal)
.style("font-size", "14px")
.style("text-align", "center")
.style("padding", "8px")
.style("background", colors.lightGray)
.style("border-radius", "6px")
.text("PUBLISHERS");
// Add publisher form
const addPubForm = pubColumn.append("div")
.style("background", colors.lightGray)
.style("padding", "10px")
.style("border-radius", "6px")
.style("font-size", "11px");
addPubForm.append("input")
.attr("type", "text")
.attr("placeholder", "Publisher Name")
.attr("class", "pub-name-input")
.style("width", "100%")
.style("padding", "6px")
.style("margin-bottom", "6px")
.style("border", `1px solid ${colors.gray}`)
.style("border-radius", "4px")
.style("font-size", "11px")
.style("box-sizing", "border-box");
addPubForm.append("input")
.attr("type", "text")
.attr("placeholder", "Topic (e.g., home/temp)")
.attr("class", "pub-topic-input")
.style("width", "100%")
.style("padding", "6px")
.style("margin-bottom", "6px")
.style("border", `1px solid ${colors.gray}`)
.style("border-radius", "4px")
.style("font-size", "11px")
.style("box-sizing", "border-box");
const addPubBtn = addPubForm.append("button")
.text("+ Add Publisher")
.style("width", "100%")
.style("padding", "8px")
.style("background", colors.teal)
.style("color", colors.white)
.style("border", "none")
.style("border-radius", "4px")
.style("cursor", "pointer")
.style("font-size", "11px")
.style("font-weight", "bold");
// Publishers list
const pubList = pubColumn.append("div")
.attr("class", "publishers-list")
.style("flex", "1")
.style("overflow-y", "auto")
.style("max-height", "250px");
// === CENTER COLUMN: Broker & Visualization ===
const centerColumn = mainArea.append("div")
.style("display", "flex")
.style("flex-direction", "column")
.style("gap", "10px");
// SVG visualization area
const svgContainer = centerColumn.append("div")
.style("background", colors.lightGray)
.style("border-radius", "8px")
.style("padding", "10px")
.style("position", "relative");
const svg = svgContainer.append("svg")
.attr("viewBox", "0 0 500 350")
.attr("width", "100%")
.style("display", "block");
// Broker in the center
const brokerGroup = svg.append("g")
.attr("transform", "translate(250, 175)");
brokerGroup.append("rect")
.attr("x", -50)
.attr("y", -40)
.attr("width", 100)
.attr("height", 80)
.attr("fill", colors.navy)
.attr("rx", 10)
.attr("stroke", colors.darkGray)
.attr("stroke-width", 3);
brokerGroup.append("text")
.attr("y", -10)
.attr("text-anchor", "middle")
.attr("fill", colors.white)
.attr("font-size", "14px")
.attr("font-weight", "bold")
.text("BROKER");
brokerGroup.append("text")
.attr("y", 10)
.attr("text-anchor", "middle")
.attr("fill", colors.lightGray)
.attr("font-size", "10px")
.text("Topic Router");
// Arrows labels
svg.append("text")
.attr("x", 100)
.attr("y", 175)
.attr("text-anchor", "middle")
.attr("fill", colors.gray)
.attr("font-size", "10px")
.text("PUBLISH");
svg.append("text")
.attr("x", 400)
.attr("y", 175)
.attr("text-anchor", "middle")
.attr("fill", colors.gray)
.attr("font-size", "10px")
.text("DELIVER");
// Animation groups
const pubNodesGroup = svg.append("g").attr("class", "pub-nodes");
const subNodesGroup = svg.append("g").attr("class", "sub-nodes");
const animationGroup = svg.append("g").attr("class", "animations");
// Message controls
const msgControls = centerColumn.append("div")
.style("background", colors.navy)
.style("padding", "15px")
.style("border-radius", "8px")
.style("color", colors.white);
msgControls.append("div")
.style("font-weight", "bold")
.style("margin-bottom", "10px")
.style("font-size", "13px")
.text("Send Message");
const selectRow = msgControls.append("div")
.style("display", "flex")
.style("gap", "10px")
.style("margin-bottom", "10px")
.style("align-items", "center");
selectRow.append("label")
.style("font-size", "11px")
.text("From:");
const pubSelect = selectRow.append("select")
.attr("class", "pub-select")
.style("flex", "1")
.style("padding", "6px")
.style("border-radius", "4px")
.style("border", "none")
.style("font-size", "11px");
const msgRow = msgControls.append("div")
.style("display", "flex")
.style("gap", "10px")
.style("align-items", "center");
msgRow.append("label")
.style("font-size", "11px")
.text("Message:");
const msgInput = msgRow.append("input")
.attr("type", "text")
.attr("value", messageContent)
.attr("placeholder", "Message payload")
.style("flex", "1")
.style("padding", "6px")
.style("border-radius", "4px")
.style("border", "none")
.style("font-size", "11px")
.on("input", function() {
messageContent = this.value;
});
const sendBtn = msgControls.append("button")
.text("Send Message")
.style("margin-top", "10px")
.style("width", "100%")
.style("padding", "12px")
.style("background", colors.orange)
.style("color", colors.white)
.style("border", "none")
.style("border-radius", "6px")
.style("cursor", "pointer")
.style("font-size", "13px")
.style("font-weight", "bold");
// === RIGHT COLUMN: Subscribers ===
const subColumn = mainArea.append("div")
.style("display", "flex")
.style("flex-direction", "column")
.style("gap", "10px");
subColumn.append("div")
.style("font-weight", "bold")
.style("color", colors.purple)
.style("font-size", "14px")
.style("text-align", "center")
.style("padding", "8px")
.style("background", colors.lightGray)
.style("border-radius", "6px")
.text("SUBSCRIBERS");
// Add subscriber form
const addSubForm = subColumn.append("div")
.style("background", colors.lightGray)
.style("padding", "10px")
.style("border-radius", "6px")
.style("font-size", "11px");
addSubForm.append("input")
.attr("type", "text")
.attr("placeholder", "Subscriber Name")
.attr("class", "sub-name-input")
.style("width", "100%")
.style("padding", "6px")
.style("margin-bottom", "6px")
.style("border", `1px solid ${colors.gray}`)
.style("border-radius", "4px")
.style("font-size", "11px")
.style("box-sizing", "border-box");
addSubForm.append("input")
.attr("type", "text")
.attr("placeholder", "Filter (e.g., home/+/temp)")
.attr("class", "sub-filter-input")
.style("width", "100%")
.style("padding", "6px")
.style("margin-bottom", "6px")
.style("border", `1px solid ${colors.gray}`)
.style("border-radius", "4px")
.style("font-size", "11px")
.style("box-sizing", "border-box");
const addSubBtn = addSubForm.append("button")
.text("+ Add Subscriber")
.style("width", "100%")
.style("padding", "8px")
.style("background", colors.purple)
.style("color", colors.white)
.style("border", "none")
.style("border-radius", "4px")
.style("cursor", "pointer")
.style("font-size", "11px")
.style("font-weight", "bold");
// Subscribers list
const subList = subColumn.append("div")
.attr("class", "subscribers-list")
.style("flex", "1")
.style("overflow-y", "auto")
.style("max-height", "250px");
// === BOTTOM: Match Results Panel ===
const resultsPanel = container.append("div")
.style("background", colors.lightGray)
.style("padding", "15px")
.style("border-radius", "0 0 12px 12px");
resultsPanel.append("div")
.style("font-weight", "bold")
.style("color", colors.navy)
.style("margin-bottom", "10px")
.style("font-size", "14px")
.text("Match Explanation Panel");
const resultsContainer = resultsPanel.append("div")
.attr("class", "results-container")
.style("display", "grid")
.style("grid-template-columns", "repeat(auto-fit, minmax(250px, 1fr))")
.style("gap", "10px");
// Helper functions
function renderPublishers() {
pubList.selectAll("*").remove();
if (publishers.length === 0) {
pubList.append("div")
.style("color", colors.gray)
.style("font-style", "italic")
.style("font-size", "11px")
.style("padding", "10px")
.style("text-align", "center")
.text("No publishers. Add one above.");
return;
}
publishers.forEach(pub => {
const item = pubList.append("div")
.style("background", selectedPublisher && selectedPublisher.id === pub.id ? colors.lightGreen : colors.white)
.style("border", `2px solid ${colors.teal}`)
.style("border-radius", "6px")
.style("padding", "8px")
.style("margin-bottom", "6px")
.style("cursor", "pointer")
.on("click", () => {
selectedPublisher = pub;
renderPublishers();
updatePubSelect();
});
item.append("div")
.style("font-weight", "bold")
.style("font-size", "12px")
.style("color", colors.navy)
.style("display", "flex")
.style("justify-content", "space-between")
.style("align-items", "center")
.html(`<span>${pub.name}</span>`)
.append("button")
.text("X")
.style("background", "transparent")
.style("border", "none")
.style("color", colors.red)
.style("cursor", "pointer")
.style("font-size", "14px")
.style("font-weight", "bold")
.style("padding", "0 5px")
.on("click", (e) => {
e.stopPropagation();
publishers = publishers.filter(p => p.id !== pub.id);
if (selectedPublisher && selectedPublisher.id === pub.id) {
selectedPublisher = publishers[0] || null;
}
renderPublishers();
updatePubSelect();
renderVisualization();
});
item.append("div")
.style("font-size", "10px")
.style("color", colors.gray)
.style("margin-top", "4px")
.style("word-break", "break-all")
.text(pub.topic);
});
}
function renderSubscribers() {
subList.selectAll("*").remove();
if (subscribers.length === 0) {
subList.append("div")
.style("color", colors.gray)
.style("font-style", "italic")
.style("font-size", "11px")
.style("padding", "10px")
.style("text-align", "center")
.text("No subscribers. Add one above.");
return;
}
subscribers.forEach(sub => {
const hasWildcard = sub.filter.includes('+') || sub.filter.includes('#');
const item = subList.append("div")
.style("background", colors.white)
.style("border", `2px solid ${colors.purple}`)
.style("border-radius", "6px")
.style("padding", "8px")
.style("margin-bottom", "6px");
item.append("div")
.style("font-weight", "bold")
.style("font-size", "12px")
.style("color", colors.navy)
.style("display", "flex")
.style("justify-content", "space-between")
.style("align-items", "center")
.html(`<span>${sub.name}</span>`)
.append("button")
.text("X")
.style("background", "transparent")
.style("border", "none")
.style("color", colors.red)
.style("cursor", "pointer")
.style("font-size", "14px")
.style("font-weight", "bold")
.style("padding", "0 5px")
.on("click", () => {
subscribers = subscribers.filter(s => s.id !== sub.id);
renderSubscribers();
renderVisualization();
});
const filterRow = item.append("div")
.style("font-size", "10px")
.style("color", colors.gray)
.style("margin-top", "4px")
.style("word-break", "break-all");
filterRow.text(sub.filter);
if (hasWildcard) {
filterRow.append("span")
.style("margin-left", "5px")
.style("padding", "2px 5px")
.style("background", colors.orange)
.style("color", colors.white)
.style("border-radius", "3px")
.style("font-size", "8px")
.text("WILDCARD");
}
});
}
function updatePubSelect() {
pubSelect.selectAll("*").remove();
if (publishers.length === 0) {
pubSelect.append("option")
.attr("value", "")
.text("No publishers");
return;
}
publishers.forEach(pub => {
pubSelect.append("option")
.attr("value", pub.id)
.attr("selected", selectedPublisher && selectedPublisher.id === pub.id ? true : null)
.text(`${pub.name} (${pub.topic})`);
});
pubSelect.on("change", function() {
selectedPublisher = publishers.find(p => p.id === parseInt(this.value));
renderPublishers();
});
}
function renderVisualization() {
pubNodesGroup.selectAll("*").remove();
subNodesGroup.selectAll("*").remove();
const pubSpacing = publishers.length > 0 ? 280 / Math.max(publishers.length, 1) : 280;
const subSpacing = subscribers.length > 0 ? 280 / Math.max(subscribers.length, 1) : 280;
// Draw publisher nodes
publishers.forEach((pub, i) => {
const y = 50 + i * pubSpacing;
const isSelected = selectedPublisher && selectedPublisher.id === pub.id;
const node = pubNodesGroup.append("g")
.attr("transform", `translate(40, ${y})`)
.attr("class", `pub-node-${pub.id}`);
node.append("circle")
.attr("r", 18)
.attr("fill", isSelected ? colors.teal : colors.lightGray)
.attr("stroke", colors.teal)
.attr("stroke-width", 2);
node.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", isSelected ? colors.white : colors.navy)
.attr("font-size", "8px")
.attr("font-weight", "bold")
.text(pub.name.substring(0, 3).toUpperCase());
// Connection line to broker
pubNodesGroup.append("line")
.attr("class", `pub-line-${pub.id}`)
.attr("x1", 58)
.attr("y1", y)
.attr("x2", 200)
.attr("y2", 175)
.attr("stroke", colors.lightGray)
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4,2");
});
// Draw subscriber nodes
subscribers.forEach((sub, i) => {
const y = 50 + i * subSpacing;
const node = subNodesGroup.append("g")
.attr("transform", `translate(460, ${y})`)
.attr("class", `sub-node-${sub.id}`);
node.append("circle")
.attr("r", 18)
.attr("fill", colors.lightGray)
.attr("stroke", colors.purple)
.attr("stroke-width", 2);
node.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", colors.navy)
.attr("font-size", "8px")
.attr("font-weight", "bold")
.text(sub.name.substring(0, 3).toUpperCase());
// Connection line from broker
subNodesGroup.append("line")
.attr("class", `sub-line-${sub.id}`)
.attr("x1", 300)
.attr("y1", 175)
.attr("x2", 442)
.attr("y2", y)
.attr("stroke", colors.lightGray)
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4,2");
});
}
function animateMessage(topic, matchResults) {
if (animationInProgress) return;
animationInProgress = true;
animationGroup.selectAll("*").remove();
const pub = selectedPublisher;
if (!pub) {
animationInProgress = false;
return;
}
const pubIndex = publishers.findIndex(p => p.id === pub.id);
const pubSpacing = 280 / Math.max(publishers.length, 1);
const pubY = 50 + pubIndex * pubSpacing;
// Phase 1: Publisher to Broker
const msg1 = animationGroup.append("g");
msg1.append("circle")
.attr("r", 10)
.attr("fill", colors.orange);
msg1.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", colors.white)
.attr("font-size", "8px")
.attr("font-weight", "bold")
.text("M");
msg1.attr("transform", `translate(58, ${pubY})`)
.transition()
.duration(600)
.attr("transform", "translate(250, 175)")
.on("end", () => {
// Phase 2: Broker to Subscribers
msg1.remove();
const subSpacing = 280 / Math.max(subscribers.length, 1);
// Animate to each matching subscriber
let animationCount = 0;
const totalAnimations = matchResults.filter(r => r.matches).length;
if (totalAnimations === 0) {
// No matches - show failed message
const failMsg = animationGroup.append("g")
.attr("transform", "translate(250, 175)");
failMsg.append("circle")
.attr("r", 12)
.attr("fill", colors.red);
failMsg.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", colors.white)
.attr("font-size", "8px")
.text("X");
failMsg.transition()
.duration(1000)
.style("opacity", 0)
.on("end", () => {
failMsg.remove();
animationInProgress = false;
});
return;
}
matchResults.forEach((result, i) => {
const subY = 50 + i * subSpacing;
if (result.matches) {
const msg = animationGroup.append("g")
.attr("transform", "translate(250, 175)");
msg.append("circle")
.attr("r", 10)
.attr("fill", colors.green);
msg.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", colors.white)
.attr("font-size", "8px")
.attr("font-weight", "bold")
.text("M");
msg.transition()
.duration(600)
.attr("transform", `translate(460, ${subY})`)
.on("end", () => {
// Highlight subscriber node
svg.select(`.sub-node-${subscribers[i].id} circle`)
.transition()
.duration(200)
.attr("fill", colors.lightGreen)
.transition()
.duration(1000)
.attr("fill", colors.lightGray);
msg.transition()
.duration(500)
.style("opacity", 0)
.on("end", () => {
msg.remove();
animationCount++;
if (animationCount >= totalAnimations) {
animationInProgress = false;
}
});
});
} else {
// Show X for non-matching
const subLine = svg.select(`.sub-line-${subscribers[i].id}`);
subLine.transition()
.duration(300)
.attr("stroke", colors.lightRed)
.transition()
.duration(1000)
.attr("stroke", colors.lightGray);
}
});
if (totalAnimations === 0) {
animationInProgress = false;
}
});
}
function renderResults(topic, results) {
resultsContainer.selectAll("*").remove();
if (!results || results.length === 0) {
resultsContainer.append("div")
.style("color", colors.gray)
.style("font-style", "italic")
.style("font-size", "12px")
.text("Send a message to see match explanations.");
return;
}
// Show published topic
const topicInfo = resultsContainer.append("div")
.style("grid-column", "1 / -1")
.style("background", colors.navy)
.style("color", colors.white)
.style("padding", "10px")
.style("border-radius", "6px")
.style("margin-bottom", "5px");
topicInfo.append("div")
.style("font-size", "11px")
.style("opacity", "0.8")
.text("Published Topic:");
topicInfo.append("div")
.style("font-size", "14px")
.style("font-weight", "bold")
.style("font-family", "monospace")
.text(topic);
// Show each subscriber result
results.forEach((result, i) => {
const sub = subscribers[i];
const card = resultsContainer.append("div")
.style("background", result.matches ? colors.lightGreen : colors.lightRed)
.style("padding", "10px")
.style("border-radius", "6px")
.style("border-left", `4px solid ${result.matches ? colors.green : colors.red}`);
card.append("div")
.style("display", "flex")
.style("justify-content", "space-between")
.style("align-items", "center")
.style("margin-bottom", "5px")
.html(`
<span style="font-weight: bold; color: ${colors.navy}; font-size: 12px;">${sub.name}</span>
<span style="font-weight: bold; color: ${result.matches ? colors.green : colors.red}; font-size: 11px;">
${result.matches ? "RECEIVED" : "NOT RECEIVED"}
</span>
`);
card.append("div")
.style("font-size", "10px")
.style("color", colors.gray)
.style("margin-bottom", "5px")
.style("font-family", "monospace")
.text(`Filter: ${sub.filter}`);
card.append("div")
.style("font-size", "11px")
.style("color", colors.darkGray)
.style("padding", "5px")
.style("background", "rgba(255,255,255,0.5)")
.style("border-radius", "4px")
.text(result.reason);
});
}
function sendMessage() {
if (!selectedPublisher || animationInProgress) return;
const topic = selectedPublisher.topic;
// Calculate matches for all subscribers
const results = subscribers.map(sub => {
return topicMatches(topic, sub.filter);
});
lastMatchResults = results;
// Animate and show results
animateMessage(topic, results);
renderResults(topic, results);
}
function loadScenario(key) {
const scenario = scenarios[key];
if (!scenario) return;
publishers = scenario.publishers.map((p, i) => ({ ...p, id: i + 1 }));
subscribers = scenario.subscribers.map((s, i) => ({ ...s, id: i + 1 }));
nextPubId = publishers.length + 1;
nextSubId = subscribers.length + 1;
selectedPublisher = publishers[0] || null;
lastMatchResults = null;
renderPublishers();
renderSubscribers();
updatePubSelect();
renderVisualization();
renderResults(null, null);
}
// Event handlers
addPubBtn.on("click", () => {
const nameInput = container.select(".pub-name-input");
const topicInput = container.select(".pub-topic-input");
const name = nameInput.node().value.trim();
const topic = topicInput.node().value.trim();
if (name && topic) {
publishers.push({ id: nextPubId++, name, topic });
if (!selectedPublisher) selectedPublisher = publishers[0];
nameInput.node().value = "";
topicInput.node().value = "";
renderPublishers();
updatePubSelect();
renderVisualization();
}
});
addSubBtn.on("click", () => {
const nameInput = container.select(".sub-name-input");
const filterInput = container.select(".sub-filter-input");
const name = nameInput.node().value.trim();
const filter = filterInput.node().value.trim();
if (name && filter) {
subscribers.push({ id: nextSubId++, name, filter });
nameInput.node().value = "";
filterInput.node().value = "";
renderSubscribers();
renderVisualization();
}
});
sendBtn.on("click", sendMessage);
// Export Panel
const exportPanel = container.append("div")
.style("background", colors.navy)
.style("padding", "15px 20px")
.style("display", "flex")
.style("gap", "15px")
.style("align-items", "center")
.style("justify-content", "flex-end")
.style("border-top", `2px solid ${colors.darkGray}`);
exportPanel.append("span")
.style("font-size", "13px")
.style("color", colors.white)
.style("font-weight", "bold")
.style("margin-right", "auto")
.text("Export Flow:");
// Export JSON button
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: "Pub/Sub Flow Simulator",
publishers: publishers.map(p => ({
name: p.name,
topic: p.topic
})),
subscribers: subscribers.map(s => ({
name: s.name,
filter: s.filter,
hasWildcard: s.filter.includes('+') || s.filter.includes('#')
})),
topicMappings: publishers.map(pub => ({
publisher: pub.name,
topic: pub.topic,
matchingSubscribers: subscribers
.filter(sub => topicMatches(pub.topic, sub.filter).matches)
.map(sub => sub.name)
})),
lastMessage: selectedPublisher ? {
from: selectedPublisher.name,
topic: selectedPublisher.topic,
content: messageContent,
matchResults: lastMatchResults ? subscribers.map((sub, i) => ({
subscriber: sub.name,
filter: sub.filter,
received: lastMatchResults[i].matches,
reason: lastMatchResults[i].reason
})) : null
} : null,
scenarios: Object.keys(scenarios)
};
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 = "pubsub-flow-config.json";
a.click();
URL.revokeObjectURL(url);
});
// Copy to clipboard button
exportPanel.append("button")
.text("Copy to Clipboard")
.style("padding", "10px 20px")
.style("background", colors.orange)
.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", "#d35400");
})
.on("mouseout", function() {
d3.select(this).style("background", colors.orange);
})
.on("click", function() {
const exportConfig = {
exportedAt: new Date().toISOString(),
tool: "Pub/Sub Flow Simulator",
publishers: publishers.map(p => ({
name: p.name,
topic: p.topic
})),
subscribers: subscribers.map(s => ({
name: s.name,
filter: s.filter,
hasWildcard: s.filter.includes('+') || s.filter.includes('#')
})),
topicMappings: publishers.map(pub => ({
publisher: pub.name,
topic: pub.topic,
matchingSubscribers: subscribers
.filter(sub => topicMatches(pub.topic, sub.filter).matches)
.map(sub => sub.name)
})),
lastMessage: selectedPublisher ? {
from: selectedPublisher.name,
topic: selectedPublisher.topic,
content: messageContent,
matchResults: lastMatchResults ? subscribers.map((sub, i) => ({
subscriber: sub.name,
filter: sub.filter,
received: lastMatchResults[i].matches,
reason: lastMatchResults[i].reason
})) : null
} : null,
scenarios: Object.keys(scenarios)
};
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.orange);
}, 2000);
});
});
// Initial render
renderPublishers();
renderSubscribers();
updatePubSelect();
renderVisualization();
renderResults(null, null);
return container.node();
}