UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering

1,873 lines (1,621 loc) 68 kB
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) {