@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
1,873 lines (1,621 loc) • 68 kB
text/typescript
import {
Dom,
FunctionExt,
IS_SAFARI,
type KeyValue,
NumberExt,
ObjectExt,
Vector,
} from '../../common'
import {
Line,
normalize,
Path,
Point,
type PointLike,
Polyline,
Rectangle,
} from '../../geometry'
import type { Graph } from '../../graph'
import type {
OnEdgeLabelRenderedArgs,
ValidateConnectionArgs,
} from '../../graph/options'
import {
Edge,
type EdgeSetOptions,
type LabelPosition,
type LabelPositionObject,
type LabelPositionOptions,
type SetCellTerminalArgs,
type TerminalCellData,
type TerminalData,
type TerminalPointData,
type TerminalType,
} from '../../model/edge'
import {
type ConnectionPointManualItem,
type ConnectorBaseOptions,
type ConnectorDefinition,
connectionPointRegistry,
connectorPresets,
connectorRegistry,
edgeAnchorRegistry,
type NodeAnchorManualItem,
nodeAnchorRegistry,
type RouterDefinition,
routerPresets,
routerRegistry,
} from '../../registry'
import { CellView } from '../cell'
import type { MarkupJSONMarkup, MarkupSelectors, MarkupType } from '../markup'
import { NodeView } from '../node'
import type { ToolsViewOptions } from '../tool'
import type {
EdgeViewMouseEventArgs,
EdgeViewOptions,
EdgeViewPositionEventArgs,
EventDataArrowheadDragging,
EventDataEdgeDragging,
EventDataLabelDragging,
EventDataValidateConnectionArgs,
} from './type'
export * from './type'
export class EdgeView<
Entity extends Edge = Edge,
Options extends EdgeViewOptions = EdgeViewOptions,
> extends CellView<Entity, Options> {
protected readonly POINT_ROUNDING = 2
public path: Path
public routePoints: Point[]
public sourceAnchor: Point
public targetAnchor: Point
public sourcePoint: Point
public targetPoint: Point
public sourceMarkerPoint: Point
public targetMarkerPoint: Point
public sourceView: CellView | null
public targetView: CellView | null
public sourceMagnet: Element | null
public targetMagnet: Element | null
protected labelContainer: Element | null
protected labelCache: { [index: number]: Element }
protected labelSelectors: { [index: number]: MarkupSelectors }
protected labelDestroyFn: {
[index: number]: (args: OnEdgeLabelRenderedArgs) => void
} = {}
public static isEdgeView(instance: any): instance is EdgeView {
if (instance == null) {
return false
}
if (instance instanceof EdgeView) {
return true
}
const tag = instance[Symbol.toStringTag]
const view = instance as EdgeView
if (
(tag == null || tag === EdgeViewToStringTag) &&
typeof view.isNodeView === 'function' &&
typeof view.isEdgeView === 'function' &&
typeof view.confirmUpdate === 'function' &&
typeof view.update === 'function' &&
typeof view.getConnection === 'function'
) {
return true
}
return false
}
protected get [Symbol.toStringTag]() {
return EdgeViewToStringTag
}
protected getContainerClassName() {
return [super.getContainerClassName(), this.prefixClassName('edge')].join(
' ',
)
}
get sourceBBox() {
const sourceView = this.sourceView
if (!sourceView || !this.graph.renderer.isViewMounted(sourceView)) {
const sourceCell = this.cell.getSourceCell()
if (sourceCell) {
return sourceCell.getBBox()
}
const sourcePoint = this.cell.getSourcePoint()
return new Rectangle(sourcePoint.x, sourcePoint.y)
}
const sourceMagnet = this.sourceMagnet
if (sourceView.isEdgeElement(sourceMagnet)) {
return new Rectangle(this.sourceAnchor.x, this.sourceAnchor.y)
}
return sourceView.getBBoxOfElement(sourceMagnet || sourceView.container)
}
get targetBBox() {
const targetView = this.targetView
if (!targetView || !this.graph.renderer.isViewMounted(targetView)) {
const targetCell = this.cell.getTargetCell()
if (targetCell) {
return targetCell.getBBox()
}
const targetPoint = this.cell.getTargetPoint()
return new Rectangle(targetPoint.x, targetPoint.y)
}
const targetMagnet = this.targetMagnet
if (targetView.isEdgeElement(targetMagnet)) {
return new Rectangle(this.targetAnchor.x, this.targetAnchor.y)
}
return targetView.getBBoxOfElement(targetMagnet || targetView.container)
}
isEdgeView(): this is EdgeView {
return true
}
confirmUpdate(flag: number, options: any = {}) {
let ref = flag
if (this.hasAction(ref, 'source')) {
if (!this.updateTerminalProperties('source')) {
return ref
}
ref = this.removeAction(ref, 'source')
}
if (this.hasAction(ref, 'target')) {
if (!this.updateTerminalProperties('target')) {
return ref
}
ref = this.removeAction(ref, 'target')
}
if (this.hasAction(ref, 'render')) {
this.render()
ref = this.removeAction(ref, ['render', 'update', 'labels', 'tools'])
return ref
}
ref = this.handleAction(ref, 'update', () => this.update(options))
ref = this.handleAction(ref, 'labels', () => this.onLabelsChange(options))
ref = this.handleAction(ref, 'tools', () => this.renderTools())
return ref
}
// #region render
render() {
this.empty()
this.renderMarkup()
this.labelContainer = null
this.renderLabels()
this.update()
this.renderTools()
this.notify('view:render', { view: this })
return this
}
protected renderMarkup() {
const markup = this.cell.markup
if (markup) {
if (typeof markup === 'string') {
throw new TypeError('Not support string markup.')
}
return this.renderJSONMarkup(markup)
}
throw new TypeError('Invalid edge markup.')
}
protected renderJSONMarkup(markup: MarkupJSONMarkup | MarkupJSONMarkup[]) {
const ret = this.parseJSONMarkup(markup, this.container)
this.selectors = ret.selectors
this.container.append(ret.fragment)
}
protected customizeLabels() {
if (this.labelContainer) {
const edge = this.cell
const labels = edge.labels
for (let i = 0, n = labels.length; i < n; i += 1) {
const label = labels[i]
const container = this.labelCache[i]
const selectors = this.labelSelectors[i]
const onEdgeLabelRendered = this.graph.options.onEdgeLabelRendered
if (onEdgeLabelRendered) {
const fn = onEdgeLabelRendered({
edge,
label,
container,
selectors,
})
if (fn) {
this.labelDestroyFn[i] = fn
}
}
}
}
}
protected destroyCustomizeLabels() {
const labels = this.cell.labels
if (this.labelCache && this.labelSelectors && this.labelDestroyFn) {
for (let i = 0, n = labels.length; i < n; i += 1) {
const fn = this.labelDestroyFn[i]
const container = this.labelCache[i]
const selectors = this.labelSelectors[i]
if (fn && container && selectors) {
fn({
edge: this.cell,
label: labels[i],
container,
selectors,
})
}
}
}
this.labelDestroyFn = {}
}
protected renderLabels() {
const edge = this.cell
const labels = edge.getLabels()
const count = labels.length
let container = this.labelContainer
this.labelCache = {}
this.labelSelectors = {}
if (count <= 0) {
if (container?.parentNode) {
container.parentNode.removeChild(container)
}
return this
}
if (container) {
this.empty(container)
} else {
container = Dom.createSvgElement('g')
this.addClass(this.prefixClassName('edge-labels'), container)
this.labelContainer = container
}
for (let i = 0, ii = labels.length; i < ii; i += 1) {
const label = labels[i]
const normalized = this.normalizeLabelMarkup(
this.parseLabelMarkup(label.markup),
)
let labelNode: Element
let selectors: MarkupSelectors
if (normalized) {
labelNode = normalized.node
selectors = normalized.selectors
} else {
const defaultLabel = edge.getDefaultLabel()
const normalized = this.normalizeLabelMarkup(
this.parseLabelMarkup(defaultLabel.markup),
)!
labelNode = normalized.node
selectors = normalized.selectors
}
labelNode.setAttribute('data-index', `${i}`)
container.appendChild(labelNode)
const rootSelector = this.rootSelector
if (selectors[rootSelector]) {
throw new Error('Ambiguous label root selector.')
}
selectors[rootSelector] = labelNode
this.labelCache[i] = labelNode
this.labelSelectors[i] = selectors
}
if (container.parentNode == null) {
this.container.appendChild(container)
}
this.updateLabels()
this.customizeLabels()
return this
}
onLabelsChange(options: any = {}) {
this.destroyCustomizeLabels()
if (this.shouldRerenderLabels(options)) {
this.renderLabels()
} else {
this.updateLabels()
}
this.updateLabelPositions()
}
protected shouldRerenderLabels(options: any = {}) {
const previousLabels = this.cell.previous('labels')
if (previousLabels == null) {
return true
}
// Here is an optimization for cases when we know, that change does
// not require re-rendering of all labels.
if ('propertyPathArray' in options && 'propertyValue' in options) {
// The label is setting by `prop()` method
const pathArray = options.propertyPathArray || []
const pathLength = pathArray.length
if (pathLength > 1) {
// We are changing a single label here e.g. 'labels/0/position'
const index = pathArray[1]
if (previousLabels[index]) {
if (pathLength === 2) {
// We are changing the entire label. Need to check if the
// markup is also being changed.
return (
typeof options.propertyValue === 'object' &&
ObjectExt.has(options.propertyValue, 'markup')
)
}
// We are changing a label property but not the markup
if (pathArray[2] !== 'markup') {
return false
}
}
}
}
return true
}
protected parseLabelMarkup(markup?: MarkupType) {
if (markup) {
if (typeof markup === 'string') {
return this.parseLabelStringMarkup(markup)
}
return this.parseJSONMarkup(markup)
}
return null
}
protected parseLabelStringMarkup(labelMarkup: string) {
const children = Vector.createVectors(labelMarkup)
const fragment = document.createDocumentFragment()
for (let i = 0, n = children.length; i < n; i += 1) {
const currentChild = children[i].node
fragment.appendChild(currentChild)
}
return { fragment, selectors: {} }
}
protected normalizeLabelMarkup(
markup?: {
fragment: DocumentFragment
selectors: MarkupSelectors
} | null,
) {
if (markup == null) {
return
}
const fragment = markup.fragment
if (!(fragment instanceof DocumentFragment) || !fragment.hasChildNodes()) {
throw new Error('Invalid label markup.')
}
let vel: Vector
const childNodes = fragment.childNodes
if (childNodes.length > 1 || childNodes[0].nodeName.toUpperCase() !== 'G') {
vel = Vector.create('g').append(fragment)
} else {
vel = Vector.create(childNodes[0] as SVGElement)
}
vel.addClass(this.prefixClassName('edge-label'))
return {
node: vel.node,
selectors: markup.selectors,
}
}
protected updateLabels() {
if (this.labelContainer) {
const edge = this.cell
const labels = edge.labels
const canLabelMove = this.can('edgeLabelMovable')
const defaultLabel = edge.getDefaultLabel()
for (let i = 0, n = labels.length; i < n; i += 1) {
const elem = this.labelCache[i]
const selectors = this.labelSelectors[i]
elem.setAttribute('cursor', canLabelMove ? 'move' : 'default')
const label = labels[i]
const attrs = ObjectExt.merge({}, defaultLabel.attrs, label.attrs)
this.updateAttrs(elem, attrs, {
selectors,
rootBBox: label.size ? Rectangle.fromSize(label.size) : undefined,
})
}
}
}
protected renderTools() {
const tools = this.cell.getTools()
this.addTools(tools as ToolsViewOptions)
return this
}
// #endregion
// #region updating
update(options: any = {}) {
this.cleanCache()
this.updateConnection(options)
const { text, ...attrs } = this.cell.getAttrs()
if (attrs != null) {
// FIXME: safari 兼容,重新渲染一次edge 的g元素,确保重排/重绘能渲染出marker
if (
this.container?.tagName === 'g' &&
this.isEdgeElement(this.container) &&
IS_SAFARI
) {
const parent = this.container.parentNode
if (parent) {
const next = this.container.nextSibling
parent.removeChild(this.container)
parent.insertBefore(this.container, next)
}
}
this.updateAttrs(this.container, attrs, {
selectors: this.selectors,
})
}
this.updateLabelPositions()
this.updateTools(options)
return this
}
removeRedundantLinearVertices(options: EdgeSetOptions = {}) {
const edge = this.cell
const vertices = edge.getVertices()
const routePoints = [this.sourceAnchor, ...vertices, this.targetAnchor]
const rawCount = routePoints.length
// Puts the route points into a polyline and try to simplify.
const polyline = new Polyline(routePoints)
polyline.simplify({ threshold: 0.01 })
const simplifiedPoints = polyline.points.map((point) => point.toJSON())
const simplifiedCount = simplifiedPoints.length
// If simplification did not remove any redundant vertices.
if (rawCount === simplifiedCount) {
return 0
}
// Sets simplified polyline points as edge vertices.
// Removes first and last polyline points again (source/target anchors).
edge.setVertices(simplifiedPoints.slice(1, simplifiedCount - 1), options)
return rawCount - simplifiedCount
}
getTerminalView(type: TerminalType) {
switch (type) {
case 'source':
return this.sourceView || null
case 'target':
return this.targetView || null
default:
throw new Error(`Unknown terminal type '${type}'`)
}
}
getTerminalAnchor(type: TerminalType) {
switch (type) {
case 'source':
return Point.create(this.sourceAnchor)
case 'target':
return Point.create(this.targetAnchor)
default:
throw new Error(`Unknown terminal type '${type}'`)
}
}
getTerminalConnectionPoint(type: TerminalType) {
switch (type) {
case 'source':
return Point.create(this.sourcePoint)
case 'target':
return Point.create(this.targetPoint)
default:
throw new Error(`Unknown terminal type '${type}'`)
}
}
getTerminalMagnet(type: TerminalType, options: { raw?: boolean } = {}) {
switch (type) {
case 'source': {
if (options.raw) {
return this.sourceMagnet
}
const sourceView = this.sourceView
if (!sourceView) {
return null
}
return this.sourceMagnet || sourceView.container
}
case 'target': {
if (options.raw) {
return this.targetMagnet
}
const targetView = this.targetView
if (!targetView) {
return null
}
return this.targetMagnet || targetView.container
}
default: {
throw new Error(`Unknown terminal type '${type}'`)
}
}
}
updateConnection(options: any = {}) {
const edge = this.cell
// The edge is being translated by an ancestor that will shift
// source, target and vertices by an equal distance.
// todo isFragmentDescendantOf is invalid
if (
options.translateBy &&
edge.isFragmentDescendantOf(options.translateBy)
) {
const tx = options.tx || 0
const ty = options.ty || 0
this.routePoints = new Polyline(this.routePoints).translate(tx, ty).points
this.translateConnectionPoints(tx, ty)
this.path.translate(tx, ty)
} else {
const vertices = edge.getVertices()
// 1. Find anchor points
const anchors = this.findAnchors(vertices)
this.sourceAnchor = anchors.source
this.targetAnchor = anchors.target
// 2. Find route points
this.routePoints = this.findRoutePoints(vertices)
// 3. Find connection points
const connectionPoints = this.findConnectionPoints(
this.routePoints,
this.sourceAnchor,
this.targetAnchor,
)
this.sourcePoint = connectionPoints.source
this.targetPoint = connectionPoints.target
// 4. Find Marker Connection Point
const markerPoints = this.findMarkerPoints(
this.routePoints,
this.sourcePoint,
this.targetPoint,
)
// 5. Make path
this.path = this.findPath(
this.routePoints,
markerPoints.source || this.sourcePoint,
markerPoints.target || this.targetPoint,
)
}
this.cleanCache()
}
protected findAnchors(vertices: PointLike[]) {
const edge = this.cell
const source = edge.source as TerminalCellData
const target = edge.target as TerminalCellData
const firstVertex = vertices[0]
const lastVertex = vertices[vertices.length - 1]
if (target.priority && !source.priority) {
// Reversed order
return this.findAnchorsOrdered(
'target',
lastVertex,
'source',
firstVertex,
)
}
// Usual order
return this.findAnchorsOrdered('source', firstVertex, 'target', lastVertex)
}
protected findAnchorsOrdered(
firstType: TerminalType,
firstPoint: PointLike,
secondType: TerminalType,
secondPoint: PointLike,
) {
let firstAnchor: Point
let secondAnchor: Point
const edge = this.cell
const firstTerminal = edge[firstType]
const secondTerminal = edge[secondType]
const firstView = this.getTerminalView(firstType)
const secondView = this.getTerminalView(secondType)
const firstMagnet = this.getTerminalMagnet(firstType)
const secondMagnet = this.getTerminalMagnet(secondType)
if (firstView) {
let firstRef: Point | Element | null
if (firstPoint) {
firstRef = Point.create(firstPoint)
} else if (secondView) {
firstRef = secondMagnet
} else {
firstRef = Point.create(secondTerminal as TerminalPointData)
}
firstAnchor = this.getAnchor(
(firstTerminal as SetCellTerminalArgs).anchor,
firstView,
firstMagnet,
firstRef,
firstType,
)
} else {
firstAnchor = Point.create(firstTerminal as TerminalPointData)
}
if (secondView) {
const secondRef = Point.create(secondPoint || firstAnchor)
secondAnchor = this.getAnchor(
(secondTerminal as SetCellTerminalArgs).anchor,
secondView,
secondMagnet,
secondRef,
secondType,
)
} else {
secondAnchor = Point.isPointLike(secondTerminal)
? Point.create(secondTerminal)
: new Point()
}
return {
[firstType]: firstAnchor,
[secondType]: secondAnchor,
}
}
protected getAnchor(
def: NodeAnchorManualItem | string | undefined,
cellView: CellView,
magnet: Element | null,
ref: Point | Element | null,
terminalType: TerminalType,
): Point {
const isEdge = cellView.isEdgeElement(magnet)
const connecting = this.graph.options.connecting
let config = typeof def === 'string' ? { name: def } : def
if (!config) {
const defaults = isEdge
? (terminalType === 'source'
? connecting.sourceEdgeAnchor
: connecting.targetEdgeAnchor) || connecting.edgeAnchor
: (terminalType === 'source'
? connecting.sourceAnchor
: connecting.targetAnchor) || connecting.anchor
config = typeof defaults === 'string' ? { name: defaults } : defaults
}
if (!config) {
throw new Error(`Anchor should be specified.`)
}
let anchor: Point | undefined
const name = config.name
if (isEdge) {
const fn = edgeAnchorRegistry.get(name)
if (typeof fn !== 'function') {
return edgeAnchorRegistry.onNotFound(name)
}
anchor = FunctionExt.call(
fn,
this,
cellView as EdgeView,
magnet as SVGElement,
ref as PointLike,
config.args || {},
terminalType,
)
} else {
const fn = nodeAnchorRegistry.get(name)
if (typeof fn !== 'function') {
return nodeAnchorRegistry.onNotFound(name)
}
anchor = FunctionExt.call(
fn,
this,
cellView as NodeView,
magnet as SVGElement,
ref as PointLike,
config.args || {},
terminalType,
)
}
return anchor ? anchor.round(this.POINT_ROUNDING) : new Point()
}
protected findRoutePoints(vertices: PointLike[] = []): Point[] {
const defaultRouter =
this.graph.options.connecting.router || routerPresets.normal
const router = this.cell.getRouter() || defaultRouter
let routePoints: PointLike[] | null | undefined
if (typeof router === 'function') {
routePoints = FunctionExt.call(
router as RouterDefinition<any>,
this,
vertices,
{},
this,
)
} else {
const name = typeof router === 'string' ? router : router.name
const args = typeof router === 'string' ? {} : router.args || {}
const fn = name ? routerRegistry.get(name) : routerPresets.normal
if (typeof fn !== 'function') {
return routerRegistry.onNotFound(name as string)
}
routePoints = FunctionExt.call(fn, this, vertices, args, this)
}
return routePoints == null
? vertices.map((p) => Point.create(p))
: routePoints.map((p) => Point.create(p))
}
protected findConnectionPoints(
routePoints: Point[],
sourceAnchor: Point,
targetAnchor: Point,
) {
const edge = this.cell
const connecting = this.graph.options.connecting
const sourceTerminal = edge.getSource()
const targetTerminal = edge.getTarget()
const sourceView = this.sourceView
const targetView = this.targetView
const firstRoutePoint = routePoints[0]
const lastRoutePoint = routePoints[routePoints.length - 1]
// source
let sourcePoint: Point
if (sourceView && !sourceView.isEdgeElement(this.sourceMagnet)) {
const sourceMagnet = this.sourceMagnet || sourceView.container
const sourcePointRef = firstRoutePoint || targetAnchor
const sourceLine = new Line(sourcePointRef, sourceAnchor)
const connectionPointDef =
sourceTerminal.connectionPoint ||
connecting.sourceConnectionPoint ||
connecting.connectionPoint
sourcePoint = this.getConnectionPoint(
connectionPointDef,
sourceView,
sourceMagnet,
sourceLine,
'source',
)
} else {
sourcePoint = sourceAnchor
}
// target
let targetPoint: Point
if (targetView && !targetView.isEdgeElement(this.targetMagnet)) {
const targetMagnet = this.targetMagnet || targetView.container
const targetConnectionPointDef =
targetTerminal.connectionPoint ||
connecting.targetConnectionPoint ||
connecting.connectionPoint
const targetPointRef = lastRoutePoint || sourceAnchor
const targetLine = new Line(targetPointRef, targetAnchor)
targetPoint = this.getConnectionPoint(
targetConnectionPointDef,
targetView,
targetMagnet,
targetLine,
'target',
)
} else {
targetPoint = targetAnchor
}
return {
source: sourcePoint,
target: targetPoint,
}
}
protected getConnectionPoint(
def: string | ConnectionPointManualItem | undefined,
view: CellView,
magnet: Element,
line: Line,
endType: TerminalType,
) {
const anchor = line.end
if (def == null) {
return anchor
}
const name = typeof def === 'string' ? def : def.name
const args = typeof def === 'string' ? {} : def.args
const fn = connectionPointRegistry.get(name)
if (typeof fn !== 'function') {
return connectionPointRegistry.onNotFound(name)
}
const connectionPoint = FunctionExt.call(
fn,
this,
line,
view,
magnet as SVGElement,
args || {},
endType,
)
return connectionPoint ? connectionPoint.round(this.POINT_ROUNDING) : anchor
}
protected findMarkerPoints(
routePoints: Point[],
sourcePoint: Point,
targetPoint: Point,
) {
const getLineWidth = (type: TerminalType) => {
const attrs = this.cell.getAttrs()
const keys = Object.keys(attrs)
for (let i = 0, l = keys.length; i < l; i += 1) {
const attr = attrs[keys[i]]
if (attr[`${type}Marker`] || attr[`${type}-marker`]) {
const strokeWidth =
(attr.strokeWidth as string) || (attr['stroke-width'] as string)
if (strokeWidth) {
return parseFloat(strokeWidth)
}
break
}
}
return null
}
const firstRoutePoint = routePoints[0]
const lastRoutePoint = routePoints[routePoints.length - 1]
let sourceMarkerPoint: Point | undefined
let targetMarkerPoint: Point | undefined
const sourceStrokeWidth = getLineWidth('source')
if (sourceStrokeWidth) {
sourceMarkerPoint = sourcePoint
.clone()
.move(firstRoutePoint || targetPoint, -sourceStrokeWidth)
}
const targetStrokeWidth = getLineWidth('target')
if (targetStrokeWidth) {
targetMarkerPoint = targetPoint
.clone()
.move(lastRoutePoint || sourcePoint, -targetStrokeWidth)
}
this.sourceMarkerPoint = sourceMarkerPoint || sourcePoint.clone()
this.targetMarkerPoint = targetMarkerPoint || targetPoint.clone()
return {
source: sourceMarkerPoint,
target: targetMarkerPoint,
}
}
protected findPath(
routePoints: Point[],
sourcePoint: Point,
targetPoint: Point,
): Path {
const def =
this.cell.getConnector() || this.graph.options.connecting.connector
let name: string | undefined
let args: ConnectorBaseOptions | undefined
let fn: ConnectorDefinition
if (typeof def === 'string') {
name = def
} else {
name = def.name
args = def.args
}
if (name) {
const method = connectorRegistry.get(name)
if (typeof method !== 'function') {
return connectorRegistry.onNotFound(name)
}
fn = method
} else {
fn = connectorPresets.normal
}
const path = FunctionExt.call(
fn,
this,
sourcePoint,
targetPoint,
routePoints,
{ ...args, raw: true },
this,
)
return typeof path === 'string' ? Path.parse(path) : path
}
protected translateConnectionPoints(tx: number, ty: number) {
this.sourcePoint.translate(tx, ty)
this.targetPoint.translate(tx, ty)
this.sourceAnchor.translate(tx, ty)
this.targetAnchor.translate(tx, ty)
this.sourceMarkerPoint.translate(tx, ty)
this.targetMarkerPoint.translate(tx, ty)
}
updateLabelPositions() {
if (this.labelContainer == null) {
return this
}
const path = this.path
if (!path) {
return this
}
const edge = this.cell
const labels = edge.getLabels()
if (labels.length === 0) {
return this
}
const defaultLabel = edge.getDefaultLabel()
const defaultPosition = this.normalizeLabelPosition(
defaultLabel.position as LabelPosition,
)
for (let i = 0, ii = labels.length; i < ii; i += 1) {
const label = labels[i]
const labelNode = this.labelCache[i]
if (!labelNode) {
continue
}
const labelPosition = this.normalizeLabelPosition(
label.position as LabelPosition,
)
const pos = ObjectExt.merge({}, defaultPosition, labelPosition)
const matrix = this.getLabelTransformationMatrix(pos)
labelNode.setAttribute('transform', Dom.matrixToTransformString(matrix))
}
return this
}
updateTerminalProperties(type: TerminalType) {
const edge = this.cell
const graph = this.graph
const terminal = edge[type]
const nodeId = terminal && (terminal as TerminalCellData).cell
const viewKey = `${type}View` as 'sourceView' | 'targetView'
// terminal is a point
if (!nodeId) {
this[viewKey] = null
this.updateTerminalMagnet(type)
return true
}
const terminalCell = graph.getCellById(nodeId)
if (!terminalCell) {
throw new Error(`Edge's ${type} node with id "${nodeId}" not exists`)
}
const endView = terminalCell.findView(graph)
if (!endView) {
return false
}
this[viewKey] = endView
this.updateTerminalMagnet(type)
return true
}
updateTerminalMagnet(type: TerminalType) {
const propName = `${type}Magnet` as 'sourceMagnet' | 'targetMagnet'
const terminalView = this.getTerminalView(type)
if (terminalView) {
let magnet = terminalView.getMagnetFromEdgeTerminal(this.cell[type])
if (magnet === terminalView.container) {
magnet = null
}
this[propName] = magnet
} else {
this[propName] = null
}
}
protected getLabelPositionAngle(idx: number) {
const label = this.cell.getLabelAt(idx)
if (label && label.position && typeof label.position === 'object') {
return label.position.angle || 0
}
return 0
}
protected getLabelPositionArgs(idx: number) {
const label = this.cell.getLabelAt(idx)
if (label && label.position && typeof label.position === 'object') {
return label.position.options
}
}
protected getDefaultLabelPositionArgs() {
const defaultLabel = this.cell.getDefaultLabel()
if (
defaultLabel &&
defaultLabel.position &&
typeof defaultLabel.position === 'object'
) {
return defaultLabel.position.options
}
}
protected mergeLabelPositionArgs(
labelPositionArgs?: LabelPositionOptions,
defaultLabelPositionArgs?: LabelPositionOptions,
) {
if (labelPositionArgs === null) {
return null
}
if (labelPositionArgs === undefined) {
if (defaultLabelPositionArgs === null) {
return null
}
return defaultLabelPositionArgs
}
return ObjectExt.merge({}, defaultLabelPositionArgs, labelPositionArgs)
}
// #endregion
getConnection() {
return this.path != null ? this.path.clone() : null
}
getConnectionPathData() {
if (this.path == null) {
return ''
}
const cache = this.cache.pathCache
if (!ObjectExt.has(cache, 'data')) {
cache.data = this.path.serialize()
}
return cache.data || ''
}
getConnectionSubdivisions() {
if (this.path == null) {
return null
}
const cache = this.cache.pathCache
if (!ObjectExt.has(cache, 'segmentSubdivisions')) {
cache.segmentSubdivisions = this.path.getSegmentSubdivisions()
}
return cache.segmentSubdivisions
}
getConnectionLength() {
if (this.path == null) {
return 0
}
const cache = this.cache.pathCache
if (!ObjectExt.has(cache, 'length')) {
cache.length = this.path.length({
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
return cache.length
}
getPointAtLength(length: number) {
if (this.path == null) {
return null
}
return this.path.pointAtLength(length, {
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
getPointAtRatio(ratio: number) {
if (this.path == null) {
return null
}
if (NumberExt.isPercentage(ratio)) {
// eslint-disable-next-line
ratio = parseFloat(ratio) / 100
}
return this.path.pointAt(ratio, {
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
getTangentAtLength(length: number) {
if (this.path == null) {
return null
}
return this.path.tangentAtLength(length, {
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
getTangentAtRatio(ratio: number) {
if (this.path == null) {
return null
}
return this.path.tangentAt(ratio, {
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
getClosestPoint(point: PointLike) {
if (this.path == null) {
return null
}
return this.path.closestPoint(point, {
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
getClosestPointLength(point: PointLike) {
if (this.path == null) {
return null
}
return this.path.closestPointLength(point, {
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
getClosestPointRatio(point: PointLike) {
if (this.path == null) {
return null
}
return this.path.closestPointNormalizedLength(point, {
segmentSubdivisions: this.getConnectionSubdivisions(),
})
}
getLabelPosition(
x: number,
y: number,
options?: LabelPositionOptions | null,
): LabelPositionObject
getLabelPosition(
x: number,
y: number,
angle: number,
options?: LabelPositionOptions | null,
): LabelPositionObject
getLabelPosition(
x: number,
y: number,
p3?: number | LabelPositionOptions | null,
p4?: LabelPositionOptions | null,
): LabelPositionObject {
const pos: LabelPositionObject = { distance: 0 }
// normalize data from the two possible signatures
let angle = 0
let options: LabelPositionOptions | null | undefined
if (typeof p3 === 'number') {
angle = p3
options = p4
} else {
options = p3
}
if (options != null) {
pos.options = options
}
// identify distance/offset settings
const isOffsetAbsolute = options?.absoluteOffset
const isDistanceRelative = !options?.absoluteDistance
const isDistanceAbsoluteReverse =
options?.absoluteDistance && options.reverseDistance
// find closest point t
const path = this.path
const pathOptions = {
segmentSubdivisions: this.getConnectionSubdivisions(),
}
const labelPoint = new Point(x, y)
const t = path.closestPointT(labelPoint, pathOptions) ?? 0
// distance
const totalLength = this.getConnectionLength() || 0
let labelDistance = path.lengthAtT(t, pathOptions)
if (isDistanceRelative) {
labelDistance = totalLength > 0 ? labelDistance / totalLength : 0
}
if (isDistanceAbsoluteReverse) {
// fix for end point (-0 => 1)
labelDistance = -1 * (totalLength - labelDistance) || 1
}
pos.distance = labelDistance
// offset
// use absolute offset if:
// - options.absoluteOffset is true,
// - options.absoluteOffset is not true but there is no tangent
let tangent: Line | null
if (!isOffsetAbsolute) tangent = path.tangentAtT(t)
let labelOffset: number | { x: number; y: number }
if (tangent) {
labelOffset = tangent.pointOffset(labelPoint)
} else {
const closestPoint = path.pointAtT(t)
if (closestPoint) {
const labelOffsetDiff = labelPoint.diff(closestPoint)
labelOffset = { x: labelOffsetDiff.x, y: labelOffsetDiff.y }
} else {
labelOffset = { x: 0, y: 0 }
}
}
pos.offset = labelOffset
pos.angle = angle
return pos
}
protected normalizeLabelPosition(): undefined
protected normalizeLabelPosition(pos: LabelPosition): LabelPositionObject
protected normalizeLabelPosition(
pos?: LabelPosition,
): LabelPositionObject | undefined {
if (typeof pos === 'number') {
return { distance: pos }
}
return pos
}
protected getLabelTransformationMatrix(labelPosition: LabelPosition) {
const pos = this.normalizeLabelPosition(labelPosition)
const options = pos.options || {}
const labelAngle = pos.angle || 0
const labelDistance = pos.distance
const isDistanceRelative = labelDistance > 0 && labelDistance <= 1
let labelOffset = 0
const offsetCoord = { x: 0, y: 0 }
const offset = pos.offset
if (offset) {
if (typeof offset === 'number') {
labelOffset = offset
} else {
if (offset.x != null) {
offsetCoord.x = offset.x
}
if (offset.y != null) {
offsetCoord.y = offset.y
}
}
}
const isOffsetAbsolute =
offsetCoord.x !== 0 || offsetCoord.y !== 0 || labelOffset === 0
const isKeepGradient = options.keepGradient
const isEnsureLegibility = options.ensureLegibility
const path = this.path
const pathOpt = { segmentSubdivisions: this.getConnectionSubdivisions() }
const distance = isDistanceRelative
? labelDistance * (this.getConnectionLength() || 0)
: labelDistance
const tangent = path.tangentAtLength(distance, pathOpt)
let translation: Point
let angle = labelAngle
if (tangent) {
if (isOffsetAbsolute) {
translation = tangent.start
translation.translate(offsetCoord)
} else {
const normal = tangent.clone()
normal.rotate(-90, tangent.start)
normal.setLength(labelOffset)
translation = normal.end
}
if (isKeepGradient) {
angle = tangent.angle() + labelAngle
if (isEnsureLegibility) {
angle = normalize(((angle + 90) % 180) - 90)
}
}
} else {
// fallback - the connection has zero length
translation = path.pointAtLength(0, pathOpt) || new Point()
if (isOffsetAbsolute) {
translation.translate(offsetCoord)
}
}
return Dom.createSVGMatrix()
.translate(translation.x, translation.y)
.rotate(angle)
}
getVertexIndex(x: number, y: number) {
const edge = this.cell
const vertices = edge.getVertices()
const vertexLength = this.getClosestPointLength(new Point(x, y))
let index = 0
if (vertexLength != null) {
for (const ii = vertices.length; index < ii; index += 1) {
const currentVertex = vertices[index]
const currentLength = this.getClosestPointLength(currentVertex)
if (currentLength != null && vertexLength < currentLength) {
break
}
}
}
return index
}
// #region events
protected getEventArgs<E>(e: E): EdgeViewMouseEventArgs<E>
protected getEventArgs<E>(
e: E,
x: number,
y: number,
): EdgeViewPositionEventArgs<E>
protected getEventArgs<E>(e: E, x?: number, y?: number) {
const view = this // eslint-disable-line
const edge = view.cell
const cell = edge
if (x == null || y == null) {
return { e, view, edge, cell } as EdgeViewMouseEventArgs<E>
}
return { e, x, y, view, edge, cell } as EdgeViewPositionEventArgs<E>
}
protected notifyUnhandledMouseDown(
e: Dom.MouseDownEvent,
x: number,
y: number,
) {
this.notify('edge:unhandled:mousedown', {
e,
x,
y,
view: this,
cell: this.cell,
edge: this.cell,
})
}
notifyMouseDown(e: Dom.MouseDownEvent, x: number, y: number) {
super.onMouseDown(e, x, y)
this.notify('edge:mousedown', this.getEventArgs(e, x, y))
}
notifyMouseMove(e: Dom.MouseMoveEvent, x: number, y: number) {
super.onMouseMove(e, x, y)
this.notify('edge:mousemove', this.getEventArgs(e, x, y))
}
notifyMouseUp(e: Dom.MouseUpEvent, x: number, y: number) {
super.onMouseUp(e, x, y)
this.notify('edge:mouseup', this.getEventArgs(e, x, y))
}
onClick(e: Dom.ClickEvent, x: number, y: number) {
super.onClick(e, x, y)
this.notify('edge:click', this.getEventArgs(e, x, y))
}
onDblClick(e: Dom.DoubleClickEvent, x: number, y: number) {
super.onDblClick(e, x, y)
this.notify('edge:dblclick', this.getEventArgs(e, x, y))
}
onContextMenu(e: Dom.ContextMenuEvent, x: number, y: number) {
super.onContextMenu(e, x, y)
this.notify('edge:contextmenu', this.getEventArgs(e, x, y))
}
onMouseDown(e: Dom.MouseDownEvent, x: number, y: number) {
this.notifyMouseDown(e, x, y)
this.startEdgeDragging(e, x, y)
}
onMouseMove(e: Dom.MouseMoveEvent, x: number, y: number) {
const data = this.getEventData(e)
switch (data.action) {
case 'drag-label': {
this.dragLabel(e, x, y)
break
}
case 'drag-arrowhead': {
this.dragArrowhead(e, x, y)
break
}
case 'drag-edge': {
this.dragEdge(e, x, y)
break
}
default:
break
}
this.notifyMouseMove(e, x, y)
return data
}
onMouseUp(e: Dom.MouseUpEvent, x: number, y: number) {
const data = this.getEventData(e)
switch (data.action) {
case 'drag-label': {
this.stopLabelDragging(e, x, y)
break
}
case 'drag-arrowhead': {
this.stopArrowheadDragging(e, x, y)
break
}
case 'drag-edge': {
this.stopEdgeDragging(e, x, y)
break
}
default:
break
}
this.notifyMouseUp(e, x, y)
this.checkMouseleave(e)
return data
}
onMouseOver(e: Dom.MouseOverEvent) {
super.onMouseOver(e)
this.notify('edge:mouseover', this.getEventArgs(e))
}
onMouseOut(e: Dom.MouseOutEvent) {
super.onMouseOut(e)
this.notify('edge:mouseout', this.getEventArgs(e))
}
onMouseEnter(e: Dom.MouseEnterEvent) {
super.onMouseEnter(e)
this.notify('edge:mouseenter', this.getEventArgs(e))
}
onMouseLeave(e: Dom.MouseLeaveEvent) {
super.onMouseLeave(e)
this.notify('edge:mouseleave', this.getEventArgs(e))
}
onMouseWheel(e: Dom.EventObject, x: number, y: number, delta: number) {
super.onMouseWheel(e, x, y, delta)
this.notify('edge:mousewheel', {
delta,
...this.getEventArgs(e, x, y),
})
}
onCustomEvent(e: Dom.MouseDownEvent, name: string, x: number, y: number) {
// For default edge tool
const tool = Dom.findParentByClass(e.target, 'edge-tool', this.container)
if (tool) {
e.stopPropagation() // no further action to be executed
if (this.can('useEdgeTools')) {
if (name === 'edge:remove') {
this.cell.remove({ ui: true })
return
}
this.notify('edge:customevent', { name, ...this.getEventArgs(e, x, y) })
}
this.notifyMouseDown(e as Dom.MouseDownEvent, x, y)
} else {
this.notify('edge:customevent', { name, ...this.getEventArgs(e, x, y) })
super.onCustomEvent(e, name, x, y)
}
}
onLabelMouseDown(e: Dom.MouseDownEvent, x: number, y: number) {
this.notifyMouseDown(e, x, y)
this.startLabelDragging(e, x, y)
const stopPropagation = this.getEventData(e).stopPropagation
if (stopPropagation) {
e.stopPropagation()
}
}
// #region drag edge
protected startEdgeDragging(e: Dom.MouseDownEvent, x: number, y: number) {
if (!this.can('edgeMovable')) {
this.notifyUnhandledMouseDown(e, x, y)
return
}
this.setEventData<EventDataEdgeDragging>(e, {
x,
y,
moving: false,
action: 'drag-edge',
})
}
protected dragEdge(e: Dom.MouseMoveEvent, x: number, y: number) {
const data = this.getEventData<EventDataEdgeDragging>(e)
if (!data.moving) {
data.moving = true
this.addClass('edge-moving')
this.notify('edge:move', {
e,
x,
y,
view: this,
cell: this.cell,
edge: this.cell,
})
}
this.cell.translate(x - data.x, y - data.y, { ui: true })
this.setEventData<Partial<EventDataEdgeDragging>>(e, { x, y })
this.notify('edge:moving', {
e,
x,
y,
view: this,
cell: this.cell,
edge: this.cell,
})
}
protected stopEdgeDragging(e: Dom.MouseUpEvent, x: number, y: number) {
const data = this.getEventData<EventDataEdgeDragging>(e)
if (data.moving) {
this.removeClass('edge-moving')
this.notify('edge:moved', {
e,
x,
y,
view: this,
cell: this.cell,
edge: this.cell,
})
}
data.moving = false
}
// #endregion
// #region drag arrowhead
prepareArrowheadDragging(
type: TerminalType,
options: {
x: number
y: number
options?: KeyValue
isNewEdge?: boolean
fallbackAction?: EventDataArrowheadDragging['fallbackAction']
},
) {
const magnet = this.getTerminalMagnet(type)
const data: EventDataArrowheadDragging = {
action: 'drag-arrowhead',
x: options.x,
y: options.y,
isNewEdge: options.isNewEdge === true,
terminalType: type,
initialMagnet: magnet,
initialTerminal: ObjectExt.clone(this.cell[type]) as TerminalData,
fallbackAction: options.fallbackAction || 'revert',
getValidateConnectionArgs: this.createValidateConnectionArgs(type),
options: options.options,
}
this.beforeArrowheadDragging(data)
return data
}
protected createValidateConnectionArgs(type: TerminalType) {
const args: EventDataValidateConnectionArgs = [
undefined,
undefined,
undefined,
undefined,
type,
this,
]
let opposite: TerminalType
let i = 0
let j = 0
if (type === 'source') {
i = 2
opposite = 'target'
} else {
j = 2
opposite = 'source'
}
const terminal = this.cell[opposite]
const cellId = (terminal as TerminalCellData).cell
if (cellId) {
let magnet: Element | undefined
const view = this.graph.findViewByCell(cellId)
args[i] = view
if (view) {
magnet = view.getMagnetFromEdgeTerminal(terminal)
if (magnet === view.container) {
magnet = undefined
}
}
args[i + 1] = magnet
}
return (cellView: CellView, magnet: Element) => {
args[j] = cellView
args[j + 1] = cellView.container === magnet ? undefined : magnet
return args
}
}
protected beforeArrowheadDragging(data: EventDataArrowheadDragging) {
data.zIndex = this.cell.zIndex
this.cell.toFront()
const style = (this.container as HTMLElement).style
data.pointerEvents = style.pointerEvents
style.pointerEvents = 'none'
if (this.graph.options.connecting.highlight) {
this.highlightAvailableMagnets(data)
}
}
protected afterArrowheadDragging(data: EventDataArrowheadDragging) {
if (data.zIndex != null) {
this.cell.setZIndex(data.zIndex, { ui: true })
data.zIndex = null
}
const container = this.container as HTMLElement
container.style.pointerEvents = data.pointerEvents || ''
if (this.graph.options.connecting.highlight) {
this.unhighlightAvailableMagnets(data)
}
}
protected validateConnection(
sourceView: CellView | null | undefined,
sourceMagnet: Element | null | undefined,
targetView: CellView | null | undefined,
targetMagnet: Element | null | undefined,
terminalType: TerminalType,
edgeView?: EdgeView | null | undefined,
candidateTerminal?: TerminalCellData | null | undefined,
) {
const options = this.graph.options.connecting
const allowLoop = options.allowLoop
const allowNode = options.allowNode
const allowEdge = options.allowEdge
const allowPort = options.allowPort
const allowMulti = options.allowMulti
const validate = options.validateConnection
const edge = edgeView ? edgeView.cell : null
const terminalView = terminalType === 'target' ? targetView : sourceView
const terminalMagnet =
terminalType === 'target' ? targetMagnet : sourceMagnet
let valid = true
const doValidate = (
validate: (this: Graph, args: ValidateConnectionArgs) => boolean,
) => {
const sourcePort =
terminalType === 'source'
? candidateTerminal
? candidateTerminal.port
: null
: edge
? edge.getSourcePortId()
: null
const targetPort =
terminalType === 'target'
? candidateTerminal
? candidateTerminal.port
: null
: edge
? edge.getTargetPortId()
: null
return FunctionExt.call(validate, this.graph, {
edge,
edgeView,
sourceView,
targetView,
sourcePort,
targetPort,
sourceMagnet,
targetMagnet,
sourceCell: sourceView ? sourceView.cell : null,
targetCell: targetView ? targetView.cell : null,
type: terminalType,
})
}
if (allowLoop != null && sourceView != null && sourceView === targetView) {
if (typeof allowLoop === 'boolean') {
if (!allowLoop) {
valid = false
}
} else {
valid = doValidate(allowLoop)
}
}
if (valid && allowPort != null) {
if (typeof allowPort === 'boolean') {
if (!allowPort && terminalMagnet) {
valid = false
}
} else {
valid = doValidate(allowPort)
}
}
if (valid && allowEdge != null) {
if (typeof allowEdge === 'boolean') {
if (!allowEdge && EdgeView.isEdgeView(terminalView)) {
valid = false
}
} else {
valid = doValidate(allowEdge)
}
}
// When judging nodes, the influence of the ports should be excluded,
// because the ports and nodes have the same terminalView
if (valid && allowNode != null && terminalMagnet == null) {