trassel
Version:
Graph computing in JavaScript
1,165 lines (1,117 loc) • 42.5 kB
JavaScript
import * as PIXI from "pixi.js"
import { OrthogonalConnector } from "./orthogonalRouter"
/**
* The WebGL renderer class is a bit messy right now, and some functionality should be broken out into separate files.
* There are also several performance optimizations that could be made. Most especially with regards to text and culling of edges.
* This started out as a "basic" renderer, but grew into a playground for fun and interesting ideas.
*/
export class WebGLRenderer {
constructor(element, nodes, edges, options) {
this.element = element
this.nodes = nodes
this.edges = edges
this.options = options
this.renderer = null
this.stage = null
this.backdrop = null
this.resizeObserver = null
this.lassoEnabled = false
this.lineType = options?.lineType || "line"
this.LINE_MARGIN_PX = 10
this.sceneSize = 50000
this.primaryColor = options?.primaryColor ? options.primaryColor : 0x3289e2
this.backgroundColor = this.options?.backdropColor ? this.options.backdropColor : 0xe6e7e8
this.listeners = new Map([
["backdropclick", new Set()],
["backdroprightclick", new Set()],
["entityclick", new Set()],
["entityrightclick", new Set()],
["entityhoverstart", new Set()],
["entityhoverend", new Set()],
["entitydragstart", new Set()],
["entitydragmove", new Set()],
["entitydragend", new Set()],
["edgelabelhoverstart", new Set()],
["edgelabelhoverend", new Set()],
["edgelabelclick", new Set()],
["edgelabelrightclick", new Set()],
["canvasdragstart", new Set()],
["canvasdragend", new Set()],
["lassostart", new Set()],
["lassoupdate", new Set()],
["lassoend", new Set()]
])
this.markers = {
none: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' %3E%3C/svg%3E",
arrow: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' style='fill:%23000000;'%3E%3Cpolygon points='0,0 30,15 0,30'/%3E%3C/svg%3E",
hollowArrow:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' style='fill:%23ffffff;stroke:%23000000;stroke-width:4;'%3E%3Cpolygon points='0,0 30,15 0,30'/%3E%3C/svg%3E"
}
this.initializeRenderer()
this.initializePanAndBackdropEvents()
this.initializeZoom()
this.initializeResizer()
this.initializeData(nodes, edges)
this.initializeEdgeCounters()
this.initializeLasso()
}
/**
* Registers an event listener
* @param {string} name - Event name to listen for
* @param {(...any) => any} fn - Callback on event
*/
on(name, fn) {
if (!this.listeners.has(name)) {
console.error(`No such event name: ${name}`)
}
this.listeners.get(name).add(fn)
}
triggerEvent(name, payload) {
this.listeners.get(name).forEach(fn => fn(payload))
}
/**
* Initializes the main renderer classes and variables
*/
initializeRenderer() {
this.worldWidth = this.sceneSize
this.worldHeight = this.sceneSize
const width = this.element.clientWidth
const height = this.element.clientHeight
this.stage = new PIXI.Container()
this.stage.position.set(width / 2, height / 2)
this.renderer = PIXI.autoDetectRenderer({
resolution: 2,
width,
height,
autoDensity: true,
antialias: true,
backgroundAlpha: 1,
backgroundColor: this.backgroundColor
})
this.renderer.view.style.display = "block"
this.element.appendChild(this.renderer.view)
this.backdrop = new PIXI.Container()
this.backdrop.interactive = true
this.backdrop.containsPoint = () => true
const svg = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='200' height='200' style='background-color:%23${this.backgroundColor.toString(
16
)};'%3E%3Cpath style='stroke: %23a0a0a0; stroke-width: 4px;fill: none;stroke-dasharray: 0;stroke-linecap: round; stroke-linejoin: round;' d='M95 100 L105 100 M100 95 L100 105 Z' /%3E%3C/svg%3E`
const texture = PIXI.Texture.from(svg, { resolution: 4 })
const tilingSprite = new PIXI.TilingSprite(texture, this.worldWidth, this.worldHeight)
tilingSprite.anchor.set(0.5)
this.backdrop.addChild(tilingSprite)
this.stage.addChild(this.backdrop)
}
/**
* Makes the canvas panable (dragable) and interactive
*/
initializePanAndBackdropEvents() {
let initialMouseX
let initialMouseY
let mouseDrag = false
let blockCanvasClick = false
const onStageDragStart = event => {
if (!this.lassoEnabled) {
initialMouseX = event.data.global.x - this.stage.x
initialMouseY = event.data.global.y - this.stage.y
mouseDrag = true
this.triggerEvent("canvasdragstart")
}
}
const onStageDragMove = event => {
if (mouseDrag) {
blockCanvasClick = true
this.stage.x = event.data.global.x - initialMouseX
this.stage.y = event.data.global.y - initialMouseY
requestAnimationFrame(() => this.renderer.render(this.stage))
}
}
const onStageDragEnd = () => {
if (mouseDrag) {
mouseDrag = false
this.triggerEvent("canvasdragend")
setTimeout(() => {
blockCanvasClick = false
}, 1)
}
}
const onClick = event => {
if (!blockCanvasClick && !this.lassoEnabled) {
this.triggerEvent("backdropclick", { position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY } })
}
}
const onRightClick = event => {
if (!blockCanvasClick && !this.lassoEnabled) {
this.triggerEvent("backdroprightclick", { position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY } })
}
}
this.backdrop
.on("pointerdown", onStageDragStart)
.on("pointermove", onStageDragMove)
.on("pointerup", onStageDragEnd)
.on("pointerupoutside", onStageDragEnd)
.on("click", onClick)
.on("rightclick", onRightClick)
}
/**
* makes the canvas zoomable
*/
initializeZoom() {
let scale = 1
const maxScale = 4
const minScale = 0.05
const handleZoom = event => {
event.stopPropagation()
event.preventDefault()
const mouseX = event.clientX
const mouseY = event.clientY
const localPointBefore = this.stage.toLocal(new PIXI.Point(mouseX, mouseY))
const alpha = 1 + Math.abs(event.wheelDelta) / 2000
let shouldRender = false
if (event.wheelDelta < 0) {
scale = Math.min(scale * alpha, maxScale)
this.stage.setTransform(this.stage.x, this.stage.y, scale, scale)
shouldRender = true
} else if (event.wheelDelta) {
scale = Math.max(scale / alpha, minScale)
this.stage.setTransform(this.stage.x, this.stage.y, scale, scale)
shouldRender = true
}
const localPointAfter = this.stage.toLocal(new PIXI.Point(mouseX, mouseY))
if (localPointAfter.x !== localPointBefore.x || localPointAfter.y !== localPointBefore.y) {
this.stage.x += (localPointAfter.x - localPointBefore.x) * this.stage.scale.x
this.stage.y += (localPointAfter.y - localPointBefore.y) * this.stage.scale.y
}
if (shouldRender) {
this.render()
}
}
this.renderer.view.addEventListener("wheel", handleZoom)
}
/**
* Makes the canvas auto resize when the parent element size changes
*/
initializeResizer() {
let throttle = null
this.resizeObserver = new ResizeObserver(() => {
if (throttle) {
clearTimeout(throttle)
}
throttle = setTimeout(() => {
this.renderer.resize(this.element.clientWidth, this.element.clientHeight)
requestAnimationFrame(() => this.renderer.render(this.stage))
}, 100)
})
this.resizeObserver.observe(this.element)
}
/**
* Initializes all the graphics for provided nodes and edges
* @param {import("../model/rendereroptions").INodeWithRendererOptions[]} nodes
* @param {import("../model/rendereroptions").IEdgeWithRendererOptions[]} edges
*/
initializeData(nodes, edges) {
const FOCUS_SHAPE_SIZE_HALF = 6
this.nodes = nodes
this.edges = edges
const nodeLookupMap = new Map()
const stageNodes = []
//Initializes Nodes
this.nodes.forEach(node => {
nodeLookupMap.set(node.id, node)
if (!node.renderer) node.renderer = {}
if (node.renderer.shape === "rectangle") {
!node.width && (node.width = 50)
!node.height && (node.height = 50)
!node.radius && (node.radius = Math.max(node.width, node.height) / 2)
} else {
!node.radius && (node.radius = 50)
}
node.renderer._private = {
container: new PIXI.Container(),
node: new PIXI.Graphics(),
text: null,
icon: null,
selected: new PIXI.Graphics(),
isFocused: false,
isSelected: false
}
//Draw shape
const nodeGfx = node.renderer._private.node
const nodeShape = node.renderer.shape
const nodeHeight = node.height || node.radius * 2
const nodeWidth = node.width || node.radius * 2
nodeGfx.lineStyle(2, 0xffffff)
nodeGfx.beginFill(node.renderer.backgroundColor || 0xffffff)
if (nodeShape === "rectangle") {
nodeGfx.drawRoundedRect(-(nodeWidth / 2), -(nodeHeight / 2), nodeWidth, nodeHeight, 4)
} else {
nodeGfx.drawCircle(0, 0, node.radius)
}
//Draw label
const fontSize = Math.max(nodeHeight * 0.1, 12)
const icon = node.renderer.icon
const iconMaxSize = 50
const iconMinSize = 16
const iconSize = Math.max(Math.min(nodeHeight * 0.2, iconMaxSize), iconMinSize)
const wordWrapWidth = nodeShape === "rectangle" ? (icon ? nodeWidth - iconSize * 3 : nodeWidth - iconSize * 2) : node.radius * 1.25
const textStyle = new PIXI.TextStyle({
fontFamily: "Arial",
fontSize,
wordWrap: true,
breakWords: true,
wordWrapWidth,
align: "center",
fill: node.renderer.textColor || 0x000000
})
const label = node.renderer.label || node.id
const measurements = PIXI.TextMetrics.measureText(label, textStyle)
let processedLabel = measurements.lines.shift()
const shouldWrapText = (nodeShape === "rectangle" && nodeHeight > 50) || (nodeShape !== "rectangle" && node.radius > 40)
if (measurements.lines.length && shouldWrapText) {
processedLabel = `${processedLabel}\n${measurements.lines.shift()}`
}
if (measurements.lines.length) {
processedLabel = `${processedLabel.slice(0, processedLabel.length - 2)}..`
}
const text = new PIXI.Text(processedLabel, textStyle)
text.resolution = 3
text.anchor.set(0.5)
nodeGfx.addChild(text)
node.renderer._private.text = text
//Draw icon
if (node.renderer.icon) {
const icon = PIXI.Sprite.from(node.renderer.icon)
icon.width = iconSize
icon.height = iconSize
icon.anchor.x = 0.5
icon.anchor.y = 0.5
text.anchor.y = 0.5
text.anchor.x = 0.5
nodeGfx.addChild(icon)
if (nodeShape === "rectangle") {
icon.x = -measurements.maxLineWidth / 2 - icon.width / 2
text.x = iconSize / 2
} else {
icon.anchor.y = 1.2
text.anchor.y = 0
}
node.renderer._private.icon = icon
}
//Add selection graphics
const selectedGfx = node.renderer._private.selected
selectedGfx.beginFill(this.primaryColor)
if (node.renderer.shape === "rectangle") {
selectedGfx.drawRoundedRect(
-(node.width / 2 + FOCUS_SHAPE_SIZE_HALF),
-(node.height / 2 + FOCUS_SHAPE_SIZE_HALF),
node.width + FOCUS_SHAPE_SIZE_HALF * 2,
node.height + FOCUS_SHAPE_SIZE_HALF * 2,
12
)
} else {
selectedGfx.drawCircle(0, 0, node.radius + FOCUS_SHAPE_SIZE_HALF)
}
selectedGfx.alpha = 0
node.renderer._private.container.addChild(selectedGfx)
//Make node dragable
nodeGfx.interactive = true
nodeGfx.buttonMode = true
let dragEventData = null
let dragging = false
let blockClick = false
let initialMouse = null
let initialNode = { x: null, y: null }
const onDragStart = event => {
dragEventData = event.data
initialMouse = dragEventData.getLocalPosition(this.stage)
dragging = true
initialNode = { x: node.x, y: node.y }
this.triggerEvent("entitydragstart", { node, position: { ...initialNode } })
}
const onDragMove = () => {
if (dragging) {
blockClick = true
const newPosition = dragEventData.getLocalPosition(this.stage)
const delta = { x: newPosition.x - initialMouse.x, y: newPosition.y - initialMouse.y }
this.triggerEvent("entitydragmove", { node, position: { x: initialNode.x + delta.x, y: initialNode.y + delta.y }, delta: { ...delta } })
}
}
const onDragEnd = () => {
setTimeout(() => {
blockClick = false
}, 1)
dragging = false
dragEventData = null
this.triggerEvent("entitydragend", { node })
}
nodeGfx.on("pointerdown", onDragStart)
nodeGfx.on("pointermove", onDragMove)
nodeGfx.on("pointerup", onDragEnd)
nodeGfx.on("pointerupoutside", onDragEnd)
//Give node hover effect
const focusGfx = new PIXI.Graphics()
const focusGfx2 = new PIXI.Graphics()
focusGfx.beginFill(this.primaryColor)
focusGfx2.beginFill(this.primaryColor)
focusGfx.alpha = 0.2
focusGfx2.alpha = 0.1
if (node.renderer.shape === "rectangle") {
focusGfx.drawRoundedRect(
-(node.width / 2 + FOCUS_SHAPE_SIZE_HALF),
-(node.height / 2 + FOCUS_SHAPE_SIZE_HALF),
node.width + FOCUS_SHAPE_SIZE_HALF * 2,
node.height + FOCUS_SHAPE_SIZE_HALF * 2,
12
)
focusGfx2.drawRoundedRect(
-(node.width / 2 + FOCUS_SHAPE_SIZE_HALF * 2),
-(node.height / 2 + FOCUS_SHAPE_SIZE_HALF * 2),
node.width + FOCUS_SHAPE_SIZE_HALF * 4,
node.height + FOCUS_SHAPE_SIZE_HALF * 4,
16
)
} else {
focusGfx.drawCircle(0, 0, node.radius + FOCUS_SHAPE_SIZE_HALF)
focusGfx2.drawCircle(0, 0, node.radius + FOCUS_SHAPE_SIZE_HALF * 2)
}
const pointerOver = () => {
if (!dragging) {
node.renderer._private.container.addChildAt(focusGfx, 0)
node.renderer._private.container.addChildAt(focusGfx2, 0)
node.renderer._private.isFocused = true
this.render()
}
}
const pointerOut = () => {
if (node.renderer._private.isFocused) {
node.renderer._private.container.removeChild(focusGfx)
node.renderer._private.container.removeChild(focusGfx2)
node.renderer._private.isFocused = false
this.render()
}
}
nodeGfx.on("pointerover", pointerOver)
nodeGfx.on("pointerout", pointerOut)
//Make node clickable
const onClick = event => {
if (!blockClick) {
this.triggerEvent("entityclick", { node, position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY } })
}
}
const onRightClick = event => {
if (!blockClick) {
this.triggerEvent("entityclick", { node, position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY } })
}
}
nodeGfx.on("click", onClick)
nodeGfx.on("rightclick", onRightClick)
//Add node to stage
node.renderer._private.container.addChild(nodeGfx)
node.renderer._private.container.cullable = true
stageNodes.push(node)
})
//Initialize Edges
this.edges.forEach(edge => {
//Initialize edge properties
if (!edge.renderer) edge.renderer = {}
edge.renderer._private = {
source: nodeLookupMap.get(edge.sourceNode),
target: nodeLookupMap.get(edge.targetNode),
container: new PIXI.Container(),
line: new PIXI.Graphics(),
markerSource: new PIXI.Sprite.from(edge.renderer.markerSource ? this.markers[edge.renderer.markerSource] : this.markers.none, {
resolution: 4
}),
markerTarget: new PIXI.Sprite.from(edge.renderer.markerTarget ? this.markers[edge.renderer.markerTarget] : this.markers.arrow, {
resolution: 4
}),
text: null
}
edge.renderer._private.container.addChild(edge.renderer._private.line)
edge.renderer._private.container.addChild(edge.renderer._private.markerSource)
edge.renderer._private.container.addChild(edge.renderer._private.markerTarget)
edge.renderer._private.markerSource.anchor.set(0.45, 0.25)
edge.renderer._private.markerTarget.anchor.set(0.45, 0.25)
edge.renderer._private.container.cullable = true
this.stage.addChild(edge.renderer._private.container)
//Initialize label
if (edge.renderer.label) {
const textStyle = new PIXI.TextStyle({
fontFamily: "Arial",
fontSize: 10,
wordWrap: true,
breakWords: true,
align: "center",
wordWrapWidth: (edge.distance || 100) * 0.5,
fill: edge.renderer.labelColor || 0x000000
})
const label = edge.renderer.label
const measurements = PIXI.TextMetrics.measureText(label, textStyle)
let processedLabel = measurements.lines[0]
if (measurements.lines.length > 1) {
processedLabel = `${processedLabel.slice(0, processedLabel.length - 2)}..`
}
const text = new PIXI.Text(processedLabel, textStyle)
text.resolution = 3
if (edge.sourceNode !== edge.targetNode) {
text.anchor.x = 0.5
text.anchor.y = 1.2
} else {
text.anchor.x = 0.5
text.anchor.y = 0.5
}
const textContainer = new PIXI.Container()
textContainer.addChild(text)
//If the edge is interactive, then update the label
if (edge.renderer.isInteractive) {
text.anchor.y = 0.5
const width = text.width + 10
const height = text.height + 10
const textBackground = new PIXI.Graphics()
textBackground.lineStyle(2, edge.renderer.labelBackgroundColor || 0xffffff)
textBackground.beginFill(edge.renderer.labelBackgroundColor || 0xffffff)
textBackground.alpha = 1
textBackground.drawRoundedRect(-width / 2, -height / 2, width, height, 4)
textBackground.endFill()
textContainer.addChildAt(textBackground, 0)
const textFocusBackground = new PIXI.Graphics()
const textFocusBackground2 = new PIXI.Graphics()
textFocusBackground.beginFill(this.primaryColor)
textFocusBackground2.beginFill(this.primaryColor)
textFocusBackground.drawRoundedRect(
-width / 2 - FOCUS_SHAPE_SIZE_HALF * 2,
-height / 2 - FOCUS_SHAPE_SIZE_HALF * 2,
width + FOCUS_SHAPE_SIZE_HALF * 4,
height + FOCUS_SHAPE_SIZE_HALF * 4,
12
)
textFocusBackground2.drawRoundedRect(
-width / 2 - FOCUS_SHAPE_SIZE_HALF,
-height / 2 - FOCUS_SHAPE_SIZE_HALF,
width + FOCUS_SHAPE_SIZE_HALF * 2,
height + FOCUS_SHAPE_SIZE_HALF * 2,
8
)
textFocusBackground.endFill()
textFocusBackground2.endFill()
textFocusBackground.alpha = 0.1
textFocusBackground2.alpha = 0.2
textContainer.interactive = true
const pointerOver = event => {
textContainer.addChildAt(textFocusBackground, 0)
textContainer.addChildAt(textFocusBackground2, 0)
edge.renderer._private.isFocused = true
this.render()
this.triggerEvent("edgelabelhoverstart", {
edge,
position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY }
})
}
const pointerOut = event => {
if (edge.renderer._private.isFocused) {
textContainer.removeChild(textFocusBackground)
textContainer.removeChild(textFocusBackground2)
edge.renderer._private.isFocused = false
this.render()
this.triggerEvent("edgelabelhoverend", {
edge,
position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY }
})
}
}
const onClick = event => {
this.triggerEvent("edgelabelclick", { edge, position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY } })
}
const onRightClick = event => {
this.triggerEvent("edgelabelclick", { edge, position: { x: event.data.originalEvent.screenX, y: event.data.originalEvent.screenY } })
}
textContainer.on("pointerover", pointerOver)
textContainer.on("pointerout", pointerOut)
textContainer.on("click", onClick)
textContainer.on("rightclick", onRightClick)
}
edge.renderer._private.container.addChild(textContainer)
edge.renderer._private.text = textContainer
}
})
stageNodes.forEach(node => {
this.stage.addChild(node.renderer._private.container)
})
}
/**
* Initializes counters for how many edges exist between different sets of nodes
*/
initializeEdgeCounters() {
const edgeMap = new Map()
for (let i = 0; i < this.edges.length; i++) {
const edge = this.edges[i]
const ID = edge.sourceNode > edge.targetNode ? `${edge.sourceNode}${edge.targetNode}` : `${edge.targetNode}${edge.sourceNode}`
if (!edgeMap.has(ID)) {
edgeMap.set(ID, [edge])
continue
}
edgeMap.get(ID).push(edge)
}
for (const [, edgeArray] of edgeMap) {
for (let i = 0; i < edgeArray.length; i++) {
const edge = edgeArray[i]
edge.renderer._private.edgeCounter = { total: edgeArray.length, index: i }
}
}
}
/**
* Initializes the lasso
*/
initializeLasso() {
let initialMouse
let moving = false
let rect
let lastLassoCoveredSelection = new Set()
const onLassoStart = event => {
if (this.lassoEnabled) {
initialMouse = this.stage.toLocal(new PIXI.Point(event.data.global.x, event.data.global.y))
rect = new PIXI.Graphics()
this.stage.addChild(rect)
rect.alpha = 0.4
moving = true
this.triggerEvent("lassostart")
}
}
const onLassoMove = event => {
if (moving) {
const currentMouse = this.stage.toLocal(new PIXI.Point(event.data.global.x, event.data.global.y))
const width = currentMouse.x - initialMouse.x
const height = currentMouse.y - initialMouse.y
rect.clear()
rect.beginFill(this.primaryColor)
rect.drawRect(initialMouse.x, initialMouse.y, width, height)
rect.endFill()
const lassoEndX = initialMouse.x + width
const lassoEndY = initialMouse.y + height
const coveredSelection = new Set(Array.from(lastLassoCoveredSelection))
let selectionChanged = false
const removed = []
const added = []
this.nodes.forEach(node => {
if (
node.x >= Math.min(initialMouse.x, lassoEndX) &&
node.y >= Math.min(initialMouse.y, lassoEndY) &&
node.x + (node.width || node.radius) <= Math.max(lassoEndX, initialMouse.x) &&
node.y + (node.height || node.radius) <= Math.max(lassoEndY, initialMouse.y)
) {
if (!lastLassoCoveredSelection.has(node.id)) {
added.push(node)
coveredSelection.add(node.id)
selectionChanged = true
}
} else if (lastLassoCoveredSelection.has(node.id)) {
removed.push(node)
coveredSelection.delete(node.id)
selectionChanged = true
}
})
if (selectionChanged) {
this.triggerEvent("lassoupdate", { added, removed, selection: Array.from(coveredSelection) })
lastLassoCoveredSelection = coveredSelection
}
this.render()
}
}
const onStageLassoEnd = () => {
moving = false
this.stage.removeChild(rect)
this.triggerEvent("lassoend", { selection: Array.from(lastLassoCoveredSelection) })
lastLassoCoveredSelection = new Set()
this.render()
}
this.backdrop.on("pointerdown", onLassoStart).on("pointermove", onLassoMove).on("pointerup", onStageLassoEnd).on("pointerupoutside", onStageLassoEnd)
}
/**
* Toggles the lasso selector on and off
* @param {boolean} newStatus - If provided the lasso status will be set, otherwise toggled
*/
toggleLasso(newStatus) {
this.lassoEnabled = typeof newStatus === "boolean" ? newStatus : !this.lassoEnabled
}
/**
* Selects or deselects a node.
* @param {import("../model/rendereroptions").INodeWithRendererOptions} node
* @param {boolean} value - Optional value to set. If ommitted current value will be toggled.
*/
toggleSelectNode(node, value = null) {
if (typeof value !== "boolean") {
node.renderer._private.isSelected = !node.renderer._private.isSelected
} else {
node.renderer._private.isSelected = value
}
if (node.renderer._private.isSelected) {
node.renderer._private.selected.alpha = 1
} else {
node.renderer._private.selected.alpha = 0
}
this.render()
}
/**
* Updates the nodes and edges in the renderer.
* @param {import("../model/rendereroptions").INodeWithRendererOptions[]} nodes
* @param {import("../model/rendereroptions").IEdgeWithRendererOptions[]} edges
*/
updateNodesAndEdges(nodes, edges) {
while (this.stage.children[0]) {
this.stage.removeChild(this.stage.children[0])
}
this.stage.addChild(this.backdrop)
this.nodes.forEach(node => delete node.renderer._private)
this.edges.forEach(edge => delete edge.renderer._private)
this.initializeData(nodes, edges)
this.render()
}
/**
* Returns if the node is selected or not
* @param {import("../model/ibasicnode").IBasicNode} - Node to check
* @returns {boolean} - selected status
*/
isNodeSelected(node) {
return !!node?.renderer?._private?.isSelected
}
/**
* Clears all node selections
*/
clearAllNodeSelections() {
this.nodes.forEach(node => {
node.renderer._private.isSelected = false
node.renderer._private.selected.alpha = 0
})
this.render()
}
/**
* Sets the line type for edges
* @param {"line" | "taxi" | "orthogonal" | "cubicbezier"} newType
*/
setLineType(newType) {
this.lineType = newType
this.render()
}
/**
* scales and moves the view so that all nodes are included in the view
* @param {number} duration - Time in milliseconds for the transition
*/
zoomToFit(duration = 200) {
const PADDING_PX = 250
const parentWidth = this.element.clientWidth
const parentHeight = this.element.clientHeight
const sizeCoordinates = { lowestX: Infinity, lowestY: Infinity, highestX: -Infinity, highestY: -Infinity }
let node
for (let i = 0; i < this.nodes.length; i++) {
node = this.nodes[i]
if (node.x - node.radius < sizeCoordinates.lowestX) sizeCoordinates.lowestX = node.x - node.radius
if (node.y - node.radius < sizeCoordinates.lowestY) sizeCoordinates.lowestY = node.y - node.radius
if (node.x + node.radius > sizeCoordinates.highestX) sizeCoordinates.highestX = node.x + node.radius
if (node.y + node.radius > sizeCoordinates.highestY) sizeCoordinates.highestY = node.y + node.radius
}
const width = Math.abs(sizeCoordinates.highestX - sizeCoordinates.lowestX + PADDING_PX)
const height = Math.abs(sizeCoordinates.highestY - sizeCoordinates.lowestY + PADDING_PX)
const widthRatio = parentWidth / width
const heightRatio = parentHeight / height
const newScale = Math.min(widthRatio, heightRatio)
const midX = (sizeCoordinates.highestX + sizeCoordinates.lowestX) / 2
const midY = (sizeCoordinates.highestY + sizeCoordinates.lowestY) / 2
const animation = {
sourceX: this.stage.x,
sourceY: this.stage.y,
sourceScale: this.stage.scale.x,
targetX: -midX + parentWidth / 2,
targetY: -midY + parentHeight / 2,
targetScale: newScale
}
const startTime = Date.now()
const loop = () => {
setTimeout(() => {
const deltaTime = Date.now() - startTime
const percentOfAnimation = Math.min(deltaTime / duration, 100)
const nextX = animation.sourceX + (animation.targetX - animation.sourceX) * percentOfAnimation
const nextY = animation.sourceY + (animation.targetY - animation.sourceY) * percentOfAnimation
const nextScale = animation.sourceScale + (animation.targetScale - animation.sourceScale) * percentOfAnimation
this.stage.setTransform(nextX, nextY, nextScale, nextScale)
this.render()
if (deltaTime < duration) {
loop()
}
}, 1)
}
loop()
}
/**
* Sets new coordinates and scale for the renderer's stage
* @param {number} x
* @param {number} y
* @param {number} scale
*/
setTransform(x, y, scale) {
this.stage.setTransform(-x + this.element.clientWidth / 2, -y + this.element.clientHeight / 2, scale, scale)
this.render()
}
/**
* Takes coordinates from the viewport as input and returns the local (relative) coordinates in the graph
* @param {number} x - Viewport X coordinate
* @param {number} y - Viewport Y coordinate
*/
viewportToLocalCoordinates(x, y) {
const newCoordinates = this.stage.toLocal({ x, y })
return { x: newCoordinates.x, y: newCoordinates.y }
}
/**
* Takes coordinates from the graph as input and returns the corresponding viewport coordinates
* @param {number} x - Local X coordinate
* @param {number} y - Local Y coordinate
*/
localToViewportCoordinates(x, y) {
const newCoordinates = this.stage.toGlobal({ x, y })
return { x: newCoordinates.x, y: newCoordinates.y }
}
/**
* disables and grays out nodes that match a given filter function.
* Connected edges will also be disabled.
* @param {import("../model/rendereroptions").INodeWithRendererOptions => boolean} fn - filter function for nodes
*/
disableNodes(fn) {
this.clearAllFilters()
const includedNodes = new Set()
this.nodes
.filter(node => fn(node))
.forEach(node => {
node.renderer._private.isDisabled = true
includedNodes.add(node.id)
})
this.edges.forEach(edge => {
if (includedNodes.has(edge.sourceNode) || includedNodes.has(edge.targetNode)) {
edge.renderer._private.isDisabled = true
}
})
this.render()
}
/**
* Clears all disabled statuses on nodes and edges
*/
clearAllDisabledStatuses() {
this.nodes.forEach(node => {
node.renderer._private.isDisabled = false
})
this.edges.forEach(edge => {
edge.renderer._private.isDisabled = false
})
this.render()
}
/**
* Cleanup function when dismounting.
*/
dismount() {
this.resizeObserver.unobserve(this.element)
}
/**
* Calculates the point where the edge between the source and target node intersects the border of the target node.
* @param {{shape: string, x: number, y: number}} source - source node of the edge
* @param {{shape: string, x: number, y: number}} target - target node of the edge
* @param {number} additionalDistance - additional distance, or what is essentially a padding.
* @returns {{x: number, y: number}}
*/
calculateIntersection(source, target, additionalDistance) {
const dx = target.x - source.x
const dy = target.y - source.y
let innerDistance = target.radius
//Rectangles require some more work...
if (target.renderer.shape === "rectangle") {
const mEdge = Math.abs(dy / dx)
const mRect = target.height / target.width
if (mEdge <= mRect) {
const timesX = dx / (target.width / 2)
const rectY = dy / timesX
innerDistance = Math.sqrt(Math.pow(target.width / 2, 2) + rectY * rectY)
} else {
const timesY = dy / (target.height / 2)
const rectX = dx / timesY
innerDistance = Math.sqrt(Math.pow(target.height / 2, 2) + rectX * rectX)
}
}
const length = Math.sqrt(dx * dx + dy * dy)
const ratio = (length - (innerDistance + additionalDistance)) / length
const x = dx * ratio + source.x
const y = dy * ratio + source.y
return { x, y }
}
/**
* Calculates the angle for a label in the graph
* @param {{x: number, y: number}} point1 - First vector of the edge
* @param {{x: number, y: number}} point2 - Second vector of the edge
*/
computeLabelAngle(point1, point2) {
//Get the angle in degrees
const dx = point1.x - point2.x
const dy = point1.y - point2.y
const theta = Math.atan2(dy, dx)
let angle = theta * (180 / Math.PI)
//Convert to a 360 scale
angle += 180
//Make sure the label is never upside-down
if (angle > 90 && angle < 270) {
angle -= 180
}
return angle
}
/**
* Calculates a point between two points for creating a curved line.
* @param {object} source - Point where the source node is intersected by the edge
* @param {object} target - Point where the target node is intersected by the edge
* @param {{total: number, index: number}} edgeCounter - Edge counter
*/
computeCurvePoint(source, target, edgeCounter) {
const level = Math.floor((edgeCounter.index - (edgeCounter.total % 2)) / 2) + 1
const oddConstant = (edgeCounter.total % 2) * 15
let distance = 0
switch (level) {
case 1:
distance = 20 + oddConstant
break
case 2:
distance = 45 + oddConstant
break
default:
break
}
const dx = target.x - source.x
const dy = target.y - source.y
const cx = source.x + dx / 2
const cy = source.y + dy / 2
const nx = -dy
const ny = dx
const vlength = Math.sqrt(nx * nx + ny * ny)
const ratio = distance / vlength
const n = { x: nx * ratio, y: ny * ratio }
if (source.index < target.index) {
n.x = -n.x
n.y = -n.y
}
if (edgeCounter.index % 2 !== 0) {
n.x = -n.x
n.y = -n.y
}
return { x: cx + n.x, y: cy + n.y }
}
/**
* Calculates the radian of an angle.
* @param {number} angle
*/
computeRadian(angle) {
angle = angle % 360
if (angle < 0) {
angle = angle + 360
}
let arc = (2 * Math.PI * angle) / 360
if (arc < 0) {
arc = arc + 2 * Math.PI
}
return arc
}
/**
* Calculates edges to its input and stores the point for the labels. Only for circle shaped nodes!
* @param {{radius: number, x: number, y: number}} node - Edge to be processed
* @param {{total: number, index: number}} edgeCounter - Edge to be processed
* @param {number} additionalDistance - Additional padding in px
*/
computeSelfEdgePath(node, edgeCounter, additionalDistance = 0) {
const loopShiftAngle = 360 / edgeCounter.total
const loopAngle = Math.min(60, loopShiftAngle)
const arcFrom = this.computeRadian(loopShiftAngle * edgeCounter.index)
const arcTo = this.computeRadian(loopShiftAngle * edgeCounter.index + loopAngle)
const x1 = Math.cos(arcFrom) * (node.radius + additionalDistance)
const y1 = Math.sin(arcFrom) * (node.radius + additionalDistance)
const x2 = Math.cos(arcTo) * (node.radius + additionalDistance)
const y2 = Math.sin(arcTo) * (node.radius + additionalDistance)
const fixPoint1 = { x: node.x + x1, y: node.y + y1 }
const fixPoint2 = { x: node.x + x2, y: node.y + y2 }
const distanceMultiplier = 5
const dx = ((x1 + x2) / 2) * distanceMultiplier
const dy = ((y1 + y2) / 2) * distanceMultiplier
const curvePoint = { x: node.x + dx, y: node.y + dy }
return { start: fixPoint1, end: fixPoint2, curvePoint, label: { x: node.x + dx * 0.8, y: node.y + dy * 0.8 } }
}
/**
* Main render function that updates the canvas
*/
render() {
this.nodes.forEach(node => {
const { x, y } = node
node.renderer._private.container.position = new PIXI.Point(x, y)
if (this.stage.scale._x < 0.3) {
if (node.renderer._private?.text) node.renderer._private.text.renderable = false
if (node.renderer._private?.icon) node.renderer._private.icon.renderable = false
} else {
if (node.renderer._private?.text) node.renderer._private.text.renderable = true
if (node.renderer._private?.icon) node.renderer._private.icon.renderable = true
}
if (node.renderer._private.isDisabled) {
node.renderer._private.node.alpha = 0.2
node.renderer._private.node.interactive = false
} else {
node.renderer._private.node.alpha = 1
node.renderer._private.node.interactive = true
}
})
this.edges.forEach(edge => {
if (this.stage.scale._x < 0.1) {
edge.renderer._private.line.renderable = false
edge.renderer._private.markerSource.renderable = false
edge.renderer._private.markerTarget.renderable = false
if (edge.renderer._private.text) edge.renderer._private.text.renderable = false
return
} else {
edge.renderer._private.line.renderable = true
edge.renderer._private.markerSource.renderable = true
edge.renderer._private.markerTarget.renderable = true
if (edge.renderer._private.text) edge.renderer._private.text.renderable = true
}
const source = edge.renderer._private.source
const target = edge.renderer._private.target
const line = edge.renderer._private.line
line.clear()
line.alpha = 1
line.lineStyle(1, edge.renderer.color || 0x000000)
let pathStart
let pathEnd
let curvePoint
let labelPoint
if (source === target) {
const selfPath = this.computeSelfEdgePath(source, edge.renderer._private.edgeCounter, this.LINE_MARGIN_PX)
curvePoint = selfPath.curvePoint
pathStart = selfPath.start
pathEnd = selfPath.end
labelPoint = selfPath.label
line.moveTo(pathStart.x, pathStart.y)
line.quadraticCurveTo(curvePoint.x, curvePoint.y, pathEnd.x, pathEnd.y)
line.endFill()
} else if (this.lineType === "taxi") {
curvePoint = this.computeCurvePoint(source, target, edge.renderer._private.edgeCounter)
pathStart = this.calculateIntersection(curvePoint, source, this.LINE_MARGIN_PX)
pathEnd = this.calculateIntersection(curvePoint, target, this.LINE_MARGIN_PX)
labelPoint = { x: (pathStart.x + pathEnd.x) / 2, y: (pathStart.y + pathEnd.y) / 2 }
const midPointY = pathStart.y + (pathEnd.y - pathStart.y) / 2
line.moveTo(pathStart.x, pathStart.y)
line.lineTo(pathStart.x, midPointY)
line.lineTo(pathEnd.x, midPointY)
line.lineTo(pathEnd.x, pathEnd.y)
line.endFill()
} else if (this.lineType === "cubicbezier") {
//TODO:: Marker angles need to be computed based on the curve rather than the angle between start and end.
curvePoint = this.computeCurvePoint(source, target, edge.renderer._private.edgeCounter)
pathStart = this.calculateIntersection(curvePoint, source, this.LINE_MARGIN_PX)
pathEnd = this.calculateIntersection(curvePoint, target, this.LINE_MARGIN_PX)
labelPoint = { x: (pathStart.x + pathEnd.x) / 2, y: (pathStart.y + pathEnd.y) / 2 }
line.moveTo(pathStart.x, pathStart.y)
line.bezierCurveTo((pathStart.x + pathEnd.x) / 2, pathStart.y, (pathStart.x + pathEnd.x) / 2, pathEnd.y, pathEnd.x, pathEnd.y)
line.endFill()
} else if (this.lineType === "orthogonal") {
//TODO:: Make this go faster
const sourceWidth = edge.source.width ? edge.source.width : edge.source.radius * 2
const sourceHeight = edge.source.height ? edge.source.height : edge.source.radius * 2
const targetWidth = edge.target.width ? edge.target.width : edge.target.radius * 2
const targetHeight = edge.target.height ? edge.target.height : edge.target.radius * 2
const sourceSide = edge.renderer.sourceEdgePosition
const targetSide = edge.renderer.targetEdgePosition
const routeOptions = {
pointA: {
shape: {
left: edge.source.x - sourceWidth / 2 - this.LINE_MARGIN_PX / 2,
top: edge.source.y - sourceHeight / 2 - this.LINE_MARGIN_PX / 2,
width: sourceWidth + this.LINE_MARGIN_PX,
height: sourceHeight + this.LINE_MARGIN_PX
},
side: sourceSide,
distance: 0.5
},
pointB: {
shape: {
left: edge.target.x - targetWidth / 2 - this.LINE_MARGIN_PX / 2,
top: edge.target.y - targetHeight / 2 - this.LINE_MARGIN_PX / 2,
width: targetWidth + this.LINE_MARGIN_PX,
height: targetHeight + this.LINE_MARGIN_PX
},
side: targetSide,
distance: 0.5
},
shapeMargin: this.LINE_MARGIN_PX,
globalBoundsMargin: 100,
globalBounds: { left: -this.sceneSize / 2, top: -this.sceneSize / 2, width: this.sceneSize, height: this.sceneSize }
}
const router = new OrthogonalConnector()
const path = router.route(routeOptions)
if (!path.length) {
//this can occur if the sides are so close that the padding makes a path impossible.
//If so we just exit the loop.
return
}
const { x, y } = path.shift()
const finalStep = path[path.length - 1]
pathStart = { x, y }
pathEnd = { x: finalStep.x, y: finalStep.y }
labelPoint = { x: (pathStart.x + pathEnd.x) / 2, y: (pathStart.y + pathEnd.y) / 2 }
//We hijack the curvepoint parameter to use later for positioning the markers
curvePoint = { source: routeOptions.pointA.side, target: routeOptions.pointB.side }
line.moveTo(x, y)
path.forEach(path => line.lineTo(path.x, path.y))
line.endFill()
} else {
curvePoint = this.computeCurvePoint(source, target, edge.renderer._private.edgeCounter)
pathStart = this.calculateIntersection(curvePoint, source, this.LINE_MARGIN_PX)
pathEnd = this.calculateIntersection(curvePoint, target, this.LINE_MARGIN_PX)
labelPoint = { x: (pathStart.x + pathEnd.x) / 2, y: (pathStart.y + pathEnd.y) / 2 }
line.moveTo(pathStart.x, pathStart.y)
line.quadraticCurveTo(curvePoint.x, curvePoint.y, pathEnd.x, pathEnd.y)
line.endFill()
}
if (this.lineType === "taxi" && source !== target) {
const markerTarget = edge.renderer._private.markerTarget
markerTarget.angle = target.y > source.y ? 90 : 270
markerTarget.position = new PIXI.Point(pathEnd.x, pathEnd.y)
const markerSource = edge.renderer._private.markerSource
markerSource.angle = source.y > target.y ? 90 : 270
markerSource.position = new PIXI.Point(pathStart.x, pathStart.y)
} else if (this.lineType === "orthogonal" && source !== target) {
const markerTarget = edge.renderer._private.markerTarget
markerTarget.angle = curvePoint.target === "left" ? 0 : curvePoint.target === "top" ? 90 : curvePoint.target === "right" ? 180 : 270
markerTarget.position = new PIXI.Point(pathEnd.x, pathEnd.y)
const markerSource = edge.renderer._private.markerSource
markerSource.angle = curvePoint.source === "left" ? 0 : curvePoint.source === "top" ? 90 : curvePoint.source === "right" ? 180 : 270
markerSource.position = new PIXI.Point(pathStart.x, pathStart.y)
} else {
const markerTarget = edge.renderer._private.markerTarget
markerTarget.rotation = Math.atan2(target.y - curvePoint.y, target.x - curvePoint.x)
markerTarget.position = new PIXI.Point(pathEnd.x, pathEnd.y)
const markerSource = edge.renderer._private.markerSource
markerSource.rotation = Math.atan2(source.y - curvePoint.y, source.x - curvePoint.x)
markerSource.position = new PIXI.Point(pathStart.x, pathStart.y)
}
const text = edge.renderer._private.text
if (text) {
text.position = new PIXI.Point(labelPoint.x, labelPoint.y)
if (this.lineType === "line") {
text.angle = this.computeLabelAngle(source, target)
}
text.alpha = this.stage.scale._x < 0.3 ? 0 : 1
}
if (edge.renderer._private.isDisabled) {
edge.renderer._private.line.alpha = 0.2
edge.renderer._private.markerSource.alpha = 0.2
edge.renderer._private.markerTarget.alpha = 0.2
edge.renderer._private.text && (edge.renderer._private.text.alpha = 0.2)
edge.renderer._private.text && (edge.renderer._private.text.interactive = false)
} else {
edge.renderer._private.line.alpha = 1
edge.renderer._private.markerSource.alpha = 1
edge.renderer._private.markerTarget.alpha = 1
edge.renderer._private.text && (edge.renderer._private.text.alpha = 1)
edge.renderer._private.text && (edge.renderer._private.text.interactive = true)
}
})
requestAnimationFrame(() => this.renderer.render(this.stage))
}
}