App-Netdisco
view release on metacpan or search on metacpan
share/views/js/netmap.js view on Meta::CPAN
// The netmap: force-graph on canvas, fed by the same payload and posting the
// same positions as the d3 renderer it replaced.
var graph; // accessor object; the harness and device.js use window.graph
var saveMapPositions; // device.js binds the sidebar Save button to this
$.getJSON('[% uri_for("/ajax/data/device/netmap") | none %]?[% my_query | none %]', function (mapdata) {
// the netmap fragment reloads in place (do_search's $(target).html()), so
// this callback runs again while the previous ForceGraph instance's rAF
// loop is still running; without tearing it down first, its stale
// onEngineStop fires against the new, still-settling graph through the
// reassigned global saveMapPositions and can autosave half-settled positions
if (window.graph && window.graph.fg && typeof window.graph.fg._destructor === 'function') {
window.graph.fg._destructor();
}
// from here the bottom-right spinner carries the signal through layout to
// settle, so the two never show at once
var loading = document.getElementById('nd2_netmap-loading');
if (loading) { loading.remove() }
var container = document.getElementById('nd2_netmap-container');
var nodes = mapdata['data']['nodes'];
// radius = 4 + rank of the node's SIZEVALUE among the distinct values
// (max 4 + numsizes - 1); this approximates the old renderer's sqrt scale
// over the SIZEVALUE extent, it does not reproduce it exactly
var distinct = {};
nodes.forEach(function (n) { distinct[n.SIZEVALUE] = true });
var rankOf = {};
Object.keys(distinct).map(Number).sort(function (a, b) { return a - b })
.forEach(function (v, i) { rankOf[v] = i });
// the ten categorical colors the old renderer's color10 scheme used,
// assigned per distinct COLORVALUE in first-appearance order
var COLOR10 = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'];
var colorOf = {}, nextColor = 0;
nodes.forEach(function (n) {
var key = ('COLORVALUE' in n) ? String(n.COLORVALUE) : '__plain';
if (!(key in colorOf)) { colorOf[key] = COLOR10[nextColor++ % 10] }
n.color = colorOf[key];
n.radius = 4 + (rankOf[n.SIZEVALUE] || 0);
if (n.fixed) { n.fx = +n.x; n.fy = +n.y; n.x = +n.x; n.y = +n.y }
});
// centroid of the payload's stored fixed positions, so unpinned nodes
// gather around a restored layout instead of splitting off toward the
// origin, and so the camera below can be pointed at that layout
var fixedNodes = nodes.filter(function (n) { return n.fixed });
var cx = 0, cy = 0;
if (fixedNodes.length) {
fixedNodes.forEach(function (n) { cx += +n.x; cy += +n.y });
cx /= fixedNodes.length; cy /= fixedNodes.length;
}
// the backend builds links before filtering nodes by group selection, so a
// link can reference an ID that was filtered out of the node set; force-graph
// throws mid-simulation on such a dangling reference (the old renderer
// filtered these out client-side too, d3-force-network-chart.js's "sort out
// links with invalid node references"), which aborts the paint loop for that
// tick and leaves nodes drawn only until the next successful repaint (zoom)
var nodeIds = {};
nodes.forEach(function (n) { nodeIds[n.ID] = true });
var links = mapdata['data']['links']
.filter(function (l) { return (l.FROMID in nodeIds) && (l.TOID in nodeIds) })
.map(function (l) {
return { source: l.FROMID, target: l.TOID, SPEED: l.SPEED, INFOSTRING: l.INFOSTRING };
});
// force-graph swaps a link's source/target from a plain ID to the node
// object once it resolves them, so any code touching links after that
// point has to handle both shapes; one helper for both call sites
function endpointId(l, end) { var v = l[end]; return (typeof v === 'object') ? v.ID : v }
// read the template condition once; two handlers below need it
// Read at each use, not cached: the sidebar toggle changes this checkbox
// without re-rendering the map.
function autosaveOn() {
var box = document.getElementById('nd_autosave');
return !!(box && box.checked);
}
// force-graph reheats the simulation on every drag event, and once the first
// layout has finished alpha is already below d3AlphaMin, so the engine stops
// again at once having run no ticks. Counting ticks is what tells a real
// settle apart from those, and without it the save below fires once per
// mousemove, posting the whole map each time.
var ticksSinceStop = 0;
// The pane can be replaced while this instance is still running: nothing
// destroys it until the next fragment's callback reaches the teardown above,
// and its tick and engine-stop handlers keep firing until then, against a
// spinner that is no longer in the document.
function setSpinnerState(state) {
var el = document.getElementById('nd2_netmap-spinner');
if (el && el.className !== state) { el.className = state }
}
var fg = ForceGraph()(container)
.width(parseInt(jQuery('#netmap_pane').parent().css('width')))
.height(window.innerHeight - 100)
.nodeId('ID')
.nodeRelSize(1)
.nodeVal(function (n) { return n.radius * n.radius })
.nodeColor(function (n) { return n.color })
.nodeLabel(function (n) { return n.INFOSTRING })
.linkLabel(function (l) { return l.INFOSTRING })
.linkWidth(1)
.linkColor(() => 'rgba(150, 150, 150, 0.73)')
.minZoom(0.1)
.maxZoom(10)
.cooldownTime(Infinity)
.d3AlphaMin(0.001)
.onNodeDragEnd(function (n) {
n.fx = n.x; n.fy = n.y; dragSnap = null;
// the engine stop that follows a drag runs no ticks, so this is the only
// place a hand-moved node gets persisted; once per drag, not per mousemove
if (autosaveOn()) { saveMapPositions() }
})
.onEngineStop(function () {
setSpinnerState('nd_netmap-settled');
fg.graphData().nodes.forEach(function (n) { n.fx = n.x; n.fy = n.y });
var ranTicks = ticksSinceStop;
ticksSinceStop = 0;
if (ranTicks && autosaveOn()) { saveMapPositions() }
})
.graphData({ nodes: nodes, links: links });
// netdisco maps hold disconnected islands, so pull each node to the middle
// instead of centering the mean (the old renderer's gravity did the same)
function ndPull(axis, target, strength) {
var ns;
function force(alpha) {
for (var i = 0; i < ns.length; i++) {
ns[i]['v' + axis] += (target - ns[i][axis]) * strength * alpha;
}
}
force.initialize = function (init) { ns = init };
return force;
}
fg.d3Force('charge').strength(-550);
fg.d3Force('link').distance(120);
fg.d3Force('center', null);
// force-graph's origin is the viewport center, unlike the old SVG renderer's
// top-left origin, so "the middle" above is the stored layout's centroid
fg.d3Force('pullx', ndPull('x', cx, 0.06));
fg.d3Force('pully', ndPull('y', cy, 0.06));
// point the camera at the stored layout; for an all-fresh map cx/cy are
// (0, 0), force-graph's own default, so this is a no-op there
fg.centerAt(cx, cy);
share/views/js/netmap.js view on Meta::CPAN
var now = Date.now();
// 500 ms matches the platform double-click default the old renderer's
// dblclick event inherited
if (n.ID === lastClick.id && (now - lastClick.at) < 500) {
window.location.assign(n.LINK);
return;
}
lastClick = { id: n.ID, at: now };
});
fg.linkCurvature(function (l) {
var s = endpointId(l, 'source'), t = endpointId(l, 'target');
return (s === t) ? 0.6 : 0;
});
// the old template zoomed to the center node 1.5 s after start when
// mapshow=neighbors (a legacy value still reachable from bookmarks)
if ('[% params.mapshow | html_entity %]' == 'neighbors') {
setTimeout(function () {
var n = graph.nodeDataById(graph.centernode);
if (n) { fg.centerAt(n.x, n.y, 600); fg.zoom(4, 600) }
}, 1500);
}
// box select: shift-drag replaces the old freehand lasso by ruling.
// capture-phase listener so force-graph's own pan never sees the drag.
var box = { active: false, x0: 0, y0: 0, el: null };
container.addEventListener('pointerdown', function (ev) {
if (!ev.shiftKey) { return }
ev.stopPropagation(); ev.preventDefault();
fg.enablePanInteraction(false).enableZoomInteraction(false);
box.active = true; box.x0 = ev.clientX; box.y0 = ev.clientY;
box.el = document.createElement('div');
box.el.id = 'nd2_netmap-boxselect';
// document.body sits outside the fullscreen element, so a box drawn
// there would be invisible while fullscreen; append into whichever is
// actually showing
(document.fullscreenElement || document.body).appendChild(box.el);
}, true);
function onBoxPointerMove(ev) {
if (!box.active) { return }
var x = Math.min(box.x0, ev.clientX), y = Math.min(box.y0, ev.clientY);
box.el.style.left = x + 'px';
box.el.style.top = y + 'px';
box.el.style.width = Math.abs(ev.clientX - box.x0) + 'px';
box.el.style.height = Math.abs(ev.clientY - box.y0) + 'px';
}
function onBoxPointerUp(ev) {
if (!box.active) { return }
box.active = false;
box.el.remove();
fg.enablePanInteraction(true).enableZoomInteraction(true);
var r = container.querySelector('canvas').getBoundingClientRect();
var a = fg.screen2GraphCoords(Math.min(box.x0, ev.clientX) - r.left, Math.min(box.y0, ev.clientY) - r.top);
var b = fg.screen2GraphCoords(Math.max(box.x0, ev.clientX) - r.left, Math.max(box.y0, ev.clientY) - r.top);
fg.graphData().nodes.forEach(function (n) {
n.selected = (n.x >= a.x && n.x <= b.x && n.y >= a.y && n.y <= b.y);
});
fg.nodeRelSize(fg.nodeRelSize());
}
// raw listeners cannot be namespaced like jQuery's; on a fragment reload,
// remove the previous render's pair by reference before adding this one,
// or they accumulate on window forever
if (window.__ndNetmapPointerHandlers) {
window.removeEventListener('pointermove', window.__ndNetmapPointerHandlers.move);
window.removeEventListener('pointerup', window.__ndNetmapPointerHandlers.up);
}
window.__ndNetmapPointerHandlers = { move: onBoxPointerMove, up: onBoxPointerUp };
window.addEventListener('pointermove', onBoxPointerMove);
window.addEventListener('pointerup', onBoxPointerUp);
// dragging one selected node carries the rest of the selection
var dragSnap = null;
fg.onNodeDrag(function (n, translate) {
if (!n.selected) { return }
if (!dragSnap) {
// hold the node objects themselves, not their IDs: an ID-keyed lookup
// means a linear nodeDataById() scan per node per tick, and re-keying
// by ID risks the numeric-vs-string coercion Object.keys() does
dragSnap = [];
fg.graphData().nodes.forEach(function (o) {
if (o.selected && o.ID !== n.ID) { dragSnap.push({ node: o, x: o.x, y: o.y }) }
});
}
// translate is the per-tick incremental delta, not cumulative from drag
// start, so the snapshot itself has to accumulate it tick by tick
dragSnap.forEach(function (entry) {
entry.x += translate.x;
entry.y += translate.y;
entry.node.fx = entry.node.x = entry.x;
entry.node.fy = entry.node.y = entry.y;
});
});
// fullscreen: same API dance the old template used, on the pane so the
// sidebar stays outside it
document.getElementById('nd2_netmap-fullscreen').addEventListener('click', function () {
requestFullScreen(document.getElementById('netmap_pane'));
});
// namespaced so a fragment reload's .off() removes only this render's
// handler instead of every handler ever bound to these shared elements
$(document).off('.ndnetmap').on('webkitfullscreenchange.ndnetmap mozfullscreenchange.ndnetmap fullscreenchange.ndnetmap', function () {
resizeGraphContainer();
$('#nd2_netmap-fullscreen i').attr('class',
isFullScreen() ? 'fas fa-compress fa-lg' : 'fas fa-expand fa-lg');
});
function resizeGraphContainer() {
setTimeout(function () {
fg.width(parseInt(jQuery('#netmap_pane').parent().css('width')))
.height(window.innerHeight - 100);
}, 500);
}
$('#nd_sidebar-toggle-img-in').off('.ndnetmap').on('click.ndnetmap', resizeGraphContainer);
$('#nd_sidebar-toggle-img-out').off('.ndnetmap').on('click.ndnetmap', resizeGraphContainer);
$(window).off('resize.ndnetmap').on('resize.ndnetmap', resizeGraphContainer);
// onEngineTick is a setter, not a subscription, so the tick count lives in
// this handler rather than a second one that would replace it
fg.onEngineTick(function () {
ticksSinceStop++;
setSpinnerState('nd_netmap-running');
});
// labels draw above this zoom
var LABEL_ZOOM = +'[% settings.netmap.node_label_zoom_threshold || 0.9 %]';
var LABEL_SIZE = +'[% settings.netmap.node_label_font_size || 8 %]';
// read once, not once per node per frame
var showips = document.getElementById('nd_showips');
fg.nodeCanvasObjectMode(function () { return 'after' })
.nodeCanvasObject(function (n, ctx, scale) {
if (n.selected) {
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius + 2, 0, 2 * Math.PI);
ctx.strokeStyle = '#0d6efd';
ctx.lineWidth = 1.5 / scale;
ctx.stroke();
}
if (scale < LABEL_ZOOM) { return }
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = '#333';
// Drawn from the two fields rather than splitting LABEL: a device name
// may contain spaces, and a two-word split drops the rest of it.
var gap = LABEL_SIZE * 0.5; // graph units, so it holds as the map zooms
ctx.font = 'bold ' + LABEL_SIZE + 'px sans-serif';
ctx.fillText(n.ORIG_LABEL, n.x, n.y + n.radius + gap);
if (showips && showips.checked && n.ORIG_LABEL !== n.ID) {
ctx.font = LABEL_SIZE + 'px sans-serif';
ctx.fillText(n.ID, n.x, n.y + n.radius + gap + LABEL_SIZE + 1);
}
});
// read once, not once per link per frame
var showspeed = document.getElementById('nd_showspeed');
fg.linkCanvasObjectMode(function () { return 'after' })
.linkCanvasObject(function (l, ctx) {
( run in 0.722 second using v1.01-cache-2.11-cpan-b16cb0d3907 )