@finos/legend-extension-dsl-diagram
Version:
Legend extension for Diagram DSL
966 lines • 128 kB
JavaScript
/**
* Copyright (c) 2020-present, Goldman Sachs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { uuid, noop, UnsupportedOperationError, IllegalStateError, guaranteeNonNullable, findLast, uniqBy, } from '@finos/legend-shared';
import { Class, Enumeration, PrimitiveType, DerivedProperty, PackageableElementExplicitReference, PropertyExplicitReference, GenericTypeExplicitReference, GenericType, Property, Multiplicity, getAllSuperclasses, getAllOwnClassProperties, generateMultiplicityString, getRawGenericType, AggregationKind, } from '@finos/legend-graph';
import { action, makeObservable, observable } from 'mobx';
import { Rectangle } from '../graph/metamodel/pure/packageableElements/diagram/geometry/DSL_Diagram_Rectangle.js';
import { Point } from '../graph/metamodel/pure/packageableElements/diagram/geometry/DSL_Diagram_Point.js';
import { PositionedRectangle } from '../graph/metamodel/pure/packageableElements/diagram/geometry/DSL_Diagram_PositionedRectangle.js';
import { ClassView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_ClassView.js';
import { GeneralizationView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_GeneralizationView.js';
import { RelationshipView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_RelationshipView.js';
import { PropertyView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_PropertyView.js';
import { boxContains, buildBottomRightCornerBox, getBottomRightCornerPoint, getElementPosition, rotatePointX, rotatePointY, } from '../graph/helpers/DSL_Diagram_Helper.js';
import { AssociationView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_AssociationView.js';
import { class_addProperty, class_addSuperType, } from '@finos/legend-application-studio';
import { classView_setHideProperties, classView_setHideStereotypes, classView_setHideTaggedValues, diagram_addClassView, diagram_addGeneralizationView, diagram_addPropertyView, diagram_deleteAssociationView, diagram_deleteClassView, diagram_deleteGeneralizationView, diagram_deletePropertyView, diagram_setAssociationViews, diagram_setClassViews, diagram_setGeneralizationViews, diagram_setPropertyViews, findOrBuildPoint, positionedRectangle_forceRefreshHash, positionedRectangle_setPosition, positionedRectangle_setRectangle, relationshipEdgeView_setOffsetX, relationshipEdgeView_setOffsetY, relationshipView_changePoint, relationshipView_simplifyPath, relationshipView_setPath, } from '../stores/studio/DSL_Diagram_GraphModifierHelper.js';
export var DIAGRAM_INTERACTION_MODE;
(function (DIAGRAM_INTERACTION_MODE) {
DIAGRAM_INTERACTION_MODE[DIAGRAM_INTERACTION_MODE["LAYOUT"] = 0] = "LAYOUT";
DIAGRAM_INTERACTION_MODE[DIAGRAM_INTERACTION_MODE["PAN"] = 1] = "PAN";
DIAGRAM_INTERACTION_MODE[DIAGRAM_INTERACTION_MODE["ZOOM_IN"] = 2] = "ZOOM_IN";
DIAGRAM_INTERACTION_MODE[DIAGRAM_INTERACTION_MODE["ZOOM_OUT"] = 3] = "ZOOM_OUT";
DIAGRAM_INTERACTION_MODE[DIAGRAM_INTERACTION_MODE["ADD_RELATIONSHIP"] = 4] = "ADD_RELATIONSHIP";
DIAGRAM_INTERACTION_MODE[DIAGRAM_INTERACTION_MODE["ADD_CLASS"] = 5] = "ADD_CLASS";
})(DIAGRAM_INTERACTION_MODE || (DIAGRAM_INTERACTION_MODE = {}));
export var DIAGRAM_RELATIONSHIP_EDIT_MODE;
(function (DIAGRAM_RELATIONSHIP_EDIT_MODE) {
// ASSOCIATION,
DIAGRAM_RELATIONSHIP_EDIT_MODE[DIAGRAM_RELATIONSHIP_EDIT_MODE["PROPERTY"] = 0] = "PROPERTY";
DIAGRAM_RELATIONSHIP_EDIT_MODE[DIAGRAM_RELATIONSHIP_EDIT_MODE["INHERITANCE"] = 1] = "INHERITANCE";
DIAGRAM_RELATIONSHIP_EDIT_MODE[DIAGRAM_RELATIONSHIP_EDIT_MODE["NONE"] = 2] = "NONE";
})(DIAGRAM_RELATIONSHIP_EDIT_MODE || (DIAGRAM_RELATIONSHIP_EDIT_MODE = {}));
export var DIAGRAM_ALIGNER_OPERATOR;
(function (DIAGRAM_ALIGNER_OPERATOR) {
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["ALIGN_LEFT"] = 0] = "ALIGN_LEFT";
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["ALIGN_CENTER"] = 1] = "ALIGN_CENTER";
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["ALIGN_RIGHT"] = 2] = "ALIGN_RIGHT";
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["ALIGN_TOP"] = 3] = "ALIGN_TOP";
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["ALIGN_MIDDLE"] = 4] = "ALIGN_MIDDLE";
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["ALIGN_BOTTOM"] = 5] = "ALIGN_BOTTOM";
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["SPACE_HORIZONTALLY"] = 6] = "SPACE_HORIZONTALLY";
DIAGRAM_ALIGNER_OPERATOR[DIAGRAM_ALIGNER_OPERATOR["SPACE_VERTICALLY"] = 7] = "SPACE_VERTICALLY";
})(DIAGRAM_ALIGNER_OPERATOR || (DIAGRAM_ALIGNER_OPERATOR = {}));
const MIN_ZOOM_LEVEL = 0.05; // 5%
const FIT_ZOOM_PADDING = 10;
export const DIAGRAM_ZOOM_LEVELS = [
50, 75, 90, 100, 110, 125, 150, 200, 250, 300, 400,
];
const getPropertyDisplayName = (property) => (property instanceof DerivedProperty ? '/ ' : '') + property.name;
export class DiagramRenderer {
diagram;
isReadOnly;
enableLayoutAutoAdjustment;
div;
canvas;
ctx;
// Rendering elements
canvasDimension; // dimension of the canvas, i.e. the dimension of the container element that hosts the canvas
canvasCenter;
/**
* The screen or artboard that contains all parts of the diagram. It's important to understand that this is indeed a virtual screen
* because it is constructed by constructing the smallest possible bounding rectangle around the diagram. As such, information about
* the screen is not persisted (in the protocol JSON)
*/
virtualScreen;
/**
* This refers the offset of the virtual screen with respect to the canvas. We have 2 types of coordinate:
* `stored` (in the JSON protocol of class and relationship views) vs. `rendering`.
*
* There are 2 important facts about stored coordinates:
* 1. Zoom is not taken into account (unlike rendering coordinates which change as we zoom)
* 2. They are with respect to the canvas, not the screen (because the screen is virtual - see above)
*
* As such, when we debug, let's say we have a position (x,y), if we want to find that coordinate in the coordiante system of the canvas, we have to
* add the offset, so the coordinate of (x, y) is (x + screenOffset.x, y + screenOffset.y) when we refer to the canvas coordinate system
* So if we turn on debug mode and try to move the top left corner of the screen to the `offset crosshair` the screen coordinate system should align
* with the canvas coordinate system. Of course due to centering and moving the screen around there is still an offset between the 2 coordinate system,
* but we know for a fact that the top left of the screen will have stored coordinate (0,0)
*/
screenOffset;
zoom;
// edit modes
// NOTE: we keep the edit mode separated like this
// becase we anticipate more complex interactions in the future
interactionMode;
relationshipMode;
// UML specific shapes
triangle;
diamond;
// Font
fontFamily;
fontSize;
lineHeight;
// Wrap text
truncateText;
maxLineLength;
// TODO: we might want to do text wrapping as well
// Spacing
screenPadding; // the padding of the diagram (art board)
classViewSpaceX;
classViewSpaceY;
propertySpacing;
// Screen Grid (for debugging)
showScreenGrid; // show the screen coordinate system
showScreenBoxGuide; // show the screen border box and center
screenGridAxisTickLength;
screenGridSpacingX;
screenGridSpacingY;
screenGridLineWidth;
screenGridLineColor;
screenGridLabelTextColor;
screenGuideLineColor;
screenGuideLabelTextColor;
// Canvas Grid (for debugging)
showCanvasGrid; // show the canvas coordinate system
showCanvasBoxGuide; // show the canvas border box and center
showScreenOffsetGuide; // show the offset of the screen with respect to the canvas
canvasGridAxisTickLength;
canvasGridSpacingX;
canvasGridSpacingY;
canvasGridLineWidth;
canvasGridLineColor;
canvasGridLabelTextColor;
canvasGuideLineColor;
canvasGuideLabelTextColor;
screenOffsetGuideLineColor;
screenOffsetGuideLabelTextColor;
// Line
defaultLineWidth;
// Color
defaultLineColor;
canvasColor;
backgroundColor;
classViewFillColor;
classViewHeaderTextColor;
classViewPropertyTextColor;
classViewPrimitivePropertyTextColor;
classViewDerivedPropertyTextColor;
relationshipViewTextColor;
propertyViewOwnedDiamondColor;
propertyViewSharedDiamondColor;
generalizationViewInheritanceTriangeFillColor;
selectionBoxBorderColor;
// Selection
selection;
selectionStart;
selectedClassCorner; // the class view which we currently select the bottom right corner
selectedClassProperty;
selectedClasses;
selectedPropertyOrAssociation;
selectedInheritance;
selectedPoint;
_selectedClassesInitialPositions;
// Relationship
startClassView;
handleAddRelationship;
mouseOverClassCorner;
mouseOverClassName;
mouseOverClassView;
mouseOverClassProperty;
mouseOverPropertyHolderViewLabel;
cursorPosition;
leftClick;
middleClick;
rightClick;
clickX;
clickY;
positionBeforeLastMove;
// interactions
onAddClassViewClick = noop();
onClassViewRightClick = noop();
onBackgroundDoubleClick;
onClassViewDoubleClick;
onClassNameDoubleClick;
onClassPropertyDoubleClick;
handleEditClassView = noop();
handleEditProperty = noop();
handleAddSimpleProperty = noop();
constructor(div, diagram) {
makeObservable(this, {
isReadOnly: observable,
enableLayoutAutoAdjustment: observable,
interactionMode: observable,
relationshipMode: observable,
zoom: observable,
mouseOverClassView: observable,
mouseOverClassName: observable,
mouseOverClassCorner: observable,
mouseOverClassProperty: observable,
mouseOverPropertyHolderViewLabel: observable,
selectionStart: observable,
selectedClassCorner: observable,
selectedClasses: observable,
selectedPropertyOrAssociation: observable,
selectedInheritance: observable,
leftClick: observable,
middleClick: observable,
rightClick: observable,
changeMode: action,
setIsReadOnly: action,
setEnableLayoutAutoAdjustment: action,
setMouseOverClassView: action,
setMouseOverClassName: action,
setMouseOverClassCorner: action,
setMouseOverClassProperty: action,
setMouseOverPropertyHolderViewLabel: action,
setSelectionStart: action,
setSelectedClassCorner: action,
setSelectedClasses: action,
setSelectedPropertyOrAssociation: action,
setSelectedInheritance: action,
setLeftClick: action,
setMiddleClick: action,
setRightClick: action,
setZoomLevel: action,
align: action,
});
this.diagram = diagram;
// Container and canvas
this.div = div;
this.div.childNodes.forEach((node) => this.div.removeChild(node)); // Clear the <div> container
this.canvas = document.createElement('canvas');
this.canvasDimension = new Rectangle(this.div.offsetWidth, this.div.offsetHeight);
this.canvasCenter = new Point(this.canvasDimension.width / 2, this.canvasDimension.height / 2);
this.canvas.width = this.canvasDimension.width;
this.canvas.height = this.canvasDimension.height;
this.canvas.style.position = 'absolute';
this.canvas.style.left = '0';
this.canvas.style.top = '0';
this.div.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
this.screenOffset = new Point(0, 0);
this.virtualScreen = new PositionedRectangle(new Point(0, 0), new Rectangle(0, 0));
this.zoom = 1;
// UML specific shapes
this.triangle = [new Point(0, 0), new Point(-15, -10), new Point(-15, 10)];
this.diamond = [
new Point(0, 0),
new Point(-10, -5),
new Point(-20, 0),
new Point(-10, 5),
];
// Font
// this.fontFamily = 'Roboto Mono'; // intentionally choose a monospaced font so it's easier for calculation (such as text wrapping)
this.fontFamily = 'Arial'; // convert this back to non-monospaced font for now since monospaced fonts look rather off
this.fontSize = 12;
this.lineHeight = 14;
// Wrap text
this.truncateText = true;
this.maxLineLength = 40;
// Screen Grid (for debugging purpose)
this.showScreenGrid = false;
this.showScreenBoxGuide = true;
this.screenGridAxisTickLength = 50;
this.screenGridSpacingX = 100;
this.screenGridSpacingY = 100;
this.screenGridLineWidth = 0.5;
// Canvas Grid
this.showCanvasGrid = false;
this.showCanvasBoxGuide = true;
this.showScreenOffsetGuide = true;
this.canvasGridAxisTickLength = 50;
this.canvasGridSpacingX = 100;
this.canvasGridSpacingY = 100;
this.canvasGridLineWidth = 0.5;
// Line
this.defaultLineWidth = 1;
// Color
this.screenGridLineColor = 'rgba(61,126,154,0.56)';
this.screenGridLabelTextColor = 'rgba(61,126,154,0.56)';
this.screenGuideLineColor = 'red';
this.screenGuideLabelTextColor = 'red';
this.canvasGridLineColor = 'green';
this.canvasGridLabelTextColor = 'green';
this.canvasGuideLineColor = 'orange';
this.canvasGuideLabelTextColor = 'orange';
this.screenOffsetGuideLineColor = 'purple';
this.screenOffsetGuideLabelTextColor = 'purple';
this.defaultLineColor = 'rgb(0,0,0)';
this.canvasColor = 'rgb(220,220,220)';
this.backgroundColor = 'rgb(255,255,255)';
this.classViewFillColor = 'rgb(185,185,185)';
this.classViewHeaderTextColor = 'rgb(0,0,0)';
this.classViewPropertyTextColor = 'rgb(0,0,0)';
this.classViewPrimitivePropertyTextColor = 'rgb(255,255,255)';
this.classViewDerivedPropertyTextColor = 'rgb(100,100,100)';
this.relationshipViewTextColor = 'rgb(0,0,0)';
this.propertyViewSharedDiamondColor = 'rgb(255,255,255)';
this.propertyViewOwnedDiamondColor = 'rgb(0,0,0)';
this.generalizationViewInheritanceTriangeFillColor = 'rgb(255,255,255)';
this.selectionBoxBorderColor = 'rgba(0,0,0, 0.02)';
// Preferences
this.interactionMode = DIAGRAM_INTERACTION_MODE.LAYOUT;
this.relationshipMode = DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE;
this.isReadOnly = false;
this.enableLayoutAutoAdjustment = false;
this.screenPadding = 20;
this.classViewSpaceX = 10;
this.classViewSpaceY = 4;
this.propertySpacing = 10;
// Event handlers
this.selectionStart = undefined;
this.selection = undefined;
this.selectedClasses = [];
this._selectedClassesInitialPositions = [];
this.cursorPosition = new Point(0, 0);
this.leftClick = false;
this.middleClick = false;
this.rightClick = false;
this.clickX = 0;
this.clickY = 0;
this.positionBeforeLastMove = new Point(0, 0);
this.div.onwheel = this.mousewheel.bind(this);
this.div.onmousedown = this.mousedown.bind(this);
this.div.onkeydown = this.keydown.bind(this);
this.div.ondblclick = this.mousedblclick.bind(this);
this.div.onmouseup = this.mouseup.bind(this);
this.div.onmousemove = this.mousemove.bind(this);
}
setIsReadOnly(val) {
this.isReadOnly = val;
}
setEnableLayoutAutoAdjustment(val) {
this.enableLayoutAutoAdjustment = val;
}
setMouseOverClassView(val) {
this.mouseOverClassView = val;
}
setMouseOverClassName(val) {
this.mouseOverClassName = val;
}
setMouseOverClassCorner(val) {
this.mouseOverClassCorner = val;
}
setMouseOverClassProperty(val) {
this.mouseOverClassProperty = val;
}
setMouseOverPropertyHolderViewLabel(val) {
this.mouseOverPropertyHolderViewLabel = val;
}
setSelectionStart(val) {
this.selectionStart = val;
}
setSelectedClassCorner(val) {
this.selectedClassCorner = val;
}
setSelectedClasses(val) {
this.selectedClasses = val;
}
setSelectedPropertyOrAssociation(val) {
this.selectedPropertyOrAssociation = val;
}
setSelectedInheritance(val) {
this.selectedInheritance = val;
}
setLeftClick(val) {
this.leftClick = val;
}
setMiddleClick(val) {
this.middleClick = val;
}
setRightClick(val) {
this.rightClick = val;
}
setZoomLevel(val) {
this.zoom = val;
}
render(options) {
this.diagram.classViews.forEach((classView) => this.ensureClassViewMeetMinDimensions(classView));
this.refresh();
if (options?.initial) {
this.autoRecenter();
// only zoom to fit if needed
if (this.canvas.width <
this.virtualScreen.rectangle.width +
this.screenPadding * 2 +
FIT_ZOOM_PADDING * 2 ||
this.canvas.height <
this.virtualScreen.rectangle.height +
this.screenPadding * 2 +
FIT_ZOOM_PADDING * 2) {
this.zoomToFit();
}
}
}
refresh() {
this.refreshCanvas();
this.drawScreen();
}
refreshCanvas() {
this.canvasDimension = new Rectangle(this.div.offsetWidth, this.div.offsetHeight);
this.canvasCenter = new Point(this.canvasDimension.width / 2, this.canvasDimension.height / 2);
this.canvas.width = this.canvasDimension.width;
this.canvas.height = this.canvasDimension.height;
}
clearScreen() {
this.ctx.fillStyle = this.canvasColor;
this.ctx.fillRect(0, 0, this.canvasDimension.width, this.canvasDimension.height);
}
drawScreen() {
this.manageVirtualScreen();
this.clearScreen();
this.drawAll();
}
autoRecenter() {
this.center(this.virtualScreen.position.x + this.virtualScreen.rectangle.width / 2, this.virtualScreen.position.y + this.virtualScreen.rectangle.height / 2);
}
/**
* Reset the screen offset
*/
center(x, y) {
this.screenOffset = new Point(-x + this.canvasCenter.x, -y + this.canvasCenter.y);
this.refresh();
}
changeMode(editMode, relationshipMode) {
switch (editMode) {
case DIAGRAM_INTERACTION_MODE.LAYOUT:
case DIAGRAM_INTERACTION_MODE.PAN:
case DIAGRAM_INTERACTION_MODE.ZOOM_IN:
case DIAGRAM_INTERACTION_MODE.ZOOM_OUT:
case DIAGRAM_INTERACTION_MODE.ADD_CLASS: {
if (relationshipMode !== DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE) {
throw new IllegalStateError(`Can't change to '${editMode}' mode: relationship mode should not be specified in layout mode`);
}
break;
}
case DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP: {
if (relationshipMode === DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE) {
throw new IllegalStateError(`Can't switch to relationship mode: relationship mode is not specified`);
}
break;
}
default:
throw new UnsupportedOperationError(`Can't switch to mode '${editMode}': unsupported mode`);
}
this.interactionMode = editMode;
this.relationshipMode = relationshipMode;
if (editMode === DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP) {
switch (relationshipMode) {
case DIAGRAM_RELATIONSHIP_EDIT_MODE.INHERITANCE: {
this.handleAddRelationship = (startClassView, targetClassView) => {
if (
// Do not allow creating self-inheritance
startClassView.class.value !== targetClassView.class.value &&
// Avoid creating inhertance that already existed
!getAllSuperclasses(startClassView.class.value).includes(targetClassView.class.value) &&
// Avoid loop (might be expensive)
!getAllSuperclasses(targetClassView.class.value).includes(startClassView.class.value)) {
class_addSuperType(startClassView.class.value, GenericTypeExplicitReference.create(new GenericType(targetClassView.class.value)));
}
// only add an inheritance relationship view if the start class
// has already had the target class as its supertype
if (startClassView.class.value.generalizations.find((generalization) => generalization.value.rawType === targetClassView.class.value)) {
const gview = new GeneralizationView(this.diagram, startClassView, targetClassView);
diagram_addGeneralizationView(this.diagram, gview);
return gview;
}
return undefined;
};
break;
}
case DIAGRAM_RELATIONSHIP_EDIT_MODE.PROPERTY: {
this.handleAddRelationship = (startClassView, targetClassView) => {
const property = new Property(`property_${startClassView.class.value.properties.length + 1}`, Multiplicity.ONE, GenericTypeExplicitReference.create(new GenericType(targetClassView.class.value)), startClassView.class.value);
class_addProperty(startClassView.class.value, property);
// only create property view if the classviews are different
// else we end up with a weird rendering where the property view
// is not targetable
if (startClassView !== targetClassView) {
const pView = new PropertyView(this.diagram, PropertyExplicitReference.create(property), startClassView, targetClassView);
diagram_addPropertyView(this.diagram, pView);
return pView;
}
return undefined;
};
break;
}
default:
throw new UnsupportedOperationError(`Can't switch to relationship mode '${relationshipMode}': unsupported mode`);
}
}
}
align(op) {
if (this.selectedClasses.length < 2) {
return;
}
switch (op) {
case DIAGRAM_ALIGNER_OPERATOR.ALIGN_LEFT: {
const leftBound = this.selectedClasses.reduce((val, view) => Math.min(val, view.position.x), Number.MAX_SAFE_INTEGER);
this.selectedClasses.forEach((view) => positionedRectangle_setPosition(view, new Point(leftBound, view.position.y)));
break;
}
case DIAGRAM_ALIGNER_OPERATOR.ALIGN_CENTER: {
const center = this.selectedClasses.reduce((val, view) => val + view.position.x + view.rectangle.width / 2, 0) / this.selectedClasses.length;
this.selectedClasses.forEach((view) => positionedRectangle_setPosition(view, new Point(center - view.rectangle.width / 2, view.position.y)));
break;
}
case DIAGRAM_ALIGNER_OPERATOR.ALIGN_RIGHT: {
const rightBound = this.selectedClasses.reduce((val, view) => Math.max(val, view.position.x + view.rectangle.width), -Number.MAX_SAFE_INTEGER);
this.selectedClasses.forEach((view) => positionedRectangle_setPosition(view, new Point(rightBound - view.rectangle.width, view.position.y)));
break;
}
case DIAGRAM_ALIGNER_OPERATOR.ALIGN_TOP: {
const topBound = this.selectedClasses.reduce((val, view) => Math.min(val, view.position.y), Number.MAX_SAFE_INTEGER);
this.selectedClasses.forEach((view) => positionedRectangle_setPosition(view, new Point(view.position.x, topBound)));
break;
}
case DIAGRAM_ALIGNER_OPERATOR.ALIGN_MIDDLE: {
const middle = this.selectedClasses.reduce((val, view) => val + view.position.y + view.rectangle.height / 2, 0) / this.selectedClasses.length;
this.selectedClasses.forEach((view) => positionedRectangle_setPosition(view, new Point(view.position.x, middle - view.rectangle.height / 2)));
break;
}
case DIAGRAM_ALIGNER_OPERATOR.ALIGN_BOTTOM: {
const bottomBound = this.selectedClasses.reduce((val, view) => Math.max(val, view.position.y + view.rectangle.height), -Number.MAX_SAFE_INTEGER);
this.selectedClasses.forEach((view) => positionedRectangle_setPosition(view, new Point(view.position.x, bottomBound - view.rectangle.height)));
break;
}
case DIAGRAM_ALIGNER_OPERATOR.SPACE_HORIZONTALLY: {
const sorted = this.selectedClasses.toSorted((a, b) => a.position.x - b.position.x);
// NOTE: handle special case where there are only 2 views, make them adjacent
if (sorted.length === 2) {
const previousView = sorted[0];
const currentView = sorted[1];
positionedRectangle_setPosition(currentView, new Point(previousView.position.x + previousView.rectangle.width, currentView.position.y));
}
else {
const spacings = [];
for (let idx = 1; idx < sorted.length; idx++) {
const previousView = sorted[idx - 1];
const currentView = sorted[idx];
spacings.push(currentView.position.x -
(previousView.position.x + previousView.rectangle.width));
}
const averageSpacing = spacings.reduce((val, distance) => val + distance, 0) /
spacings.length;
for (let idx = 1; idx < sorted.length; idx++) {
const previousView = sorted[idx - 1];
const currentView = sorted[idx];
positionedRectangle_setPosition(currentView, new Point(previousView.position.x +
previousView.rectangle.width +
averageSpacing, currentView.position.y));
}
}
break;
}
case DIAGRAM_ALIGNER_OPERATOR.SPACE_VERTICALLY: {
const sorted = this.selectedClasses.toSorted((a, b) => a.position.y - b.position.y);
// NOTE: handle special case where there are only 2 views, make them adjacent
if (sorted.length === 2) {
const previousView = sorted[0];
const currentView = sorted[1];
positionedRectangle_setPosition(currentView, new Point(currentView.position.x, previousView.position.y + previousView.rectangle.height));
}
else {
const spacings = [];
for (let idx = 1; idx < sorted.length; idx++) {
const previousView = sorted[idx - 1];
const currentView = sorted[idx];
spacings.push(currentView.position.y -
(previousView.position.y + previousView.rectangle.height));
}
const averageSpacing = spacings.reduce((val, distance) => val + distance, 0) /
spacings.length;
for (let idx = 1; idx < sorted.length; idx++) {
const previousView = sorted[idx - 1];
const currentView = sorted[idx];
positionedRectangle_setPosition(currentView, new Point(currentView.position.x, previousView.position.y +
previousView.rectangle.height +
averageSpacing));
}
}
break;
}
default:
break;
}
this.drawScreen();
}
truncateTextWithEllipsis(val, limit = this.maxLineLength) {
const ellipsis = '...';
return val.length > limit
? `${val.substring(0, limit + 1 - ellipsis.length)}${ellipsis}`
: val;
}
canvasCoordinateToModelCoordinate(point) {
return new Point((point.x - this.canvasCenter.x) / this.zoom -
this.screenOffset.x +
this.canvasCenter.x, (point.y - this.canvasCenter.y) / this.zoom -
this.screenOffset.y +
this.canvasCenter.y);
}
modelCoordinateToCanvasCoordinate(point) {
return new Point((point.x - this.canvasCenter.x + this.screenOffset.x) * this.zoom +
this.canvasCenter.x, (point.y - this.canvasCenter.y + this.screenOffset.y) * this.zoom +
this.canvasCenter.y);
}
/**
* Mouse events' coordinate x,y are relative to the viewport, not the canvas element
* so we need to calculate them relative to the canvas element
*/
resolveMouseEventCoordinate(e) {
if (e.target instanceof HTMLElement) {
const targetEl = getElementPosition(e.target);
return new Point(targetEl.x + e.offsetX, targetEl.y + e.offsetY);
}
// NOTE: this is a fallback, should not happen
// since all mouse events shoud target the canvas element
return new Point(e.x, e.y);
}
eventCoordinateToCanvasCoordinate(point) {
return new Point(point.x - this.divPosition.x + this.div.scrollLeft, point.y - this.divPosition.y + this.div.scrollTop);
}
canvasCoordinateToEventCoordinate(point) {
return new Point(point.x - this.div.scrollLeft + this.divPosition.x, point.y - this.div.scrollTop + this.divPosition.y);
}
hasPropertyView(classView, property) {
return (this.diagram.propertyViews.filter((p) => p.property.value === property && p.from.classView.value === classView).length > 0);
}
get divPosition() {
return getElementPosition(this.div);
}
manageVirtualScreen() {
if (this.diagram.classViews.length) {
const firstClassView = guaranteeNonNullable(this.diagram.classViews[0]);
let minX = firstClassView.position.x;
let minY = firstClassView.position.y;
let maxX = firstClassView.position.x + firstClassView.rectangle.width;
let maxY = firstClassView.position.y + firstClassView.rectangle.height;
for (const classView of this.diagram.classViews) {
minX = Math.min(minX, classView.position.x);
minY = Math.min(minY, classView.position.y);
maxX = Math.max(maxX, classView.position.x + classView.rectangle.width);
maxY = Math.max(maxY, classView.position.y + classView.rectangle.height);
}
const relationshipViews = this.diagram.associationViews
.concat(this.diagram.generalizationViews)
.concat(this.diagram.propertyViews);
for (const relationshipView of relationshipViews) {
const fullPath = RelationshipView.pruneUnnecessaryInsidePoints(relationshipView.buildFullPath(), relationshipView.from.classView.value, relationshipView.to.classView.value);
if (relationshipView instanceof PropertyView) {
const box = this.drawLinePropertyText(
// NOTE: by the way we compute the full path, it would guarantee
// to always have at least 2 points
guaranteeNonNullable(fullPath[fullPath.length - 2], 'Diagram path expected to have at least 2 points'), guaranteeNonNullable(fullPath[fullPath.length - 1], 'Diagram path expected to have at least 2 points'), relationshipView.to.classView.value, relationshipView.property.value, false);
minX = Math.min(minX, box.position.x);
minY = Math.min(minY, box.position.y);
maxX = Math.max(maxX, getBottomRightCornerPoint(box).x);
maxY = Math.max(maxY, getBottomRightCornerPoint(box).y);
}
// if (relationshipView.from.name) {
// var box = this.displayText(fullPath[1], fullPath[0], relationshipView.from.classView, relationshipView.property, this.ctx);
// minX = Math.min(minX, box.x);
// minY = Math.min(minY, box.y);
// maxX = Math.max(maxX, box.x2);
// maxY = Math.max(maxY, box.y2);
// }
for (const point of relationshipView.path) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
}
this.virtualScreen = new PositionedRectangle(new Point(minX, minY), new Rectangle(maxX - minX, maxY - minY));
}
else {
this.setZoomLevel(1);
this.screenOffset = new Point(0, 0);
this.virtualScreen = new PositionedRectangle(new Point(this.canvasDimension.width / 2, this.canvasDimension.height / 2), new Rectangle(0, 0));
}
}
/**
* Here we zoom with respect to the point the mouse is currently pointing at.
* The idea is fairly simple. We convert the coordinate of the zoom point
* to the model coordinate and find a way to alter `screenOffset` in response
* to change in zoom level to ensure the model coordinate stays constant.
*/
executeZoom(newZoomLevel, point) {
// NOTE: we cap the minimum zoom level to avoid negative zoom
newZoomLevel = Math.max(newZoomLevel, MIN_ZOOM_LEVEL);
const currentZoomLevel = this.zoom;
this.setZoomLevel(newZoomLevel);
this.screenOffset = new Point(((point.x - this.canvasCenter.x) * (currentZoomLevel - newZoomLevel) +
this.screenOffset.x * currentZoomLevel) /
newZoomLevel, ((point.y - this.canvasCenter.y) * (currentZoomLevel - newZoomLevel) +
this.screenOffset.y * currentZoomLevel) /
newZoomLevel);
this.clearScreen();
this.drawAll();
}
zoomPoint(zoomLevel, zoomPoint) {
this.executeZoom(zoomLevel, zoomPoint);
}
zoomCenter(zoomLevel) {
// NOTE: we cap the minimum zoom level to avoid negative zoom
this.setZoomLevel(Math.max(zoomLevel, MIN_ZOOM_LEVEL));
this.clearScreen();
this.drawAll();
}
zoomToFit() {
this.autoRecenter();
this.zoomCenter(Math.max(Math.min(this.canvas.width /
(this.virtualScreen.rectangle.width +
this.screenPadding * 2 +
FIT_ZOOM_PADDING * 2), this.canvas.height /
(this.virtualScreen.rectangle.height +
this.screenPadding * 2 +
FIT_ZOOM_PADDING * 2)), MIN_ZOOM_LEVEL));
}
/**
* Add a classview to current diagram and draw it.
* This function is intended to be used with drag and drop, hence the position paramter, which must be relative to the screen/window
*/
addClassView(addedClass, classViewModelCoordinate) {
if (!this.isReadOnly) {
// NOTE: Using `uuid` might be overkill since the `id` is only required to be unique
// across the diagram, but maintaining a counter has its own downside
// NOTE: checking for collision to guarantee stability, especially since class view is usually manually added
const existingIds = this.diagram.classViews.map((classView) => classView.id);
let id = uuid();
while (existingIds.includes(id)) {
id = uuid();
}
const newClassView = new ClassView(this.diagram, id, PackageableElementExplicitReference.create(addedClass));
positionedRectangle_setPosition(newClassView, classViewModelCoordinate ??
this.canvasCoordinateToModelCoordinate(new Point(this.virtualScreen.position.x +
this.virtualScreen.rectangle.width / 2, this.virtualScreen.position.y +
this.virtualScreen.rectangle.height / 2)));
diagram_addClassView(this.diagram, newClassView);
// Refresh hash since ClassView position is not observable
// NOTE: here we refresh after adding the class view to the diagram, that way the diagram hash is refreshed
positionedRectangle_forceRefreshHash(newClassView);
this.diagram.classViews
.filter((classView) => classView.class.value !== addedClass)
.forEach((classView) => {
const _class = classView.class.value;
// Add supertype
if (addedClass.generalizations
.map((generalization) => generalization.value.rawType)
.includes(_class)) {
diagram_addGeneralizationView(this.diagram, new GeneralizationView(this.diagram, newClassView, classView));
}
if (_class.generalizations
.map((generalization) => generalization.value.rawType)
.includes(addedClass)) {
diagram_addGeneralizationView(this.diagram, new GeneralizationView(this.diagram, classView, newClassView));
}
// Add property view
getAllOwnClassProperties(addedClass).forEach((property) => {
if (property.genericType.value.rawType === _class) {
diagram_addPropertyView(this.diagram, new PropertyView(this.diagram, PropertyExplicitReference.create(property), newClassView, classView));
}
});
getAllOwnClassProperties(_class).forEach((property) => {
if (property.genericType.value.rawType === addedClass) {
diagram_addPropertyView(this.diagram, new PropertyView(this.diagram, PropertyExplicitReference.create(property), classView, newClassView));
}
});
});
this.drawClassView(newClassView);
this.drawScreen();
return newClassView;
}
return undefined;
}
drawBoundingBox() {
this.ctx.fillStyle = this.backgroundColor;
this.ctx.lineWidth = 1;
this.ctx.fillRect((this.virtualScreen.position.x +
this.screenOffset.x -
this.canvasCenter.x -
this.screenPadding) *
this.zoom +
this.canvasCenter.x, (this.virtualScreen.position.y +
this.screenOffset.y -
this.canvasCenter.y -
this.screenPadding) *
this.zoom +
this.canvasCenter.y, (this.virtualScreen.rectangle.width + this.screenPadding * 2) * this.zoom, (this.virtualScreen.rectangle.height + this.screenPadding * 2) *
this.zoom);
this.ctx.strokeRect((this.virtualScreen.position.x +
this.screenOffset.x -
this.canvasCenter.x -
this.screenPadding) *
this.zoom +
this.canvasCenter.x, (this.virtualScreen.position.y +
this.screenOffset.y -
this.canvasCenter.y -
this.screenPadding) *
this.zoom +
this.canvasCenter.y, (this.virtualScreen.rectangle.width + this.screenPadding * 2) * this.zoom, (this.virtualScreen.rectangle.height + this.screenPadding * 2) *
this.zoom);
}
drawDiagram() {
this.diagram.associationViews.forEach((associationView) => this.drawPropertyOrAssociation(associationView));
this.diagram.generalizationViews.forEach((generalizationView) => this.drawInheritance(generalizationView));
this.diagram.propertyViews.forEach((propertyView) => this.drawPropertyOrAssociation(propertyView));
this.diagram.classViews.forEach((classView) => this.drawClassView(classView));
if (this.showCanvasGrid) {
this.drawCanvasGrid();
}
if (this.showScreenGrid) {
this.drawScreenGrid();
}
}
drawAll() {
this.drawBoundingBox();
this.drawDiagram();
}
drawScreenGrid() {
const startX = (this.virtualScreen.position.x +
this.screenOffset.x -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x;
const startY = (this.virtualScreen.position.y +
this.screenOffset.y -
this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y;
const width = (this.virtualScreen.rectangle.width + this.screenPadding * 2) * this.zoom;
const height = (this.virtualScreen.rectangle.height + this.screenPadding * 2) *
this.zoom;
this.ctx.beginPath();
this.ctx.fillStyle = this.screenGridLabelTextColor;
this.ctx.strokeStyle = this.screenGridLineColor;
this.ctx.lineWidth = this.screenGridLineWidth;
this.ctx.font = `${(this.fontSize - 1) * this.zoom}px ${this.fontFamily}`;
const labelPadding = 5;
// draw vertical grid lines
let gridX = 0;
for (let x = startX; x < startX + width - this.screenPadding * 2; x += this.screenGridSpacingX * this.zoom) {
this.ctx.fillText(`${gridX}`, x + labelPadding * this.zoom, startY -
(this.screenGridAxisTickLength + this.screenPadding) * this.zoom);
this.ctx.fillText(`[${Math.round(this.virtualScreen.position.x + gridX)}]`, x + labelPadding * this.zoom, startY -
(this.screenGridAxisTickLength +
this.screenPadding -
this.lineHeight) *
this.zoom);
this.ctx.moveTo(x, startY -
(this.screenGridAxisTickLength + this.screenPadding) * this.zoom);
this.ctx.lineTo(x, startY + height - this.screenPadding * this.zoom);
this.ctx.stroke();
gridX += this.screenGridSpacingX;
}
// draw horizontal grid lines
let gridY = 0;
for (let y = startY; y < startY + height - this.screenPadding * 2; y += this.screenGridSpacingY * this.zoom) {
this.ctx.fillText(`${gridY}`, startX -
(this.screenGridAxisTickLength + this.screenPadding) * this.zoom, y + labelPadding * this.zoom);
this.ctx.fillText(`[${Math.round(this.virtualScreen.position.y + gridY)}]`, startX -
(this.screenGridAxisTickLength + this.screenPadding) * this.zoom, y + (labelPadding + this.lineHeight) * this.zoom);
this.ctx.moveTo(startX -
(this.screenGridAxisTickLength + this.screenPadding) * this.zoom, y);
this.ctx.lineTo(startX + width - this.screenPadding * this.zoom, y);
this.ctx.stroke();
gridY += this.screenGridSpacingY;
}
// draw screen padding border grid line
if (this.showScreenBoxGuide) {
this.ctx.beginPath();
this.ctx.fillStyle = this.screenGuideLabelTextColor;
this.ctx.strokeStyle = this.screenGuideLineColor;
this.ctx.moveTo(startX, startY);
this.ctx.lineTo(startX, startY + height - this.screenPadding * 2 * this.zoom);
this.ctx.stroke();
this.ctx.lineTo(startX, startY + height - this.screenPadding * 2 * this.zoom);
this.ctx.lineTo(startX + width - this.screenPadding * 2 * this.zoom, startY + height - this.screenPadding * 2 * this.zoom);
this.ctx.stroke();
this.ctx.lineTo(startX + width - this.screenPadding * 2 * this.zoom, startY + height - this.screenPadding * 2 * this.zoom);
this.ctx.lineTo(startX + width - this.screenPadding * 2 * this.zoom, startY);
this.ctx.stroke();
this.ctx.lineTo(startX + width - this.screenPadding * 2 * this.zoom, startY);
this.ctx.lineTo(startX, startY);
this.ctx.stroke();
// draw center guides
this.ctx.fillText(`${Math.round(this.virtualScreen.rectangle.width / 2)}`, startX + width / 2 - (this.screenPadding - labelPadding) * this.zoom, startY + labelPadding * this.zoom);
this.ctx.fillText(`[${Math.round(this.virtualScreen.position.x +
this.virtualScreen.rectangle.width / 2)}]`, startX + width / 2 - (this.screenPadding - labelPadding) * this.zoom, startY + (labelPadding + this.lineHeight) * this.zoom);
this.ctx.lineTo(startX + width / 2 - this.screenPadding * this.zoom, startY);
this.ctx.lineTo(startX + width / 2 - this.screenPadding * this.zoom, startY + height - this.screenPadding * 2 * this.zoom);
this.ctx.stroke();
this.ctx.fillText(`${Math.round(this.virtualScreen.rectangle.height / 2)}`, startX + labelPadding * this.zoom, startY + height / 2 - (this.screenPadding - labelPadding) * this.zoom);
this.ctx.fillText(`[${Math.round(this.virtualScreen.position.y +
this.virtualScreen.rectangle.height / 2)}]`, startX + labelPadding * this.zoom, startY +
height / 2 -
(this.screenPadding - labelPadding - this.lineHeight) * this.zoom);
this.ctx.lineTo(startX, startY + height / 2 - this.screenPadding * this.zoom);
this.ctx.lineTo(startX + width - this.screenPadding * 2 * this.zoom, startY + height / 2 - this.screenPadding * this.zoom);
this.ctx.stroke();
}
this.ctx.strokeStyle = this.defaultLineColor;
this.ctx.lineWidth = this.defaultLineWidth;
}
drawCanvasGrid() {
const startX = -this.canvasCenter.x * this.zoom + this.canvasCenter.x;
const startY = -this.canvasCenter.y * this.zoom + this.canvasCenter.y;
const width = this.canvasDimension.width;
const height = this.canvasDimension.height;
this.ctx.setLineDash([5, 5]);
this.ctx.fillStyle = this.canvasGridLabelTextColor;
this.ctx.strokeStyle = this.canvasGridLineColor;
this.ctx.lineWidth = this.canvasGridLineWidth;
this.ctx.font = `${(this.fontSize - 1) * this.zoom}px ${this.fontFamily}`;
const labelPadding = 5;
this.ctx.beginPath();
// draw vertical grid lines
let gridX = 0;
for (let x = startX; x < width; x += this.canvasGridSpacingX * this.zoom) {
if (x !== 0) {
this.ctx.fillText(`[${Math.round(gridX)}]`, x + labelPadding * this.zoom, labelPadding * this.zoom);
}
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, height);
this.ctx.stroke();
gridX += this.canvasGridSpacingX;
}
// draw horizontal grid lines
let gridY = 0;
for (let y = startY; y < height; y += this.canvasGridSpacingY * this.zoom) {
if (y !== 0) {
this.ctx.fillText(`[${Math.round(gridY)}]`, labelPadding * this.zoom, y + labelPadding * this.zoom);
}
this.ctx.moveTo(0, y);
this.ctx.lineTo(width, y);
this.ctx.stroke();
gridY += this.canvasGridSpacingY;
}
// draw canvas padding border grid line
if (this.showCanvasBoxGuide) {
this.ctx.beginPath();
this.ctx.fillStyle = this.canvasGuideLabelTextColor;
this.ctx.strokeStyle = this.canvasGuideLineColor;
// draw center guides
this.ctx.fillText(`[${Math.round(width / 2)}]`, width / 2 + labelPadding * this.zoom, labelPadding * this.zoom);
this.ctx.moveTo(width / 2, 0);
this.ctx.lineTo(width / 2, height);
this.ctx.stroke();
this.ctx.fillText(`[${Math.round(height / 2)}]`, labelPadding * this.zoom, height / 2 + labelPadding * this.zoom);
this.ctx.moveTo(0, height / 2);
this.ctx.lineTo(width, height / 2);
this.ctx.stroke();
}
// draw offset guides
if (this.showScreenOffsetGuide) {
const offsetPointRadius = 5;
const thresholdToHideOffsetTickLabel = 35;
this.ctx.beginPath();
this.ctx.setLineDash([]); // return solid line
this.ctx.fillStyle = this.screenOffsetGuideLabelTextColor;
this.ctx.strokeStyle = this.screenOffsetGuideLineColor;
// draw the crosshair
this.ctx.arc(start