UNPKG

@joint/core

Version:

JavaScript diagramming library

1,420 lines (1,145 loc) 77.3 kB
import { CellView } from './CellView.mjs'; import { Link } from './Link.mjs'; import V from '../V/index.mjs'; import { addClassNamePrefix, merge, assign, isObject, isFunction, clone, isPercentage, result, isEqual } from '../util/index.mjs'; import { Point, Line, Path, normalizeAngle, Rect, Polyline, intersection } from '../g/index.mjs'; import * as routers from '../routers/index.mjs'; import * as connectors from '../connectors/index.mjs'; import { env } from '../env/index.mjs'; const Flags = { TOOLS: CellView.Flags.TOOLS, RENDER: 'RENDER', UPDATE: 'UPDATE', LABELS: 'LABELS', 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(' '); }, _labelCache: null, _labelSelectors: 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 = {}; // 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], labels: [Flags.LABELS, Flags.TOOLS], labelMarkup: [Flags.LABELS], vertices: [Flags.UPDATE], source: [Flags.SOURCE, Flags.UPDATE], target: [Flags.TARGET, Flags.UPDATE] }, initFlag: [Flags.RENDER, Flags.SOURCE, Flags.TARGET, Flags.TOOLS], UPDATE_PRIORITY: 1, EPSILON: 1e-6, 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.LABELS, Flags.TOOLS, Flags.CONNECTOR]); if (env.test('isSafari')) { this.__fixSafariBug268376(); } return flags; } let updateHighlighters = false; const { model } = this; const { attributes } = model; let updateLabels = this.hasFlag(flags, Flags.LABELS); if (updateLabels) { this.onLabelsChange(model, attributes.labels, opt); flags = this.removeFlag(flags, Flags.LABELS); updateHighlighters = true; } 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; updateHighlighters = true; } if (updateLabels) { this.updateLabelPositions(); } if (updateHighlighters) { this.updateHighlighters(); } if (this.hasFlag(flags, Flags.TOOLS)) { this.updateTools(opt); flags = this.removeFlag(flags, Flags.TOOLS); } return flags; }, __fixSafariBug268376: function() { // Safari has a bug where any change after the first render is not reflected in the DOM. // https://bugs.webkit.org/show_bug.cgi?id=268376 const { el } = this; const childNodes = Array.from(el.childNodes); const fragment = document.createDocumentFragment(); for (let i = 0, n = childNodes.length; i < n; i++) { el.removeChild(childNodes[i]); fragment.appendChild(childNodes[i]); } el.appendChild(fragment); }, 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]; 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(); } }, findLabelNodes: function(labelIndex, selector) { const labelRoot = this._labelCache[labelIndex]; if (!labelRoot) return []; const labelSelectors = this._labelSelectors[labelIndex]; return this.findBySelector(selector, labelRoot, labelSelectors); }, findLabelNode: function(labelIndex, selector) { const [node = null] = this.findLabelNodes(labelIndex, selector); 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; if (!this.paper.options.labelLayer) { // If there is no label layer, the cache needs to be cleared // of the root node because the labels are attached // to it and could affect the bounding box. this.cleanNodeCache(this.el); } 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; }, // 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); }, 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 anchor points. this.sourcePoint.offset(tx, ty); this.targetPoint.offset(tx, ty); this.sourceAnchor.offset(tx, ty); this.targetAnchor.offset(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 }); // update the label position etc. this.updateLabelPositions(); // *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; // 4. Find Connection const path = this.findPath(route, sourcePoint.clone(), targetPoint.clone()); this.path = path; }, 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 (this.options.perpendicular) { // Backwards compatibility // See `manhattan` router for more details 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; 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); }, isIntersecting: function(geometryShape, geometryData) { const connection = this.getConnection(); if (!connection) return false; return intersection.exists( geometryShape, connection, geometryData, { segmentSubdivisions: this.getConnectionSubdivisions() }, ); }, isEnclosedIn: function(geometryRect) { const connection = this.getConnection(); if (!connection) return false; const bbox = connection.bbox(); if (!bbox) return false; return geometryRect.containsRect(bbox); }, isAtPoint: function(point /*, options */) { // Note: `strict` option is not applicable for links. // There is currently no method to determine if a path contains a point. const area = new Rect(point); // Intersection with a zero-size area is not possible. area.inflate(this.EPSILON); return this.isIntersecting(area); }, // 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; } }, 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; } }, _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.direction` 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.findNode(selector); } 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) .animateAlongPath(animationAttributes, connection); setTimeout(onAnimationEnd(vToken, callback), duration); }, findRoute: function(vertices) { vertices || (vertices = []); var namespace = this.paper.options.routerNamespace || routers; var router = this.model.router(); var defaultRouter = this.paper.options.defaultRouter; if (!router) { if (defaultRouter) router = defaultRouter; else return vertices.map(Point); // no router specified } var routerFn = isFunction(router) ? router : namespace[router.name]; if (!isFunction(routerFn)) { throw new Error('dia.LinkView: unknown router: "' + router.name + '".'); } var args = router.args || {}; var route = routerFn.call( this, // context vertices, // vertices args, // options this // linkView ); if (!route) return vertices.map(Point); return route; }, // Return the `d` attribute value of the `<path>` element representing the link // between `source` and `target`. findPath: function(route, sourcePoint, targetPoint) { var namespace = this.paper.options.connectorNamespace || connectors; var connector = this.model.connector(); var defaultConnector = this.paper.options.defaultConnector; if (!connector) { connector = defaultConnector || {}; } var connectorFn = isFunction(connector) ? connector : namespace[connector.name]; if (!isFunction(connectorFn)) { throw new Error('dia.LinkView: unknown connector: "' + connector.name + '".'); } var args = clone(connector.args || {}); args.raw = true; // Request raw g.Path as the result. var path = connectorFn.call( this, // context sourcePoint, // start point targetPoint, // end point route, // vertices args, // options this // linkView ); if (typeof path === 'string') { // Backwards compatibility for connectors not supporting `raw` option. path = new Path(V.normalizePathData(path)); } return path; }, // Public API. // ----------- getConnection: function() { var path = this.path; if (!path) return null; return path.clone(); }, getSerializedConnection: function() { var path = this.path; if (!path) return null; var metrics = this.metrics; if (metrics.hasOwnProperty('data')) return metrics.data; var data = path.serialize(); metrics.data = data; return data; }, getConnectionSubdivisions: function() { var path = this.path; if (!path) return null; var metrics = this.metrics; if (metrics.hasOwnProperty('segmentSubdivisions')) return metrics.segmentSubdivisions; var subdivisions = path.getSegmentSubdivisions(); metrics.segmentSubdivisions = subdivisions; return subdivisions; }, getConnectionLength: function() { var path = this.path; if (!path) return 0; var metrics = this.metrics; if (metrics.hasOwnProperty('length')) return metrics.length; var length = path.length({ segmentSubdivisions: this.getConnectionSubdivisions() }); metrics.length = length; return length; }, getPointAtLength: function(length) { var path = this.path; if (!path) return null; return path.pointAtLength(length, { segmentSubdivisions: this.getConnectionSubdivisions() }); }, getPointAtRatio: function(ratio) { var path = this.path; if (!path) return null; if (isPercentage(ratio)) ratio = parseFloat(ratio) / 100; return path.pointAt(ratio, { segmentSubdivisions: this.getConnectionSubdivisions() }); }, getTangentAtLength: function(length) { var path = this.path; if (!path) return null; return path.tangentAtLength(length, { segmentSubdivisions: this.getConnectionSubdivisions() }); }, getTangentAtRatio: function(ratio) { var path = this.path; if (!path) return null; return path.tangentAt(ratio, { segmentSubdivisions: this.getConnectionSubdivisions() }); }, getClosestPoint: function(point) { var path = this.path; if (!path) return null; return path.closestPoint(point, { segmentSubdivisions: this.getConnectionSubdivisions() }); }, getClosestPointLength: function(point) { var path = this.path; if (!path) return null; return path.closestPointLength(point, { segmentSubdivisions: this.getConnectionSubdivisions() }); }, getClosestPointRatio: function(point) { var path = this.path; if (!path) return null; return path.closestPointNormalizedLength(point, { segmentSubdivisions: this.getConnectionSubdivisions() }); }, // Get label position object based on two provided coordinates, x and y. // (Used behind the scenes when user moves labels around.) // Two signatures: // - num, num, obj = x, y, options // - num, num, num, obj = x, y, angle, options // Accepts distance/offset options = `absoluteDistance: boolean`, `reverseDistance: boolean`, `absoluteOffset: boolean` // - `absoluteOffset` is necessary in order to move beyond connection endpoints // Additional options = `keepGradient: boolean`, `ensureLegibility: boolean` getLabelPosition: function(x, y, p3, p4) { var position = {}; // normalize data from the two possible signatures var localAngle = 0; var localOpt; if (typeof p3 === 'number') { // angle and opt provided as third and fourth argument localAngle = p3; localOpt = p4; } else { // opt provided as third argument localOpt = p3; } // save localOpt as `args` of the position object that is passed along if (localOpt) position.args = localOpt; // identify distance/offset settings var isDistanceRelative = !(localOpt && localOpt.absoluteDistance); // relative by default var isDistanceAbsoluteReverse = (localOpt && localOpt.absoluteDistance && localOpt.reverseDistance); // non-reverse by default var isOffsetAbsolute = localOpt && localOpt.absoluteOffset; // offset is non-absolute by default // find closest point t var path = this.path; var pathOpt = { segmentSubdivisions: this.getConnectionSubdivisions() }; var labelPoint = new Point(x, y); var t = path.closestPointT(labelPoint, pathOpt); // DISTANCE: var labelDistance = path.lengthAtT(t, pathOpt); if (isDistanceRelative) labelDistance = (labelDistance / this.getConnectionLength()) || 0; // fix to prevent NaN for 0 length if (isDistanceAbsoluteReverse) labelDistance = (-1 * (this.getConnectionLength() - labelDistance)) || 1; // fix for end point (-0 => 1) position.distance = labelDistance; // OFFSET: // use absolute offset if: // - opt.absoluteOffset is true, // - opt.absoluteOffset is not true but there is no tangent var tangent; if (!isOffsetAbsolute) tangent = path.tangentAtT(t); var labelOffset; if (tangent) { labelOffset = tangent.pointOffset(labelPoint); } else { var closestPoint = path.pointAtT(t); var labelOffsetDiff = labelPoint.difference(closestPoint); labelOffset = { x: labelOffsetDiff.x, y: labelOffsetDiff.y }; } position.offset = labelOffset; // ANGLE: position.angle = localAngle; return position; }, _getLabelTransformationMatrix: function(labelPosition) { var labelDistance; var labelAngle = 0; var args = {}; if (typeof labelPosition === 'number') { labelDistance = labelPosition; } else if (typeof labelPosition.distance === 'number') { args = labelPosition.args || {}; labelDistance = labelPosition.distance; labelAngle = labelPosition.angle || 0; } else { throw new Error('dia.LinkView: invalid label position distance.'); } var isDistanceRelative = ((labelDistance > 0) && (labelDistance <= 1)); var labelOffset = 0; var labelOffsetCoordinates = { x: 0, y: 0 }; if (labelPosition.offset) { var positionOffset = labelPosition.offset; if (typeof positionOffset === 'number') labelOffset = positionOffset; if (positionOffset.x) labelOffsetCoordinates.x = positionOffset.x; if (positionOffset.y) labelOffsetCoordinates.y = positionOffset.y; } var isOffsetAbsolute = ((labelOffsetCoordinates.x !== 0) || (labelOffsetCoordinates.y !== 0) || labelOffset === 0); var isKeepGradient = args.keepGradient; var isEnsureLegibility = args.ensureLegibility; var path = this.path; var pathOpt = { se