Public
Edited
May 18
Insert cell
Insert cell
import { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';

export default function SensorDataFlowVisualization() {
const svgRef = useRef(null);
const [flowMode, setFlowMode] = useState('bidirectional');
const [flowSpeed, setFlowSpeed] = useState(2);
const [isPlaying, setIsPlaying] = useState(true);
const [showAnomalies, setShowAnomalies] = useState(false);
const [selectedSensor, setSelectedSensor] = useState(null);
// Simulation and animation references
const simulationRef = useRef(null);
const animationRef = useRef(null);
const timeRef = useRef(0);
useEffect(() => {
if (!svgRef.current) return;
// Clear previous SVG content
d3.select(svgRef.current).selectAll("*").remove();
// Dimensions
const width = 800;
const height = 600;
// Sensor types and their characteristic behaviors
const sensorTypes = {
'main': { count: 5, color: '#ff8042', size: 8 }, // Main control sensors
'wave': { count: 15, color: '#8884d8', size: 6 }, // Wave pattern
'linear': { count: 10, color: '#82ca9d', size: 6 }, // Linear trend
'constant': { count: 8, color: '#ffc658', size: 5 }, // Constant values
'edge': { count: 5, color: '#e91e63', size: 4 } // Edge/peripheral sensors
};
// Generate sensor nodes with hierarchical structure
// This will help create a more realistic flow network
const nodes = [];
let idCounter = 1;
// First create main control sensors (central nodes)
Object.entries(sensorTypes).forEach(([type, config]) => {
for (let i = 0; i < config.count; i++) {
const nodeTier = type === 'main' ? 0 :
type === 'edge' ? 2 : 1;
nodes.push({
id: `sensor-${idCounter}`,
label: `${type.charAt(0).toUpperCase()}${idCounter}`,
group: type,
tier: nodeTier, // 0 = central, 1 = middle, 2 = edge
radius: config.size,
baseRadius: config.size,
color: config.color,
baseColor: config.color,
value: 0,
// Position hints for initial layout
x: width/2 + (Math.cos(i * Math.PI * 2 / config.count) * (nodeTier * 120 + 50)),
y: height/2 + (Math.sin(i * Math.PI * 2 / config.count) * (nodeTier * 120 + 50)),
// Behavior function based on the group
behavior: (time) => {
switch(type) {
case 'wave':
return Math.sin(time * 0.1 + i) * 10 + 50;
case 'linear':
return 20 + (time * 0.05) + (i * 0.2);
case 'constant':
return 30 + i;
case 'main':
return (Math.sin(time * 0.05) * 15 + 70) * (1 + Math.cos(time * 0.02 + i) * 0.2);
case 'edge':
return 40 + (Math.sin(time * 0.1 + i * 2) * 10);
default:
return 50;
}
},
// Random chance to be anomalous
isAnomalyProne: Math.random() < 0.15,
// Anomaly phase tracker (0 = normal, 1 = green flash, 2 = red)
anomalyPhase: 0,
// Timer for phase transitions
anomalyTimer: 0
});
idCounter++;
}
});
// Create links between nodes that will direct the data flow
// Different link patterns based on selected flow mode
const generateLinks = (flowMode) => {
const links = [];
// Helper function to create a link
const createLink = (source, target, direction) => {
return {
source: source.id,
target: target.id,
value: 1,
// Direction: 1 = source→target, -1 = target→source, 0 = bidirectional
direction: direction,
// Data particles will follow these paths
particles: []
};
};
// Group nodes by tier for easier reference
const tierNodes = [
nodes.filter(n => n.tier === 0), // Central/main
nodes.filter(n => n.tier === 1), // Middle
nodes.filter(n => n.tier === 2) // Edge
];
// Establish different connection patterns based on flow mode
if (flowMode === 'outward') {
// Outward flow: center → middle → edge
// Connect each central node to multiple middle nodes
tierNodes[0].forEach(central => {
// Each central connects to ~5 middle nodes
const connections = Math.floor(3 + Math.random() * 4);
for (let i = 0; i < connections; i++) {
const target = tierNodes[1][Math.floor(Math.random() * tierNodes[1].length)];
links.push(createLink(central, target, 1));
}
});
// Connect middle nodes to edge nodes
tierNodes[1].forEach(middle => {
// Each middle connects to 1-3 edge nodes
const connections = Math.floor(1 + Math.random() * 2);
for (let i = 0; i < connections; i++) {
if (tierNodes[2].length > 0) {
const target = tierNodes[2][Math.floor(Math.random() * tierNodes[2].length)];
links.push(createLink(middle, target, 1));
}
}
});
}
else if (flowMode === 'inward') {
// Inward flow: edge → middle → center
// Connect edge nodes to middle nodes
tierNodes[2].forEach(edge => {
const target = tierNodes[1][Math.floor(Math.random() * tierNodes[1].length)];
links.push(createLink(edge, target, 1));
});
// Connect middle nodes to central nodes
tierNodes[1].forEach(middle => {
const target = tierNodes[0][Math.floor(Math.random() * tierNodes[0].length)];
links.push(createLink(middle, target, 1));
});
}
else if (flowMode === 'circular') {
// Circular flow: creates rings of connections
// Connect within each tier in a ring
tierNodes.forEach(tierGroup => {
for (let i = 0; i < tierGroup.length; i++) {
const next = (i + 1) % tierGroup.length;
links.push(createLink(tierGroup[i], tierGroup[next], 1));
}
});
// Add some cross-tier connections
for (let i = 0; i < 8; i++) {
const tierA = Math.floor(Math.random() * 3);
const tierB = (tierA + 1) % 3;
const sourceNode = tierNodes[tierA][Math.floor(Math.random() * tierNodes[tierA].length)];
const targetNode = tierNodes[tierB][Math.floor(Math.random() * tierNodes[tierB].length)];
links.push(createLink(sourceNode, targetNode, 1));
}
}
else { // bidirectional or other modes
// Create a more general network with mixed directions
// Connect central nodes to each other
for (let i = 0; i < tierNodes[0].length; i++) {
for (let j = i + 1; j < tierNodes[0].length; j++) {
links.push(createLink(tierNodes[0][i], tierNodes[0][j], 0));
}
}
// Connect central to middle nodes
tierNodes[0].forEach(central => {
// Each central connects to several middle nodes
const connections = Math.floor(2 + Math.random() * 3);
for (let i = 0; i < connections; i++) {
const target = tierNodes[1][Math.floor(Math.random() * tierNodes[1].length)];
// Mix of directions
const direction = Math.random() < 0.5 ? 1 : (Math.random() < 0.5 ? -1 : 0);
links.push(createLink(central, target, direction));
}
});
// Connect middle to edge nodes
tierNodes[1].forEach(middle => {
if (Math.random() < 0.7) { // Not all middle nodes connect to edge
const target = tierNodes[2][Math.floor(Math.random() * tierNodes[2].length)];
// Mix of directions
const direction = Math.random() < 0.3 ? 1 : (Math.random() < 0.3 ? -1 : 0);
links.push(createLink(middle, target, direction));
}
});
// Add some random connections for a more realistic network
for (let i = 0; i < 8; i++) {
const source = nodes[Math.floor(Math.random() * nodes.length)];
const target = nodes[Math.floor(Math.random() * nodes.length)];
if (source !== target) {
const direction = Math.random() < 0.3 ? 1 : (Math.random() < 0.3 ? -1 : 0);
links.push(createLink(source, target, direction));
}
}
}
return links;
};
// Generate initial links based on current flow mode
let links = generateLinks(flowMode);
// Create SVG container
const svg = d3.select(svgRef.current)
.attr("viewBox", [0, 0, width, height])
.attr("width", width)
.attr("height", height)
.attr("style", "max-width: 100%; height: auto; background-color: #f8f9fa;");
// Create a gradient for data flow
const defs = svg.append("defs");
// Define different gradient types for various flow patterns
const gradientTypes = [
{ id: "normal", colors: [{offset: "0%", color: "#8884d8"}, {offset: "100%", color: "#82ca9d"}] },
{ id: "anomaly", colors: [{offset: "0%", color: "#ff0000"}, {offset: "100%", color: "#ffcc00"}] },
{ id: "anomaly-green", colors: [{offset: "0%", color: "#00cc00"}, {offset: "100%", color: "#66ff66"}] }
];
gradientTypes.forEach(gradient => {
const linearGradient = defs.append("linearGradient")
.attr("id", gradient.id)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
gradient.colors.forEach(stop => {
linearGradient.append("stop")
.attr("offset", stop.offset)
.attr("stop-color", stop.color);
});
});
// Create groups for different layers
const linksGroup = svg.append("g").attr("class", "links");
const particlesGroup = svg.append("g").attr("class", "particles");
const nodesGroup = svg.append("g").attr("class", "nodes");
const labelsGroup = svg.append("g").attr("class", "labels");
// Create arrow markers for directed links
defs.append("marker")
.attr("id", "arrowForward")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 15)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#999");
defs.append("marker")
.attr("id", "arrowBackward")
.attr("viewBox", "0 -5 10 10")
.attr("refX", -5)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M10,-5L0,0L10,5")
.attr("fill", "#999");
// Create links with appropriate markers based on direction
const linkElements = linksGroup.selectAll("path")
.data(links)
.enter()
.append("path")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.attr("stroke-width", d => d.value)
.attr("fill", "none")
.attr("marker-end", d => d.direction >= 0 ? "url(#arrowForward)" : null)
.attr("marker-start", d => d.direction <= 0 ? "url(#arrowBackward)" : null);
// Create nodes
const nodeElements = nodesGroup.selectAll("circle")
.data(nodes)
.enter()
.append("circle")
.attr("r", d => d.radius)
.attr("fill", d => d.color)
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.call(drag(simulationRef))
.on("click", (event, d) => {
// Toggle selected sensor
setSelectedSensor(selectedSensor === d.id ? null : d.id);
});
// Add labels
const labels = labelsGroup.selectAll("text")
.data(nodes)
.enter()
.append("text")
.text(d => d.label)
.attr("font-size", "8px")
.attr("text-anchor", "middle")
.attr("dy", "-10px")
.style("pointer-events", "none"); // Don't interfere with node interaction
// Create tooltip
const tooltip = d3.select("body")
.append("div")
.style("position", "absolute")
.style("visibility", "hidden")
.style("background-color", "white")
.style("border", "solid")
.style("border-width", "1px")
.style("border-radius", "5px")
.style("padding", "10px")
.style("font-size", "12px")
.style("z-index", "10");
// Initialize force simulation
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(80))
.force("charge", d3.forceManyBody().strength(-150))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(d => d.radius * 2))
.force("x", d3.forceX(width / 2).strength(0.05))
.force("y", d3.forceY(height / 2).strength(0.05));
// Store simulation in ref for access outside
simulationRef.current = simulation;
// Function to update path for curved links
const updateLinkPaths = () => {
linkElements.attr("d", d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
// Shorter links are straighter
const curve = Math.min(dr * 0.5, 30);
// Use quadratic curves for a smoother look
return `M${d.source.x},${d.source.y} Q${(d.source.x + d.target.x) / 2 + dy / 8},${(d.source.y + d.target.y) / 2 - dx / 8} ${d.target.x},${d.target.y}`;
});
};
// Define tick function for updating positions
const ticked = () => {
// Update node and label positions
nodeElements
.attr("cx", d => d.x)
.attr("cy", d => d.y);
labels
.attr("x", d => d.x)
.attr("y", d => d.y);
// Update link paths (curved)
updateLinkPaths();
};
// Register tick handler
simulation.on("tick", ticked);
// Create particles for data flow visualization
const createParticle = (link, isAnomaly = false, anomalyPhase = 2) => {
// Define length for animation duration
const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target);
if (!sourceNode || !targetNode) return null;
// Create particle
return {
id: Math.random().toString(36).substr(2, 9),
link: link,
progress: 0, // Animation progress from 0 to 1
size: 3 + Math.random() * 2,
speed: 0.005 + Math.random() * 0.005,
isAnomaly: isAnomaly,
anomalyPhase: anomalyPhase
};
};
// Update all sensors' values and check for anomalies
const updateSensorValues = (time) => {
nodes.forEach(node => {
// Calculate normal value based on sensor behavior
const normalValue = node.behavior(time);
// Check if this sensor should show anomalous behavior
const shouldBeAnomaly = node.isAnomalyProne &&
showAnomalies &&
(time % 100 > 70 && time % 100 < 85);
if (shouldBeAnomaly) {
// If anomaly should start and node is currently normal
if (node.anomalyPhase === 0) {
// Start anomaly with green phase
node.anomalyPhase = 1;
node.anomalyTimer = 0;
node.value = normalValue + (Math.random() * 20 - 10);
node.radius = node.baseRadius * 1.3;
node.color = '#00cc00'; // Green flash
node.anomalyActive = true;
}
// If already in anomaly state, update the timer and check for phase transition
else {
node.anomalyTimer += 1;
// After 20 frames (~1 second) in green phase, switch to red
if (node.anomalyPhase === 1 && node.anomalyTimer > 20) {
node.anomalyPhase = 2;
node.color = 'red'; // Switch to red
node.radius = node.baseRadius * 1.5;
}
// Erratic behavior increases with time
node.value = normalValue + (Math.random() * 30 - 15);
}
} else {
// Return to normal if anomaly should end
if (node.anomalyPhase !== 0) {
node.anomalyPhase = 0;
node.anomalyTimer = 0;
node.anomalyActive = false;
}
// Normal behavior
node.value = normalValue;
node.radius = node.baseRadius;
node.color = node.baseColor;
}
// Visual feedback if this is selected sensor
if (node.id === selectedSensor) {
node.radius = node.baseRadius * 1.8;
node.stroke = "#ffcc00";
node.strokeWidth = 3;
} else {
node.stroke = "#ffffff";
node.strokeWidth = 1.5;
}
});
// Update node appearances
nodeElements
.attr("r", d => d.radius)
.attr("fill", d => d.color)
.attr("stroke", d => d.stroke)
.attr("stroke-width", d => d.strokeWidth);
};
// Spawn particles based on current data and flow
const updateParticles = (time, speed) => {
// Chance to spawn particles on links
links.forEach(link => {
// Get source and target nodes
const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target);
if (!sourceNode || !targetNode) return;
// Skip if wrong direction for current flow mode
if ((flowMode === 'outward' && sourceNode.tier > targetNode.tier) ||
(flowMode === 'inward' && sourceNode.tier < targetNode.tier)) {
return;
}
// For bidirectional links, show particles in both directions
const flowDirections = [];
if (link.direction >= 0) flowDirections.push(1); // Forward
if (link.direction <= 0) flowDirections.push(-1); // Backward
// Spawn chance based on animation speed and node values
const valueSum = sourceNode.value + targetNode.value;
const spawnChance = (0.01 + valueSum / 2000) * speed;
flowDirections.forEach(direction => {
if (Math.random() < spawnChance) {
// Check if source or target has anomaly
const isAnomaly = sourceNode.anomalyActive || targetNode.anomalyActive;
const anomalyPhase = sourceNode.anomalyActive ? sourceNode.anomalyPhase :
targetNode.anomalyActive ? targetNode.anomalyPhase : 0;
const particle = createParticle(link, isAnomaly, anomalyPhase);
if (particle) {
particle.direction = direction;
link.particles.push(particle);
}
}
});
});
// Update existing particles
links.forEach(link => {
link.particles = link.particles.filter(particle => {
// Update progress based on speed and direction
particle.progress += particle.speed * speed * particle.direction;
// Remove if complete
return particle.progress >= 0 && particle.progress <= 1;
});
});
// Flatten particles for rendering
const allParticles = links.flatMap(link => link.particles);
// Update particle elements
const particleElements = particlesGroup.selectAll("circle.particle")
.data(allParticles, d => d.id);
// Enter new particles
particleElements.enter()
.append("circle")
.attr("class", "particle")
.attr("r", d => d.size)
.attr("fill", d => {
if (!d.isAnomaly) return "url(#normal)";
return d.anomalyPhase === 1 ? "url(#anomaly-green)" : "url(#anomaly)";
})
.merge(particleElements)
.attr("cx", d => {
const link = d.link;
const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target);
if (!sourceNode || !targetNode) return 0;
// Calculate position along the path
// This is a simplified approach - for curved paths we'd need more complex math
const x = sourceNode.x + (targetNode.x - sourceNode.x) * d.progress;
return x;
})
.attr("cy", d => {
const link = d.link;
const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target);
if (!sourceNode || !targetNode) return 0;
// Calculate position along the path
const y = sourceNode.y + (targetNode.y - sourceNode.y) * d.progress;
return y;
});
// Remove old particles
particleElements.exit().remove();
};
// Animation frame handler
const animate = () => {
if (isPlaying) {
// Update time counter
timeRef.current += 0.2 * flowSpeed;
// Update sensor values based on time
updateSensorValues(timeRef.current);
// Update particles for data flow
updateParticles(timeRef.current, flowSpeed);
}
// Continue animation loop
animationRef.current = requestAnimationFrame(animate);
};
// Start animation
animationRef.current = requestAnimationFrame(animate);
// Create drag handler function
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.current.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.current.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
// Clean up on unmount
return () => {
if (simulationRef.current) {
simulationRef.current.stop();
}
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
tooltip.remove();
};
}, [flowMode, flowSpeed, isPlaying, showAnomalies, selectedSensor]);
// When flow mode changes, we need to recreate the visualization
const handleFlowModeChange = (newMode) => {
setFlowMode(newMode);
};
return (
<div className="bg-gray-50 rounded-lg p-4">
<h2 className="text-xl font-bold mb-4">Sensor Data Flow Visualization</h2>
<div className="flex flex-wrap gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Flow Pattern</label>
<select
value={flowMode}
onChange={(e) => handleFlowModeChange(e.target.value)}
className="border rounded px-2 py-1"
>
<option value="bidirectional">Bidirectional (Mixed)</option>
<option value="outward">Outward (Center → Edge)</option>
<option value="inward">Inward (Edge → Center)</option>
<option value="circular">Circular (Ring Pattern)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Flow Speed</label>
<input
type="range"
min="0.5"
max="5"
step="0.5"
value={flowSpeed}
onChange={(e) => setFlowSpeed(parseFloat(e.target.value))}
className="w-32"
/>
<span className="ml-2 text-sm">{flowSpeed}x</span>
</div>
<div className="flex items-end">
<button
className={`px-3 py-1 rounded ${isPlaying ? 'bg-red-500 text-white' : 'bg-blue-500 text-white'}`}
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
<div className="flex items-end">
<label className="flex items-center">
<input
type="checkbox"
checked={showAnomalies}
onChange={() => setShowAnomalies(!showAnomalies)}
className="mr-2"
/>
<span>Show Anomalies</span>
</label>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<svg ref={svgRef} width="800" height="600" />
</div>
<div className="mt-4 p-4 bg-gray-100 rounded-lg text-sm">
<h3 className="font-semibold mb-2">Understanding Sensor Data Flow:</h3>
<ul className="list-disc pl-5">
<li><span className="font-medium">Nodes:</span> 43 sensors categorized by type (main control, wave pattern, linear trend, constant, edge)</li>
<li><span className="font-medium">Arrows:</span> Direction of data flow between sensors</li>
<li><span className="font-medium">Moving particles:</span> Actual data signals flowing through your sensor network</li>
<li><span className="font-medium">Green→Red nodes/particles:</span> Anomalies that first flash green then turn red, indicating erratic sensor behavior (visible when "Show Anomalies" is enabled)</li>
</ul>
<p className="mt-2">
This visualization demonstrates how sensor data moves through your network in different flow patterns. The particles represent
actual data signals traveling between sensors. When a sensor behaves erratically, it first flashes green as an early warning, then turns red as the anomaly
propagates through the network, showing how failures can cascade through your system.
</p>
</div>
</div>
);
}
Insert cell
data = FileAttachment("miserables.json").json()
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more