@finos/legend-extension-dsl-diagram
Version:
Legend extension for Diagram DSL
1,495 lines (1,384 loc) • 133 kB
text/typescript
/**
* 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 {
type AbstractProperty,
type GenericTypeReference,
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 type { Diagram } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_Diagram.js';
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 type { PropertyHolderView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_PropertyHolderView.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,
layoutDiagram,
rotatePointX,
rotatePointY,
} from '../graph/helpers/DSL_Diagram_Helper.js';
import { AssociationView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_AssociationView.js';
import type { RelationshipViewEnd } from '../graph-manager/index.js';
export class DiagramAnalysisResult {
readonly uuid = uuid();
title!: string;
description?: string | undefined;
diagram!: Diagram;
}
export interface DiagramRendererCallbacks {
// Domain mutations (from legend-application-studio)
onClass_addSuperType?: (
subclass: Class,
superType: GenericTypeReference,
) => void;
onClass_addProperty?: (ownerClass: Class, property: Property) => void;
// Diagram mutations - ClassView
onDiagram_setClassViews?: (diagram: Diagram, classViews: ClassView[]) => void;
onDiagram_addClassView?: (diagram: Diagram, classView: ClassView) => void;
onDiagram_deleteClassView?: (diagram: Diagram, classView: ClassView) => void;
// Diagram mutations - AssociationView
onDiagram_setAssociationViews?: (
diagram: Diagram,
associationViews: AssociationView[],
) => void;
onDiagram_deleteAssociationView?: (
diagram: Diagram,
associationView: AssociationView,
) => void;
// Diagram mutations - GeneralizationView
onDiagram_setGeneralizationViews?: (
diagram: Diagram,
generalizationViews: GeneralizationView[],
) => void;
onDiagram_addGeneralizationView?: (
diagram: Diagram,
view: GeneralizationView,
) => void;
onDiagram_deleteGeneralizationView?: (
diagram: Diagram,
view: GeneralizationView,
) => void;
// Diagram mutations - PropertyView
onDiagram_setPropertyViews?: (
diagram: Diagram,
propertyViews: PropertyView[],
) => void;
onDiagram_addPropertyView?: (diagram: Diagram, view: PropertyView) => void;
onDiagram_deletePropertyView?: (diagram: Diagram, view: PropertyView) => void;
// ClassView display mutations
onClassView_setHideProperties?: (classView: ClassView, hide: boolean) => void;
onClassView_setHideStereotypes?: (
classView: ClassView,
hide: boolean,
) => void;
onClassView_setHideTaggedValues?: (
classView: ClassView,
hide: boolean,
) => void;
// RelationshipViewEnd mutations
onRelationshipEdgeView_setOffsetX?: (
edge: RelationshipViewEnd,
offsetX: number,
) => void;
onRelationshipEdgeView_setOffsetY?: (
edge: RelationshipViewEnd,
offsetY: number,
) => void;
// RelationshipView path mutations
onRelationshipView_changePoint?: (
relationship: RelationshipView,
oldPoint: Point,
newPoint: Point,
) => void;
onRelationshipView_simplifyPath?: (relationship: RelationshipView) => void;
onRelationshipView_setPath?: (
relationship: RelationshipView,
path: Point[],
) => void;
// PositionedRectangle mutations
onPositionedRectangle_setRectangle?: (
classView: ClassView,
rectangle: Rectangle,
) => void;
onPositionedRectangle_setPosition?: (
classView: ClassView,
position: Point,
) => void;
onPositionedRectangle_forceRefreshHash?: (
positionedRectangle: PositionedRectangle,
) => void;
onFindOrBuildPoint?: (
relationship: RelationshipView,
x: number,
y: number,
zoom: number,
editable: boolean,
) => Point | undefined;
}
export enum DIAGRAM_INTERACTION_MODE {
LAYOUT,
PAN,
ZOOM_IN,
ZOOM_OUT,
ADD_RELATIONSHIP,
ADD_CLASS,
}
export enum DIAGRAM_RELATIONSHIP_EDIT_MODE {
// ASSOCIATION,
PROPERTY,
INHERITANCE,
NONE,
}
export enum DIAGRAM_ALIGNER_OPERATOR {
ALIGN_LEFT,
ALIGN_CENTER,
ALIGN_RIGHT,
ALIGN_TOP,
ALIGN_MIDDLE,
ALIGN_BOTTOM,
SPACE_HORIZONTALLY,
SPACE_VERTICALLY,
}
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: AbstractProperty): string =>
(property instanceof DerivedProperty ? '/ ' : '') + property.name;
export class DiagramRenderer {
diagram: Diagram;
isReadOnly: boolean;
enableLayoutAutoAdjustment: boolean;
div: HTMLDivElement;
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
// Rendering elements
canvasDimension: Rectangle; // dimension of the canvas, i.e. the dimension of the container element that hosts the canvas
canvasCenter: Point;
/**
* 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: PositionedRectangle;
/**
* 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: Point;
zoom: number;
// edit modes
// NOTE: we keep the edit mode separated like this
// becase we anticipate more complex interactions in the future
interactionMode: DIAGRAM_INTERACTION_MODE;
relationshipMode: DIAGRAM_RELATIONSHIP_EDIT_MODE;
// UML specific shapes
triangle: [Point, Point, Point];
diamond: [Point, Point, Point, Point];
// Font
fontFamily: string;
fontSize: number;
lineHeight: number;
// Wrap text
truncateText: boolean;
maxLineLength: number;
// TODO: we might want to do text wrapping as well
// Spacing
screenPadding: number; // the padding of the diagram (art board)
classViewSpaceX: number;
classViewSpaceY: number;
propertySpacing: number;
// Screen Grid (for debugging)
showScreenGrid: boolean; // show the screen coordinate system
showScreenBoxGuide: boolean; // show the screen border box and center
screenGridAxisTickLength: number;
screenGridSpacingX: number;
screenGridSpacingY: number;
screenGridLineWidth: number;
screenGridLineColor: string;
screenGridLabelTextColor: string;
screenGuideLineColor: string;
screenGuideLabelTextColor: string;
// Canvas Grid (for debugging)
showCanvasGrid: boolean; // show the canvas coordinate system
showCanvasBoxGuide: boolean; // show the canvas border box and center
showScreenOffsetGuide: boolean; // show the offset of the screen with respect to the canvas
canvasGridAxisTickLength: number;
canvasGridSpacingX: number;
canvasGridSpacingY: number;
canvasGridLineWidth: number;
canvasGridLineColor: string;
canvasGridLabelTextColor: string;
canvasGuideLineColor: string;
canvasGuideLabelTextColor: string;
screenOffsetGuideLineColor: string;
screenOffsetGuideLabelTextColor: string;
// Line
defaultLineWidth: number;
// Color
defaultLineColor: string;
canvasColor: string;
backgroundColor: string;
classViewFillColor: string;
classViewHeaderTextColor: string;
classViewPropertyTextColor: string;
classViewPrimitivePropertyTextColor: string;
classViewDerivedPropertyTextColor: string;
relationshipViewTextColor: string;
propertyViewOwnedDiamondColor: string;
propertyViewSharedDiamondColor: string;
generalizationViewInheritanceTriangeFillColor: string;
selectionBoxBorderColor: string;
// Selection
selection?: PositionedRectangle | undefined;
selectionStart?: Point | undefined;
selectedClassCorner?: ClassView | undefined; // the class view which we currently select the bottom right corner
selectedClassProperty?:
| { property: AbstractProperty; selectionPoint: Point }
| undefined;
selectedClasses: ClassView[];
selectedPropertyOrAssociation?: PropertyHolderView | undefined;
selectedInheritance?: GeneralizationView | undefined;
selectedPoint?: Point | undefined;
private _selectedClassesInitialPositions: {
classView: ClassView;
oldPos: Point;
}[];
// Relationship
startClassView?: ClassView | undefined;
handleAddRelationship?:
| ((start: ClassView, target: ClassView) => RelationshipView | undefined)
| undefined;
mouseOverClassCorner?: ClassView | undefined;
mouseOverClassName?: ClassView | undefined;
mouseOverClassView?: ClassView | undefined;
mouseOverClassProperty?: AbstractProperty | undefined;
mouseOverPropertyHolderViewLabel?: PropertyHolderView | undefined;
cursorPosition: Point;
callbacks?: DiagramRendererCallbacks | undefined;
leftClick: boolean;
middleClick: boolean;
rightClick: boolean;
clickX: number;
clickY: number;
positionBeforeLastMove: Point;
// interactions
onAddClassViewClick: (point: Point) => void = noop();
onClassViewRightClick: (classView: ClassView, point: Point) => void = noop();
onBackgroundDoubleClick?: ((point: Point) => void) | undefined;
onClassViewDoubleClick?:
| ((classView: ClassView, point: Point) => void)
| undefined;
onClassNameDoubleClick?:
| ((classView: ClassView, point: Point) => void)
| undefined;
onClassPropertyDoubleClick?:
| ((
property: AbstractProperty,
point: Point,
propertyHolderView: PropertyHolderView | undefined,
) => void)
| undefined;
handleEditClassView: (classView: ClassView) => void = noop();
handleEditProperty: (
property: AbstractProperty,
point: Point,
propertyHolderView: PropertyHolderView | undefined,
) => void = noop();
handleAddSimpleProperty: (classView: ClassView) => void = noop();
constructor(
div: HTMLDivElement,
diagram: Diagram,
callbacks?: DiagramRendererCallbacks,
) {
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;
this.callbacks = callbacks;
// 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') as CanvasRenderingContext2D;
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: boolean): void {
this.isReadOnly = val;
}
setEnableLayoutAutoAdjustment(val: boolean): void {
this.enableLayoutAutoAdjustment = val;
}
setMouseOverClassView(val: ClassView | undefined): void {
this.mouseOverClassView = val;
}
setMouseOverClassName(val: ClassView | undefined): void {
this.mouseOverClassName = val;
}
setMouseOverClassCorner(val: ClassView | undefined): void {
this.mouseOverClassCorner = val;
}
setMouseOverClassProperty(val: AbstractProperty | undefined): void {
this.mouseOverClassProperty = val;
}
setMouseOverPropertyHolderViewLabel(
val: PropertyHolderView | undefined,
): void {
this.mouseOverPropertyHolderViewLabel = val;
}
setSelectionStart(val: Point | undefined): void {
this.selectionStart = val;
}
setSelectedClassCorner(val: ClassView | undefined): void {
this.selectedClassCorner = val;
}
setSelectedClasses(val: ClassView[]): void {
this.selectedClasses = val;
}
setSelectedPropertyOrAssociation(val: PropertyHolderView | undefined): void {
this.selectedPropertyOrAssociation = val;
}
setSelectedInheritance(val: GeneralizationView | undefined): void {
this.selectedInheritance = val;
}
setLeftClick(val: boolean): void {
this.leftClick = val;
}
setMiddleClick(val: boolean): void {
this.middleClick = val;
}
setRightClick(val: boolean): void {
this.rightClick = val;
}
setZoomLevel(val: number): void {
this.zoom = val;
}
render(options?: { initial?: boolean | undefined }): void {
this.diagram.classViews.forEach((classView) =>
this.ensureClassViewMeetMinDimensions(classView),
);
// Auto-layout on initial render when all class views still sit at the origin,
// which indicates the diagram was just generated and needs re-positioning
if (
options?.initial &&
this.diagram.classViews.length > 1 &&
this.diagram.classViews.every(
(cv) => cv.position.x === 0 && cv.position.y === 0,
)
) {
layoutDiagram(this.diagram);
}
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(): void {
this.refreshCanvas();
this.drawScreen();
}
refreshCanvas(): void {
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(): void {
this.ctx.fillStyle = this.canvasColor;
this.ctx.fillRect(
0,
0,
this.canvasDimension.width,
this.canvasDimension.height,
);
}
private drawScreen(): void {
this.manageVirtualScreen();
this.clearScreen();
this.drawAll();
}
autoRecenter(): void {
this.center(
this.virtualScreen.position.x + this.virtualScreen.rectangle.width / 2,
this.virtualScreen.position.y + this.virtualScreen.rectangle.height / 2,
);
}
/**
* Reset the screen offset
*/
private center(x: number, y: number): void {
this.screenOffset = new Point(
-x + this.canvasCenter.x,
-y + this.canvasCenter.y,
);
this.refresh();
}
changeMode(
editMode: DIAGRAM_INTERACTION_MODE,
relationshipMode: DIAGRAM_RELATIONSHIP_EDIT_MODE,
): void {
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: ClassView,
targetClassView: ClassView,
): RelationshipView | undefined => {
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,
)
) {
this.callbacks?.onClass_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,
);
this.callbacks?.onDiagram_addGeneralizationView?.(
this.diagram,
gview,
);
return gview;
}
return undefined;
};
break;
}
case DIAGRAM_RELATIONSHIP_EDIT_MODE.PROPERTY: {
this.handleAddRelationship = (
startClassView: ClassView,
targetClassView: ClassView,
): PropertyView | undefined => {
const property = new Property(
`property_${startClassView.class.value.properties.length + 1}`,
Multiplicity.ONE,
GenericTypeExplicitReference.create(
new GenericType(targetClassView.class.value),
),
startClassView.class.value,
);
this.callbacks?.onClass_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,
);
this.callbacks?.onDiagram_addPropertyView?.(this.diagram, pView);
return pView;
}
return undefined;
};
break;
}
default:
throw new UnsupportedOperationError(
`Can't switch to relationship mode '${relationshipMode}': unsupported mode`,
);
}
}
}
align(op: DIAGRAM_ALIGNER_OPERATOR): void {
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) => {
this.callbacks?.onPositionedRectangle_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) =>
this.callbacks?.onPositionedRectangle_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) =>
this.callbacks?.onPositionedRectangle_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) =>
this.callbacks?.onPositionedRectangle_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) =>
this.callbacks?.onPositionedRectangle_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) =>
this.callbacks?.onPositionedRectangle_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] as ClassView;
const currentView = sorted[1] as ClassView;
this.callbacks?.onPositionedRectangle_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] as ClassView;
const currentView = sorted[idx] as ClassView;
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] as ClassView;
const currentView = sorted[idx] as ClassView;
this.callbacks?.onPositionedRectangle_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] as ClassView;
const currentView = sorted[1] as ClassView;
this.callbacks?.onPositionedRectangle_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] as ClassView;
const currentView = sorted[idx] as ClassView;
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] as ClassView;
const currentView = sorted[idx] as ClassView;
this.callbacks?.onPositionedRectangle_setPosition?.(
currentView,
new Point(
currentView.position.x,
previousView.position.y +
previousView.rectangle.height +
averageSpacing,
),
);
}
}
break;
}
default:
break;
}
this.drawScreen();
}
truncateTextWithEllipsis(val: string, limit = this.maxLineLength): string {
const ellipsis = '...';
return val.length > limit
? `${val.substring(0, limit + 1 - ellipsis.length)}${ellipsis}`
: val;
}
canvasCoordinateToModelCoordinate(point: Point): 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: Point): 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: MouseEvent): Point {
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: Point): Point {
return new Point(
point.x - this.divPosition.x + this.div.scrollLeft,
point.y - this.divPosition.y + this.div.scrollTop,
);
}
canvasCoordinateToEventCoordinate(point: Point): Point {
return new Point(
point.x - this.div.scrollLeft + this.divPosition.x,
point.y - this.div.scrollTop + this.divPosition.y,
);
}
hasPropertyView(classView: ClassView, property: AbstractProperty): boolean {
return (
this.diagram.propertyViews.filter(
(p) =>
p.property.value === property && p.from.classView.value === classView,
).length > 0
);
}
get divPosition(): Point {
return getElementPosition(this.div);
}
private manageVirtualScreen(): void {
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 as RelationshipView[]
)
.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.
*/
private executeZoom(newZoomLevel: number, point: Point): void {
// 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();
}
private zoomPoint(zoomLevel: number, zoomPoint: Point): void {
this.executeZoom(zoomLevel, zoomPoint);
}
zoomCenter(zoomLevel: number): void {
// NOTE: we cap the minimum zoom level to avoid negative zoom
this.setZoomLevel(Math.max(zoomLevel, MIN_ZOOM_LEVEL));
this.clearScreen();
this.drawAll();
}
zoomToFit(): void {
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: Class,
classViewModelCoordinate?: Point,
): ClassView | undefined {
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),
);
this.callbacks?.onPositionedRectangle_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,
),
),
);
this.callbacks?.onDiagram_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
this.callbacks?.onPositionedRectangle_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)
) {
this.callbacks?.onDiagram_addGeneralizationView?.(
this.diagram,
new GeneralizationView(this.diagram, newClassView, classView),
);
}
if (
_class.generalizations
.map((generalization) => generalization.value.rawType)
.includes(addedClass)
) {
this.callbacks?.onDiagram_addGeneralizationView?.(
this.diagram,
new GeneralizationView(this.diagram, classView, newClassView),
);
}
// Add property view
getAllOwnClassProperties(addedClass).forEach((property) => {
if (property.genericType.value.rawType === _class) {
this.callbacks?.onDiagram_addPropertyView?.(
this.diagram,
new PropertyView(
this.diagram,
PropertyExplicitReference.create(property),
newClassView,
classView,
),
);
}
});
getAllOwnClassProperties(_class).forEach((property) => {
if (property.genericType.value.rawType === addedClass) {
this.callbacks?.onDiagram_addPropertyView?.(
this.diagram,
new PropertyView(
this.diagram,
PropertyExplicitReference.create(property),
classView,
newClassView,
),
);
}
});
});
this.drawClassView(newClassView);
this.drawScreen();
return newClassView;
}
return undefined;
}
private drawBoundingBox(): void {
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,
);
}
private drawDiagram(): void {
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(): void {
this.drawBoundingBox();
this.drawDiagram();
}
private drawScreenGrid(): void {
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,
st