Files
DarkflameServer/dDashboardServer/static/js/network-graph.js
Aaron Kimbrell a110594c9e feat(dashboard): Network model has ports, machines and cross-machine links
Server boxes and instance rows carry the listening port, player rows their
remote ports when the list has them. With servers on more than one machine
the graph lists the machines with their totals, a zone on several machines
is a box per machine, a folded machine is one box, and the layout stacks
each machine's boxes in a framed band; links between machines are marked.
One machine lays out as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 02:39:24 -05:00

535 lines
30 KiB
JavaScript

/**
* The Network page's diagram model, without the DOM so it can be tested with node (tests/dWebTests/network-graph.test.mjs).
*
* build(summary, options) turns what /api/diagnostics/network (and the `traffic` WebSocket topic) sends into nodes and
* links: game clients on the left, auth, chat, the worlds (one node per zone) and any other reporting server in the
* middle, then master, the dashboard and the UGC server, and web clients on the right. Nodes come from whatever servers
* report, so a new kind of server shows up by itself. Each link has its rates each way: `fwd` from `from` to `to`,
* `back` the other way, in packets (HTTP links: requests) and bytes per second; `exact` says whether they are measured
* on that link or worked out from a server's totals (older servers report no split).
*
* Groups (Game clients, Web clients, a zone with several instances) open in place: the box gets `open`, its `members`
* (single connections, or instances) and `shown` (the ids of those matching the group's filter). Each shown member is
* a link end of its own, and the open box itself has none; `graph.members` finds a member by id.
*
* Servers carry their listening `port` and their machine (`host`, a token) from master's server list. When they run on
* more than one machine, `graph.hosts` lists the machines (master's first) with their totals and the boxes on each;
* every server box has its `host`, a zone with instances on several machines is a box per machine, a machine the
* viewer collapsed (options.collapsedHosts) is one box of kind 'host', and links between two machines are `cross`.
* With one machine there are no hosts and nothing changes.
*
* layout(graph, width, moved, scroll) places the nodes in six columns in a fixed order, grows open boxes by their rows
* and gives each link its path; rows in view of an open box's list are anchors (`anchors`). With several machines
* each machine's boxes (columns 1 to 4) are a band of their own, one under the other, framed (`frames`).
*/
(function (root) {
'use strict';
var COLUMNS = [0, 1, 2, 3, 4, 5]; // game clients | auth, worlds, others | chat | master | dashboard, UGC | web clients
// Chat has a column of its own between the worlds and master: every world has a link to it
// An open group's box: its header, the filter, the rows (at most MAX_ROWS in view, the rest scroll) and a margin
var GROUP = { head: 58, filter: 30, row: 30, maxRows: 8, foot: 6 }; // head: a name on up to two lines and one line under it
// A machine's frame: room for its label above its boxes, around them, and between two frames
var FRAME = { head: 28, side: 8, gap: 6 };
function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; }
function rates(r) { r = r || {}; return { packets_in: num(r.packets_in), packets_out: num(r.packets_out), bytes_in: num(r.bytes_in), bytes_out: num(r.bytes_out) }; }
function any(r) { return r.packets_in > 0 || r.packets_out > 0 || r.bytes_in > 0 || r.bytes_out > 0; }
// The node a server belongs to (a zone's instances are all in the zone's box)
function nodeOf(server) {
switch (server.type) {
case 'AUTH': return { id: 'auth', kind: 'auth', label: 'Auth', column: 1, order: 0 };
case 'CHAT': return { id: 'chat', kind: 'chat', label: 'Chat', column: 2, order: 0 };
case 'MASTER': return { id: 'master', kind: 'master', label: 'Master', column: 3, order: 0 };
case 'DASHBOARD': return { id: 'dashboard', kind: 'dashboard', label: 'Dashboard', column: 4, order: 0 };
case 'UGC': return { id: 'ugc', kind: 'ugc', label: 'UGC server', column: 4, order: 1 };
case 'WORLD': {
var zone = num(server.zone);
// "World 1200 Nimbus Station #3" -> "World 1200 Nimbus Station"
var zoneLabel = String(server.label || ('World ' + zone)).replace(/\s+#\d+$/, '');
return { id: 'zone:' + zone, kind: 'zone', label: zoneLabel, column: 1, order: 2 + zone, zone: zone };
}
default: return { id: server.key, kind: 'other', label: server.label || server.key, column: 1, order: 1e9 };
}
}
// Which group a connection from /api/diagnostics/network/connections is a member of: players are game clients, and
// so are HTTP clients of the UGC server only (game clients fetching models; the closed box counts them there too)
function peerGroup(peer, servers) {
if (peer.kind === 'game') return 'clients';
if (peer.kind !== 'web') return null;
var list = peer.servers || [];
var ugcOnly = list.length > 0 && list.every(function (e) { return servers[e.server] && servers[e.server].type === 'UGC'; });
return ugcOnly ? 'clients' : 'web';
}
// ":1001", or '' without a port
function portLabel(port) { return typeof port === 'number' && port > 0 ? ':' + port : ''; }
// A box's name with its listening port ("Auth :1001"); a zone box has its instance's port while it has one instance
function nameOf(n) {
var port = portLabel(n.port);
return port ? n.label + ' ' + port : n.label;
}
// A machine for people: its address when the viewer may see it (`names`: token -> address), else its token
function hostLabel(host, names) {
if (host == null || host === '') return 'unknown machine';
return (names && names[host]) || host;
}
// A connection's address with its remote ports (only there with network_ips): "203.0.113.5:51234"
function peerAddress(peer) {
var ports = [];
(peer.servers || []).forEach(function (e) { if (!e.http && typeof e.port === 'number' && ports.indexOf(e.port) === -1) ports.push(e.port); });
return String(peer.address || '') + (ports.length ? ':' + ports.join('/') : '');
}
// What a group's filter matches a member by
function matches(member, filter) {
filter = String(filter || '').trim().toLowerCase();
if (!filter) return true;
return filter.split(/\s+/).every(function (word) { return member.search.indexOf(word) !== -1; });
}
function build(summary, options) {
options = options || {};
var servers = (summary && summary.servers) || {};
var keys = Object.keys(servers).sort();
var expanded = options.expanded || {}, filters = options.filters || {};
var nodes = {}, edges = {}, members = {};
// The machines the servers run on; with more than one, boxes are grouped by machine
var hostList = [];
keys.forEach(function (k) { var h = servers[k].host; if (h && hostList.indexOf(h) === -1) hostList.push(h); });
var multi = hostList.length > 1;
var collapsed = multi ? (options.collapsedHosts || {}) : {};
var hostNames = options.hostNames || {};
var masterHost = servers.master ? servers.master.host || null : null;
var zoneHosts = {}; // zone -> {host: true}
keys.forEach(function (k) {
var s = servers[k];
if (s.type === 'WORLD') (zoneHosts[num(s.zone)] = zoneHosts[num(s.zone)] || {})[s.host || ''] = true;
});
// The box a server is in: nodeOf's, a box per machine for a zone on several, or its machine's box when collapsed
function place(server) {
var spec = nodeOf(server);
if (!multi) return spec;
var h = server.host || null;
if (h && collapsed[h]) return { id: 'host:' + h, kind: 'host', label: hostLabel(h, hostNames), column: h === masterHost ? 3 : 1, order: -1, host: h };
if (spec.kind === 'zone' && Object.keys(zoneHosts[spec.zone] || {}).length > 1) spec.id += '@' + (h || '?');
spec.host = h;
return spec;
}
function node(spec) {
var n = nodes[spec.id];
if (!n) {
n = nodes[spec.id] = { id: spec.id, kind: spec.kind, label: spec.label, column: spec.column, order: spec.order, zone: spec.zone, host: spec.host, port: null, servers: [], instances: 0,
connections: 0, pingSum: 0, pingCount: 0, resends: 0, resendQueue: 0, workersBusy: 0, workersThreads: 0, workersQueued: 0, websockets: 0,
packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, http: 0, noSplit: false, open: false, members: [], shown: [] };
}
return n;
}
function member(group, spec) {
spec.group = group.id;
spec.connections = spec.connections || 0;
spec.pingSum = spec.pingSum || 0;
spec.pingCount = spec.pingCount || 0;
spec.resends = spec.resends || 0;
members[spec.id] = spec;
group.members.push(spec);
group.open = true;
return spec;
}
// Adds a link's rates; links between the same two nodes add up (every instance of a zone)
function edge(from, to, fwdPackets, fwdBytes, backPackets, backBytes, exact, kind, note) {
if (from === to) return null; // inside a collapsed machine
var id = from + '>' + to + (kind && kind !== 'packets' ? ':' + kind : '');
var e = edges[id];
if (!e) e = edges[id] = { id: id, from: from, to: to, kind: kind || 'packets', exact: true, notes: [], fwd: { packets: 0, bytes: 0 }, back: { packets: 0, bytes: 0 }, bytesKnown: { fwd: true, back: true } };
e.fwd.packets += num(fwdPackets);
e.back.packets += num(backPackets);
if (fwdBytes == null) e.bytesKnown.fwd = false; else e.fwd.bytes += num(fwdBytes);
if (backBytes == null) e.bytesKnown.back = false; else e.back.bytes += num(backBytes);
if (!exact) e.exact = false;
if (note && e.notes.indexOf(note) === -1) e.notes.push(note);
return e;
}
// Where a link to a server ends: the server's box, or its row while its zone is open
function endOf(server) {
var spec = place(server);
return spec.kind === 'zone' && expanded[num(server.zone)] ? server.key : spec.id;
}
// Master, chat and the UGC server as link ends (their machine's box while it is collapsed)
var byType = function (type) { return keys.filter(function (k) { return servers[k].type === type; }).map(function (k) { return servers[k]; })[0]; };
var masterId = byType('MASTER') ? endOf(byType('MASTER')) : 'master';
var chatId = byType('CHAT') ? endOf(byType('CHAT')) : 'chat';
var ugcId = byType('UGC') ? endOf(byType('UGC')) : 'ugc';
var clients = node({ id: 'clients', kind: 'clients', label: 'Game clients', column: 0, order: 0 });
var web = node({ id: 'web', kind: 'web', label: 'Web clients', column: 5, order: 0 });
var hasChat = keys.some(function (k) { return servers[k].type === 'CHAT'; });
keys.forEach(function (key) {
var s = servers[key];
var n = node(place(s));
var link = s.link || {}, gauges = s.gauges || {};
// Links end at the zone's box, or at the instance's row while the zone is open
var id = s.type === 'WORLD' ? endOf(s) : n.id;
n.servers.push(key);
// A box of one server has its port (a zone's while it has one instance)
n.port = n.kind !== 'host' && n.servers.length === 1 && typeof s.port === 'number' ? s.port : null;
if (s.type === 'WORLD') {
n.instances++;
if (id !== n.id) {
member(n, { id: key, kind: 'world', label: '#' + num(s.instance), title: s.label || key, server: key, instance: num(s.instance),
port: typeof s.port === 'number' ? s.port : null, host: s.host || null,
connections: num(link.connections), pingSum: num(link.connections) > 0 ? num(link.ping_ms) : 0, pingCount: num(link.connections) > 0 ? 1 : 0,
resends: num(link.resends), search: (String(s.label || key) + ' #' + num(s.instance) + ' ' + key).toLowerCase() });
}
}
n.connections += num(link.connections);
if (num(link.connections) > 0) { n.pingSum += num(link.ping_ms); n.pingCount++; }
n.resends += num(link.resends);
n.resendQueue += num(link.resend_queue);
n.workersBusy += num(gauges.workers_busy);
n.workersThreads += num(gauges.workers_threads);
n.workersQueued += num(gauges.workers_queued);
n.websockets += num(gauges.websocket_clients);
n.packetsIn += num(s.packets_in); n.packetsOut += num(s.packets_out);
n.bytesIn += num(s.bytes_in); n.bytesOut += num(s.bytes_out);
n.http += num(s.http);
var split = s.split;
if (!split) n.noSplit = true;
var totals = rates(s);
var ESTIMATED = 'from the server\'s totals (it reports no split by peer)';
// Its master link, measured on its side (master itself is the other end of all of them)
if (s.type !== 'MASTER') {
if (split) {
var m = rates(split.master);
if (any(m)) edge(id, masterId, m.packets_out, m.bytes_out, m.packets_in, m.bytes_in, true);
} else if (s.type === 'DASHBOARD' || s.type === 'UGC' || s.type === 'CHAT') {
// Nearly all of their packets are with master (chat's are with the worlds too)
edge(id, masterId, totals.packets_out, totals.bytes_out, totals.packets_in, totals.bytes_in, false, 'packets', ESTIMATED);
}
}
if (s.type === 'DASHBOARD' || s.type === 'UGC') {
// HTTP: requests from browsers and game clients (all but other servers'), and the bodies answered
var fromServers = split ? num(split.http_from_servers) : 0, fromServersBytes = split ? num(split.http_from_servers_bytes_out) : 0;
var requests = Math.max(0, num(s.http) - fromServers), bytesOut = Math.max(0, num(s.http_bytes_out) - fromServersBytes);
var who = s.type === 'UGC' ? 'clients' : 'web';
if (requests > 0 || bytesOut > 0) edge(who, id, requests, null, requests, bytesOut, !!split, 'http', split ? '' : 'other servers\' requests not told apart (older server)');
// The dashboard's own requests to the UGC server, counted where they were made
if (split && num(split.http_out) > 0) edge(id, ugcId, split.http_out, null, split.http_out, split.http_out_bytes_in, true, 'http');
return;
}
if (s.type === 'MASTER') return;
if (split) {
var c = rates(split.clients);
// A player standing still sends nothing for a while: their connection is still drawn (idle)
var players = (s.type === 'AUTH' || s.type === 'WORLD') ? Math.max(0, num((s.link || {}).connections) - 1) : 0;
if (any(c) || players > 0) edge('clients', id, c.packets_in, c.bytes_in, c.packets_out, c.bytes_out, true);
var o = rates(split.servers);
// A world's "other servers" is its chat link; chat's are the worlds, drawn from their side
if (s.type === 'WORLD' && any(o)) edge(id, hasChat ? chatId : masterId, o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, hasChat, 'packets', hasChat ? '' : 'chat link, chat not reporting');
else if (s.type !== 'CHAT' && any(o)) edge(id, masterId, o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, false, 'servers', 'with other servers, drawn to master');
} else if (s.type !== 'CHAT') {
edge('clients', id, totals.packets_in, totals.bytes_in, totals.packets_out, totals.bytes_out, false, 'packets', ESTIMATED);
}
});
// Game clients and web clients: connections from the servers they use
keys.forEach(function (key) {
var s = servers[key], link = s.link || {};
if (s.type === 'AUTH' || s.type === 'WORLD') clients.connections += Math.max(0, num(link.connections) - 1); // less its master link
});
web.websockets = keys.reduce(function (t, k) { return t + (servers[k].type === 'DASHBOARD' ? num((servers[k].gauges || {}).websocket_clients) : 0); }, 0);
// An open Game clients or Web clients box: each connection (from /api/diagnostics/network/connections) is a row
// with its own links, in place of the box's. Until the list has loaded the box keeps its summed links.
var peersLoaded = Array.isArray(options.peers);
var peerOpen = { clients: !!options.clientsExpanded && peersLoaded, web: !!options.webExpanded && peersLoaded };
if (peerOpen.clients) clients.open = true;
if (peerOpen.web) web.open = true;
var seen = {};
(peersLoaded ? options.peers : []).forEach(function (peer) {
var groupId = peerGroup(peer, servers);
if (!groupId || !peerOpen[groupId]) return;
var base = 'peer:' + groupId + ':' + (peer.account_id ? peer.account_id + '@' : '') + peer.address, id = base;
for (var k = 2; seen[id]; k++) id = base + '#' + k;
seen[id] = true;
var names = [peer.character, peer.account, peer.user];
var p = member(nodes[groupId], { id: id, kind: 'peer', label: String(peer.character || peer.account || peer.user || peer.address || ''), peer: peer,
search: names.concat([options.addressesShown ? peerAddress(peer) : ''], (peer.servers || []).map(function (e) { return e.label; }))
.filter(Boolean).join(' ').toLowerCase() });
(peer.servers || []).forEach(function (entry) {
var target = servers[entry.server];
if (!target) return;
var to = endOf(target);
if (entry.http) edge(id, to, entry.packets_in, entry.bytes_in, entry.packets_out, entry.bytes_out, true, 'peer-http');
else edge(id, to, entry.packets_in, entry.bytes_in, entry.packets_out, entry.bytes_out, true, 'peer', 'RakNet datagrams of this connection, acknowledgements and resends included');
if (!entry.http) { p.connections++; p.pingSum += num(entry.ping_ms); p.pingCount++; p.resends += num(entry.resends); }
});
});
var list = Object.keys(nodes).map(function (k) { return nodes[k]; });
// Open groups: members in a steady order (a list that is read and scrolled should not reshuffle every update),
// and the ones matching the group's filter
list.forEach(function (n) {
if (!n.open) return;
n.members.sort(function (a, b) {
return a.kind === 'world' ? a.instance - b.instance : String(a.label).localeCompare(String(b.label)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
});
n.shown = n.members.filter(function (m) { return matches(m, filters[n.id]); }).map(function (m) { return m.id; });
});
list.forEach(function (n) { n.ping = n.pingCount ? Math.round(n.pingSum / n.pingCount) : null; });
Object.keys(members).forEach(function (k) { var m = members[k]; m.ping = m.pingCount ? Math.round(m.pingSum / m.pingCount) : null; });
// A link ends at a box that is not open, or at a row its group's filter shows: an open box's traffic is its rows'
var shown = {};
list.forEach(function (n) { n.shown.forEach(function (id) { shown[id] = true; }); });
var end = function (id) { return nodes[id] ? !nodes[id].open : !!shown[id]; };
var edgeList = Object.keys(edges).sort().map(function (k) { return edges[k]; }).filter(function (e) { return end(e.from) && end(e.to); });
// Nodes nothing is going through and no server is behind (no web clients without a web server) stay out
var linked = {};
Object.keys(edges).forEach(function (k) { linked[edges[k].from] = linked[edges[k].to] = true; });
list = list.filter(function (n) { return n.servers.length || n.open || linked[n.id] || (n.kind === 'clients' && n.connections > 0); });
list.sort(function (a, b) { return a.column - b.column || a.order - b.order || String(a.label).localeCompare(String(b.label)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); });
// Several machines: each with its totals and its boxes, master's first; links between two machines are marked
var hosts = [];
if (multi) {
var byHost = {};
hosts = hostList.slice().sort(function (a, b) { return (b === masterHost) - (a === masterHost) || (a < b ? -1 : a > b ? 1 : 0); }).map(function (h) {
return byHost[h] = { id: h, label: hostLabel(h, hostNames), master: h === masterHost, collapsed: !!collapsed[h], nodes: [], servers: 0, connections: 0,
packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0 };
});
keys.forEach(function (k) {
var s = servers[k], h = byHost[s.host];
if (!h) return;
h.servers++;
h.connections += num((s.link || {}).connections);
h.packetsIn += num(s.packets_in); h.packetsOut += num(s.packets_out);
h.bytesIn += num(s.bytes_in); h.bytesOut += num(s.bytes_out);
});
list.forEach(function (n) { if (n.host && byHost[n.host]) byHost[n.host].nodes.push(n.id); });
}
var hostOf = function (id) { return (nodes[id] && nodes[id].host) || (members[id] && members[id].host) || null; };
edgeList.forEach(function (e) {
var a = multi ? hostOf(e.from) : null, b = multi ? hostOf(e.to) : null;
e.cross = !!(a && b && a !== b);
if (e.cross) e.hosts = [a, b];
});
return { nodes: list, edges: edgeList, members: members, hosts: hosts };
}
// Load of a link against its recent peak: 'idle', 'low', 'mid' or 'high'
function loadClass(value, peak) {
if (!(value > 0)) return 'idle';
var ratio = peak > 0 ? value / peak : 1;
return ratio >= 0.8 ? 'high' : ratio >= 0.4 ? 'mid' : 'low';
}
// Line width from bytes (or requests) per second: 1.5 px idle up to ~12 px
function width(value) {
return Math.round((1.5 + Math.min(10.5, Math.log10(1 + Math.max(0, value)) * 1.6)) * 10) / 10;
}
// Seconds one dash takes along the line: faster with more packets, still at nothing
function speed(packets) {
if (!(packets > 0)) return 0;
return Math.round(Math.max(0.35, Math.min(4, 4 / Math.log10(10 + packets))) * 100) / 100;
}
// An open group's box height for `rows` members (at least one row, for "no matches" or "loading")
function groupHeight(rows) {
return GROUP.head + GROUP.filter + Math.max(1, Math.min(GROUP.maxRows, rows)) * GROUP.row + GROUP.foot;
}
/**
* Positions: columns across `width` (node centres), rows in each column spread over the tallest column's height.
* `moved` ({id: {fx, y}}, optional) places nodes the viewer dragged: fx is a fraction of the width, y in pixels.
* `scroll` ({groupId: px}, optional) is how far each open box's list is scrolled.
* Returns {width, height, nodeWidth, nodeHeight, nodes: {id: {x, y, h}}, lists: {groupId: {top, height, rows}},
* anchors: {memberId: {x, y}}, paths: {edgeId: {fwd, back}}}. A node's y is the centre of its header (of the box
* when closed), h its whole height; an open box's list starts at lists[id].top and anchors are the rows in view.
*/
function layout(graph, totalWidth, moved, scroll) {
// Boxes take at most 60% of the space between column centres, so there is always room for the links between them
var nodeWidth = Math.max(120, Math.min(230, Math.floor((totalWidth - 32) / 9))), nodeHeight = 68, rowHeight = 88, pad = 16;
var byColumn = COLUMNS.map(function () { return []; });
graph.nodes.forEach(function (n) { byColumn[n.column].push(n); });
var rows = Math.max.apply(null, byColumn.map(function (c) { return c.length; }).concat([1]));
var height = rows * rowHeight + pad * 2;
var usable = totalWidth - nodeWidth - pad * 2;
var xs = [0, 0.2, 0.4, 0.6, 0.8, 1].map(function (f) { return Math.round(pad + nodeWidth / 2 + f * usable); });
var heightOf = function (n) { return n.open ? groupHeight(n.shown.length) : nodeHeight; };
var at = {};
// Rows at a fixed pitch from `top`; an open box pushes the boxes under it down by what it grew, so opening a group
// only moves the boxes of its own column. Returns where the column ends.
var stackColumn = function (column, c, top) {
var extra = 0;
column.forEach(function (n, i) {
at[n.id] = { x: xs[c], y: Math.round(top + rowHeight * (i + 0.5) + extra), h: heightOf(n), column: c };
extra += at[n.id].h - nodeHeight;
});
return top + rowHeight * column.length + extra;
};
var hosts = graph.hosts || [];
if (hosts.length < 2) {
// One machine: each column centred on the tallest
byColumn.forEach(function (column, c) { stackColumn(column, c, pad + (rows - column.length) * rowHeight / 2); });
} else {
// Several machines: each machine's boxes of columns 1 to 4 are a band, one under the other (master's first, the
// servers of no known machine last, unframed); game and web clients are centred on them all
var bandOf = {};
hosts.forEach(function (h, i) { bandOf[h.id] = i; });
var bands = hosts.map(function () { return COLUMNS.map(function () { return []; }); }).concat([COLUMNS.map(function () { return []; })]);
graph.nodes.forEach(function (n) {
if (n.column === 0 || n.column === COLUMNS.length - 1) return;
var b = n.host != null && bandOf[n.host] != null ? bandOf[n.host] : hosts.length;
bands[b][n.column].push(n);
});
var cursor = pad;
bands.forEach(function (band, b) {
var bandRows = Math.max.apply(null, band.map(function (c) { return c.length; }));
if (!bandRows) return;
var start = cursor + (b < hosts.length ? FRAME.head : 0), bottom = start;
band.forEach(function (column, c) {
if (column.length) bottom = Math.max(bottom, stackColumn(column, c, start + (bandRows - column.length) * rowHeight / 2));
});
cursor = bottom + FRAME.gap;
});
[0, COLUMNS.length - 1].forEach(function (c) {
var column = byColumn[c];
stackColumn(column, c, pad + Math.max(0, (cursor - pad - column.length * rowHeight) / 2));
});
height = Math.max(height, cursor + pad);
}
moved = moved || {};
scroll = scroll || {};
var clampX = function (x) { return Math.round(Math.min(totalWidth - pad - nodeWidth / 2, Math.max(pad + nodeWidth / 2, x))); };
// Boxes the viewer dragged
graph.nodes.forEach(function (n) {
var m = moved[n.id], a = at[n.id];
if (!m || !(m.fx >= 0) || !(m.y >= 0)) return;
a.x = clampX(m.fx * totalWidth);
a.y = Math.round(Math.max(pad + nodeHeight / 2, m.y));
});
graph.nodes.forEach(function (n) { var a = at[n.id]; height = Math.max(height, a.y - nodeHeight / 2 + a.h + pad); });
// Each machine's frame: around its boxes (wherever they were dragged), its label above them
var frames = {};
if (hosts.length > 1) {
hosts.forEach(function (h) {
var ids = h.nodes.filter(function (id) { return at[id]; });
if (!ids.length) return;
var x1 = Infinity, x2 = -Infinity, y1 = Infinity, y2 = -Infinity;
ids.forEach(function (id) {
var a = at[id];
x1 = Math.min(x1, a.x - nodeWidth / 2); x2 = Math.max(x2, a.x + nodeWidth / 2);
y1 = Math.min(y1, a.y - nodeHeight / 2); y2 = Math.max(y2, a.y - nodeHeight / 2 + a.h);
});
frames[h.id] = { x: Math.round(x1 - FRAME.side), y: Math.round(y1 - FRAME.head + 2), w: Math.round(x2 - x1 + FRAME.side * 2), h: Math.round(y2 - y1 + FRAME.head - 2 + FRAME.side) };
});
}
// An open box's rows in view of its list are link ends of their own, on the box's sides
var lists = {}, anchors = {};
graph.nodes.forEach(function (n) {
if (!n.open) return;
var a = at[n.id], top = a.y - nodeHeight / 2 + GROUP.head + GROUP.filter;
var visible = Math.max(1, Math.min(GROUP.maxRows, n.shown.length)) * GROUP.row;
var offset = Math.max(0, Math.min(num(scroll[n.id]), n.shown.length * GROUP.row - visible));
lists[n.id] = { top: top, height: visible, rows: n.shown.length, scroll: offset };
n.shown.forEach(function (id, i) {
var y = top + (i + 0.5) * GROUP.row - offset;
if (y < top || y > top + visible) return; // scrolled out of view: no links
anchors[id] = { x: a.x, y: Math.round(y), h: GROUP.row, column: a.column, row: true };
});
});
// Links leaving a box's side spread down that side, ordered by where their other end is, instead of all meeting
// at one point (an open box's rows keep their own row)
var ends = {}; // "id|side" -> [{edge, end, otherY}]
var endOf = function (id) { return at[id] || anchors[id]; };
graph.edges.forEach(function (e) {
var a = endOf(e.from), b = endOf(e.to);
if (!a || !b || Math.abs(b.x - a.x) < nodeWidth * 0.75) return;
var dir = b.x > a.x ? 1 : -1;
if (!a.row) (ends[e.from + '|' + dir] = ends[e.from + '|' + dir] || []).push({ edge: e.id, end: 'a', other: b.y });
if (!b.row) (ends[e.to + '|' + (-dir)] = ends[e.to + '|' + (-dir)] || []).push({ edge: e.id, end: 'b', other: a.y });
});
var ports = {}; // edgeId -> {a: y, b: y}
Object.keys(ends).forEach(function (key) {
var list = ends[key], box = at[key.split('|')[0]];
if (!box) return;
list.sort(function (p, q) { return p.other - q.other; });
var top = box.y - nodeHeight / 2 + 12, span = Math.min(box.h, nodeHeight) - 24;
list.forEach(function (p, i) {
var y = list.length === 1 ? box.y : top + span * i / (list.length - 1);
(ports[p.edge] = ports[p.edge] || {})[p.end] = Math.round(y);
});
});
var withPort = function (p, y) { return y === undefined ? p : { x: p.x, y: y, h: p.h, column: p.column, row: p.row, port: true }; };
var paths = {};
graph.edges.forEach(function (e) {
var a = endOf(e.from), b = endOf(e.to);
if (!a || !b) return;
var pa = withPort(a, ports[e.id] && ports[e.id].a), pb = withPort(b, ports[e.id] && ports[e.id].b);
var fwd = curve(pa, pb, nodeWidth, 3.5, nodeHeight);
paths[e.id] = { fwd: fwd.d, back: curve(pb, pa, nodeWidth, 3.5, nodeHeight).d };
// A link between two machines gets a mark half way
if (e.cross) paths[e.id].mid = fwd.mid;
});
return { width: totalWidth, height: height, nodeWidth: nodeWidth, nodeHeight: nodeHeight, nodes: at, lists: lists, anchors: anchors, paths: paths, frames: frames };
}
// A curve between two boxes (or an open box's rows), leaving and entering on the sides that face each other
// (left/right when they are apart sideways, top/bottom when one is above the other), moved `offset` px to its left so
// the two directions sit side by side. Boxes in the same column with others between them, and rows of a box with
// something in their own column, bulge out sideways instead of cutting through.
function curve(a, b, nodeWidth, offset, nodeHeight) {
var half = nodeWidth / 2, x1, y1, x2, y2, c1, c2;
var halfA = (a.row ? a.h : nodeHeight || 54) / 2, halfB = (b.row ? b.h : nodeHeight || 54) / 2;
var dx0 = b.x - a.x, dy0 = b.y - a.y;
if (Math.abs(dx0) >= nodeWidth * 0.75) {
var dir = dx0 > 0 ? 1 : -1;
x1 = a.x + dir * half; x2 = b.x - dir * half; y1 = a.y; y2 = b.y;
var mid = (x2 - x1) / 2;
c1 = [x1 + mid, y1]; c2 = [x2 - mid, y2];
} else if ((a.row || b.row) || (Math.abs(dy0) > nodeHeight * 2.2 && a.column === b.column && Math.abs(dx0) < 1)) {
// Same column, far apart or from a row: out of the side with room and back in, clear of the boxes between
var side = a.column === COLUMNS.length - 1 ? -1 : 1;
x1 = a.x + side * half; x2 = b.x + side * half; y1 = a.y; y2 = b.y;
var bulge = side * Math.max(50, Math.abs(dy0) * 0.35);
c1 = [x1 + bulge, y1]; c2 = [x2 + bulge, y2];
} else {
// From the bottom of the upper box (an open one's too) to the top of the lower
var nh = nodeHeight || 54;
var top = function (p, half) { return p.row ? p.y - half : p.y - nh / 2; };
var bottom = function (p, half) { return p.row ? p.y + half : p.y - nh / 2 + (p.h || nh); };
x1 = a.x; x2 = b.x;
if (dy0 >= 0) { y1 = bottom(a, halfA); y2 = top(b, halfB); } else { y1 = top(a, halfA); y2 = bottom(b, halfB); }
var midY = (y2 - y1) / 2;
c1 = [x1, y1 + midY]; c2 = [x2, y2 - midY];
}
// Offset along the normal of the chord
var dx = x2 - x1, dy = y2 - y1, len = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = -dy / len * offset, ny = dx / len * offset;
var p = function (x, y) { return (Math.round((x + nx) * 10) / 10) + ',' + (Math.round((y + ny) * 10) / 10); };
// The curve's point half way (t = 0.5)
var mid = { x: Math.round(((x1 + 3 * c1[0] + 3 * c2[0] + x2) / 8 + nx) * 10) / 10, y: Math.round(((y1 + 3 * c1[1] + 3 * c2[1] + y2) / 8 + ny) * 10) / 10 };
return { d: 'M' + p(x1, y1) + ' C' + p(c1[0], c1[1]) + ' ' + p(c2[0], c2[1]) + ' ' + p(x2, y2), mid: mid };
}
var api = { build: build, layout: layout, loadClass: loadClass, width: width, speed: speed, matches: matches, groupHeight: groupHeight, GROUP: GROUP, FRAME: FRAME,
portLabel: portLabel, nameOf: nameOf, hostLabel: hostLabel, peerAddress: peerAddress };
root.NetworkGraph = api;
})(typeof window !== 'undefined' ? window : globalThis);