496 lines
14 KiB
JavaScript
496 lines
14 KiB
JavaScript
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const socket = new WebSocket(`${protocol}//${window.location.host}`);
|
|
|
|
socket.addEventListener('open', () => {
|
|
console.log('WebSocket connection established');
|
|
});
|
|
|
|
socket.addEventListener('close', () => {
|
|
console.log('WebSocket connection closed');
|
|
});
|
|
|
|
socket.addEventListener('error', (error) => {
|
|
console.error('WebSocket error:', error);
|
|
});
|
|
|
|
const nodes = new vis.DataSet();
|
|
const edges = new vis.DataSet();
|
|
|
|
const container = document.getElementById('network');
|
|
const data = {
|
|
nodes: nodes,
|
|
edges: edges
|
|
};
|
|
const DEFAULT_COLOR = '#e0e0e0'; // Very light grey
|
|
const MIN_COLOR_DISTANCE = 100; // Minimum color distance for new colors
|
|
|
|
let pieChart = null;
|
|
let colorDistribution = {};
|
|
let showPieChart = false; // Hide pie chart by default
|
|
let showOutlierHalos = true; // Show outlier halos by default
|
|
let nodesToUpdate = [];
|
|
let edgesToUpdate = [];
|
|
|
|
// Create explanation text
|
|
const explanationText = document.createElement('div');
|
|
explanationText.id = 'explanationText';
|
|
explanationText.style.position = 'fixed';
|
|
explanationText.style.bottom = '10px';
|
|
explanationText.style.left = '10px';
|
|
explanationText.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
|
explanationText.style.color = 'white';
|
|
explanationText.style.padding = '10px';
|
|
explanationText.style.borderRadius = '5px';
|
|
explanationText.style.fontFamily = 'Arial, sans-serif';
|
|
explanationText.style.fontSize = '14px';
|
|
explanationText.style.zIndex = '1000';
|
|
explanationText.innerHTML = `
|
|
<div>DST Visualiser</div>
|
|
<div>Toggle outlier halos: H</div>
|
|
<div>Toggle pie chart: P</div>
|
|
`;
|
|
document.body.appendChild(explanationText);
|
|
|
|
function throttle(func, limit) {
|
|
let lastFunc;
|
|
let lastRan;
|
|
return function() {
|
|
const context = this;
|
|
const args = arguments;
|
|
if (!lastRan) {
|
|
func.apply(context, args);
|
|
lastRan = Date.now();
|
|
} else {
|
|
clearTimeout(lastFunc);
|
|
lastFunc = setTimeout(function() {
|
|
if ((Date.now() - lastRan) >= limit) {
|
|
func.apply(context, args);
|
|
lastRan = Date.now();
|
|
}
|
|
}, limit - (Date.now() - lastRan));
|
|
}
|
|
}
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
function updatePieChart() {
|
|
if (!showPieChart) {
|
|
document.getElementById('pieChartContainer').style.display = 'none';
|
|
document.getElementById('colorDistribution').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const pieChartContainer = document.getElementById('pieChartContainer');
|
|
const colorDistributionElement = document.getElementById('colorDistribution');
|
|
|
|
pieChartContainer.style.display = 'block';
|
|
colorDistributionElement.style.display = 'block';
|
|
pieChartContainer.style.zIndex = '10000';
|
|
pieChartContainer.style.width = '400px';
|
|
pieChartContainer.style.height = '450px';
|
|
pieChartContainer.style.backgroundColor = 'white';
|
|
pieChartContainer.style.padding = '10px';
|
|
pieChartContainer.style.borderRadius = '10px';
|
|
|
|
const currentColorDistribution = {};
|
|
const allNodes = nodes.get();
|
|
allNodes.forEach(node => {
|
|
const color = node.color.background;
|
|
currentColorDistribution[color] = (currentColorDistribution[color] || 0) + 1;
|
|
});
|
|
|
|
const colors = Object.keys(currentColorDistribution);
|
|
const data = colors.map(color => currentColorDistribution[color]);
|
|
const totalNodes = data.reduce((a, b) => a + b, 0);
|
|
|
|
if (!pieChart) {
|
|
const ctx = document.getElementById('pieChart').getContext('2d');
|
|
pieChart = new Chart(ctx, {
|
|
type: 'pie',
|
|
data: {
|
|
labels: colors,
|
|
datasets: [{
|
|
data: data,
|
|
backgroundColor: colors
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: 'right',
|
|
labels: {
|
|
color: 'black'
|
|
}
|
|
},
|
|
tooltip: {
|
|
enabled: false
|
|
}
|
|
}
|
|
},
|
|
plugins: [{
|
|
afterDraw: function(chart) {
|
|
var ctx = chart.ctx;
|
|
ctx.save();
|
|
var centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
|
|
var centerY = (chart.chartArea.top + chart.chartArea.bottom) / 2;
|
|
|
|
chart.data.datasets.forEach(function(dataset, datasetIndex) {
|
|
var meta = chart.getDatasetMeta(datasetIndex);
|
|
if (!meta.hidden) {
|
|
meta.data.forEach(function(element, index) {
|
|
var model = element.getProps(['startAngle', 'endAngle', 'circumference', 'outerRadius', 'x', 'y']);
|
|
var midAngle = model.startAngle + (model.endAngle - model.startAngle) / 2;
|
|
var x = centerX + Math.cos(midAngle) * (model.outerRadius * 0.7);
|
|
var y = centerY + Math.sin(midAngle) * (model.outerRadius * 0.7);
|
|
|
|
ctx.font = 'bold 12px Arial';
|
|
ctx.fillStyle = 'black';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
var percent = ((dataset.data[index] / totalNodes) * 100).toFixed(1) + '%';
|
|
ctx.fillText(percent, x, y);
|
|
});
|
|
}
|
|
});
|
|
ctx.restore();
|
|
}
|
|
}]
|
|
});
|
|
} else {
|
|
pieChart.data.labels = colors;
|
|
pieChart.data.datasets[0].data = data;
|
|
pieChart.data.datasets[0].backgroundColor = colors;
|
|
pieChart.update();
|
|
}
|
|
|
|
const distributionText = colors.map(color => {
|
|
const count = currentColorDistribution[color];
|
|
const percentage = ((count / totalNodes) * 100).toFixed(1);
|
|
return `${count}/${totalNodes} (${percentage}%) nodes are ${color}`;
|
|
}).join('<br>');
|
|
colorDistributionElement.innerHTML = distributionText;
|
|
colorDistributionElement.style.fontSize = '12px';
|
|
colorDistributionElement.style.marginTop = '10px';
|
|
colorDistributionElement.style.color = 'black';
|
|
}
|
|
|
|
// Update this to not show the pie chart when the page loads
|
|
window.addEventListener('load', () => {
|
|
updatePieChart(); // This will hide the pie chart initially
|
|
resizeAndCenterNetwork(); // Ensure the network is resized and centered when the page loads
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'p' || event.key === 'P') {
|
|
showPieChart = !showPieChart;
|
|
updatePieChart();
|
|
console.log(showPieChart ? 'Pie chart is now visible' : 'Pie chart is now hidden');
|
|
} else if (event.key === 'h' || event.key === 'H') {
|
|
showOutlierHalos = !showOutlierHalos;
|
|
updateCounter(); // This will update the halos
|
|
console.log(showOutlierHalos ? 'Outlier halos are now visible' : 'Outlier halos are now hidden');
|
|
}
|
|
});
|
|
|
|
const options = {
|
|
width: '100%',
|
|
height: '100%',
|
|
physics: {
|
|
enabled: true,
|
|
solver: 'forceAtlas2Based',
|
|
forceAtlas2Based: {
|
|
gravitationalConstant: -50,
|
|
centralGravity: 0.01,
|
|
springConstant: 0.08,
|
|
springLength: 80, // Reduced from 100 to 80 (20% closer)
|
|
damping: 0.4,
|
|
avoidOverlap: 0
|
|
},
|
|
stabilization: {
|
|
enabled: true,
|
|
iterations: 1000,
|
|
updateInterval: 100,
|
|
onlyDynamicEdges: false,
|
|
fit: true
|
|
}
|
|
},
|
|
nodes: {
|
|
shape: 'circle',
|
|
size: 28,
|
|
borderWidth: 0,
|
|
borderWidthSelected: 2,
|
|
color: {
|
|
background: '#e0e0e0',
|
|
border: '#e0e0e0'
|
|
}
|
|
},
|
|
edges: {
|
|
width: 1,
|
|
color: { color: '#ffffff', opacity: 0.5 }
|
|
}
|
|
};
|
|
|
|
// Set the background color to black
|
|
document.body.style.backgroundColor = '#000000';
|
|
|
|
// Create counter display
|
|
const counterDisplay = document.createElement('div');
|
|
counterDisplay.id = 'counterDisplay';
|
|
counterDisplay.style.position = 'fixed';
|
|
counterDisplay.style.top = '10px';
|
|
counterDisplay.style.right = '10px';
|
|
counterDisplay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
|
counterDisplay.style.color = 'white';
|
|
counterDisplay.style.padding = '10px';
|
|
counterDisplay.style.borderRadius = '5px';
|
|
counterDisplay.style.fontFamily = 'Arial, sans-serif';
|
|
counterDisplay.style.fontSize = '14px';
|
|
counterDisplay.style.zIndex = '1000';
|
|
document.body.appendChild(counterDisplay);
|
|
|
|
let smallGroups = new Set();
|
|
let colorCounts = {};
|
|
let totalNodes = 0;
|
|
|
|
function calculateSmallGroups() {
|
|
colorCounts = {};
|
|
totalNodes = nodes.length;
|
|
|
|
nodes.get().forEach(node => {
|
|
const color = node.color.background;
|
|
colorCounts[color] = (colorCounts[color] || 0) + 1;
|
|
});
|
|
|
|
smallGroups = new Set();
|
|
Object.entries(colorCounts).forEach(([color, count]) => {
|
|
const percentage = ((count / totalNodes) * 100);
|
|
if (percentage < 5) {
|
|
smallGroups.add(color);
|
|
}
|
|
});
|
|
}
|
|
|
|
const updateCounter = debounce(() => {
|
|
let counterHTML = '';
|
|
Object.entries(colorCounts).forEach(([color, count]) => {
|
|
const percentage = ((count / totalNodes) * 100).toFixed(1);
|
|
counterHTML += `<div><span style="color:${color};">■</span> ${count} (${percentage}%)</div>`;
|
|
});
|
|
counterHTML += `<div>Total: ${totalNodes}</div>`;
|
|
|
|
counterDisplay.innerHTML = counterHTML;
|
|
|
|
// Update node appearances based on small groups
|
|
nodes.get().forEach(node => {
|
|
if (showOutlierHalos && smallGroups.has(node.color.background)) {
|
|
const haloColor = getContrastingColor(node.color.background);
|
|
nodesToUpdate.push({
|
|
id: node.id,
|
|
borderWidth: 3,
|
|
borderWidthSelected: 0,
|
|
color: {
|
|
...node.color,
|
|
border: haloColor
|
|
}
|
|
});
|
|
} else if (node.borderWidth !== 0 || node.color.border !== node.color.background) {
|
|
nodesToUpdate.push({
|
|
id: node.id,
|
|
borderWidth: 0,
|
|
borderWidthSelected: 2,
|
|
color: {
|
|
...node.color,
|
|
border: node.color.background
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}, 100);
|
|
|
|
const network = new vis.Network(container, data, options);
|
|
|
|
// Function to calculate the center of all nodes
|
|
function calculateNetworkCenter() {
|
|
const nodePositions = network.getPositions();
|
|
const nodeIds = Object.keys(nodePositions);
|
|
if (nodeIds.length === 0) return { x: 0, y: 0 };
|
|
|
|
const sum = nodeIds.reduce((acc, id) => {
|
|
acc.x += nodePositions[id].x;
|
|
acc.y += nodePositions[id].y;
|
|
return acc;
|
|
}, { x: 0, y: 0 });
|
|
|
|
return {
|
|
x: sum.x / nodeIds.length,
|
|
y: sum.y / nodeIds.length
|
|
};
|
|
}
|
|
|
|
// Ensure the network takes up the full browser window and center the view
|
|
function resizeAndCenterNetwork() {
|
|
container.style.width = '100vw';
|
|
container.style.height = '100vh';
|
|
const center = calculateNetworkCenter();
|
|
network.moveTo({
|
|
position: center,
|
|
scale: 1,
|
|
animation: {
|
|
duration: 1000,
|
|
easingFunction: 'easeInOutQuad'
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener('resize', resizeAndCenterNetwork);
|
|
// Call this function once to center the network initially
|
|
// Removed the stabilizationIterationsDone event listener to avoid redundant calls to resizeAndCenterNetwork
|
|
|
|
|
|
let lastPieChartUpdateTime = 0;
|
|
const pieChartUpdateInterval = 1000; // Update pie chart every second
|
|
|
|
function updateNodes() {
|
|
if (nodesToUpdate.length > 0) {
|
|
nodes.update(nodesToUpdate);
|
|
nodesToUpdate = [];
|
|
}
|
|
|
|
if (edgesToUpdate.length > 0) {
|
|
edges.update(edgesToUpdate);
|
|
edgesToUpdate = [];
|
|
}
|
|
|
|
calculateSmallGroups();
|
|
updateCounter();
|
|
|
|
const currentTime = performance.now();
|
|
if (currentTime - lastPieChartUpdateTime >= pieChartUpdateInterval) {
|
|
updatePieChart();
|
|
lastPieChartUpdateTime = currentTime;
|
|
}
|
|
|
|
requestAnimationFrame(updateNodes);
|
|
}
|
|
|
|
updateNodes();
|
|
|
|
const processUpdates = (batchedUpdates) => {
|
|
batchedUpdates.forEach(logData => {
|
|
const { msg_hash, receivedTime, peerId, newNode } = logData;
|
|
|
|
if (newNode) {
|
|
nodesToUpdate.push({
|
|
id: peerId,
|
|
title: `Node ${peerId}`,
|
|
borderWidth: 0,
|
|
borderWidthSelected: 2,
|
|
color: {
|
|
background: DEFAULT_COLOR,
|
|
border: DEFAULT_COLOR,
|
|
highlight: {
|
|
background: DEFAULT_COLOR,
|
|
border: DEFAULT_COLOR
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const colorHex = generateColorFromHash(msg_hash);
|
|
|
|
nodesToUpdate.push({
|
|
id: peerId,
|
|
color: {
|
|
background: colorHex,
|
|
highlight: {
|
|
background: colorHex
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add or update edge
|
|
const edgeId = `${msg_hash}-${peerId}`;
|
|
if (!edges.get(edgeId)) {
|
|
edgesToUpdate.push({
|
|
id: edgeId,
|
|
from: msg_hash,
|
|
to: peerId,
|
|
arrows: 'to',
|
|
color: { color: '#ffffff', opacity: 0.5 }
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
socket.addEventListener('message', (event) => {
|
|
const batchedUpdates = JSON.parse(event.data);
|
|
processUpdates(batchedUpdates);
|
|
});
|
|
|
|
// Initial counter update
|
|
updateCounter();
|
|
|
|
|
|
function generateColorFromHash(hash) {
|
|
const colorInt = parseInt(hash.slice(2), 16);
|
|
return '#' + (colorInt % 0xFFFFFF).toString(16).padStart(6, '0');
|
|
}
|
|
function hexToRgb(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? {
|
|
r: parseInt(result[1], 16),
|
|
g: parseInt(result[2], 16),
|
|
b: parseInt(result[3], 16)
|
|
} : null;
|
|
}
|
|
|
|
function rgbToHex(rgb) {
|
|
return "#" + ((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1);
|
|
}
|
|
|
|
function getContrastingColor(hexColor) {
|
|
const nodeColor = hexToRgb(hexColor);
|
|
const options = [
|
|
{ name: 'cyan', color: { r: 0, g: 255, b: 255 } },
|
|
{ name: 'bright pink', color: { r: 255, g: 20, b: 147 } },
|
|
{ name: 'bright green', color: { r: 0, g: 255, b: 0 } },
|
|
{ name: 'white', color: { r: 255, g: 255, b: 255 } }
|
|
];
|
|
|
|
let bestColor = options[0];
|
|
let maxDistance = 0;
|
|
|
|
options.forEach(option => {
|
|
const distance = colorDistance(nodeColor, option.color);
|
|
if (distance > maxDistance) {
|
|
maxDistance = distance;
|
|
bestColor = option;
|
|
}
|
|
});
|
|
|
|
return rgbToHex(bestColor.color);
|
|
}
|
|
|
|
function colorDistance(rgb1, rgb2) {
|
|
return Math.sqrt(
|
|
Math.pow(rgb1.r - rgb2.r, 2) +
|
|
Math.pow(rgb1.g - rgb2.g, 2) +
|
|
Math.pow(rgb1.b - rgb2.b, 2)
|
|
);
|
|
}
|