Skip to content

Instantly share code, notes, and snippets.

@bliaxiong
Created July 26, 2025 18:37
Show Gist options
  • Save bliaxiong/d0123c722642696aaaac7fb06a1c7e6e to your computer and use it in GitHub Desktop.
Save bliaxiong/d0123c722642696aaaac7fb06a1c7e6e to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Streaming Tornado Histogram</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/plotly.js/2.18.0/plotly.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.controls {
margin-bottom: 20px;
text-align: center;
}
button {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.start { background-color: #4CAF50; color: white; }
.stop { background-color: #f44336; color: white; }
.clear { background-color: #2196F3; color: white; }
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}
.stat-group {
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
}
.stat-group h3 {
margin-top: 0;
color: #333;
}
#tornado-chart {
width: 100%;
height: 600px;
}
</style>
</head>
<body>
<div class="container">
<h1>Real-Time Tornado Histogram</h1>
<p>This tornado chart compares two data streams side by side, with positive values on the right and negative values on the left.</p>
<div class="controls">
<button id="startBtn" class="start">Start Streaming</button>
<button id="stopBtn" class="stop">Stop Streaming</button>
<button id="clearBtn" class="clear">Clear Data</button>
</div>
<div id="tornado-chart"></div>
<div class="stats">
<div class="stat-group">
<h3>Dataset A (Right Side)</h3>
<p><strong>Data Points:</strong> <span id="dataCountA">0</span></p>
<p><strong>Mean:</strong> <span id="meanA">0</span></p>
<p><strong>Std Dev:</strong> <span id="stdDevA">0</span></p>
</div>
<div class="stat-group">
<h3>Dataset B (Left Side)</h3>
<p><strong>Data Points:</strong> <span id="dataCountB">0</span></p>
<p><strong>Mean:</strong> <span id="meanB">0</span></p>
<p><strong>Std Dev:</strong> <span id="stdDevB">0</span></p>
</div>
</div>
</div>
<script>
// Data storage for two datasets
let datasetA = []; // Will be shown on right (positive)
let datasetB = []; // Will be shown on left (negative)
let isStreaming = false;
let streamInterval;
let currentMaxA = { bin: null, count: 0 };
let currentMaxB = { bin: null, count: 0 };
let previousCountsA = [];
let previousCountsB = [];
let flashingBarsA = new Set();
let flashingBarsB = new Set();
let decreasingBarsA = new Set();
let decreasingBarsB = new Set();
// DOM elements
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const clearBtn = document.getElementById('clearBtn');
// Stat elements
const dataCountAEl = document.getElementById('dataCountA');
const meanAEl = document.getElementById('meanA');
const stdDevAEl = document.getElementById('stdDevA');
const dataCountBEl = document.getElementById('dataCountB');
const meanBEl = document.getElementById('meanB');
const stdDevBEl = document.getElementById('stdDevB');
// Create dynamic lines for highest bars
function createDynamicLines(histA, histB) {
if (histA.counts.length === 0 || histB.counts.length === 0) {
return [];
}
const lines = [];
// Find highest bar for Dataset A (right side)
const maxCountA = Math.max(...histA.counts);
const maxIndexA = histA.counts.indexOf(maxCountA);
if (maxCountA > 0) {
currentMaxA = { bin: histA.bins[maxIndexA], count: maxCountA };
lines.push({
type: 'line',
x0: 0, x1: maxCountA + 5,
y0: maxIndexA, y1: maxIndexA,
line: {
color: '#00FF88',
width: 3,
dash: 'solid'
}
});
}
// Find highest bar for Dataset B (left side)
const maxCountB = Math.max(...histB.counts);
const maxIndexB = histB.counts.indexOf(maxCountB);
if (maxCountB > 0) {
currentMaxB = { bin: histB.bins[maxIndexB], count: maxCountB };
lines.push({
type: 'line',
x0: -(maxCountB + 5), x1: 0,
y0: maxIndexB, y1: maxIndexB,
line: {
color: '#FF8800',
width: 3,
dash: 'solid'
}
});
}
return lines;
}
// Create dynamic annotations for highest bars
function createDynamicAnnotations(histA, histB) {
if (histA.counts.length === 0 || histB.counts.length === 0) {
return [];
}
const annotations = [];
// Annotation for Dataset A max
const maxCountA = Math.max(...histA.counts);
const maxIndexA = histA.counts.indexOf(maxCountA);
if (maxCountA > 0) {
annotations.push({
x: maxCountA + 8,
y: maxIndexA,
text: `Call Wall: ${histA.bins[maxIndexA]}`,
showarrow: true,
arrowhead: 2,
arrowsize: 1,
arrowwidth: 2,
arrowcolor: '#00FF88',
font: { color: '#00FF88', size: 11, family: 'Arial Black' },
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#00FF88',
borderwidth: 1
});
}
// Annotation for Dataset B max
const maxCountB = Math.max(...histB.counts);
const maxIndexB = histB.counts.indexOf(maxCountB);
if (maxCountB > 0) {
annotations.push({
x: -(maxCountB + 8),
y: maxIndexB,
text: `Put Wall: ${histB.bins[maxIndexB]}`,
showarrow: true,
arrowhead: 2,
arrowsize: 1,
arrowwidth: 2,
arrowcolor: '#FF8800',
font: { color: '#FF8800', size: 11, family: 'Arial Black' },
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#FF8800',
borderwidth: 1
});
}
return annotations;
}
// Initialize the tornado chart
function initializeTornado() {
const traceA = {
y: [],
x: [],
type: 'bar',
orientation: 'h',
name: 'Dataset A',
marker: {
color: 'rgba(54, 162, 235, 0.7)',
line: {
color: 'rgba(54, 162, 235, 1)',
width: 0
}
},
hovertemplate: 'Dataset A<br>Range: %{y}<br>Count: %{x}<extra></extra>'
};
const traceB = {
y: [],
x: [],
type: 'bar',
orientation: 'h',
name: 'Dataset B',
marker: {
color: 'rgba(255, 99, 132, 0.7)',
line: {
color: 'rgba(255, 99, 132, 1)',
width: 0
}
},
hovertemplate: 'Dataset B<br>Range: %{y}<br>Count: %{x}<extra></extra>'
};
const layout = {
title: 'Real-Time Tornado Histogram with Dynamic Walls',
xaxis: {
title: 'Frequency',
zeroline: true,
zerolinecolor: '#666',
zerolinewidth: 2,
range: [-50, 50]
},
yaxis: {
title: 'Value Ranges',
side: 'left'
},
barmode: 'relative',
showlegend: true,
legend: {
x: 1,
y: 1,
xanchor: 'right'
},
margin: { t: 50, r: 50, b: 50, l: 80 },
shapes: [],
annotations: []
};
const config = {
responsive: true,
displayModeBar: true
};
Plotly.newPlot('tornado-chart', [traceA, traceB], layout, config);
}
// Generate streaming data for dataset A (e.g., sales data)
function generateDataA() {
const rand = Math.random();
let value;
if (rand < 0.6) {
// Normal distribution around 100
value = 100 + (Math.random() - 0.5) * 40;
} else if (rand < 0.9) {
// Some higher values
value = 150 + Math.random() * 50;
} else {
// Rare low values
value = 50 + Math.random() * 30;
}
return Math.max(0, value); // Ensure positive
}
// Generate streaming data for dataset B (e.g., costs data)
function generateDataB() {
const rand = Math.random();
let value;
if (rand < 0.7) {
// Normal distribution around 80
value = 80 + (Math.random() - 0.5) * 30;
} else if (rand < 0.9) {
// Some lower values
value = 40 + Math.random() * 25;
} else {
// Rare high values
value = 120 + Math.random() * 40;
}
return Math.max(0, value); // Ensure positive
}
// Create histogram bins and counts for both datasets with aligned bins
function createAlignedHistogramData(dataA, dataB, numBins = 15) {
if (dataA.length === 0 && dataB.length === 0) {
return {
histA: { bins: [], counts: [] },
histB: { bins: [], counts: [] }
};
}
// Combine both datasets to find overall min/max for consistent binning
const allData = [...dataA, ...dataB];
const min = Math.floor(Math.min(...allData));
const max = Math.ceil(Math.max(...allData));
const binWidth = Math.max(1, Math.round((max - min) / numBins));
const bins = [];
const countsA = [];
const countsB = [];
// Create bins with whole number ranges
let currentBin = min;
while (currentBin < max) {
const binEnd = Math.min(currentBin + binWidth, max);
const countA = dataA.filter(value =>
value >= currentBin && value < binEnd
).length;
const countB = dataB.filter(value =>
value >= currentBin && value < binEnd
).length;
// Use whole numbers for bin labels
if (binWidth === 1) {
bins.push(`${currentBin}`);
} else {
bins.push(`${currentBin}-${binEnd-1}`);
}
countsA.push(countA);
countsB.push(countB);
currentBin += binWidth;
}
// Detect which bars increased or decreased
flashingBarsA.clear();
flashingBarsB.clear();
decreasingBarsA.clear();
decreasingBarsB.clear();
if (previousCountsA.length === countsA.length) {
for (let i = 0; i < countsA.length; i++) {
if (countsA[i] > previousCountsA[i]) {
flashingBarsA.add(i);
} else if (countsA[i] < previousCountsA[i]) {
decreasingBarsA.add(i);
}
}
}
if (previousCountsB.length === countsB.length) {
for (let i = 0; i < countsB.length; i++) {
if (countsB[i] > previousCountsB[i]) {
flashingBarsB.add(i);
} else if (countsB[i] < previousCountsB[i]) {
decreasingBarsB.add(i);
}
}
}
// Store current counts for next comparison
previousCountsA = [...countsA];
previousCountsB = [...countsB];
return {
histA: { bins, counts: countsA },
histB: { bins, counts: countsB }
};
}
// Update tornado chart
function updateTornado() {
const { histA, histB } = createAlignedHistogramData(datasetA, datasetB);
// Make dataset B negative for left side of tornado
const countsB = histB.counts.map(count => -count);
// Create color arrays for flashing effect
const colorsA = histA.counts.map((count, index) => {
if (flashingBarsA.has(index)) {
return '#87CEEB'; // Light blue color for increasing bars
} else if (decreasingBarsA.has(index)) {
return '#FFF1C2'; // Soft cream color for decreasing bars
}
return 'rgba(54, 162, 235, 0.7)'; // Default blue
});
const colorsB = histB.counts.map((count, index) => {
if (flashingBarsB.has(index)) {
return '#FF69B4'; // Hot pink color for increasing bars
} else if (decreasingBarsB.has(index)) {
return '#FFF1C2'; // Soft cream color for decreasing bars
}
return 'rgba(255, 99, 132, 0.7)'; // Default red
});
const updateA = {
y: [histA.bins],
x: [histA.counts],
'marker.color': [colorsA]
};
const updateB = {
y: [histB.bins],
x: [countsB],
'marker.color': [colorsB]
};
// Update both traces
Plotly.restyle('tornado-chart', updateA, [0]);
Plotly.restyle('tornado-chart', updateB, [1]);
// Update shapes and annotations with current histogram data
const layoutUpdate = {
shapes: createDynamicLines(histA, histB),
annotations: createDynamicAnnotations(histA, histB)
};
Plotly.relayout('tornado-chart', layoutUpdate);
// Clear flashing effect after a short delay
setTimeout(() => {
const resetColorsA = histA.counts.map(() => 'rgba(54, 162, 235, 0.7)');
const resetColorsB = histB.counts.map(() => 'rgba(255, 99, 132, 0.7)');
Plotly.restyle('tornado-chart', {'marker.color': [resetColorsA]}, [0]);
Plotly.restyle('tornado-chart', {'marker.color': [resetColorsB]}, [1]);
}, 300); // Flash for 300ms
}
// Calculate statistics
function calculateStats(data) {
if (data.length === 0) {
return { count: 0, mean: 0, stdDev: 0 };
}
const count = data.length;
const mean = data.reduce((a, b) => a + b, 0) / count;
const variance = data.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / count;
const stdDev = Math.sqrt(variance);
return { count, mean, stdDev };
}
// Update statistics display
function updateStats() {
const statsA = calculateStats(datasetA);
const statsB = calculateStats(datasetB);
dataCountAEl.textContent = statsA.count;
meanAEl.textContent = statsA.mean.toFixed(2);
stdDevAEl.textContent = statsA.stdDev.toFixed(2);
dataCountBEl.textContent = statsB.count;
meanBEl.textContent = statsB.mean.toFixed(2);
stdDevBEl.textContent = statsB.stdDev.toFixed(2);
}
// Start streaming
function startStreaming() {
if (isStreaming) return;
isStreaming = true;
startBtn.disabled = true;
stopBtn.disabled = false;
streamInterval = setInterval(() => {
// Determine if this update adds or removes data
const rand = Math.random();
if (rand < 0.8) {
// 80% chance to add new data points
const newValueA = generateDataA();
const newValueB = generateDataB();
datasetA.push(newValueA);
datasetB.push(newValueB);
} else {
// 20% chance to remove some data points (simulating decreasing activity)
if (datasetA.length > 10) {
// Remove 1-3 random data points from each dataset
const removeCountA = Math.floor(Math.random() * 3) + 1;
const removeCountB = Math.floor(Math.random() * 3) + 1;
for (let i = 0; i < removeCountA && datasetA.length > 0; i++) {
const randomIndex = Math.floor(Math.random() * datasetA.length);
datasetA.splice(randomIndex, 1);
}
for (let i = 0; i < removeCountB && datasetB.length > 0; i++) {
const randomIndex = Math.floor(Math.random() * datasetB.length);
datasetB.splice(randomIndex, 1);
}
}
}
// Keep only last 500 points for each dataset to prevent memory issues
if (datasetA.length > 500) {
datasetA.shift();
}
if (datasetB.length > 500) {
datasetB.shift();
}
// Update visualization
updateTornado();
updateStats();
}, 200); // Update every 200ms
}
// Stop streaming
function stopStreaming() {
if (!isStreaming) return;
isStreaming = false;
startBtn.disabled = false;
stopBtn.disabled = true;
if (streamInterval) {
clearInterval(streamInterval);
}
}
// Clear all data
function clearData() {
datasetA = [];
datasetB = [];
currentMaxA = { bin: null, count: 0 };
currentMaxB = { bin: null, count: 0 };
previousCountsA = [];
previousCountsB = [];
flashingBarsA.clear();
flashingBarsB.clear();
decreasingBarsA.clear();
decreasingBarsB.clear();
updateTornado();
updateStats();
}
// Event listeners
startBtn.addEventListener('click', startStreaming);
stopBtn.addEventListener('click', stopStreaming);
clearBtn.addEventListener('click', clearData);
// Initialize
initializeTornado();
updateStats();
stopBtn.disabled = true;
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment