UNPKG

jointjs

Version:

JavaScript diagramming library

1,381 lines (1,109 loc) 89.7 kB
import { CellView } from './CellView.mjs'; import { Link } from './Link.mjs'; import V from '../V/index.mjs'; import { addClassNamePrefix, removeClassNamePrefix, merge, template, assign, toArray, isObject, isFunction, clone, isPercentage, result, isEqual } from '../util/index.mjs'; import { Point, Line, Path, normalizeAngle, Rect, Polyline } from '../g/index.mjs'; import * as routers from '../routers/index.mjs'; import * as connectors from '../connectors/index.mjs'; import $ from 'jquery'; const Flags = { TOOLS: CellView.Flags.TOOLS, RENDER: 'RENDER', UPDATE: 'UPDATE', LEGACY_TOOLS: 'LEGACY_TOOLS', LABELS: 'LABELS', VERTICES: 'VERTICES', SOURCE: 'SOURCE', TARGET: 'TARGET', CONNECTOR: 'CONNECTOR' }; // Link base view and controller. // ---------------------------------------- export const LinkView = CellView.extend({ className: function() { var classNames = CellView.prototype.className.apply(this).split(' '); classNames.push('link'); return classNames.join(' '); }, options: { shortLinkLength: 105, doubleLinkTools: false, longLinkLength: 155, linkToolsOffset: 40, doubleLinkToolsOffset: 65, sampleInterval: 50 }, _labelCache: null, _labelSelectors: null, _markerCache: null, _V: null, _dragData: null, // deprecated metrics: null, decimalsRounding: 2, initialize: function() { CellView.prototype.initialize.apply(this, arguments); // `_.labelCache` is a mapping of indexes of labels in the `this.get('labels')` array to // `<g class="label">` nodes wrapped by Vectorizer. This allows for quick access to the // nodes in `updateLabelPosition()` in order to update the label positions. this._labelCache = {}; // a cache of label selectors this._labelSelectors = {}; // keeps markers bboxes and positions again for quicker access this._markerCache = {}; // cache of default markup nodes this._V = {}; // connection path metrics this.cleanNodesCache(); }, presentationAttributes: { markup: [Flags.RENDER], attrs: [Flags.UPDATE], router: [Flags.UPDATE], connector: [Flags.CONNECTOR], smooth: [Flags.UPDATE], manhattan: [Flags.UPDATE], toolMarkup: [Flags.LEGACY_TOOLS], labels: [Flags.LABELS], labelMarkup: [Flags.LABELS], vertices: [Flags.VERTICES, Flags.UPDATE], vertexMarkup: [Flags.VERTICES], source: [Flags.SOURCE, Flags.UPDATE], target: [Flags.TARGET, Flags.UPDATE] }, initFlag: [Flags.RENDER, Flags.SOURCE, Flags.TARGET, Flags.TOOLS], UPDATE_PRIORITY: 1, confirmUpdate: function(flags, opt) { opt || (opt = {}); if (this.hasFlag(flags, Flags.SOURCE)) { if (!this.updateEndProperties('source')) return flags; flags = this.removeFlag(flags, Flags.SOURCE); } if (this.hasFlag(flags, Flags.TARGET)) { if (!this.updateEndProperties('target')) return flags; flags = this.removeFlag(flags, Flags.TARGET); } const { paper, sourceView, targetView } = this; if (paper && ((sourceView && !paper.isViewMounted(sourceView)) || (targetView && !paper.isViewMounted(targetView)))) { // Wait for the sourceView and targetView to be rendered return flags; } if (this.hasFlag(flags, Flags.RENDER)) { this.render(); this.updateHighlighters(true); this.updateTools(opt); flags = this.removeFlag(flags, [Flags.RENDER, Flags.UPDATE, Flags.VERTICES, Flags.LABELS, Flags.TOOLS, Flags.LEGACY_TOOLS, Flags.CONNECTOR]); return flags; } let updateHighlighters = false; if (this.hasFlag(flags, Flags.VERTICES)) { this.renderVertexMarkers(); flags = this.removeFlag(flags, Flags.VERTICES); } const { model } = this; const { attributes } = model; let updateLabels = this.hasFlag(flags, Flags.LABELS); let updateLegacyTools = this.hasFlag(flags, Flags.LEGACY_TOOLS); if (updateLabels) { this.onLabelsChange(model, attributes.labels, opt); flags = this.removeFlag(flags, Flags.LABELS); updateHighlighters = true; } if (updateLegacyTools) { this.renderTools(); flags = this.removeFlag(flags, Flags.LEGACY_TOOLS); } const updateAll = this.hasFlag(flags, Flags.UPDATE); const updateConnector = this.hasFlag(flags, Flags.CONNECTOR); if (updateAll || updateConnector) { if (!updateAll) { // Keep the current route and update the geometry this.updatePath(); this.updateDOM(); } else if (opt.translateBy && model.isRelationshipEmbeddedIn(opt.translateBy)) { // The link is being translated by an ancestor that will // shift source point, target point and all vertices // by an equal distance. this.translate(opt.tx, opt.ty); } else { this.update(); } this.updateTools(opt); flags = this.removeFlag(flags, [Flags.UPDATE, Flags.TOOLS, Flags.CONNECTOR]); updateLabels = false; updateLegacyTools = false; updateHighlighters = true; } if (updateLabels) { this.updateLabelPositions(); } if (updateLegacyTools) { this.updateToolsPosition(); } if (updateHighlighters) { this.updateHighlighters(); } if (this.hasFlag(flags, Flags.TOOLS)) { this.updateTools(opt); flags = this.removeFlag(flags, Flags.TOOLS); } return flags; }, requestConnectionUpdate: function(opt) { this.requestUpdate(this.getFlag(Flags.UPDATE), opt); }, isLabelsRenderRequired: function(opt = {}) { const previousLabels = this.model.previous('labels'); if (!previousLabels) return true; // Here is an optimization for cases when we know, that change does // not require re-rendering of all labels. if (('propertyPathArray' in opt) && ('propertyValue' in opt)) { // The label is setting by `prop()` method var pathArray = opt.propertyPathArray || []; var pathLength = pathArray.length; if (pathLength > 1) { // We are changing a single label here e.g. 'labels/0/position' var labelExists = !!previousLabels[pathArray[1]]; if (labelExists) { if (pathLength === 2) { // We are changing the entire label. Need to check if the // markup is also being changed. return ('markup' in Object(opt.propertyValue)); } else if (pathArray[2] !== 'markup') { // We are changing a label property but not the markup return false; } } } } return true; }, onLabelsChange: function(_link, _labels, opt) { // Note: this optimization works in async=false mode only if (this.isLabelsRenderRequired(opt)) { this.renderLabels(); } else { this.updateLabels(); } }, // Rendering. // ---------- render: function() { this.vel.empty(); this.unmountLabels(); this._V = {}; this.renderMarkup(); // rendering labels has to be run after the link is appended to DOM tree. (otherwise <Text> bbox // returns zero values) this.renderLabels(); this.update(); return this; }, renderMarkup: function() { var link = this.model; var markup = link.get('markup') || link.markup; if (!markup) throw new Error('dia.LinkView: markup required'); if (Array.isArray(markup)) return this.renderJSONMarkup(markup); if (typeof markup === 'string') return this.renderStringMarkup(markup); throw new Error('dia.LinkView: invalid markup'); }, renderJSONMarkup: function(markup) { var doc = this.parseDOMJSON(markup, this.el); // Selectors this.selectors = doc.selectors; // Fragment this.vel.append(doc.fragment); }, renderStringMarkup: function(markup) { // A special markup can be given in the `properties.markup` property. This might be handy // if e.g. arrowhead markers should be `<image>` elements or any other element than `<path>`s. // `.connection`, `.connection-wrap`, `.marker-source` and `.marker-target` selectors // of elements with special meaning though. Therefore, those classes should be preserved in any // special markup passed in `properties.markup`. var children = V(markup); // custom markup may contain only one children if (!Array.isArray(children)) children = [children]; // Cache all children elements for quicker access. var cache = this._V; // vectorized markup; for (var i = 0, n = children.length; i < n; i++) { var child = children[i]; var className = child.attr('class'); if (className) { // Strip the joint class name prefix, if there is one. className = removeClassNamePrefix(className); cache[$.camelCase(className)] = child; } } // partial rendering this.renderTools(); this.renderVertexMarkers(); this.renderArrowheadMarkers(); this.vel.append(children); }, _getLabelMarkup: function(labelMarkup) { if (!labelMarkup) return undefined; if (Array.isArray(labelMarkup)) return this.parseDOMJSON(labelMarkup, null); if (typeof labelMarkup === 'string') return this._getLabelStringMarkup(labelMarkup); throw new Error('dia.linkView: invalid label markup'); }, _getLabelStringMarkup: function(labelMarkup) { var children = V(labelMarkup); var fragment = document.createDocumentFragment(); if (!Array.isArray(children)) { fragment.appendChild(children.node); } else { for (var i = 0, n = children.length; i < n; i++) { var currentChild = children[i].node; fragment.appendChild(currentChild); } } return { fragment: fragment, selectors: {}}; // no selectors }, // Label markup fragment may come wrapped in <g class="label" />, or not. // If it doesn't, add the <g /> container here. _normalizeLabelMarkup: function(markup) { if (!markup) return undefined; var fragment = markup.fragment; if (!(markup.fragment instanceof DocumentFragment) || !markup.fragment.hasChildNodes()) throw new Error('dia.LinkView: invalid label markup.'); var vNode; var childNodes = fragment.childNodes; if ((childNodes.length > 1) || childNodes[0].nodeName.toUpperCase() !== 'G') { // default markup fragment is not wrapped in <g /> // add a <g /> container vNode = V('g').append(fragment); } else { vNode = V(childNodes[0]); } vNode.addClass('label'); return { node: vNode.node, selectors: markup.selectors }; }, renderLabels: function() { var cache = this._V; var vLabels = cache.labels; var labelCache = this._labelCache = {}; var labelSelectors = this._labelSelectors = {}; var model = this.model; var labels = model.attributes.labels || []; var labelsCount = labels.length; if (labelsCount === 0) { if (vLabels) vLabels.remove(); return this; } if (vLabels) { vLabels.empty(); } else { // there is no label container in the markup but some labels are defined // add a <g class="labels" /> container vLabels = cache.labels = V('g').addClass('labels'); if (this.options.labelsLayer) { vLabels.addClass(addClassNamePrefix(result(this, 'className'))); vLabels.attr('model-id', model.id); } } for (var i = 0; i < labelsCount; i++) { var label = labels[i]; var labelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(label.markup)); var labelNode; var selectors; if (labelMarkup) { labelNode = labelMarkup.node; selectors = labelMarkup.selectors; } else { var builtinDefaultLabel = model._builtins.defaultLabel; var builtinDefaultLabelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(builtinDefaultLabel.markup)); var defaultLabel = model._getDefaultLabel(); var defaultLabelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(defaultLabel.markup)); var defaultMarkup = defaultLabelMarkup || builtinDefaultLabelMarkup; labelNode = defaultMarkup.node; selectors = defaultMarkup.selectors; } labelNode.setAttribute('label-idx', i); // assign label-idx vLabels.append(labelNode); labelCache[i] = labelNode; // cache node for `updateLabels()` so it can just update label node positions var rootSelector = this.selector; if (selectors[rootSelector]) throw new Error('dia.LinkView: ambiguous label root selector.'); selectors[rootSelector] = labelNode; labelSelectors[i] = selectors; // cache label selectors for `updateLabels()` } if (!vLabels.parent()) { this.mountLabels(); } this.updateLabels(); return this; }, mountLabels: function() { const { el, paper, model, _V, options } = this; const { labels: vLabels } = _V; if (!vLabels || !model.hasLabels()) return; const { node } = vLabels; if (options.labelsLayer) { paper.getLayerView(options.labelsLayer).insertSortedNode(node, model.get('z')); } else { if (node.parentNode !== el) { el.appendChild(node); } } }, unmountLabels: function() { const { options, _V } = this; if (!_V) return; const { labels: vLabels } = _V; if (vLabels && options.labelsLayer) { vLabels.remove(); } }, findLabelNode: function(labelIndex, selector) { const labelRoot = this._labelCache[labelIndex]; if (!labelRoot) return null; const labelSelectors = this._labelSelectors[labelIndex]; const [node = null] = this.findBySelector(selector, labelRoot, labelSelectors); return node; }, // merge default label attrs into label attrs (or use built-in default label attrs if neither is provided) // keep `undefined` or `null` because `{}` means something else _mergeLabelAttrs: function(hasCustomMarkup, labelAttrs, defaultLabelAttrs, builtinDefaultLabelAttrs) { if (labelAttrs === null) return null; if (labelAttrs === undefined) { if (defaultLabelAttrs === null) return null; if (defaultLabelAttrs === undefined) { if (hasCustomMarkup) return undefined; return builtinDefaultLabelAttrs; } if (hasCustomMarkup) return defaultLabelAttrs; return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs); } if (hasCustomMarkup) return merge({}, defaultLabelAttrs, labelAttrs); return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs, labelAttrs); }, // merge default label size into label size (no built-in default) // keep `undefined` or `null` because `{}` means something else _mergeLabelSize: function(labelSize, defaultLabelSize) { if (labelSize === null) return null; if (labelSize === undefined) { if (defaultLabelSize === null) return null; if (defaultLabelSize === undefined) return undefined; return defaultLabelSize; } return merge({}, defaultLabelSize, labelSize); }, updateLabels: function() { if (!this._V.labels) return this; var model = this.model; var labels = model.get('labels') || []; var canLabelMove = this.can('labelMove'); var builtinDefaultLabel = model._builtins.defaultLabel; var builtinDefaultLabelAttrs = builtinDefaultLabel.attrs; var defaultLabel = model._getDefaultLabel(); var defaultLabelMarkup = defaultLabel.markup; var defaultLabelAttrs = defaultLabel.attrs; var defaultLabelSize = defaultLabel.size; for (var i = 0, n = labels.length; i < n; i++) { var labelNode = this._labelCache[i]; labelNode.setAttribute('cursor', (canLabelMove ? 'move' : 'default')); var selectors = this._labelSelectors[i]; var label = labels[i]; var labelMarkup = label.markup; var labelAttrs = label.attrs; var labelSize = label.size; var attrs = this._mergeLabelAttrs( (labelMarkup || defaultLabelMarkup), labelAttrs, defaultLabelAttrs, builtinDefaultLabelAttrs ); var size = this._mergeLabelSize( labelSize, defaultLabelSize ); this.updateDOMSubtreeAttributes(labelNode, attrs, { rootBBox: new Rect(size), selectors: selectors }); } return this; }, renderTools: function() { if (!this._V.linkTools) return this; // Tools are a group of clickable elements that manipulate the whole link. // A good example of this is the remove tool that removes the whole link. // Tools appear after hovering the link close to the `source` element/point of the link // but are offset a bit so that they don't cover the `marker-arrowhead`. var $tools = $(this._V.linkTools.node).empty(); var toolTemplate = template(this.model.get('toolMarkup') || this.model.toolMarkup); var tool = V(toolTemplate()); $tools.append(tool.node); // Cache the tool node so that the `updateToolsPosition()` can update the tool position quickly. this._toolCache = tool; // If `doubleLinkTools` is enabled, we render copy of the tools on the other side of the // link as well but only if the link is longer than `longLinkLength`. if (this.options.doubleLinkTools) { var tool2; if (this.model.get('doubleToolMarkup') || this.model.doubleToolMarkup) { toolTemplate = template(this.model.get('doubleToolMarkup') || this.model.doubleToolMarkup); tool2 = V(toolTemplate()); } else { tool2 = tool.clone(); } $tools.append(tool2.node); this._tool2Cache = tool2; } return this; }, renderVertexMarkers: function() { if (!this._V.markerVertices) return this; var $markerVertices = $(this._V.markerVertices.node).empty(); // A special markup can be given in the `properties.vertexMarkup` property. This might be handy // if default styling (elements) are not desired. This makes it possible to use any // SVG elements for .marker-vertex and .marker-vertex-remove tools. var markupTemplate = template(this.model.get('vertexMarkup') || this.model.vertexMarkup); this.model.vertices().forEach(function(vertex, idx) { $markerVertices.append(V(markupTemplate(assign({ idx: idx }, vertex))).node); }); return this; }, renderArrowheadMarkers: function() { // Custom markups might not have arrowhead markers. Therefore, jump of this function immediately if that's the case. if (!this._V.markerArrowheads) return this; var $markerArrowheads = $(this._V.markerArrowheads.node); $markerArrowheads.empty(); // A special markup can be given in the `properties.vertexMarkup` property. This might be handy // if default styling (elements) are not desired. This makes it possible to use any // SVG elements for .marker-vertex and .marker-vertex-remove tools. var markupTemplate = template(this.model.get('arrowheadMarkup') || this.model.arrowheadMarkup); this._V.sourceArrowhead = V(markupTemplate({ end: 'source' })); this._V.targetArrowhead = V(markupTemplate({ end: 'target' })); $markerArrowheads.append(this._V.sourceArrowhead.node, this._V.targetArrowhead.node); return this; }, // remove vertices that lie on (or nearly on) straight lines within the link // return the number of removed points removeRedundantLinearVertices: function(opt) { const SIMPLIFY_THRESHOLD = 0.001; const link = this.model; const vertices = link.vertices(); const routePoints = [this.sourceAnchor, ...vertices, this.targetAnchor]; const numRoutePoints = routePoints.length; // put routePoints into a polyline and try to simplify const polyline = new Polyline(routePoints); polyline.simplify({ threshold: SIMPLIFY_THRESHOLD }); const polylinePoints = polyline.points.map((point) => (point.toJSON())); // JSON of points after simplification const numPolylinePoints = polylinePoints.length; // number of points after simplification // shortcut if simplification did not remove any redundant vertices: if (numRoutePoints === numPolylinePoints) return 0; // else: set simplified polyline points as link vertices // remove first and last polyline points again (= source/target anchors) link.vertices(polylinePoints.slice(1, numPolylinePoints - 1), opt); return (numRoutePoints - numPolylinePoints); }, updateDefaultConnectionPath: function() { var cache = this._V; if (cache.connection) { cache.connection.attr('d', this.getSerializedConnection()); } if (cache.connectionWrap) { cache.connectionWrap.attr('d', this.getSerializedConnection()); } if (cache.markerSource && cache.markerTarget) { this._translateAndAutoOrientArrows(cache.markerSource, cache.markerTarget); } }, getEndView: function(type) { switch (type) { case 'source': return this.sourceView || null; case 'target': return this.targetView || null; default: throw new Error('dia.LinkView: type parameter required.'); } }, getEndAnchor: function(type) { switch (type) { case 'source': return new Point(this.sourceAnchor); case 'target': return new Point(this.targetAnchor); default: throw new Error('dia.LinkView: type parameter required.'); } }, getEndConnectionPoint: function(type) { switch (type) { case 'source': return new Point(this.sourcePoint); case 'target': return new Point(this.targetPoint); default: throw new Error('dia.LinkView: type parameter required.'); } }, getEndMagnet: function(type) { switch (type) { case 'source': var sourceView = this.sourceView; if (!sourceView) break; return this.sourceMagnet || sourceView.el; case 'target': var targetView = this.targetView; if (!targetView) break; return this.targetMagnet || targetView.el; default: throw new Error('dia.LinkView: type parameter required.'); } return null; }, // Updating. // --------- update: function() { this.updateRoute(); this.updatePath(); this.updateDOM(); return this; }, translate: function(tx = 0, ty = 0) { const { route, path } = this; if (!route || !path) return; // translate the route const polyline = new Polyline(route); polyline.translate(tx, ty); this.route = polyline.points; // translate source and target connection and marker points. this._translateConnectionPoints(tx, ty); // translate the geometry path path.translate(tx, ty); this.updateDOM(); }, updateDOM() { const { el, model, selectors } = this; this.cleanNodesCache(); // update SVG attributes defined by 'attrs/'. this.updateDOMSubtreeAttributes(el, model.attr(), { selectors }); // legacy link path update this.updateDefaultConnectionPath(); // update the label position etc. this.updateLabelPositions(); this.updateToolsPosition(); this.updateArrowheadMarkers(); // *Deprecated* // Local perpendicular flag (as opposed to one defined on paper). // Could be enabled inside a connector/router. It's valid only // during the update execution. this.options.perpendicular = null; }, updateRoute: function() { const { model } = this; const vertices = model.vertices(); // 1. Find Anchors const anchors = this.findAnchors(vertices); const sourceAnchor = this.sourceAnchor = anchors.source; const targetAnchor = this.targetAnchor = anchors.target; // 2. Find Route const route = this.findRoute(vertices); this.route = route; // 3. Find Connection Points var connectionPoints = this.findConnectionPoints(route, sourceAnchor, targetAnchor); this.sourcePoint = connectionPoints.source; this.targetPoint = connectionPoints.target; }, updatePath: function() { const { route, sourcePoint, targetPoint } = this; // 3b. Find Marker Connection Point - Backwards Compatibility const markerPoints = this.findMarkerPoints(route, sourcePoint, targetPoint); // 4. Find Connection const path = this.findPath(route, markerPoints.source || sourcePoint, markerPoints.target || targetPoint); this.path = path; }, findMarkerPoints: function(route, sourcePoint, targetPoint) { var firstWaypoint = route[0]; var lastWaypoint = route[route.length - 1]; // Move the source point by the width of the marker taking into account // its scale around x-axis. Note that scale is the only transform that // makes sense to be set in `.marker-source` attributes object // as all other transforms (translate/rotate) will be replaced // by the `translateAndAutoOrient()` function. var cache = this._markerCache; // cache source and target points var sourceMarkerPoint, targetMarkerPoint; if (this._V.markerSource) { cache.sourceBBox = cache.sourceBBox || this._V.markerSource.getBBox(); sourceMarkerPoint = Point(sourcePoint).move( firstWaypoint || targetPoint, cache.sourceBBox.width * this._V.markerSource.scale().sx * -1 ).round(); } if (this._V.markerTarget) { cache.targetBBox = cache.targetBBox || this._V.markerTarget.getBBox(); targetMarkerPoint = Point(targetPoint).move( lastWaypoint || sourcePoint, cache.targetBBox.width * this._V.markerTarget.scale().sx * -1 ).round(); } // if there was no markup for the marker, use the connection point. cache.sourcePoint = sourceMarkerPoint || sourcePoint.clone(); cache.targetPoint = targetMarkerPoint || targetPoint.clone(); return { source: sourceMarkerPoint, target: targetMarkerPoint }; }, findAnchorsOrdered: function(firstEndType, firstRef, secondEndType, secondRef) { var firstAnchor, secondAnchor; var firstAnchorRef, secondAnchorRef; var model = this.model; var firstDef = model.get(firstEndType); var secondDef = model.get(secondEndType); var firstView = this.getEndView(firstEndType); var secondView = this.getEndView(secondEndType); var firstMagnet = this.getEndMagnet(firstEndType); var secondMagnet = this.getEndMagnet(secondEndType); // Anchor first if (firstView) { if (firstRef) { firstAnchorRef = new Point(firstRef); } else if (secondView) { firstAnchorRef = secondMagnet; } else { firstAnchorRef = new Point(secondDef); } firstAnchor = this.getAnchor(firstDef.anchor, firstView, firstMagnet, firstAnchorRef, firstEndType); } else { firstAnchor = new Point(firstDef); } // Anchor second if (secondView) { secondAnchorRef = new Point(secondRef || firstAnchor); secondAnchor = this.getAnchor(secondDef.anchor, secondView, secondMagnet, secondAnchorRef, secondEndType); } else { secondAnchor = new Point(secondDef); } var res = {}; res[firstEndType] = firstAnchor; res[secondEndType] = secondAnchor; return res; }, findAnchors: function(vertices) { var model = this.model; var firstVertex = vertices[0]; var lastVertex = vertices[vertices.length - 1]; if (model.target().priority && !model.source().priority) { // Reversed order return this.findAnchorsOrdered('target', lastVertex, 'source', firstVertex); } // Usual order return this.findAnchorsOrdered('source', firstVertex, 'target', lastVertex); }, findConnectionPoints: function(route, sourceAnchor, targetAnchor) { var firstWaypoint = route[0]; var lastWaypoint = route[route.length - 1]; var model = this.model; var sourceDef = model.get('source'); var targetDef = model.get('target'); var sourceView = this.sourceView; var targetView = this.targetView; var paperOptions = this.paper.options; var sourceMagnet, targetMagnet; // Connection Point Source var sourcePoint; if (sourceView && !sourceView.isNodeConnection(this.sourceMagnet)) { sourceMagnet = (this.sourceMagnet || sourceView.el); var sourceConnectionPointDef = sourceDef.connectionPoint || paperOptions.defaultConnectionPoint; var sourcePointRef = firstWaypoint || targetAnchor; var sourceLine = new Line(sourcePointRef, sourceAnchor); sourcePoint = this.getConnectionPoint( sourceConnectionPointDef, sourceView, sourceMagnet, sourceLine, 'source' ); } else { sourcePoint = sourceAnchor; } // Connection Point Target var targetPoint; if (targetView && !targetView.isNodeConnection(this.targetMagnet)) { targetMagnet = (this.targetMagnet || targetView.el); var targetConnectionPointDef = targetDef.connectionPoint || paperOptions.defaultConnectionPoint; var targetPointRef = lastWaypoint || sourceAnchor; var targetLine = new Line(targetPointRef, targetAnchor); targetPoint = this.getConnectionPoint( targetConnectionPointDef, targetView, targetMagnet, targetLine, 'target' ); } else { targetPoint = targetAnchor; } return { source: sourcePoint, target: targetPoint }; }, getAnchor: function(anchorDef, cellView, magnet, ref, endType) { var isConnection = cellView.isNodeConnection(magnet); var paperOptions = this.paper.options; if (!anchorDef) { if (isConnection) { anchorDef = paperOptions.defaultLinkAnchor; } else { if (paperOptions.perpendicularLinks || this.options.perpendicular) { // Backwards compatibility // If `perpendicularLinks` flag is set on the paper and there are vertices // on the link, then try to find a connection point that makes the link perpendicular // even though the link won't point to the center of the targeted object. anchorDef = { name: 'perpendicular' }; } else { anchorDef = paperOptions.defaultAnchor; } } } if (!anchorDef) throw new Error('Anchor required.'); var anchorFn; if (typeof anchorDef === 'function') { anchorFn = anchorDef; } else { var anchorName = anchorDef.name; var anchorNamespace = isConnection ? 'linkAnchorNamespace' : 'anchorNamespace'; anchorFn = paperOptions[anchorNamespace][anchorName]; if (typeof anchorFn !== 'function') throw new Error('Unknown anchor: ' + anchorName); } var anchor = anchorFn.call( this, cellView, magnet, ref, anchorDef.args || {}, endType, this ); if (!anchor) return new Point(); return anchor.round(this.decimalsRounding); }, getConnectionPoint: function(connectionPointDef, view, magnet, line, endType) { var connectionPoint; var anchor = line.end; var paperOptions = this.paper.options; // Backwards compatibility if (typeof paperOptions.linkConnectionPoint === 'function') { var linkConnectionMagnet = (magnet === view.el) ? undefined : magnet; connectionPoint = paperOptions.linkConnectionPoint(this, view, linkConnectionMagnet, line.start, endType); if (connectionPoint) return connectionPoint; } if (!connectionPointDef) return anchor; var connectionPointFn; if (typeof connectionPointDef === 'function') { connectionPointFn = connectionPointDef; } else { var connectionPointName = connectionPointDef.name; connectionPointFn = paperOptions.connectionPointNamespace[connectionPointName]; if (typeof connectionPointFn !== 'function') throw new Error('Unknown connection point: ' + connectionPointName); } connectionPoint = connectionPointFn.call(this, line, view, magnet, connectionPointDef.args || {}, endType, this); if (!connectionPoint) return anchor; return connectionPoint.round(this.decimalsRounding); }, _translateConnectionPoints: function(tx, ty) { var cache = this._markerCache; cache.sourcePoint.offset(tx, ty); cache.targetPoint.offset(tx, ty); this.sourcePoint.offset(tx, ty); this.targetPoint.offset(tx, ty); this.sourceAnchor.offset(tx, ty); this.targetAnchor.offset(tx, ty); }, // combine default label position with built-in default label position _getDefaultLabelPositionProperty: function() { var model = this.model; var builtinDefaultLabel = model._builtins.defaultLabel; var builtinDefaultLabelPosition = builtinDefaultLabel.position; var defaultLabel = model._getDefaultLabel(); var defaultLabelPosition = this._normalizeLabelPosition(defaultLabel.position); return merge({}, builtinDefaultLabelPosition, defaultLabelPosition); }, // if label position is a number, normalize it to a position object // this makes sure that label positions can be merged properly _normalizeLabelPosition: function(labelPosition) { if (typeof labelPosition === 'number') return { distance: labelPosition, offset: null, angle: 0, args: null }; return labelPosition; }, // expects normalized position properties // e.g. `this._normalizeLabelPosition(labelPosition)` and `this._getDefaultLabelPositionProperty()` _mergeLabelPositionProperty: function(normalizedLabelPosition, normalizedDefaultLabelPosition) { if (normalizedLabelPosition === null) return null; if (normalizedLabelPosition === undefined) { if (normalizedDefaultLabelPosition === null) return null; return normalizedDefaultLabelPosition; } return merge({}, normalizedDefaultLabelPosition, normalizedLabelPosition); }, updateLabelPositions: function() { if (!this._V.labels) return this; var path = this.path; if (!path) return this; // This method assumes all the label nodes are stored in the `this._labelCache` hash table // by their indices in the `this.get('labels')` array. This is done in the `renderLabels()` method. var model = this.model; var labels = model.get('labels') || []; if (!labels.length) return this; var defaultLabelPosition = this._getDefaultLabelPositionProperty(); for (var idx = 0, n = labels.length; idx < n; idx++) { var labelNode = this._labelCache[idx]; if (!labelNode) continue; var label = labels[idx]; var labelPosition = this._normalizeLabelPosition(label.position); var position = this._mergeLabelPositionProperty(labelPosition, defaultLabelPosition); var transformationMatrix = this._getLabelTransformationMatrix(position); labelNode.setAttribute('transform', V.matrixToTransformString(transformationMatrix)); this._cleanLabelMatrices(idx); } return this; }, _cleanLabelMatrices: function(index) { // Clean magnetMatrix for all nodes of the label. // Cached BoundingRect does not need to updated when the position changes // TODO: this doesn't work for labels with XML String markups. const { metrics, _labelSelectors } = this; const selectors = _labelSelectors[index]; if (!selectors) return; for (let selector in selectors) { const { id } = selectors[selector]; if (id && (id in metrics)) delete metrics[id].magnetMatrix; } }, updateToolsPosition: function() { if (!this._V.linkTools) return this; // Move the tools a bit to the target position but don't cover the `sourceArrowhead` marker. // Note that the offset is hardcoded here. The offset should be always // more than the `this.$('.marker-arrowhead[end="source"]')[0].bbox().width` but looking // this up all the time would be slow. var scale = ''; var offset = this.options.linkToolsOffset; var connectionLength = this.getConnectionLength(); // Firefox returns connectionLength=NaN in odd cases (for bezier curves). // In that case we won't update tools position at all. if (!Number.isNaN(connectionLength)) { // If the link is too short, make the tools half the size and the offset twice as low. if (connectionLength < this.options.shortLinkLength) { scale = 'scale(.5)'; offset /= 2; } var toolPosition = this.getPointAtLength(offset); this._toolCache.attr('transform', 'translate(' + toolPosition.x + ', ' + toolPosition.y + ') ' + scale); if (this.options.doubleLinkTools && connectionLength >= this.options.longLinkLength) { var doubleLinkToolsOffset = this.options.doubleLinkToolsOffset || offset; toolPosition = this.getPointAtLength(connectionLength - doubleLinkToolsOffset); this._tool2Cache.attr('transform', 'translate(' + toolPosition.x + ', ' + toolPosition.y + ') ' + scale); this._tool2Cache.attr('display', 'inline'); } else if (this.options.doubleLinkTools) { this._tool2Cache.attr('display', 'none'); } } return this; }, updateArrowheadMarkers: function() { if (!this._V.markerArrowheads) return this; // getting bbox of an element with `display="none"` in IE9 ends up with access violation if ($.css(this._V.markerArrowheads.node, 'display') === 'none') return this; var sx = this.getConnectionLength() < this.options.shortLinkLength ? .5 : 1; this._V.sourceArrowhead.scale(sx); this._V.targetArrowhead.scale(sx); this._translateAndAutoOrientArrows(this._V.sourceArrowhead, this._V.targetArrowhead); return this; }, updateEndProperties: function(endType) { const { model, paper } = this; const endViewProperty = `${endType}View`; const endDef = model.get(endType); const endId = endDef && endDef.id; if (!endId) { // the link end is a point ~ rect 0x0 this[endViewProperty] = null; this.updateEndMagnet(endType); return true; } const endModel = paper.getModelById(endId); if (!endModel) throw new Error('LinkView: invalid ' + endType + ' cell.'); const endView = endModel.findView(paper); if (!endView) { // A view for a model should always exist return false; } this[endViewProperty] = endView; this.updateEndMagnet(endType); return true; }, updateEndMagnet: function(endType) { const endMagnetProperty = `${endType}Magnet`; const endView = this.getEndView(endType); if (endView) { let connectedMagnet = endView.getMagnetFromLinkEnd(this.model.get(endType)); if (connectedMagnet === endView.el) connectedMagnet = null; this[endMagnetProperty] = connectedMagnet; } else { this[endMagnetProperty] = null; } }, _translateAndAutoOrientArrows: function(sourceArrow, targetArrow) { // Make the markers "point" to their sticky points being auto-oriented towards // `targetPosition`/`sourcePosition`. And do so only if there is a markup for them. var route = toArray(this.route); if (sourceArrow) { sourceArrow.translateAndAutoOrient( this.sourcePoint, route[0] || this.targetPoint, this.paper.cells ); } if (targetArrow) { targetArrow.translateAndAutoOrient( this.targetPoint, route[route.length - 1] || this.sourcePoint, this.paper.cells ); } }, _getLabelPositionProperty: function(idx) { return (this.model.label(idx).position || {}); }, _getLabelPositionAngle: function(idx) { var labelPosition = this._getLabelPositionProperty(idx); return (labelPosition.angle || 0); }, _getLabelPositionArgs: function(idx) { var labelPosition = this._getLabelPositionProperty(idx); return labelPosition.args; }, _getDefaultLabelPositionArgs: function() { var defaultLabel = this.model._getDefaultLabel(); var defaultLabelPosition = defaultLabel.position || {}; return defaultLabelPosition.args; }, // merge default label position args into label position args // keep `undefined` or `null` because `{}` means something else _mergeLabelPositionArgs: function(labelPositionArgs, defaultLabelPositionArgs) { if (labelPositionArgs === null) return null; if (labelPositionArgs === undefined) { if (defaultLabelPositionArgs === null) return null; return defaultLabelPositionArgs; } return merge({}, defaultLabelPositionArgs, labelPositionArgs); }, // Add default label at given position at end of `labels` array. // Four signatures: // - obj, obj = point, opt // - obj, num, obj = point, angle, opt // - num, num, obj = x, y, opt // - num, num, num, obj = x, y, angle, opt // Assigns relative coordinates by default: // `opt.absoluteDistance` forces absolute coordinates. // `opt.reverseDistance` forces reverse absolute coordinates (if absoluteDistance = true). // `opt.absoluteOffset` forces absolute coordinates for offset. // Additional args: // `opt.keepGradient` auto-adjusts the angle of the label to match path gradient at position. // `opt.ensureLegibility` rotates labels so they are never upside-down. addLabel: function(p1, p2, p3, p4) { // normalize data from the four possible signatures var localX; var localY; var localAngle = 0; var localOpt; if (typeof p1 !== 'number') { // {x, y} object provided as first parameter localX = p1.x; localY = p1.y; if (typeof p2 === 'number') { // angle and opt provided as second and third parameters localAngle = p2; localOpt = p3; } else { // opt provided as second parameter localOpt = p2; } } else { // x and y provided as first and second parameters localX = p1; localY = p2; if (typeof p3 === 'number') { // angle and opt provided as third and fourth parameters localAngle = p3; localOpt = p4; } else { // opt provided as third parameter localOpt = p3; } } // merge label position arguments var defaultLabelPositionArgs = this._getDefaultLabelPositionArgs(); var labelPositionArgs = localOpt; var positionArgs = this._mergeLabelPositionArgs(labelPositionArgs, defaultLabelPositionArgs); // append label to labels array var label = { position: this.getLabelPosition(localX, localY, localAngle, positionArgs) }; var idx = -1; this.model.insertLabel(idx, label, localOpt); return idx; }, // Add a new vertex at calculated index to the `vertices` array. addVertex: function(x, y, opt) { // accept input in form `{ x, y }, opt` or `x, y, opt` var isPointProvided = (typeof x !== 'number'); var localX = isPointProvided ? x.x : x; var localY = isPointProvided ? x.y : y; var localOpt = isPointProvided ? y : opt; var vertex = { x: localX, y: localY }; var idx = this.getVertexIndex(localX, localY); this.model.insertVertex(idx, vertex, localOpt); return idx; }, // Send a token (an SVG element, usually a circle) along the connection path. // Example: `link.findView(paper).sendToken(V('circle', { r: 7, fill: 'green' }).node)` // `opt.duration` is optional and is a time in milliseconds that the token travels from the source to the target of the link. Default is `1000`. // `opt.directon` is optional and it determines whether the token goes from source to target or other way round (`reverse`) // `opt.connection` is an optional selector to the connection path. // `callback` is optional and is a function to be called once the token reaches the target. sendToken: function(token, opt, callback) { function onAnimationEnd(vToken, callback) { return function() { vToken.remove(); if (typeof callback === 'function') { callback(); } }; } var duration, isReversed, selector; if (isObject(opt)) { duration = opt.duration; isReversed = (opt.direction === 'reverse'); selector = opt.connection; } else { // Backwards compatibility duration = opt; isReversed = false; selector = null; } duration = duration || 1000; var animationAttributes = { dur: duration + 'ms', repeatCount: 1, calcMode: 'linear', fill: 'freeze' }; if (isReversed) { animationAttributes.keyPoints = '1;0'; animationAttributes.keyTimes = '0;1'; } var vToken = V(token); var connection; if (typeof selector === 'string') { // Use custom connection path. connection = this.findBySelector(selector, this.el, this.selectors)[0]; } else { // Select connection path automatically. var cache = this._V; connection = (cache.connection) ? cache.connection.node : this.el.querySelector('path'); } if (!(connection instanceof SVGPathElement)) { throw new Error('dia.LinkView: token animation requires a valid connection path.'); } vToken .appendTo(this.paper.cells)