Created
July 26, 2025 18:37
-
-
Save bliaxiong/d0123c722642696aaaac7fb06a1c7e6e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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