jointjs
Version:
JavaScript diagramming library
1,381 lines (1,109 loc) • 89.7 kB
JavaScript
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)