Skip to content

Instantly share code, notes, and snippets.

@josiahbryan
Last active May 25, 2024 16:23
Show Gist options
  • Save josiahbryan/354a0357dd52e43b14b3df4022267b9f to your computer and use it in GitHub Desktop.
Save josiahbryan/354a0357dd52e43b14b3df4022267b9f to your computer and use it in GitHub Desktop.
Focus the Orb View on a Specific Node
// These 3x d3 imports ARE provided by the orb package itself, that's why I've silenced eslint here for these 3 lines
// eslint-disable-next-line import/no-extraneous-dependencies
import { zoomIdentity } from 'd3-zoom';
// eslint-disable-next-line import/no-extraneous-dependencies
import { select } from 'd3-selection';
// eslint-disable-next-line import/no-extraneous-dependencies
import { easeLinear } from 'd3-ease';
/**
* Calculates the bounding rectangle for a given set of points.
*
* @param {Array<Array<number>>} points - The array of points, where each point is represented as an array of two numbers [x, y].
* @returns {Object} The bounding rectangle object with properties x, y, width, and height.
*/
function getBoundingRectangle(points) {
if (points.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
// Initialize min and max coordinates based on the first point
let minX = points[0][0];
let maxX = points[0][0];
let minY = points[0][1];
let maxY = points[0][1];
// Iterate through the list to find min and max for x and y using destructuring
for (let i = 1; i < points.length; i++) {
const [x, y] = points[i]; // Destructuring each point into x and y
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
// Calculate width and height
const width = maxX - minX;
const height = maxY - minY;
// Return the rectangle object
return { x: minX, y: minY, width, height };
}
/**
* Focuses on a specific node in the Orb graph view by adjusting the zoom and translation and animating the transition.
*
* Heavily based on the code already existing in the Orb library, but modified to work with a node/array of nodes instead
* of just the entire graph.
*
* @param {Orb} orb - The Orb object containing the view to manipulate.
* @param {ObNode|Array<OrbNode>} orbNode - The node or array of nodes to focus on. If an array, it will focus on the bounding box of all nodes. If a single node, it will focus only on that node. The node(s) must have properties position.x and position.y. This MAY be null if you provide a `boundingBox`.
* @param {Object} options - Additional options.
* @param {Function} options.onRendered - Optional callback function to be called after rendering.
* @param {Object} options.boundingBox - Optional bounding box object to use instead of calculating it from the nodes. Should have properties x, y, width, and height.
* @param {number} options.fitZoomMargin - The margin to add to the zoom level when fitting the view. Default is 0.2.
* @param {number} options.maxZoom - The maximum zoom level allowed. Default is 8.
* @param {number} options.minZoom - The minimum zoom level allowed. Default is 0.25.
* @param {number} options.zoomFitTransitionMs - The duration of the zoom transition in milliseconds. Default is 200.
* @returns {void}
*/
export function orbFocusOnNode(
orb,
orbNode,
{
onRendered,
boundingBox = undefined,
fitZoomMargin = orb.view._renderer._settings.fitZoomMargin || 0.2,
maxZoom = orb.view._renderer._settings.maxZoom || 8,
minZoom = orb.view._renderer._settings.minZoom || 0.25,
zoomFitTransitionMs = orb.view._settings.zoomFitTransitionMs ||
200,
} = {},
) {
// The VAST majority of this function is based off of code in the Orb library already,
// based on guidance given here: https://github.com/memgraph/orb/issues/52#issuecomment-1529513689
const { view } = orb;
const { _renderer: renderer } = view;
// I've found that size=0 makes the node centered - makes no sense to me.
// The larger the size, the further offset the node is from the center to the top-left.
// No idea why. But just FYI for future readers.
const sizeEst = 0;
// Graph view is a bounding box of the graph nodes that takes into
// account node positions (x, y) and node sizes (style: size + border width)
const graphView =
boundingBox ||
(Array.isArray(orbNode)
? getBoundingRectangle(
orbNode.map((node) => [
node.position.x,
node.position.y,
]),
)
: {
x: orbNode.position.x,
y: orbNode.position.y,
width: sizeEst,
height: sizeEst,
});
// graph.getBoundingBox();
const graphMiddleX = graphView.x + graphView.width / 2;
const graphMiddleY = graphView.y + graphView.height / 2;
// Simulation view is actually a renderer view (canvas) but in the coordinate system of
// the simulator: node position (x, y). We want to fit a graph view into a simulation view.
const simulationView = renderer.getSimulationViewRectangle();
const heightScale =
simulationView.height / (graphView.height * (1 + fitZoomMargin));
const widthScale =
simulationView.width / (graphView.width * (1 + fitZoomMargin));
// The scale of the translation and the zoom needed to fit a graph view
// into a simulation view (renderer canvas)
const scale = Math.min(heightScale, widthScale);
const previousZoom = renderer.transform.k;
const newZoom = Math.max(
Math.min(scale * previousZoom, maxZoom),
minZoom,
);
// Translation is done in the following way for both coordinates:
// - M = expected movement to the middle of the view (simulation width or height / 2)
// - Z(-1) = previous zoom level
// - S = scale to fit the graph view into simulation view
// - Z(0) = new zoom level / Z(0) := S * Z(-1)
// - GM = current middle coordinate of the graph view
// Formula:
// X/Y := M * Z(-1) - M * Z(-1) * Z(0) - GM * Z(0)
// X/Y := M * Z(-1) * (1 - Z(0)) - GM * Z(0)
const newX =
(simulationView.width / 2) * previousZoom * (1 - newZoom) -
graphMiddleX * newZoom;
const newY =
(simulationView.height / 2) * previousZoom * (1 - newZoom) -
graphMiddleY * newZoom;
const fitZoomTransform = zoomIdentity
.translate(newX, newY)
.scale(newZoom);
select(view._canvas)
.transition()
.duration(zoomFitTransitionMs)
.ease(easeLinear)
.call(view._d3Zoom.transform, fitZoomTransform)
.call(() => {
renderer.render(view._graph);
onRendered?.();
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment