Last active
May 25, 2024 16:23
-
-
Save josiahbryan/354a0357dd52e43b14b3df4022267b9f to your computer and use it in GitHub Desktop.
Focus the Orb View on a Specific Node
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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