Files
BLE-mesh-sim/script.js
T

1247 lines
44 KiB
JavaScript

var UPDATE_INTERVAL = 10;
var CONNECTION_DISTANCE = 250;
var DROP_PENALTY = 1.2;
var TTL = 5;
var MAX_CONNECTIONS = 6;
var warningModalShown = false;
var displayWarningWhenDone = false;
var stillRouting = false;
var markNeighborInterval = null;
var showGrid = false;
var showUpdates = false;
// Track all active intervals for marking neighbors, and a cancel flag
var markNeighborIntervals = new Set();
var cancelMarkNeighborRequested = false;
var markNeighborRafs = new Set();
var nodeUpdateCache = [];
var edgeUpdateCache = [];
// Debounced batched updater for nodes/edges to reduce render thrash
var debouncedVis = (function () {
var nodeMap = new Map();
var edgeMap = new Map();
var timer = null;
var debounceMs = 50; // flush after 50ms of inactivity
function flush() {
timer = null;
if (nodeMap.size) {
nodes.update(Array.from(nodeMap.values()));
nodeMap.clear();
}
if (edgeMap.size) {
edges.update(Array.from(edgeMap.values()));
edgeMap.clear();
}
}
function schedule() {
if (timer) clearTimeout(timer);
timer = setTimeout(flush, debounceMs);
}
function enqueueNode(update) {
// keep only the latest update per id
var existing = nodeMap.get(update.id) || {};
nodeMap.set(update.id, Object.assign({}, existing, update));
schedule();
}
function enqueueEdge(update) {
var existing = edgeMap.get(update.id) || {};
edgeMap.set(update.id, Object.assign({}, existing, update));
schedule();
}
function enqueueEdges(updates) {
for (var i = 0; i < updates.length; i++) {
var u = updates[i];
var existing = edgeMap.get(u.id) || {};
edgeMap.set(u.id, Object.assign({}, existing, u));
}
schedule();
}
return { enqueueNode: enqueueNode, enqueueEdge: enqueueEdge, enqueueEdges: enqueueEdges, flush: flush };
})();
function smoothColorTransition(color1, color2, min, max, current) {
// Clamp current between min and max
const clamped = Math.min(Math.max(current, min), max);
// Convert hex to RGB
const hexToRgb = (hex) => {
hex = hex.replace(/^#/, "");
if (hex.length === 3) {
hex = hex
.split("")
.map((c) => c + c)
.join("");
}
const num = parseInt(hex, 16);
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
};
// Convert RGB to hex
const rgbToHex = (r, g, b) =>
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("");
const c1 = hexToRgb(color1);
const c2 = hexToRgb(color2);
// Normalize factor (0 → min, 1 → max)
const t = (clamped - min) / (max - min);
// Interpolate each channel
const r = Math.round(c1.r + (c2.r - c1.r) * t);
const g = Math.round(c1.g + (c2.g - c1.g) * t);
const b = Math.round(c1.b + (c2.b - c1.b) * t);
return rgbToHex(r, g, b);
}
var nodes = new vis.DataSet([
]);
var edges = new vis.DataSet([
]);
var nodeTable = {};
var grid = new GridIndex(CONNECTION_DISTANCE);
var container = document.getElementById('simContainer');
var data = {
nodes: nodes,
edges: edges,
};
var options = {
nodes: {
shape: 'box',
physics: false,
borderWidth: 0,
borderWidthSelected: 2
},
edges: {
physics: true,
smooth: {
enabled: false,
},
arrows: {
to: {
enabled: true,
scaleFactor: 0.5,
}
},
},
physics: {
enabled: true,
}
};
var network = new vis.Network(container, data, options);
let selectedNode = null;
var dotNodes = new vis.DataSet([
]);
var forwardTable = {};
var onEdgeEngine = new CompleteNodeOnEdgeEngine(network, nodes, dotNodes, edges, forwardTable);
onEdgeEngine.createEdgesTable();
onEdgeEngine.initMovement();
onEdgeEngine.setArrivalCallback(async ({ from, to, dot }) => {
var ttl = parseInt(dot.id.split('-')[2]);
if (stillRouting) {
await routePacket(to, dot.id.split('-')[4], dot.id);
return;
}
// Batch edge updates for better performance
if (document.getElementById('showTTL').checked) {
const edgeColor = smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl+1);
debouncedVis.enqueueEdges([
{id: `${from}->${to}`, color: {color: edgeColor, highlight: edgeColor}},
{id: `${to}->${from}`, color: {color: edgeColor, highlight: edgeColor}}
]);
}
if (nodeTable[to].packetCache.has(dot.id.split('-')[0])) {
return;
}
// update color of receiving node for visualization
nodeTable[to].packetCache.add(dot.id.split('-')[0]);
var packetId = dot.id.split('-')[0];
var originNode = dot.id.split('-')[3];
ttl--;
if (document.getElementById('showTTL').checked) {
debouncedVis.enqueueNode({id: to, color: {background: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl)}});
}
if (ttl > 0) {
const neighborsSnapshot = nodeTable[to].connections.slice();
for (let neighbor of neighborsSnapshot) {
if (neighbor === from || neighbor === originNode) {
continue;
}
var packetShift = generateRealisticLabel();
var movingNode = {
id: `${packetId}-${packetShift}-${ttl}-${originNode}`,
label: packetId + `-${ttl}`,
shape: dot.shape,
size: dot.size,
color: dot.color,
font: {
color: document.getElementById('darkMode').checked ? '#e0e0e0' : '#000'
}
};
onEdgeEngine.createDotNode(movingNode, to, neighbor);
}
} else {
if (document.getElementById('showTTL').checked) {
// Use a unique marker packet ID to bypass cache and mark unreachable nodes
const markerPacketId = `${packetId}`;
const neighborsSnapshot = nodeTable[to].connections.slice();
for (let neighbor of neighborsSnapshot) {
if (neighbor === from || neighbor === originNode) {
continue;
}
quickPacketRaf(neighbor, `${markerPacketId}-${generateRealisticLabel()}-${0}-${originNode}`, from);
}
}
}
});
function quickPacketRaf(startNode, packetInfo, fromNode, opts = {}) {
const packetId = packetInfo.split('-')[0];
const ttlStart = parseInt(packetInfo.split('-')[2]);
const originNode = packetInfo.split('-')[3];
// Dequeue with head index (avoid Array.shift() O(n))
let queue = [{ nodeId: startNode, ttl: ttlStart, from: fromNode }];
let head = 0;
// Tuning knobs
const timeBudgetMs = opts.timeBudgetMs ?? 100; // per frame budget
const maxPerFrame = opts.maxPerFrame ?? 2000; // hard cap per frame
let cancelled = false;
function step() {
if (cancelled) return;
const t0 = performance.now();
let processed = 0;
while (head < queue.length) {
const { nodeId, ttl, from } = queue[head++];
if (ttl < 0 && !document.getElementById('showTTL').checked) {
// legacy marking
if (document.getElementById('showTTL').checked) {
markNeighborsAsFailed(nodeId, from, packetId);
}
continue;
}
const node = nodeTable[nodeId];
if (!node) continue;
if (document.getElementById('showTTL').checked) {
if (ttl < 0) {
edgeUpdateCache.push({id: `${from}->${nodeId}`, color: {color: '#878787', highlight: '#878787'}});
edgeUpdateCache.push({id: `${nodeId}->${from}`, color: {color: '#878787', highlight: '#878787'}})
} else {
edgeUpdateCache.push({id: `${from}->${nodeId}`, color: {color: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl+1), highlight: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl)}})
edgeUpdateCache.push({id: `${nodeId}->${from}`, color: {color: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl+1), highlight: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl)}})
}
}
const cache = node.packetCache;
if (cache && cache.has(packetId)) continue;
if (document.getElementById('showTTL').checked) {
if (ttl < 0) {
nodeUpdateCache.push({id: nodeId, color: {background: '#878787'}})
} else {
nodeUpdateCache.push({id: nodeId, color: {background: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl)}})
}
}
cache.add(packetId);
// Iterate connections directly (avoid .slice() allocations)
const neighbors = node.connections || [];
for (let i = 0; i < neighbors.length; i++) {
const neighbor = neighbors[i];
if (neighbor === originNode || neighbor === from) continue;
queue.push({ nodeId: neighbor, ttl: ttl - 1, from: nodeId });
}
processed++;
if (processed >= maxPerFrame || (performance.now() - t0) >= timeBudgetMs) {
requestAnimationFrame(step);
return;
}
}
popUpdateCache();
}
requestAnimationFrame(step);
return { cancel: () => { cancelled = true; } };
}
function popUpdateCache() {
nodes.update(nodeUpdateCache);
edges.update(edgeUpdateCache);
nodeUpdateCache = []
edgeUpdateCache = []
}
// This is a modified version of quickPacket that contains some extra logic to save routing tables.
// It also disables the TTL colors for unreachable nodes to save performance.
function quickRoute(startNode, packetInfo, fromNode) {
const packetId = packetInfo.split('-')[0];
const ttlStart = parseInt(packetInfo.split('-')[2]);
const originNode = packetInfo.split('-')[3];
const queue = [{ nodeId: startNode, ttl: ttlStart, from: fromNode }];
while (queue.length > 0) {
const { nodeId, ttl, from } = queue.shift();
if (ttl < 0) {
displayWarningWhenDone = true;
continue;
}
if (nodeTable[nodeId]?.packetCache?.has(packetId)) {
continue;
}
nodeTable[nodeId].packetCache.add(packetId);
nodeTable[nodeId].routingTable[originNode] = from;
// Batch updates for better performance
if (document.getElementById('showTTL').checked) {
debouncedVis.enqueueNode({id: nodeId, color: {background: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl)}});
const edgeColor = smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl+1);
debouncedVis.enqueueEdges([
{id: `${from}->${nodeId}`, color: {color: edgeColor, highlight: edgeColor}},
{id: `${nodeId}->${from}`, color: {color: edgeColor, highlight: edgeColor}}
]);
} else {
nodes.update({id: nodeId, color: {background: '#97c2fc'}});
edges.update([
{id: `${from}->${nodeId}`, color: {color: '#2b7ce9', highlight: '#2b7ce9'}},
{id: `${nodeId}->${from}`, color: {color: '#2b7ce9', highlight: '#2b7ce9'}}
]);
}
const neighborsSnapshot = (nodeTable[nodeId]?.connections?.slice()) || [];
for (let neighbor of neighborsSnapshot) {
if (neighbor === originNode || neighbor === from) {
continue;
}
queue.push({ nodeId: neighbor, ttl: ttl - 1, from: nodeId });
}
}
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function routePacket(currentNode, goalNode, packetInfo, first=false) {
if (!nodeTable[currentNode]) {
consoleLog(`Node ${currentNode} not found. Dropping packet.`);
return;
}
if (!nodeTable[goalNode]) {
consoleLog(`Node ${goalNode} not found. Dropping packet.`);
return;
}
stillRouting = true;
const packetId = packetInfo.split('-')[0];
var packetShift = generateRealisticLabel();
let ttl = parseInt(packetInfo.split('-')[2]);
nodes.update({id: currentNode, color: {background: '#97c2fc'}});
nodes.update({id: goalNode, color: {background: '#ff00d9'}});
if (ttl <= 0) {
consoleLog(`Packet ${packetId} TTL expired. Dropping packet.`);
stillRouting = false;
return;
}
if (currentNode === goalNode) {
consoleLog(`Packet ${packetId} reached ${goalNode}.`);
stillRouting = false;
return;
}
if (document.getElementById('showTTL').checked) {
debouncedVis.enqueueNode({id: currentNode, color: {background: smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl)}});
} else {
if (first) {
nodes.update({id: currentNode, color: {background: '#ff00d9'}});
} else {
nodes.update({id: currentNode, color: {background: 'yellow'}});
}
}
ttl--;
let nextNode = nodeTable[currentNode].routingTable[goalNode];
if (!nodeTable[currentNode].connections.includes(nextNode)) {
consoleLog(`No route found from ${currentNode} to ${goalNode}. Dropping packet.`);
stillRouting = false;
return;
}
if (nextNode) {
var movingNode = {
id: `${packetId}-${packetShift}-${ttl}-${currentNode}-${goalNode}`,
label: packetId + `-${ttl}`,
shape: 'dot',
size: 10,
color: {
background: 'white',
border: 'black'
},
font: {color: document.getElementById('darkMode').checked ? '#e0e0e0' : '#000'}
};
onEdgeEngine.createDotNode(movingNode, currentNode, nextNode);
// Batch edge updates for better performance
if (document.getElementById('showTTL').checked) {
const edgeColor = smoothColorTransition('#eb4034', '#40eb34', 0, TTL, ttl+1);
debouncedVis.enqueueEdges([
{id: `${currentNode}->${nextNode}`, color: {color: edgeColor, highlight: edgeColor}},
{id: `${nextNode}->${currentNode}`, color: {color: edgeColor, highlight: edgeColor}}
]);
} else {
edges.update([
{id: `${currentNode}->${nextNode}`, color: {color: 'yellow', highlight: 'yellow'}},
{id: `${nextNode}->${currentNode}`, color: {color: 'yellow', highlight: 'yellow'}}
]);
}
} else {
consoleLog(`No route found from ${currentNode} to ${goalNode} via ${nextNode}. Dropping packet.`);
console.log(nodeTable[currentNode].routingTable)
stillRouting = false;
}
}
// RAF-driven neighbor marking system
const markNeighborsAsFailed = (startNodeId, from, packetId, visited = new Set(), opts = {}) => {
if (cancelMarkNeighborRequested) return;
if (visited.has(startNodeId)) return;
visited.add(startNodeId);
const timeBudgetMs = opts.timeBudgetMs ?? 3; // tighter budget prevents long frames
const maxOpsPerFrame = opts.maxOpsPerFrame ?? 500;
const batchSize = opts.batchSize ?? 512; // batch visual updates
// show cancel button when we start work
if (markNeighborRafs.size === 0 && markNeighborIntervals.size === 0) {
cancelMarkNeighborRequested = false;
document.getElementById('cancelMarkNeighbor').style.display = 'block';
}
// BFS queue of per-node neighbor iterators (no .slice())
const tasks = [];
let head = 0;
const pushTask = (id, fromId) => {
const n = nodeTable[id];
const neighbors = (n && n.connections) ? n.connections : [];
tasks.push({ nodeId: id, from: fromId, neighbors, idx: 0 });
};
pushTask(startNodeId, from);
// batch updates to reduce DOM churn
let nodeBatch = [];
let edgeBatch = [];
function flushBatches() {
if (nodeBatch.length) {
nodes.update(nodeBatch);
nodeBatch = [];
}
if (edgeBatch.length) {
edges.update(edgeBatch);
edgeBatch = [];
}
}
let rafId = null;
function step() {
if (cancelMarkNeighborRequested) {
if (rafId != null) {
cancelAnimationFrame(rafId);
markNeighborRafs.delete(rafId);
rafId = null;
}
flushBatches();
if (markNeighborRafs.size === 0 && markNeighborIntervals.size === 0) {
document.getElementById('cancelMarkNeighbor').style.display = 'none';
}
return;
}
const deadline = performance.now() + timeBudgetMs;
let ops = 0;
while (head < tasks.length) {
const task = tasks[head];
// process a few neighbors at a time; yield aggressively
while (task.idx < task.neighbors.length) {
const neighbor = task.neighbors[task.idx++];
if (neighbor === task.from) continue;
if (visited.has(neighbor)) continue;
const neighborNode = nodeTable[neighbor];
if (!neighborNode) continue;
if (neighborNode.packetCache?.has(packetId)) continue;
visited.add(neighbor);
// enqueue child (BFS)
const nextNeighbors = neighborNode.connections || [];
tasks.push({ nodeId: neighbor, from: task.nodeId, neighbors: nextNeighbors, idx: 0 });
// batch visuals
nodeBatch.push({ id: neighbor, color: { background: '#878787' } });
edgeBatch.push({ id: `${task.nodeId}->${neighbor}`, color: { color: '#878787', highlight: '#878787' } });
edgeBatch.push({ id: `${neighbor}->${task.nodeId}`, color: { color: '#878787', highlight: '#878787' } });
ops++;
// time/ops-based yield
if (ops >= maxOpsPerFrame || performance.now() >= deadline) {
flushBatches(); // flush once per frame
if (rafId != null) markNeighborRafs.delete(rafId);
rafId = requestAnimationFrame(step);
markNeighborRafs.add(rafId);
return;
}
// keep batches bounded
if (nodeBatch.length >= batchSize || edgeBatch.length >= batchSize * 2) {
flushBatches();
}
}
// done with this node
head++;
}
// finished
flushBatches();
if (rafId != null) {
markNeighborRafs.delete(rafId);
rafId = null;
}
if (markNeighborRafs.size === 0 && markNeighborIntervals.size === 0) {
document.getElementById('cancelMarkNeighbor').style.display = 'none';
}
}
rafId = requestAnimationFrame(step);
markNeighborRafs.add(rafId);
};
function recomputeNodeConnections(nodeId, dragging=false) {
const p = network.getPosition(nodeId);
const candidates = grid.getNeighborCandidates(p.x, p.y);
// cache node positions now to prevent duplicate work later
const posById = new Map();
for (let id of candidates) {
if (nodeTable[id]) {
posById.set(id, network.getPosition(id));
}
}
// check all nearby nodes against all other nearby nodes
for (let a of candidates) {
if (!nodeTable[a]) {
continue;
}
// in case user drags node too fast, drop far connections
if (dragging) {
for (let connection of nodeTable[a].connections) {
const aPos = network.getPosition(a);
const bPos = network.getPosition(connection);
const distance = calculateDistance(aPos,bPos);
if (distance > CONNECTION_DISTANCE) {
dropConnection(a,connection);
}
}
}
if (showUpdates) {
console.log('updating')
let lastTime;
let fadePerS = 10;
let currentFade = 0;
let animationId;
function animateFlash(time) {
if (!lastTime) {
lastTime = time;
}
const dTs = (time - lastTime) / 1000;
lastTime = time;
const diff = fadePerS * dTs;
currentFade += diff;
nodes.update({id: a, color: {background: smoothColorTransition('#eb4034', '#97c2fc', 0, 5, currentFade)}})
if (currentFade < 5) {
animationId = requestAnimationFrame(animateFlash);
} else {
nodes.update({id: a, color: {background: '#97c2fc'}});
animationId = null;
}
}
animationId = requestAnimationFrame(animateFlash);
}
let trueNeighbors = []
for (let b of candidates) {
if (a === b) {
continue;
}
if (!nodeTable[b]) {
continue;
}
const aPos = posById.get(a);
const bPos = posById.get(b);
const distance = calculateDistance(aPos,bPos);
if (distance < CONNECTION_DISTANCE) {
let connectionScore = 0;
let dropScore = 0;
connectionScore += (100 + distanceToRSSI(distance));
connectionScore += 100 / (nodeTable[b].connections.length + 1);
if (nodeTable[b].connections.length == 0) {
connectionScore += 100;
}
if (nodeTable[a].connections.includes(b) && nodeTable[b].connections.length == 1) {
// assume current node is only connection
connectionScore += 1000;
}
if (nodeTable[a].connections.includes(b)) {
dropScore = connectionScore * DROP_PENALTY;
}
trueNeighbors.push({'nodeId': b, 'distance': distanceToRSSI(distance), 'score': connectionScore, 'dropScore': dropScore});
} else {
if (nodeTable[a].connections.includes(b)) {
dropConnection(a,b);
}
}
}
trueNeighbors.sort((aS, bS) => bS.score - aS.score);
const neighborMap = new Map(trueNeighbors.map(n => [n.nodeId, n]));
const topCandidates = trueNeighbors.slice(0, MAX_CONNECTIONS);
let toDrop = new Set();
let toConnect = new Set();
for (let node of topCandidates) {
if (nodeTable[node.nodeId].connections.length >= MAX_CONNECTIONS) {
continue;
}
if (nodeTable[a].connections.length >= MAX_CONNECTIONS || (nodeTable[a].connections.length + toConnect.size) >= MAX_CONNECTIONS) {
if (nodeTable[a].connections.includes(node.nodeId)) {
continue;
}
for (let activeConnection of nodeTable[a].connections) {
if (toDrop.has(activeConnection)) {
continue;
}
if (neighborMap.get(activeConnection) && neighborMap.get(activeConnection).dropScore < node.score) {
toDrop.add(activeConnection);
toConnect.add(node.nodeId);
}
}
} else {
toConnect.add(node.nodeId);
}
}
if (toDrop.size != 0) {
for (let drop of toDrop) {
dropConnection(a, drop);
}
}
if (toConnect.size != 0) {
for (let connect of toConnect) {
// if already connected, ignore
if (nodeTable[a].connections.includes(connect)) {
continue;
}
nodeTable[a].connections.push(connect);
nodeTable[connect].connections.push(a);
connectNodes(a,connect);
}
}
}
}
function computeAllConnectionsFast() {
// Cache positions and initialize degree/connection sets
const items = nodes.get(); // [{id,x,y,...}]
const pos = new Map(items.map(n => [n.id, { x: n.x, y: n.y }]));
const ids = items.map(n => n.id);
for (let id of ids) {
if (!nodeTable[id]) {
nodeTable[id] = { connections: [], packetCache: new Set(), routingTable: {} };
}
}
const degree = new Map(ids.map(id => [id, nodeTable[id].connections.length || 0]));
const connSet = new Map(ids.map(id => [id, new Set(nodeTable[id].connections || [])]));
const newEdges = [];
// Greedy, capacity-aware linking within neighborhood
for (let a of ids) {
const ap = pos.get(a);
if (!ap) continue;
const candidates = grid.getNeighborCandidates(ap.x, ap.y);
const scored = [];
for (let b of candidates) {
if (b === a) continue;
if (!pos.has(b)) continue;
if (connSet.get(a).has(b)) continue;
const bp = pos.get(b);
const d = calculateDistance(ap, bp);
if (d >= CONNECTION_DISTANCE) continue;
// Favor closer, low-degree, and isolated nodes
const bdeg = degree.get(b) || 0;
let score = 100 + distanceToRSSI(d);
score += 100 / (bdeg + 1);
if (bdeg === 0) score += 100;
scored.push({ id: b, score });
}
if (scored.length === 0) continue;
scored.sort((x, y) => y.score - x.score);
for (let s of scored) {
if ((degree.get(a) || 0) >= MAX_CONNECTIONS) break;
const b = s.id;
if ((degree.get(b) || 0) >= MAX_CONNECTIONS) continue;
if (connSet.get(a).has(b)) continue;
connSet.get(a).add(b);
connSet.get(b).add(a);
degree.set(a, (degree.get(a) || 0) + 1);
degree.set(b, (degree.get(b) || 0) + 1);
nodeTable[a].connections.push(b);
nodeTable[b].connections.push(a);
newEdges.push({ id: `${a}->${b}`, from: a, to: b });
newEdges.push({ id: `${b}->${a}`, from: b, to: a });
if ((degree.get(a) || 0) >= MAX_CONNECTIONS) break;
}
}
if (newEdges.length > 0) {
edges.add(newEdges);
}
}
function dropConnection(nodeA, nodeB) {
edges.remove(`${nodeA}->${nodeB}`);
edges.remove(`${nodeB}->${nodeA}`);
const aIdx = nodeTable[nodeA].connections.indexOf(nodeB);
const bIdx = nodeTable[nodeB].connections.indexOf(nodeA);
if (aIdx !== -1) {
nodeTable[nodeA].connections.splice(aIdx, 1);
}
if (bIdx !== -1) {
nodeTable[nodeB].connections.splice(bIdx, 1);
}
}
function onNodeAdd(nodeId, defer=false, nodeData=null) {
// update grid
if (!defer) {
const position = network.getPosition(nodeId);
grid.add(nodeId, position.x, position.y);
recomputeNodeConnections(nodeId);
} else {
const position = nodeData;
grid.add(nodeId, nodeData.x, nodeData.y);
}
var nodeLength = Object.keys(nodeTable).length;
document.getElementById('nodeCount').innerText = 'Node Count: ' + nodeLength;
}
document.getElementById('addNode').addEventListener('click', function() {
const nodeId = generateRealisticLabel();
nodes.add({id: nodeId, label: nodeId});
nodeTable[nodeId] = {'connections': [], 'packetCache': new Set(), 'routingTable': {}};
onNodeAdd(nodeId);
});
/*
NETWORK EVENTS
*/
network.on('click', function(properties) {
if (properties.nodes.length > 0) {
selectedNode = properties.nodes[0];
} else {
selectedNode = null;
}
if (document.getElementById('quickPlace').checked) {
const nodeId = generateRealisticLabel();
nodes.add({id: nodeId, label: nodeId, x: properties.pointer.canvas.x, y: properties.pointer.canvas.y});
nodeTable[nodeId] = {'connections': [], 'packetCache': new Set(), 'routingTable': {}};
onNodeAdd(nodeId);
}
});
network.on('doubleClick', function(properties) {
if (document.getElementById('quickPlace').checked) {
selectedNode = null;
return;
}
if (properties.nodes.length > 0) {
navigator.clipboard.writeText(properties.nodes[0]);
return;
}
const nodeId = generateRealisticLabel();
nodes.add({id: nodeId, label: nodeId, x: properties.pointer.canvas.x, y: properties.pointer.canvas.y});
nodeTable[nodeId] = {'connections': [], 'packetCache': new Set(), 'routingTable': {}};
onNodeAdd(nodeId);
});
network.on('dragging', (properties) => {
if (properties.nodes.length == 0) {
return;
}
const nodeId = properties.nodes[0];
const pos = network.getPosition(nodeId);
grid.update(nodeId, pos.x, pos.y);
recomputeNodeConnections(nodeId, true);
});
network.on('beforeDrawing', function(ctx) {
if (showGrid) {
drawGrid(ctx);
}
if (selectedNode && document.getElementById('showRanges').checked) {
drawRange(ctx);
}
});
function drawGrid(ctx) {
ctx.strokeStyle = '#878787';
ctx.lineWidth = 1;
ctx.beginPath();
const scale = network.getScale();
const gridSize = CONNECTION_DISTANCE;
// Get the visible area bounds
const viewPosition = network.getViewPosition();
const canvasElement = network.canvas.frame.canvas;
const canvasWidth = canvasElement.width;
const canvasHeight = canvasElement.height;
// Calculate grid bounds based on view position and scale
const startX = Math.floor((viewPosition.x - canvasWidth / (2 * scale)) / gridSize) * gridSize;
const endX = Math.ceil((viewPosition.x + canvasWidth / (2 * scale)) / gridSize) * gridSize;
const startY = Math.floor((viewPosition.y - canvasHeight / (2 * scale)) / gridSize) * gridSize;
const endY = Math.ceil((viewPosition.y + canvasHeight / (2 * scale)) / gridSize) * gridSize;
// Draw vertical lines
for (let x = startX; x <= endX; x += gridSize) {
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
}
// Draw horizontal lines
for (let y = startY; y <= endY; y += gridSize) {
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
}
ctx.stroke();
}
function drawRange(ctx) {
ctx.strokeStyle = '#878787';
ctx.lineWidth = 1;
ctx.beginPath();
const position = network.getPosition(selectedNode);
ctx.arc(position.x, position.y, CONNECTION_DISTANCE, 0, 2 * Math.PI);
ctx.stroke();
}
document.getElementById('sendPacket').addEventListener('click', function() {
if (selectedNode == null) {
return;
}
onEdgeEngine.createEdgesTable();
const packetId = generateRealisticLabel();
var packetShift = generateRealisticLabel();
let ttl = TTL;
nodes.update({id: selectedNode, color: {background: '#97c2fc'}});
for (let connection of nodeTable[selectedNode].connections) {
var movingNode = {
id: `${packetId}-${packetShift}-${ttl}-${selectedNode}`,
label: packetId + `-${ttl}`,
shape: 'dot',
size: 6,
color: {
background: 'white',
border: 'black'
},
font: {
color: document.getElementById('darkMode').checked ? '#e0e0e0' : '#000'
}
};
onEdgeEngine.createDotNode(movingNode, selectedNode, connection);
nodeTable[selectedNode].packetCache.add(packetId);
packetShift = generateRealisticLabel();
}
});
document.getElementById('resetTTLColors').addEventListener('click', function() {
let nodeUpdates = [];
let edgeUpdates = [];
for (let node of nodes.get()) {
nodeUpdates.push({id: node.id, color: {background: '#97c2fc'}});
}
for (let edge of edges.get()) {
edgeUpdates.push({id: edge.id, color: {color: '#2b7ce9', highlight: '#2b7ce9'}});
}
nodes.update(nodeUpdates);
edges.update(edgeUpdates);
});
document.getElementById('updateInterval').addEventListener('change', function() {
UPDATE_INTERVAL = parseInt(this.value);
});
document.getElementById('simSpeed').addEventListener('change', function() {
const speed = parseFloat(this.value);
if (!isFinite(speed) || speed <= 0) {
return;
}
onEdgeEngine.setSimulationSpeed(speed);
});
document.getElementById('connectionDistance').addEventListener('change', function() {
CONNECTION_DISTANCE = parseInt(this.value);
});
document.getElementById('dropPenalty').addEventListener('change', function() {
DROP_PENALTY = parseFloat(this.value);
});
document.getElementById('ttl').addEventListener('change', function() {
TTL = parseInt(this.value);
});
document.getElementById('maxConnections').addEventListener('change', function() {
MAX_CONNECTIONS = parseInt(this.value);
});
document.getElementById('createRandomGraph').addEventListener('click', function() {
document.getElementById('randomGraphModal').style.display = 'block';
});
document.getElementById('createRandomGraphConfirm').addEventListener('click', function() {
const nodeCount = parseInt(document.getElementById('randomGraphNodes').value);
const density = Math.max(1, parseInt(document.getElementById('randomGraphDensity').value) || 1);
document.getElementById('randomGraphModal').style.display = 'none';
if (!Number.isFinite(nodeCount) || nodeCount <= 0) {
return;
}
// Reset existing graph
nodes.clear();
edges.clear();
nodeTable = {};
grid = new GridIndex(CONNECTION_DISTANCE);
selectedNode = null;
let nodesToAdd = [];
const cellSize = CONNECTION_DISTANCE;
const cellsNeeded = Math.max(1, Math.ceil(nodeCount / density));
const cols = Math.ceil(Math.sqrt(cellsNeeded));
const rows = Math.ceil(cellsNeeded / cols);
// Center the layout on the current view
const center = network.getViewPosition();
// Distribute node counts across cells (roughly even, no big clumps)
const counts = new Array(cellsNeeded).fill(Math.floor(nodeCount / cellsNeeded));
let remainder = nodeCount % cellsNeeded;
const idxs = Array.from({ length: cellsNeeded }, (_, i) => i);
for (let i = idxs.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[idxs[i], idxs[j]] = [idxs[j], idxs[i]];
}
for (let i = 0; i < remainder; i++) {
counts[idxs[i]]++;
}
// Jitter within each cell so nodes are spread but remain near the cell center
const jitter = cellSize * 0.45;
let cellsPerFrame = cols;
let runId;
let nodesAdded = 0;
let cellsFilled = 0;
let lastNodeAdded;
function addCells() {
const end = Math.min(cellsFilled+cellsPerFrame, cellsNeeded);
for (let i = cellsFilled; i < end; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const cx = center.x + (col - (cols - 1) / 2) * cellSize;
const cy = center.y + (row - (rows - 1) / 2) * cellSize;
for (let k = 0; k < counts[i]; k++) {
const x = cx + (Math.random() * 2 - 1) * jitter;
const y = cy + (Math.random() * 2 - 1) * jitter;
const nodeId = generateRealisticLabel();
const nodeData = { id: nodeId, label: nodeId, x: x, y: y };
nodesToAdd.push(nodeData);
nodeTable[nodeId] = { 'connections': [], 'packetCache': new Set(), 'routingTable': {} };
onNodeAdd(nodeId, true, nodeData);
lastNodeAdded = nodeId;
nodesAdded++;
}
//recomputeNodeConnections(lastNodeAdded);
cellsFilled++;
}
if (cellsFilled < cellsNeeded) {
runId = requestAnimationFrame(addCells);
} else {
cancelAnimationFrame(runId);
console.log('done')
nodes.add(nodesToAdd);
computeAllConnectionsFast();
}
}
runId = requestAnimationFrame(addCells);
});
document.getElementById('darkMode').addEventListener('change', function() {
if (this.checked) {
document.getElementById('styleSheet').href = 'style-dark.css';
localStorage.setItem('darkMode', 'true');
} else {
document.getElementById('styleSheet').href = 'style.css';
localStorage.setItem('darkMode', 'false');
}
});
document.getElementById('quickPacket').addEventListener('click', async function() {
if (selectedNode == null) {
return;
}
const packetId = generateRealisticLabel();
var packetShift = generateRealisticLabel();
let ttl = TTL;
quickPacketRaf(selectedNode, `${packetId}-${packetShift}-${ttl}-${selectedNode}`, selectedNode);
});
document.getElementById('createRoutingTables').addEventListener('click', function() {
let nodeCounter = 0;
document.getElementById('routingProgressContainer').style.display = 'flex';
document.getElementById('routingProgress').value = 0;
document.getElementById('routingProgress').max = Object.keys(nodeTable).length;
const interval = setInterval(() => {
const packetId = generateRealisticLabel();
var packetShift = generateRealisticLabel();
let ttl = TTL;
const nodeId = Object.keys(nodeTable)[nodeCounter];
quickRoute(nodeId, `${packetId}-${packetShift}-${ttl}-${nodeId}`, nodeId);
nodeCounter++;
document.getElementById('routingProgress').value = nodeCounter;
if (nodeCounter >= Object.keys(nodeTable).length) {
clearInterval(interval);
document.getElementById('routingProgressContainer').style.display = 'none';
document.getElementById('routingProgress').value = 0;
if (displayWarningWhenDone) {
displayWarningWhenDone = false;
document.getElementById('warningModal').style.display = 'block';
document.getElementById('warningModalMessage').innerHTML = 'TTL hit zero while routing, some routes may be missing. If you experience issues, try increasing the TTL and routing again.';
document.getElementById('warningModalCustomContent').innerHTML = '';
} else {
document.getElementById('successModal').style.display = 'block';
document.getElementById('successModalMessage').innerHTML = 'Routing tables created successfully.';
}
}
}, 10);
});
window.onload = function() {
if (localStorage.getItem('darkMode') === 'true') {
document.getElementById('darkMode').checked = true;
document.getElementById('styleSheet').href = 'style-dark.css';
} else {
document.getElementById('darkMode').checked = false;
document.getElementById('styleSheet').href = 'style.css';
}
let consoleContainer = document.createElement('div');
consoleContainer.id = 'consoleContainer';
document.getElementById('simContainer').appendChild(consoleContainer);
let openConsoleButton = document.createElement('button');
openConsoleButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal-icon lucide-terminal"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></svg>';
openConsoleButton.id = 'openConsole';
openConsoleButton.addEventListener('click', function() {
const consoleElement = document.getElementById('consoleContainer');
const currentDisplay = window.getComputedStyle(consoleElement).display;
consoleElement.style.display = currentDisplay === 'none' ? 'flex' : 'none';
});
let consoleInputContainer = document.createElement('div');
consoleInputContainer.id = 'consoleInputContainer';
consoleContainer.appendChild(consoleInputContainer);
let consoleCarrot = document.createElement('pre');
consoleCarrot.innerHTML = '>';
consoleInputContainer.appendChild(consoleCarrot);
let consoleInput = document.createElement('input');
consoleInput.id = 'consoleInput';
consoleInput.placeholder = 'Enter command';
consoleInputContainer.appendChild(consoleInput);
let consoleInputHistory = [];
let consoleInputHistoryIndex = 0;
// Handle Enter key for command execution
consoleInput.addEventListener('keypress', async function(event) {
if (event.key === 'Enter') {
const command = consoleInput.value.trim();
if (command) { // Only add non-empty commands to history
consoleInputHistory.push(command);
}
consoleInputHistoryIndex = consoleInputHistory.length;
const rootCommand = command.split(' ')[0];
const args = command.split(' ')[1];
switch (rootCommand) {
case 'help':
switch (args) {
case 'routePacket':
consoleLog('Sends a packet from one specific node to another. Usage: routePacket [startNode]->[endNode]');
break;
case 'exit':
consoleLog('Exits the console');
break;
default:
consoleLog('Available commands: help, routePacket, exit');
break;
}
break;
case 'routePacket':
if (!args) {
consoleLog('Usage: routePacket [startNode]->[endNode]');
break;
}
document.getElementById('resetTTLColors').click();
const packetInfo = `${generateRealisticLabel()}-${generateRealisticLabel()}-${TTL}-${args.split('->')[0]}-${args.split('->')[1]}`;
nodes.update({id: args.split('->')[0], color: {background: '#ff00d9'}});
nodes.update({id: args.split('->')[1], color: {background: '#ff00d9'}});
await routePacket(args.split('->')[0], args.split('->')[1], packetInfo, true);
break;
case 'exit':
document.getElementById('consoleContainer').style.display = 'none';
break;
default:
consoleLog(`Unknown command "${rootCommand}"`);
break;
}
consoleInput.value = '';
}
});
// Handle arrow keys for history navigation
consoleInput.addEventListener('keydown', function(event) {
if (event.key === 'ArrowUp') {
event.preventDefault(); // Prevent cursor movement
if (consoleInputHistory.length === 0) return;
if (consoleInputHistoryIndex > 0) {
consoleInputHistoryIndex--;
}
consoleInput.value = consoleInputHistory[consoleInputHistoryIndex] || '';
} else if (event.key === 'ArrowDown') {
event.preventDefault(); // Prevent cursor movement
if (consoleInputHistory.length === 0) return;
consoleInputHistoryIndex++;
if (consoleInputHistoryIndex >= consoleInputHistory.length) {
consoleInputHistoryIndex = consoleInputHistory.length;
consoleInput.value = ''; // Clear input when going beyond history
} else {
consoleInput.value = consoleInputHistory[consoleInputHistoryIndex] || '';
}
}
});
let consoleOutput = document.createElement('pre');
consoleOutput.id = 'consoleOutput';
consoleContainer.appendChild(consoleOutput);
document.getElementById('simContainer').appendChild(openConsoleButton);
// Ensure cancel button is hidden on load
document.getElementById('cancelMarkNeighbor').style.display = 'none';
}
document.getElementById('cancelMarkNeighbor').addEventListener('click', function() {
consoleLog('Cancelling mark neighbor...');
cancelMarkNeighborRequested = true;
// Clear any tracked intervals (legacy)
for (let id of Array.from(markNeighborIntervals)) {
clearInterval(id);
markNeighborIntervals.delete(id);
}
// Clear any tracked RAFs
for (let id of Array.from(markNeighborRafs)) {
cancelAnimationFrame(id);
markNeighborRafs.delete(id);
}
document.getElementById('cancelMarkNeighbor').style.display = 'none';
});
document.getElementById('showGrid').addEventListener('change', function() {
showGrid = this.checked;
network.redraw();
});
document.getElementById('showNodeUpdates').addEventListener('change', function() {
console.log(this.checked);
showUpdates = this.checked;
});
function consoleLog(message) {
const consoleOutput = document.getElementById('consoleOutput');
consoleOutput.innerHTML = `${message}`;
consoleOutput.scrollTop = consoleOutput.scrollHeight;
}