jointjs
Version:
JavaScript diagramming library
444 lines (340 loc) • 15.8 kB
JavaScript
import { Cell } from './Cell.mjs';
import { Point, toRad, normalizeAngle, Rect } from '../g/index.mjs';
import { isNumber, isObject, interpolate, assign, invoke, normalizeSides } from '../util/index.mjs';
import { elementPortPrototype } from './ports.mjs';
// Element base model.
// -----------------------------
export const Element = Cell.extend({
defaults: {
position: { x: 0, y: 0 },
size: { width: 1, height: 1 },
angle: 0
},
initialize: function() {
this._initializePorts();
Cell.prototype.initialize.apply(this, arguments);
},
/**
* @abstract
*/
_initializePorts: function() {
// implemented in ports.js
},
_refreshPorts: function() {
// implemented in ports.js
},
isElement: function() {
return true;
},
position: function(x, y, opt) {
var isSetter = isNumber(y);
opt = (isSetter ? opt : x) || {};
// option `parentRelative` for setting the position relative to the element's parent.
if (opt.parentRelative) {
// Getting the parent's position requires the collection.
// Cell.parent() holds cell id only.
if (!this.graph) throw new Error('Element must be part of a graph.');
var parent = this.getParentCell();
var parentPosition = parent && !parent.isLink()
? parent.get('position')
: { x: 0, y: 0 };
}
if (isSetter) {
if (opt.parentRelative) {
x += parentPosition.x;
y += parentPosition.y;
}
if (opt.deep) {
var currentPosition = this.get('position');
this.translate(x - currentPosition.x, y - currentPosition.y, opt);
} else {
this.set('position', { x: x, y: y }, opt);
}
return this;
} else { // Getter returns a geometry point.
var elementPosition = Point(this.get('position'));
return opt.parentRelative
? elementPosition.difference(parentPosition)
: elementPosition;
}
},
translate: function(tx, ty, opt) {
tx = tx || 0;
ty = ty || 0;
if (tx === 0 && ty === 0) {
// Like nothing has happened.
return this;
}
opt = opt || {};
// Pass the initiator of the translation.
opt.translateBy = opt.translateBy || this.id;
var position = this.get('position') || { x: 0, y: 0 };
var ra = opt.restrictedArea;
if (ra && opt.translateBy === this.id) {
if (typeof ra === 'function') {
var newPosition = ra.call(this, position.x + tx, position.y + ty, opt);
tx = newPosition.x - position.x;
ty = newPosition.y - position.y;
} else {
// We are restricting the translation for the element itself only. We get
// the bounding box of the element including all its embeds.
// All embeds have to be translated the exact same way as the element.
var bbox = this.getBBox({ deep: true });
//- - - - - - - - - - - - -> ra.x + ra.width
// - - - -> position.x |
// -> bbox.x
// ▓▓▓▓▓▓▓ |
// ░░░░░░░▓▓▓▓▓▓▓
// ░░░░░░░░░ |
// ▓▓▓▓▓▓▓▓░░░░░░░
// ▓▓▓▓▓▓▓▓ |
// <-dx-> | restricted area right border
// <-width-> | ░ translated element
// <- - bbox.width - -> ▓ embedded element
var dx = position.x - bbox.x;
var dy = position.y - bbox.y;
// Find the maximal/minimal coordinates that the element can be translated
// while complies the restrictions.
var x = Math.max(ra.x + dx, Math.min(ra.x + ra.width + dx - bbox.width, position.x + tx));
var y = Math.max(ra.y + dy, Math.min(ra.y + ra.height + dy - bbox.height, position.y + ty));
// recalculate the translation taking the restrictions into account.
tx = x - position.x;
ty = y - position.y;
}
}
var translatedPosition = {
x: position.x + tx,
y: position.y + ty
};
// To find out by how much an element was translated in event 'change:position' handlers.
opt.tx = tx;
opt.ty = ty;
if (opt.transition) {
if (!isObject(opt.transition)) opt.transition = {};
this.transition('position', translatedPosition, assign({}, opt.transition, {
valueFunction: interpolate.object
}));
// Recursively call `translate()` on all the embeds cells.
invoke(this.getEmbeddedCells(), 'translate', tx, ty, opt);
} else {
this.startBatch('translate', opt);
this.set('position', translatedPosition, opt);
invoke(this.getEmbeddedCells(), 'translate', tx, ty, opt);
this.stopBatch('translate', opt);
}
return this;
},
size: function(width, height, opt) {
var currentSize = this.get('size');
// Getter
// () signature
if (width === undefined) {
return {
width: currentSize.width,
height: currentSize.height
};
}
// Setter
// (size, opt) signature
if (isObject(width)) {
opt = height;
height = isNumber(width.height) ? width.height : currentSize.height;
width = isNumber(width.width) ? width.width : currentSize.width;
}
return this.resize(width, height, opt);
},
resize: function(width, height, opt) {
opt = opt || {};
this.startBatch('resize', opt);
if (opt.direction) {
var currentSize = this.get('size');
switch (opt.direction) {
case 'left':
case 'right':
// Don't change height when resizing horizontally.
height = currentSize.height;
break;
case 'top':
case 'bottom':
// Don't change width when resizing vertically.
width = currentSize.width;
break;
}
// Get the angle and clamp its value between 0 and 360 degrees.
var angle = normalizeAngle(this.get('angle') || 0);
// This is a rectangle in size of the un-rotated element.
var bbox = this.getBBox();
var origin;
if (angle) {
var quadrant = {
'top-right': 0,
'right': 0,
'top-left': 1,
'top': 1,
'bottom-left': 2,
'left': 2,
'bottom-right': 3,
'bottom': 3
}[opt.direction];
if (opt.absolute) {
// We are taking the element's rotation into account
quadrant += Math.floor((angle + 45) / 90);
quadrant %= 4;
}
// Pick the corner point on the element, which meant to stay on its place before and
// after the rotation.
var fixedPoint = bbox[['bottomLeft', 'corner', 'topRight', 'origin'][quadrant]]();
// Find an image of the previous indent point. This is the position, where is the
// point actually located on the screen.
var imageFixedPoint = Point(fixedPoint).rotate(bbox.center(), -angle);
// Every point on the element rotates around a circle with the centre of rotation
// in the middle of the element while the whole element is being rotated. That means
// that the distance from a point in the corner of the element (supposed its always rect) to
// the center of the element doesn't change during the rotation and therefore it equals
// to a distance on un-rotated element.
// We can find the distance as DISTANCE = (ELEMENTWIDTH/2)^2 + (ELEMENTHEIGHT/2)^2)^0.5.
var radius = Math.sqrt((width * width) + (height * height)) / 2;
// Now we are looking for an angle between x-axis and the line starting at image of fixed point
// and ending at the center of the element. We call this angle `alpha`.
// The image of a fixed point is located in n-th quadrant. For each quadrant passed
// going anti-clockwise we have to add 90 degrees. Note that the first quadrant has index 0.
//
// 3 | 2
// --c-- Quadrant positions around the element's center `c`
// 0 | 1
//
var alpha = quadrant * Math.PI / 2;
// Add an angle between the beginning of the current quadrant (line parallel with x-axis or y-axis
// going through the center of the element) and line crossing the indent of the fixed point and the center
// of the element. This is the angle we need but on the un-rotated element.
alpha += Math.atan(quadrant % 2 == 0 ? height / width : width / height);
// Lastly we have to deduct the original angle the element was rotated by and that's it.
alpha -= toRad(angle);
// With this angle and distance we can easily calculate the centre of the un-rotated element.
// Note that fromPolar constructor accepts an angle in radians.
var center = Point.fromPolar(radius, alpha, imageFixedPoint);
// The top left corner on the un-rotated element has to be half a width on the left
// and half a height to the top from the center. This will be the origin of rectangle
// we were looking for.
origin = Point(center).offset(width / -2, height / -2);
} else {
// calculation for the origin Point when there is no rotation of the element
origin = bbox.topLeft();
switch (opt.direction) {
case 'top':
case 'top-right':
origin.offset(0, bbox.height - height);
break;
case 'left':
case 'bottom-left':
origin.offset(bbox.width -width, 0);
break;
case 'top-left':
origin.offset(bbox.width - width, bbox.height - height);
break;
}
}
// Resize the element (before re-positioning it).
this.set('size', { width: width, height: height }, opt);
// Finally, re-position the element.
this.position(origin.x, origin.y, opt);
} else {
// Resize the element.
this.set('size', { width: width, height: height }, opt);
}
this.stopBatch('resize', opt);
return this;
},
scale: function(sx, sy, origin, opt) {
var scaledBBox = this.getBBox().scale(sx, sy, origin);
this.startBatch('scale', opt);
this.position(scaledBBox.x, scaledBBox.y, opt);
this.resize(scaledBBox.width, scaledBBox.height, opt);
this.stopBatch('scale');
return this;
},
fitEmbeds: function(opt = {}) {
// Getting the children's size and position requires the collection.
// Cell.get('embeds') helds an array of cell ids only.
const { graph } = this;
if (!graph) throw new Error('Element must be part of a graph.');
const embeddedCells = this.getEmbeddedCells().filter(cell => cell.isElement());
if (embeddedCells.length === 0) return this;
this.startBatch('fit-embeds', opt);
if (opt.deep) {
// Recursively apply fitEmbeds on all embeds first.
invoke(embeddedCells, 'fitEmbeds', opt);
}
// Compute cell's size and position based on the children bbox
// and given padding.
const { left, right, top, bottom } = normalizeSides(opt.padding);
let { x, y, width, height } = graph.getCellsBBox(embeddedCells);
// Apply padding computed above to the bbox.
x -= left;
y -= top;
width += left + right;
height += bottom + top;
// Set new element dimensions finally.
this.set({
position: { x, y },
size: { width, height }
}, opt);
this.stopBatch('fit-embeds');
return this;
},
// Rotate element by `angle` degrees, optionally around `origin` point.
// If `origin` is not provided, it is considered to be the center of the element.
// If `absolute` is `true`, the `angle` is considered is absolute, i.e. it is not
// the difference from the previous angle.
rotate: function(angle, absolute, origin, opt) {
if (origin) {
var center = this.getBBox().center();
var size = this.get('size');
var position = this.get('position');
center.rotate(origin, this.get('angle') - angle);
var dx = center.x - size.width / 2 - position.x;
var dy = center.y - size.height / 2 - position.y;
this.startBatch('rotate', { angle: angle, absolute: absolute, origin: origin });
this.position(position.x + dx, position.y + dy, opt);
this.rotate(angle, absolute, null, opt);
this.stopBatch('rotate');
} else {
this.set('angle', absolute ? angle : (this.get('angle') + angle) % 360, opt);
}
return this;
},
angle: function() {
return normalizeAngle(this.get('angle') || 0);
},
getBBox: function(opt) {
opt = opt || {};
if (opt.deep && this.graph) {
// Get all the embedded elements using breadth first algorithm,
// that doesn't use recursion.
var elements = this.getEmbeddedCells({ deep: true, breadthFirst: true });
// Add the model itself.
elements.push(this);
return this.graph.getCellsBBox(elements);
}
var position = this.get('position');
var size = this.get('size');
return new Rect(position.x, position.y, size.width, size.height);
},
getPointFromConnectedLink: function(link, endType) {
// Center of the model
var bbox = this.getBBox();
var center = bbox.center();
// Center of a port
var endDef = link.get(endType);
if (!endDef) return center;
var portId = endDef.port;
if (!portId || !this.hasPort(portId)) return center;
var portGroup = this.portProp(portId, ['group']);
var portsPositions = this.getPortsPositions(portGroup);
var portCenter = new Point(portsPositions[portId]).offset(bbox.origin());
var angle = this.angle();
if (angle) portCenter.rotate(center, -angle);
return portCenter;
}
});
assign(Element.prototype, elementPortPrototype);