@joint/core
Version:
JavaScript diagramming library
1,420 lines (1,145 loc) • 77.3 kB
JavaScript
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