/**
* 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
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,
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;
}
private drawCanvasGrid(): void {
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(
startX + this.screenOffset.x * this.zoom,
startY + this.screenOffset.y * this.zoom,
offsetPointRadius * this.zoom,
0,
2 * Math.PI,
);
this.ctx.moveTo(
startX + (this.screenOffset.x - offsetPointRadius) * this.zoom,
startY + this.screenOffset.y * this.zoom,
);
this.ctx.lineTo(
startX + (this.screenOffset.x + offsetPointRadius * 2) * this.zoom,
startY + this.screenOffset.y * this.zoom,
);
this.ctx.moveTo(
startX + this.screenOffset.x * this.zoom,
startY + (this.screenOffset.y - offsetPointRadius) * this.zoom,
);
this.ctx.lineTo(
startX + this.screenOffset.x * this.zoom,
startY + (this.screenOffset.y + offsetPointRadius * 2) * this.zoom,
);
this.ctx.stroke();
// draw the offset distance
this.ctx.setLineDash([5, 5]);
this.ctx.moveTo(startX, startY + this.screenOffset.y * this.zoom);
this.ctx.lineTo(
startX + (this.screenOffset.x - offsetPointRadius) * this.zoom,
startY + this.screenOffset.y * this.zoom,
);
this.ctx.moveTo(startX + this.screenOffset.x * this.zoom, startY + 0);
this.ctx.lineTo(
startX + this.screenOffset.x * this.zoom,
startY + (this.screenOffset.y - offsetPointRadius) * this.zoom,
);
this.ctx.stroke();
this.ctx.beginPath();
if (
this.screenOffset.x >= thresholdToHideOffsetTickLabel &&
this.screenOffset.y >= thresholdToHideOffsetTickLabel
) {
this.ctx.fillText(
`[${Math.round(this.screenOffset.x)}]`,
startX + (this.screenOffset.x + labelPadding) * this.zoom,
startY + labelPadding * this.zoom,
);
this.ctx.fillText(
`[${Math.round(this.screenOffset.y)}]`,
startX + labelPadding * this.zoom,
startY + (this.screenOffset.y + labelPadding) * this.zoom,
);
} else if (this.screenOffset.x >= 0 && this.screenOffset.y >= 0) {
this.ctx.fillText(
`\u0394 [${Math.round(this.screenOffset.x)},${Math.round(
this.screenOffset.y,
)}]`,
startX +
(this.screenOffset.x + offsetPointRadius + labelPadding) *
this.zoom,
startY +
(this.screenOffset.y + offsetPointRadius + labelPadding) *
this.zoom,
);
} else {
this.ctx.fillText(
`\u0394 [${Math.round(this.screenOffset.x)},${Math.round(
this.screenOffset.y,
)}]`,
startX + (offsetPointRadius + labelPadding) * this.zoom,
startY + (offsetPointRadius + labelPadding) * this.zoom,
);
}
}
this.ctx.setLineDash([]); // return solid line
this.ctx.strokeStyle = this.defaultLineColor;
this.ctx.lineWidth = this.defaultLineWidth;
}
private drawClassViewProperty(
classView: ClassView,
property: AbstractProperty,
propX: number,
propY: number,
measureOnly: boolean,
): number {
this.ctx.font =
this.mouseOverClassView === classView &&
this.mouseOverClassProperty === property
? `bold ${(this.fontSize - 1) * (measureOnly ? 1 : this.zoom)}px ${
this.fontFamily
}`
: `${measureOnly ? 'bold' : ''} ${
(this.fontSize - 1) * (measureOnly ? 1 : this.zoom)
}px ${this.fontFamily}`;
const propertyName = getPropertyDisplayName(property);
let txtMeasure = this.ctx.measureText(`${propertyName} : `).width;
if (!measureOnly) {
this.ctx.fillText(`${propertyName} : `, propX, propY);
this.ctx.fillText(
property.genericType.value.rawType.name,
propX + txtMeasure,
propY,
);
// Draw Enumeration Property - with underscore
if (property.genericType.value.rawType instanceof Enumeration) {
this.ctx.beginPath();
this.ctx.moveTo(
propX + txtMeasure,
propY + (this.fontSize - 1) * this.zoom,
);
this.ctx.lineTo(
propX +
txtMeasure +
this.ctx.measureText(property.genericType.value.rawType.name).width,
propY + (this.fontSize - 1) * this.zoom,
);
this.ctx.stroke();
}
}
txtMeasure += this.ctx.measureText(
property.genericType.value.rawType.name,
).width;
this.ctx.font = `${(this.fontSize - 1) * (measureOnly ? 1 : this.zoom)}px ${
this.fontFamily
}`;
const multiplicityString = generateMultiplicityString(
property.multiplicity.lowerBound,
property.multiplicity.upperBound,
);
if (!measureOnly) {
this.ctx.fillText(`[${multiplicityString}]`, propX + txtMeasure, propY);
}
txtMeasure += this.ctx.measureText(`[${multiplicityString}]`).width;
return txtMeasure;
}
private computeClassNameWidth(classView: ClassView): number {
this.ctx.font = `bold ${this.fontSize}px ${this.fontFamily}`;
this.ctx.textBaseline = 'top'; // Compute min dimensions
// Calculate the box for the class name header
return this.ctx.measureText(
this.truncateTextWithEllipsis(classView.class.value.name),
).width;
}
ensureClassViewMeetMinDimensions(classView: ClassView): void {
// Calculate the box for the class name header
let classMinWidth = this.computeClassNameWidth(classView);
let classMinHeight = this.lineHeight + this.classViewSpaceY * 2; // padding top and bottom fo the header
// Calculate box for Stereotypes
if (!classView.hideStereotypes) {
this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
for (const stereotype of classView.class.value.stereotypes) {
const stereotypeTxt = `<< ${this.truncateTextWithEllipsis(
stereotype.value.value,
this.maxLineLength - 6,
)} >>`;
const stereoWidth = this.ctx.measureText(stereotypeTxt).width;
classMinWidth = Math.max(classMinWidth, stereoWidth);
classMinHeight = classMinHeight + this.lineHeight;
}
}
// Calculate box for TaggedValues
if (!classView.hideTaggedValues) {
this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
this.ctx.textBaseline = 'top'; // Compute min dimensions
for (const taggedValue of classView.class.value.taggedValues) {
const taggedValueTxt = `{ ${this.truncateTextWithEllipsis(
`${taggedValue.tag.value.value} = ${taggedValue.value}`,
this.maxLineLength - 4,
)} }`;
const taggedValueWidth = this.ctx.measureText(taggedValueTxt).width;
classMinWidth = Math.max(classMinWidth, taggedValueWidth);
classMinHeight = classMinHeight + this.lineHeight;
}
}
// Calculate box for properties
if (!classView.hideProperties) {
getAllOwnClassProperties(classView.class.value).forEach((property) => {
if (!this.hasPropertyView(classView, property)) {
const propertyTextMeasure = this.drawClassViewProperty(
classView,
property,
// these means nothing since we only need to measure here
0,
0,
true,
);
classMinWidth = Math.max(classMinWidth, propertyTextMeasure);
classMinHeight = classMinHeight + this.lineHeight;
}
});
classMinHeight = classMinHeight + this.classViewSpaceY * 2;
}
classMinWidth = classMinWidth + this.classViewSpaceX * 2;
// Modify the dimension according to the newly computed height and width
if (!this.isReadOnly || this.enableLayoutAutoAdjustment) {
const width =
classView.rectangle.width && classView.rectangle.width > classMinWidth
? classView.rectangle.width
: classMinWidth;
const height =
classView.rectangle.height &&
classView.rectangle.height > classMinHeight
? classView.rectangle.height
: classMinHeight;
this.callbacks?.onPositionedRectangle_setRectangle?.(
classView,
new Rectangle(width, height),
);
}
}
private drawClassView(classView: ClassView): void {
const classMinWidth = this.computeClassNameWidth(classView);
this.ensureClassViewMeetMinDimensions(classView);
this.ctx.fillStyle = this.classViewFillColor;
// Draw the Box
const position = this.modelCoordinateToCanvasCoordinate(classView.position);
this.ctx.fillRect(
position.x,
position.y,
classView.rectangle.width * this.zoom,
classView.rectangle.height * this.zoom,
);
this.ctx.lineWidth =
this.selectedClasses.length !== 0 &&
this.selectedClasses.indexOf(classView) !== -1
? 2
: 1;
this.ctx.strokeRect(
position.x,
position.y,
classView.rectangle.width * this.zoom,
classView.rectangle.height * this.zoom,
);
const startX = classView.position.x;
const startY = classView.position.y;
this.ctx.lineWidth = 1;
this.ctx.fillStyle = this.classViewHeaderTextColor;
let cursorY = startY;
this.ctx.font = `${this.fontSize * this.zoom}px ${this.fontFamily}`;
// Print Stereotypes
if (!classView.hideStereotypes) {
for (const stereotype of classView.class.value.stereotypes) {
this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
const stereotypeTxt = `<< ${this.truncateTextWithEllipsis(
stereotype.value.value,
this.maxLineLength - 6,
)} >>`;
const stereoWidth = this.ctx.measureText(stereotypeTxt).width;
this.ctx.font = `${this.fontSize * this.zoom}px ${this.fontFamily}`;
this.ctx.fillText(
stereotypeTxt,
(startX +
this.screenOffset.x +
(classView.rectangle.width - stereoWidth) / 2 -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x,
(cursorY +
this.screenOffset.y +
this.classViewSpaceY -
this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y,
);
cursorY = cursorY + this.lineHeight;
}
}
// Print Class Name
this.ctx.font = `bold ${this.fontSize * this.zoom}px ${this.fontFamily}`;
const classNameText = this.truncateTextWithEllipsis(
classView.class.value.name,
);
this.ctx.fillText(
classNameText,
(startX +
this.screenOffset.x +
(classView.rectangle.width - classMinWidth) / 2 -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x,
(cursorY +
this.screenOffset.y +
this.classViewSpaceY -
this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y,
);
cursorY = cursorY + this.lineHeight;
// Print Tagges Values
if (!classView.hideTaggedValues) {
for (const taggedValue of classView.class.value.taggedValues) {
this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
const taggedValueTxt = `{ ${this.truncateTextWithEllipsis(
`${taggedValue.tag.value.value} = ${taggedValue.value}`,
this.maxLineLength - 4,
)} }`;
const taggedValueWidth = this.ctx.measureText(taggedValueTxt).width;
this.ctx.font = `${this.fontSize * this.zoom}px ${this.fontFamily}`;
this.ctx.fillText(
taggedValueTxt,
(startX +
this.screenOffset.x +
(classView.rectangle.width - taggedValueWidth) / 2 -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x,
(cursorY +
this.screenOffset.y +
this.classViewSpaceY -
this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y,
);
cursorY = cursorY + this.lineHeight;
}
}
// 2 spaces (above and below for class name) and 1 space (above) for properties block
cursorY = cursorY + this.classViewSpaceY * 3;
// Draw properties
if (!classView.hideProperties) {
// Draw Line the line that separate header and property list
this.ctx.beginPath();
const yLineCoord =
(startY +
this.screenOffset.y +
this.lineHeight +
(!classView.hideStereotypes
? classView.class.value.stereotypes.length * this.lineHeight
: 0) +
(!classView.hideTaggedValues
? classView.class.value.taggedValues.length * this.lineHeight
: 0) +
this.classViewSpaceY -
this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y;
this.ctx.moveTo(
(startX + this.screenOffset.x - this.canvasCenter.x) * this.zoom +
this.canvasCenter.x,
yLineCoord,
);
this.ctx.lineTo(
(startX +
this.screenOffset.x +
classView.rectangle.width -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x,
yLineCoord,
);
this.ctx.stroke();
for (const property of getAllOwnClassProperties(classView.class.value)) {
if (!this.hasPropertyView(classView, property)) {
this.ctx.fillStyle =
property instanceof DerivedProperty
? this.classViewDerivedPropertyTextColor
: property.genericType.value.rawType instanceof PrimitiveType
? this.classViewPrimitivePropertyTextColor
: this.classViewPropertyTextColor;
const propX =
(startX +
this.screenOffset.x +
this.classViewSpaceX -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x;
const propY =
(cursorY + this.screenOffset.y - this.canvasCenter.y) * this.zoom +
this.canvasCenter.y;
this.drawClassViewProperty(classView, property, propX, propY, false);
cursorY = cursorY + this.lineHeight;
}
}
}
// NOTE: force hash reload when we redraw class view; this would help with cases where
// we auto add new properties to the class view, causing the box to expand, hence we need
// to recompute hash
this.callbacks?.onPositionedRectangle_forceRefreshHash?.(classView);
}
private drawLinePropertyNameAndMultiplicity(
property: AbstractProperty,
textPositionX: (n: number) => number,
textPositionY: (n: number) => number,
multiplicityPositionX: (n: number) => number,
multiplicityPositionY: (n: number) => number,
measureOnly: boolean,
): PositionedRectangle {
this.ctx.font = `${this.fontSize}px ${this.fontFamily}`;
const propertyName = getPropertyDisplayName(property);
const multiplictyString = generateMultiplicityString(
property.multiplicity.lowerBound,
property.multiplicity.upperBound,
);
const textSize = this.ctx.measureText(propertyName).width;
const mulSize = this.ctx.measureText(multiplictyString).width;
this.ctx.font = `${this.fontSize * this.zoom}px ${this.fontFamily}`;
const posX = textPositionX(textSize);
const posY = textPositionY(textSize);
const propertyPosition = this.modelCoordinateToCanvasCoordinate(
new Point(textPositionX(textSize), textPositionY(textSize)),
);
if (!measureOnly) {
this.ctx.fillText(propertyName, propertyPosition.x, propertyPosition.y);
}
const mulPosX = multiplicityPositionX(mulSize);
const mulPosY = multiplicityPositionY(mulSize);
const multiplicityPosition = this.modelCoordinateToCanvasCoordinate(
new Point(multiplicityPositionX(mulSize), multiplicityPositionY(mulSize)),
);
if (!measureOnly) {
this.ctx.fillText(
multiplictyString,
multiplicityPosition.x,
multiplicityPosition.y,
);
}
const position = new Point(
Math.min(posX, mulPosX),
Math.min(posY, mulPosY),
);
return new PositionedRectangle(
position,
new Rectangle(
Math.max(posX, mulPosX) + Math.max(textSize, mulSize) - position.x,
Math.max(posY, mulPosY) + this.lineHeight - position.y,
),
);
}
private drawLinePropertyText(
from: Point,
to: Point,
viewSide: ClassView,
property: AbstractProperty,
measureOnly: boolean,
): PositionedRectangle {
this.ctx.textBaseline = 'top';
this.ctx.fillStyle = this.relationshipViewTextColor;
const startX = from.x;
const startY = from.y;
const endX = to.x;
const endY = to.y;
const rect = viewSide.rectangle;
if (startY < endY) {
const x =
startX +
((endX - startX) / (endY - startY)) * (viewSide.position.y - startY);
if (x > viewSide.position.x && x < viewSide.position.x + rect.width) {
return this.drawLinePropertyNameAndMultiplicity(
property,
(textSize) =>
x < viewSide.position.x + rect.width / 2
? x + this.propertySpacing
: x - textSize - this.propertySpacing,
(textSize) =>
viewSide.position.y - this.lineHeight - this.propertySpacing,
(mulSize) =>
x < viewSide.position.x + rect.width / 2
? x - mulSize - this.propertySpacing
: x + this.propertySpacing,
(mulSize) =>
viewSide.position.y - this.lineHeight - this.propertySpacing,
measureOnly,
);
}
} else {
const x =
startX +
((endX - startX) / (endY - startY)) *
(viewSide.position.y + rect.height - startY);
if (x > viewSide.position.x && x < viewSide.position.x + rect.width) {
return this.drawLinePropertyNameAndMultiplicity(
property,
(textSize) =>
x < viewSide.position.x + rect.width / 2
? x + this.propertySpacing
: x - textSize - this.propertySpacing,
(textSize) =>
viewSide.position.y + rect.height + this.propertySpacing,
(mulSize) =>
x < viewSide.position.x + rect.width / 2
? x - mulSize - this.propertySpacing
: x + this.propertySpacing,
(mulSize) => viewSide.position.y + rect.height + this.propertySpacing,
measureOnly,
);
}
}
if (startX < endX) {
const y =
startY +
((endY - startY) / (endX - startX)) * (viewSide.position.x - startX);
if (y > viewSide.position.y && y < viewSide.position.y + rect.height) {
return this.drawLinePropertyNameAndMultiplicity(
property,
(textSize) => viewSide.position.x - textSize - this.propertySpacing,
(textSize) =>
y < viewSide.position.y + viewSide.rectangle.height / 2
? y + this.propertySpacing
: y - this.lineHeight - this.propertySpacing,
(mulSize) => viewSide.position.x - mulSize - this.propertySpacing,
(mulSize) =>
y < viewSide.position.y + viewSide.rectangle.height / 2
? y - this.lineHeight - this.propertySpacing
: y + this.propertySpacing,
measureOnly,
);
}
} else {
const y =
startY +
((endY - startY) / (endX - startX)) *
(viewSide.position.x + viewSide.rectangle.width - startX);
if (
y > viewSide.position.y &&
y < viewSide.position.y + viewSide.rectangle.height
) {
return this.drawLinePropertyNameAndMultiplicity(
property,
(textSize) =>
viewSide.position.x +
viewSide.rectangle.width +
this.propertySpacing,
(textSize) =>
y < viewSide.position.y + viewSide.rectangle.height / 2
? y + this.propertySpacing
: y - this.lineHeight - this.propertySpacing,
(mulSize) =>
viewSide.position.x +
viewSide.rectangle.width +
this.propertySpacing,
(mulSize) =>
y < viewSide.position.y + viewSide.rectangle.height / 2
? y - this.lineHeight - this.propertySpacing
: y + this.propertySpacing,
measureOnly,
);
}
}
return new PositionedRectangle(new Point(0, 0), new Rectangle(0, 0));
}
private drawPropertyOrAssociation(propertyView: PropertyView): void {
const fullPath = RelationshipView.pruneUnnecessaryInsidePoints(
propertyView.buildFullPath(),
propertyView.from.classView.value,
propertyView.to.classView.value,
);
// const toProperty = asso instanceof.property ? asso.property : asso.association.properties[1];
const toProperty = propertyView.property.value;
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',
),
propertyView.to.classView.value,
toProperty,
false,
);
// if (asso.association) {
// this.displayText(fullPath[1], fullPath[0], asso.from.classView, asso.association.properties[0], this.ctx);
// }
this.ctx.beginPath();
this.ctx.lineWidth =
propertyView === this.selectedPropertyOrAssociation ? 2 : 1;
fullPath.forEach((point, idx) => {
const position = this.modelCoordinateToCanvasCoordinate(point);
if (idx === 0) {
this.ctx.moveTo(position.x, position.y);
} else {
this.ctx.lineTo(position.x, position.y);
}
});
this.ctx.stroke();
this.ctx.lineWidth = 1;
if (toProperty instanceof Property) {
const startPoint = fullPath[1] as Point;
const endPoint = fullPath[0] as Point;
const startX = startPoint.x;
const startY = startPoint.y;
const endX = endPoint.x;
const endY = endPoint.y;
const to = propertyView.from;
let resultX = 0;
let resultY = 0;
if (endY > startY) {
const x =
startX +
((endX - startX) / (endY - startY)) *
(to.classView.value.position.y - startY);
if (
x > to.classView.value.position.x &&
x < to.classView.value.position.x + to.classView.value.rectangle.width
) {
resultX = (x - this.canvasCenter.x) * this.zoom + this.canvasCenter.x;
resultY =
(to.classView.value.position.y - this.canvasCenter.y) * this.zoom +
this.canvasCenter.y;
}
} else {
const x =
startX +
((endX - startX) / (endY - startY)) *
(to.classView.value.position.y +
to.classView.value.rectangle.height -
startY);
if (
x > to.classView.value.position.x &&
x < to.classView.value.position.x + to.classView.value.rectangle.width
) {
resultX = (x - this.canvasCenter.x) * this.zoom + this.canvasCenter.x;
resultY =
(to.classView.value.position.y +
to.classView.value.rectangle.height -
this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y;
}
}
if (endX > startX) {
const y =
startY +
((endY - startY) / (endX - startX)) *
(to.classView.value.position.x - startX);
if (
y > to.classView.value.position.y &&
y <
to.classView.value.position.y + to.classView.value.rectangle.height
) {
resultX =
(to.classView.value.position.x - this.canvasCenter.x) * this.zoom +
this.canvasCenter.x;
resultY = (y - this.canvasCenter.y) * this.zoom + this.canvasCenter.y;
}
} else {
const y =
startY +
((endY - startY) / (endX - startX)) *
(to.classView.value.position.x +
to.classView.value.rectangle.width -
startX);
if (
y > to.classView.value.position.y &&
y <
to.classView.value.position.y + to.classView.value.rectangle.height
) {
resultX =
(to.classView.value.position.x +
to.classView.value.rectangle.width -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x;
resultY = (y - this.canvasCenter.y) * this.zoom + this.canvasCenter.y;
}
}
if (
toProperty.aggregation === AggregationKind.COMPOSITE ||
toProperty.aggregation === AggregationKind.SHARED
) {
// Draw Diamond
let angle = Math.atan((endY - startY) / (endX - startX));
angle = endX >= startX ? angle : angle + Math.PI;
this.ctx.beginPath();
this.ctx.moveTo(
resultX +
(this.screenOffset.x + rotatePointX(this.diamond[0], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.diamond[0], angle)) *
this.zoom,
);
this.ctx.lineTo(
resultX +
(this.screenOffset.x + rotatePointX(this.diamond[1], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.diamond[1], angle)) *
this.zoom,
);
this.ctx.lineTo(
resultX +
(this.screenOffset.x + rotatePointX(this.diamond[2], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.diamond[2], angle)) *
this.zoom,
);
this.ctx.lineTo(
resultX +
(this.screenOffset.x + rotatePointX(this.diamond[3], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.diamond[3], angle)) *
this.zoom,
);
this.ctx.lineTo(
resultX +
(this.screenOffset.x + rotatePointX(this.diamond[0], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.diamond[0], angle)) *
this.zoom,
);
if (toProperty.aggregation === AggregationKind.SHARED) {
this.ctx.fillStyle = this.propertyViewSharedDiamondColor;
}
if (toProperty.aggregation === AggregationKind.COMPOSITE) {
this.ctx.fillStyle = this.propertyViewOwnedDiamondColor;
}
this.ctx.fill();
this.ctx.stroke();
this.ctx.lineWidth = 1;
}
}
}
private drawInheritance(inheritance: GeneralizationView): void {
const rect = inheritance.to.classView.value.rectangle;
const fullPath = RelationshipView.pruneUnnecessaryInsidePoints(
inheritance.buildFullPath(),
inheritance.from.classView.value,
inheritance.to.classView.value,
);
// NOTE: by the way we compute the full path, it would guarantee
// to always have at least 2 points
const startX = guaranteeNonNullable(
fullPath[fullPath.length - 2],
'Diagram path expected to have at least 2 points',
).x;
const startY = guaranteeNonNullable(
fullPath[fullPath.length - 2],
'Diagram path expected to have at least 2 points',
).y;
const endX = guaranteeNonNullable(
fullPath[fullPath.length - 1],
'Diagram path expected to have at least 2 points',
).x;
const endY = guaranteeNonNullable(
fullPath[fullPath.length - 1],
'Diagram path expected to have at least 2 points',
).y;
let resultX = 0;
let resultY = 0;
if (endY > startY) {
const x =
startX +
((endX - startX) / (endY - startY)) *
(inheritance.to.classView.value.position.y - startY);
if (
x > inheritance.to.classView.value.position.x &&
x < inheritance.to.classView.value.position.x + rect.width
) {
resultX = (x - this.canvasCenter.x) * this.zoom + this.canvasCenter.x;
resultY =
(inheritance.to.classView.value.position.y - this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y;
}
} else {
const x =
startX +
((endX - startX) / (endY - startY)) *
(inheritance.to.classView.value.position.y + rect.height - startY);
if (
x > inheritance.to.classView.value.position.x &&
x < inheritance.to.classView.value.position.x + rect.width
) {
resultX = (x - this.canvasCenter.x) * this.zoom + this.canvasCenter.x;
resultY =
(inheritance.to.classView.value.position.y +
rect.height -
this.canvasCenter.y) *
this.zoom +
this.canvasCenter.y;
}
}
if (endX > startX) {
const y =
startY +
((endY - startY) / (endX - startX)) *
(inheritance.to.classView.value.position.x - startX);
if (
y > inheritance.to.classView.value.position.y &&
y < inheritance.to.classView.value.position.y + rect.height
) {
resultX =
(inheritance.to.classView.value.position.x - this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x;
resultY = (y - this.canvasCenter.y) * this.zoom + this.canvasCenter.y;
}
} else {
const y =
startY +
((endY - startY) / (endX - startX)) *
(inheritance.to.classView.value.position.x + rect.width - startX);
if (
y > inheritance.to.classView.value.position.y &&
y < inheritance.to.classView.value.position.y + rect.height
) {
resultX =
(inheritance.to.classView.value.position.x +
rect.width -
this.canvasCenter.x) *
this.zoom +
this.canvasCenter.x;
resultY = (y - this.canvasCenter.y) * this.zoom + this.canvasCenter.y;
}
}
this.ctx.beginPath();
this.ctx.lineWidth = inheritance === this.selectedInheritance ? 2 : 1;
fullPath.forEach((point, idx) => {
const position = this.modelCoordinateToCanvasCoordinate(point);
if (idx === 0) {
this.ctx.moveTo(position.x, position.y);
} else {
this.ctx.lineTo(position.x, position.y);
}
});
this.ctx.stroke(); // Draw Triangle
let angle = Math.atan((endY - startY) / (endX - startX));
angle = endX >= startX ? angle : angle + Math.PI;
this.ctx.beginPath();
this.ctx.moveTo(
resultX +
(this.screenOffset.x + rotatePointX(this.triangle[0], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.triangle[0], angle)) *
this.zoom,
);
this.ctx.lineTo(
resultX +
(this.screenOffset.x + rotatePointX(this.triangle[1], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.triangle[1], angle)) *
this.zoom,
);
this.ctx.lineTo(
resultX +
(this.screenOffset.x + rotatePointX(this.triangle[2], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.triangle[2], angle)) *
this.zoom,
);
this.ctx.lineTo(
resultX +
(this.screenOffset.x + rotatePointX(this.triangle[0], angle)) *
this.zoom,
resultY +
(this.screenOffset.y + rotatePointY(this.triangle[0], angle)) *
this.zoom,
);
this.ctx.fillStyle = this.generalizationViewInheritanceTriangeFillColor;
this.ctx.fill();
this.ctx.stroke();
this.ctx.lineWidth = 1;
}
/**
* Reorder will move the class view to the top of the class view array of the diagram,
* This will bring it to front.
*/
private reorderDiagramDomain(
firstClass: ClassView,
diagram: Diagram,
): ClassView[] {
const newClasses = diagram.classViews.filter(
(classView) => classView !== firstClass,
);
newClasses.push(firstClass);
return newClasses;
}
/**
* Shift relationship views if both ends' classviews are moved.
*/
private potentiallyShiftRelationships(
relationshipViews: RelationshipView[],
selectedClasses: ClassView[],
newMovingDeltaX: number,
newMovingDeltaY: number,
): void {
relationshipViews.forEach((view) => {
if (
selectedClasses.indexOf(view.from.classView.value) !== -1 &&
selectedClasses.indexOf(view.to.classView.value) !== -1
) {
this.callbacks?.onRelationshipView_setPath?.(
view,
view.path.map(
(point) =>
new Point(point.x - newMovingDeltaX, point.y - newMovingDeltaY),
),
);
}
});
}
// -------------------------------- Actions ------------------------------
recenter(): void {
if (this.selectedClasses.length !== 0) {
const firstClass = this.selectedClasses[0];
if (firstClass) {
this.center(
firstClass.position.x + firstClass.rectangle.width / 2,
firstClass.position.y + firstClass.rectangle.height / 2,
);
}
} else {
this.autoRecenter();
}
}
switchToZoomMode(): void {
this.changeMode(
this.interactionMode !== DIAGRAM_INTERACTION_MODE.ZOOM_IN
? DIAGRAM_INTERACTION_MODE.ZOOM_IN
: DIAGRAM_INTERACTION_MODE.ZOOM_OUT,
DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE,
);
}
switchToViewMode(): void {
this.changeMode(
DIAGRAM_INTERACTION_MODE.LAYOUT,
DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE,
);
}
switchToPanMode(): void {
this.changeMode(
DIAGRAM_INTERACTION_MODE.PAN,
DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE,
);
}
switchToAddPropertyMode(): void {
if (!this.isReadOnly) {
this.changeMode(
DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP,
DIAGRAM_RELATIONSHIP_EDIT_MODE.PROPERTY,
);
}
}
switchToAddInheritanceMode(): void {
if (!this.isReadOnly) {
this.changeMode(
DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP,
DIAGRAM_RELATIONSHIP_EDIT_MODE.INHERITANCE,
);
}
}
switchToAddClassMode(): void {
if (!this.isReadOnly) {
this.changeMode(
DIAGRAM_INTERACTION_MODE.ADD_CLASS,
DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE,
);
}
}
ejectProperty(): void {
if (!this.isReadOnly) {
if (this.mouseOverClassProperty) {
if (
this.mouseOverClassProperty.genericType.value.rawType instanceof Class
) {
this.addClassView(
this.mouseOverClassProperty.genericType.value.rawType,
this.canvasCoordinateToModelCoordinate(
this.eventCoordinateToCanvasCoordinate(
new Point(this.cursorPosition.x, this.cursorPosition.y),
),
),
);
}
} else if (this.selectedClassProperty) {
if (
this.selectedClassProperty.property.genericType.value
.rawType instanceof Class
) {
this.addClassView(
this.selectedClassProperty.property.genericType.value.rawType,
this.selectedClassProperty.selectionPoint,
);
}
this.selectedClassProperty = undefined;
}
}
}
getSuperTypeLevels(
classViews: ClassView[],
diagram: Diagram,
currentDepth: number,
recurseMaxDepth: number,
): ClassView[][] {
if (classViews.length) {
const res = uniqBy(
classViews.flatMap((classView) =>
classView.class.value.generalizations.map(
(generation) =>
new ClassView(
diagram,
uuid(),
PackageableElementExplicitReference.create(
getRawGenericType(generation.value, Class),
),
),
),
),
(a: ClassView) => a.class.value,
);
res.forEach((classView) =>
this.ensureClassViewMeetMinDimensions(classView),
);
if (recurseMaxDepth === -1 || currentDepth < recurseMaxDepth) {
const rec = this.getSuperTypeLevels(
res,
diagram,
currentDepth + 1,
recurseMaxDepth,
);
rec.push(classViews);
return rec;
} else {
return [classViews];
}
}
return [];
}
layoutTaxonomy(
classViewLevels: ClassView[][],
diagram: Diagram,
positionInitialClass: boolean,
superType: boolean,
): [ClassView[], GeneralizationView[]] {
// Offsets
const spaceY = 30;
const spaceX = 10;
classViewLevels.reverse();
const classViews = classViewLevels.flatMap((levels, currentLevelIndex) => {
// Get the bounding box of the precedent level
let precedentTotalWidth = 0;
let precedentTotalHeight = 0;
let precedentX = 0;
let precedentY = 0;
if (currentLevelIndex > 0) {
const precedent = classViewLevels[currentLevelIndex - 1] as ClassView[];
if (precedent.length) {
const precedentByX = [...precedent].sort(
(a, b) => a.position.x - b.position.x,
);
precedentX = (precedentByX[0] as ClassView).position.x;
precedentTotalWidth =
(precedentByX[precedentByX.length - 1] as ClassView).position.x +
(precedentByX[precedentByX.length - 1] as ClassView).rectangle
.width -
(precedentByX[0] as ClassView).position.x;
const precedentByY = [...precedent].sort(
(a, b) => a.position.y - b.position.y,
);
precedentY = (precedentByY[0] as ClassView).position.y;
precedentTotalHeight =
(precedentByY[precedentByY.length - 1] as ClassView).position.y +
(precedentByY[precedentByY.length - 1] as ClassView).rectangle
.height -
(precedentByY[0] as ClassView).position.y;
}
}
// Get the bounding box of current Level
const maxHeight = Math.max(
...levels.map((classView) => classView.rectangle.height),
);
const totalWidth = levels
.map((classView) => classView.rectangle.width)
.reduce((a, b) => a + b + spaceX);
// Get the starting position
const startX = precedentX + precedentTotalWidth / 2 - totalWidth / 2;
const currentLevelY = superType
? precedentY - maxHeight - spaceY
: precedentY + precedentTotalHeight + spaceY;
// Set layout of current level
if (positionInitialClass || currentLevelIndex > 0) {
this.callbacks?.onPositionedRectangle_setPosition?.(
levels[0] as ClassView,
new Point(startX, currentLevelY),
);
levels.forEach((view, idx) => {
if (idx > 0) {
const precedent = levels[idx - 1] as ClassView;
this.callbacks?.onPositionedRectangle_setPosition?.(
view,
new Point(
precedent.position.x + precedent.rectangle.width + spaceX,
currentLevelY,
),
);
}
});
}
return levels;
});
const generalizationViews = (
superType ? classViewLevels : classViewLevels.slice().reverse()
)
.slice(0, classViewLevels.length - 1)
.flatMap((level, idx) =>
level.flatMap((fromClassView) =>
(classViewLevels[idx + 1] as ClassView[]).flatMap((toClassView) => {
if (
fromClassView.class.value.generalizations
.map((g) => g.value.rawType)
.includes(toClassView.class.value)
) {
return new GeneralizationView(
diagram,
fromClassView,
toClassView,
);
}
return [];
}),
),
);
return [classViews, generalizationViews];
}
// --------------------------------- Event handler --------------------------
keydown(e: KeyboardEvent): void {
// Remove selected view(s)
if ('Delete' === e.code) {
if (!this.isReadOnly) {
this.selectedClasses.forEach((classView) => {
this.callbacks?.onDiagram_deleteClassView?.(this.diagram, classView);
this.callbacks?.onDiagram_setAssociationViews?.(
this.diagram,
this.diagram.associationViews.filter(
(associationView) =>
!(
associationView.from.classView.value === classView ||
associationView.to.classView.value === classView
),
),
);
this.callbacks?.onDiagram_setGeneralizationViews?.(
this.diagram,
this.diagram.generalizationViews.filter(
(generalizationView) =>
!(
generalizationView.from.classView.value === classView ||
generalizationView.to.classView.value === classView
),
),
);
this.callbacks?.onDiagram_setPropertyViews?.(
this.diagram,
this.diagram.propertyViews.filter(
(propertyView) =>
!(
propertyView.from.classView.value === classView ||
propertyView.to.classView.value === classView
),
),
);
});
if (this.selectedPropertyOrAssociation instanceof AssociationView) {
this.callbacks?.onDiagram_deleteAssociationView?.(
this.diagram,
this.selectedPropertyOrAssociation,
);
} else if (this.selectedPropertyOrAssociation instanceof PropertyView) {
this.callbacks?.onDiagram_deletePropertyView?.(
this.diagram,
this.selectedPropertyOrAssociation,
);
}
if (this.selectedInheritance) {
if (
this.diagram.generalizationViews.find(
(generalizationView) =>
generalizationView === this.selectedInheritance,
)
) {
this.callbacks?.onDiagram_deleteGeneralizationView?.(
this.diagram,
this.selectedInheritance,
);
}
}
this.setSelectedClasses([]);
this.drawScreen();
}
}
// Edit selected view
// NOTE: since the current behavior when editing property is to immediately
// focus on the property name input when the inline editor pops up
// we need to call `preventDefault` to avoid typing `e` in the property name input
else if ('KeyE' === e.code) {
if (this.selectedClassProperty) {
this.handleEditProperty(
this.selectedClassProperty.property,
this.selectedClassProperty.selectionPoint,
undefined,
);
e.preventDefault();
} else if (this.selectedPropertyOrAssociation) {
// TODO: we might want to revise this to allow edit property holder view
// on the side panel instead of showing the inline property editor
this.handleEditProperty(
this.selectedPropertyOrAssociation.property.value,
this.selectedPoint ??
(this.selectedPropertyOrAssociation.path.length
? (this.selectedPropertyOrAssociation.path[0] as Point)
: this.selectedPropertyOrAssociation.from.classView.value.center()),
this.selectedPropertyOrAssociation,
);
e.preventDefault();
} else if (this.selectedClasses.length === 1) {
this.handleEditClassView(this.selectedClasses[0] as ClassView);
}
}
// Hide/show properties for selected element(s)
else if (e.altKey && 'KeyP' === e.code) {
if (!this.isReadOnly) {
if (this.selectedClasses.length !== 0) {
this.selectedClasses.forEach((classView) => {
this.callbacks?.onClassView_setHideProperties?.(
classView,
!classView.hideProperties,
);
});
this.drawScreen();
}
}
}
// Hide/show stereotypes for selected element(s)
else if (e.altKey && 'KeyS' === e.code) {
if (!this.isReadOnly) {
if (this.selectedClasses.length !== 0) {
this.selectedClasses.forEach((classView) => {
this.callbacks?.onClassView_setHideStereotypes?.(
classView,
!classView.hideStereotypes,
);
});
this.drawScreen();
}
}
}
// Hide/show tagged values for selected element(s)
else if (e.altKey && 'KeyT' === e.code) {
if (!this.isReadOnly) {
if (this.selectedClasses.length !== 0) {
this.selectedClasses.forEach((classView) => {
this.callbacks?.onClassView_setHideTaggedValues?.(
classView,
!classView.hideTaggedValues,
);
});
this.drawScreen();
}
}
}
// Add a new simple property to selected class
else if (e.altKey && 'ArrowDown' === e.code) {
if (!this.isReadOnly && this.selectedClasses.length === 1) {
this.handleAddSimpleProperty(this.selectedClasses[0] as ClassView);
}
}
// Add supertypes of selected classes to the diagram
else if ('ArrowUp' === e.code) {
const views = this.getSuperTypeLevels(
this.selectedClasses,
this.diagram,
0,
1,
);
const res = this.layoutTaxonomy(views, this.diagram, false, true);
res[0].forEach((cv) => {
this.callbacks?.onDiagram_addClassView?.(this.diagram, cv);
});
res[1].forEach((gv) => {
this.callbacks?.onDiagram_addGeneralizationView?.(this.diagram, gv);
});
this.drawScreen();
}
// Add subtypes of selected classes to the diagram
else if ('ArrowDown' === e.code) {
const views = uniqBy(
this.selectedClasses.flatMap((x) =>
x.class.value._subclasses.flatMap(
(c) =>
new ClassView(
this.diagram,
uuid(),
PackageableElementExplicitReference.create(c),
),
),
),
(cv) => cv.class.value,
);
if (views.length > 0) {
views.forEach((classView) =>
this.ensureClassViewMeetMinDimensions(classView),
);
const res = this.layoutTaxonomy(
[views, this.selectedClasses],
this.diagram,
false,
false,
);
res[0].forEach((cv) => {
this.callbacks?.onDiagram_addClassView?.(this.diagram, cv);
});
res[1].forEach((gv) => {
this.callbacks?.onDiagram_addGeneralizationView?.(this.diagram, gv);
});
}
this.drawScreen();
}
}
mouseup(e: MouseEvent): void {
const mouseEventCoordinate = this.resolveMouseEventCoordinate(e);
switch (this.interactionMode) {
case DIAGRAM_INTERACTION_MODE.ZOOM_IN: {
// Rounding up the current zoom level to make sure floating point precision
// does not come into play when comparing with recommended zoom levels:
// e.g. in Javascript, 110 === 110.000000000000001
const currentZoomLevel = Math.round(this.zoom * 100);
let nextZoomLevel: number;
// NOTE: below the smallest recommended zoom level, we will start decrement by 10
// and increment by 100 beyond the largest recommended zoom level.
if (currentZoomLevel <= (DIAGRAM_ZOOM_LEVELS[0] as number) - 10) {
nextZoomLevel = Math.floor(currentZoomLevel / 10) * 10 + 10;
} else if (
currentZoomLevel >=
(DIAGRAM_ZOOM_LEVELS[DIAGRAM_ZOOM_LEVELS.length - 1] as number)
) {
nextZoomLevel = Math.floor(currentZoomLevel / 100) * 100 + 100;
} else {
nextZoomLevel = guaranteeNonNullable(
DIAGRAM_ZOOM_LEVELS.find(
(zoomLevel) => zoomLevel > currentZoomLevel,
),
);
}
this.zoomPoint(
nextZoomLevel / 100,
this.canvasCoordinateToModelCoordinate(
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate),
),
);
break;
}
case DIAGRAM_INTERACTION_MODE.ZOOM_OUT: {
const currentZoomLevel = Math.round(this.zoom * 100);
let nextZoomLevel: number;
if (currentZoomLevel <= (DIAGRAM_ZOOM_LEVELS[0] as number)) {
nextZoomLevel = Math.ceil(currentZoomLevel / 10) * 10 - 10;
} else if (
currentZoomLevel >=
(DIAGRAM_ZOOM_LEVELS[DIAGRAM_ZOOM_LEVELS.length - 1] as number) + 100
) {
nextZoomLevel = Math.ceil(currentZoomLevel / 100) * 100 - 100;
} else {
nextZoomLevel = guaranteeNonNullable(
findLast(
DIAGRAM_ZOOM_LEVELS,
(zoomLevel) => zoomLevel < currentZoomLevel,
),
);
}
this.zoomPoint(
nextZoomLevel / 100,
this.canvasCoordinateToModelCoordinate(
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate),
),
);
break;
}
default:
break;
}
if (!this.isReadOnly) {
switch (this.interactionMode) {
case DIAGRAM_INTERACTION_MODE.LAYOUT: {
this.diagram.generalizationViews.forEach((generalizationView) => {
this.callbacks?.onRelationshipView_simplifyPath?.(
generalizationView,
);
});
this.diagram.associationViews.forEach((associationView) => {
this.callbacks?.onRelationshipView_simplifyPath?.(associationView);
});
this.diagram.propertyViews.forEach((propertyView) => {
this.callbacks?.onRelationshipView_simplifyPath?.(propertyView);
});
break;
}
case DIAGRAM_INTERACTION_MODE.ADD_CLASS: {
const eventPointInModelCoordinate =
this.canvasCoordinateToModelCoordinate(
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate),
);
this.onAddClassViewClick(eventPointInModelCoordinate);
this.changeMode(
DIAGRAM_INTERACTION_MODE.LAYOUT,
DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE,
);
break;
}
case DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP: {
if (
this.startClassView &&
this.selectionStart &&
this.handleAddRelationship
) {
const eventPointInModelCoordinate =
this.canvasCoordinateToModelCoordinate(
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate),
);
for (let i = this.diagram.classViews.length - 1; i >= 0; i--) {
const targetClassView = this.diagram.classViews[i] as ClassView;
if (
targetClassView.contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
)
) {
const gview = this.handleAddRelationship(
this.startClassView,
targetClassView,
);
if (gview) {
this.callbacks?.onRelationshipEdgeView_setOffsetX?.(
gview.from,
-(
this.startClassView.position.x +
this.startClassView.rectangle.width / 2 -
this.selectionStart.x
),
);
this.callbacks?.onRelationshipEdgeView_setOffsetY?.(
gview.from,
-(
this.startClassView.position.y +
this.startClassView.rectangle.height / 2 -
this.selectionStart.y
),
);
this.callbacks?.onRelationshipEdgeView_setOffsetX?.(
gview.to,
-(
targetClassView.position.x +
targetClassView.rectangle.width / 2 -
eventPointInModelCoordinate.x
),
);
this.callbacks?.onRelationshipEdgeView_setOffsetY?.(
gview.to,
-(
targetClassView.position.y +
targetClassView.rectangle.height / 2 -
eventPointInModelCoordinate.y
),
);
}
}
}
this.changeMode(
DIAGRAM_INTERACTION_MODE.LAYOUT,
DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE,
);
}
break;
}
default:
break;
}
}
this.setLeftClick(false);
this.setMiddleClick(false);
this.setRightClick(false);
this.setSelectedClassCorner(undefined);
this.setSelectionStart(undefined);
this.drawScreen();
}
mousedblclick(e: MouseEvent): void {
const mouseEventCoordinate = this.resolveMouseEventCoordinate(e);
if (
[
DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP,
DIAGRAM_INTERACTION_MODE.ADD_CLASS,
DIAGRAM_INTERACTION_MODE.ZOOM_IN,
DIAGRAM_INTERACTION_MODE.ZOOM_OUT,
].includes(this.interactionMode)
) {
return;
}
const eventPointInModelCoordinate = this.canvasCoordinateToModelCoordinate(
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate),
);
// Check double click on class property
if (this.mouseOverClassProperty) {
if (this.onClassPropertyDoubleClick) {
this.onClassPropertyDoubleClick(
this.mouseOverClassProperty,
eventPointInModelCoordinate,
undefined,
);
return;
}
}
// Check double click on class name
if (this.mouseOverClassName) {
if (this.onClassNameDoubleClick) {
this.onClassNameDoubleClick(
this.mouseOverClassName,
eventPointInModelCoordinate,
);
return;
}
}
// Check double click on class view
const selectedClass = this.diagram.classViews.find((classView) =>
classView.contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
),
);
if (selectedClass) {
if (this.onClassViewDoubleClick) {
this.onClassViewDoubleClick(selectedClass, eventPointInModelCoordinate);
return;
}
}
// Check double click on line property label
if (this.mouseOverPropertyHolderViewLabel) {
if (this.onClassPropertyDoubleClick) {
this.onClassPropertyDoubleClick(
this.mouseOverPropertyHolderViewLabel.property.value,
eventPointInModelCoordinate,
this.mouseOverPropertyHolderViewLabel,
);
return;
}
}
// Check double click on background
if (this.onBackgroundDoubleClick) {
this.onBackgroundDoubleClick(eventPointInModelCoordinate);
}
}
mousedown(e: MouseEvent): void {
const mouseEventCoordinate = this.resolveMouseEventCoordinate(e);
this.setSelectionStart(undefined);
this.setSelectedClassCorner(undefined);
this.setSelectedPropertyOrAssociation(undefined);
this.setSelectedInheritance(undefined);
this.selection = undefined;
this.selectedClassProperty = undefined;
this.selectedPoint = undefined;
this.startClassView = undefined;
const eventPointInCanvasCoordinate =
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate);
const eventPointInModelCoordinate = this.canvasCoordinateToModelCoordinate(
eventPointInCanvasCoordinate,
);
// left click
if (e.button === 0) {
this.setLeftClick(true);
switch (this.interactionMode) {
case DIAGRAM_INTERACTION_MODE.PAN: {
this.positionBeforeLastMove = mouseEventCoordinate;
return;
}
case DIAGRAM_INTERACTION_MODE.LAYOUT: {
// Check if the selection lies within the bottom right corner box of a box (so we can do resize of box here)
// NOTE: Traverse backwards the class views to preserve z-index buffer
for (let i = this.diagram.classViews.length - 1; i >= 0; i--) {
const targetClassView = this.diagram.classViews[i] as ClassView;
if (
buildBottomRightCornerBox(targetClassView).contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
)
) {
this.setSelectedClasses([]);
this.setSelectedClassCorner(this.diagram.classViews[i]);
if (!this.isReadOnly) {
// Bring the class view to front
this.callbacks?.onDiagram_setClassViews?.(
this.diagram,
this.reorderDiagramDomain(
guaranteeNonNullable(this.selectedClassCorner),
this.diagram,
),
);
}
break;
}
}
if (!this.selectedClassCorner) {
if (this.mouseOverClassProperty) {
// Check for selection of property within a class view
this.selectedClassProperty = {
property: this.mouseOverClassProperty,
selectionPoint: eventPointInModelCoordinate,
};
this.setSelectedClasses([]);
} else {
// Check for selection of class view(s)
let anyClassesSelected = false;
// Traverse backwards the class views to preserve z-index buffer
for (let i = this.diagram.classViews.length - 1; i >= 0; i--) {
const targetClassView = this.diagram.classViews[i] as ClassView;
if (
targetClassView.contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
)
) {
if (
this.selectedClasses.length === 0 ||
this.selectedClasses.indexOf(targetClassView) === -1
) {
this.setSelectedClasses([targetClassView]);
}
if (!this.isReadOnly) {
// Bring the class view to front
this.callbacks?.onDiagram_setClassViews?.(
this.diagram,
this.reorderDiagramDomain(
this.selectedClasses[0] as ClassView,
this.diagram,
),
);
}
this.clickX =
eventPointInCanvasCoordinate.x / this.zoom -
this.screenOffset.x;
this.clickY =
eventPointInCanvasCoordinate.y / this.zoom -
this.screenOffset.y;
// Set this here so we can keep moving the classviews
// NOTE: in the past we tried to reset this every time after we reset `this.selectedClasses`
// and that causes the selected classviews janks and jumps to a weird position during zoom.
this._selectedClassesInitialPositions =
this.selectedClasses.map((cv) => ({
classView: cv,
oldPos: new Point(cv.position.x, cv.position.y),
}));
anyClassesSelected = true;
break;
}
}
if (!anyClassesSelected) {
this.setSelectedClasses([]);
}
if (!this.selectedClasses.length) {
// NOTE: we start checking for the selected point to decide
// whether or not to set a selection (selected inheritance view, property view, etc.)
// the order really matters here as each selection does set the selection point
// which causes the next selection to not happen
// check for selection of inheritance view
for (const generalizationView of this.diagram
.generalizationViews) {
const val = this.callbacks?.onFindOrBuildPoint?.(
generalizationView,
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
this.zoom,
!this.isReadOnly,
);
if (val) {
this.selectedPoint = val;
this.setSelectedInheritance(generalizationView);
break;
}
}
// check for selection of association view
if (!this.selectedPoint) {
for (const associationView of this.diagram.associationViews) {
const val = this.callbacks?.onFindOrBuildPoint?.(
associationView,
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
this.zoom,
!this.isReadOnly,
);
if (val) {
this.selectedPoint = val;
this.setSelectedPropertyOrAssociation(associationView);
break;
}
}
}
// check for selection of property view
if (!this.selectedPoint) {
for (const propertyView of this.diagram.propertyViews) {
const val = this.callbacks?.onFindOrBuildPoint?.(
propertyView,
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
this.zoom,
!this.isReadOnly,
);
if (val) {
this.selectedPoint = val;
this.setSelectedPropertyOrAssociation(propertyView);
break;
}
}
}
// if the selected point is not identified then it is consider the start of a selection
if (!this.selectedPoint) {
this.setSelectionStart(eventPointInModelCoordinate);
}
}
}
}
break;
}
case DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP: {
this.setSelectionStart(eventPointInModelCoordinate);
this.startClassView = undefined;
for (let i = this.diagram.classViews.length - 1; i >= 0; i--) {
const targetClassView = this.diagram.classViews[i] as ClassView;
if (
targetClassView.contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
)
) {
this.startClassView = targetClassView;
}
}
if (!this.startClassView) {
this.changeMode(
DIAGRAM_INTERACTION_MODE.LAYOUT,
DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE,
);
}
break;
}
default:
break;
}
}
// middle click
else if (e.button === 1) {
this.setMiddleClick(true);
this.positionBeforeLastMove = mouseEventCoordinate;
return;
}
// right click
else if (e.button === 2) {
this.setRightClick(true);
this.positionBeforeLastMove = mouseEventCoordinate;
if (this.mouseOverClassView) {
this.onClassViewRightClick(
this.mouseOverClassView,
eventPointInModelCoordinate,
);
this.setRightClick(false);
}
return;
}
this.clearScreen();
this.drawAll();
}
mousewheel(e: WheelEvent): void {
const mouseEventCoordinate = this.resolveMouseEventCoordinate(e);
// no scrolling to be done since we want to convert scroll into zoom
e.preventDefault();
e.stopImmediatePropagation();
// if scrolling is more horizontal than vertical, i.e. zoom using trackpad
// we don't want to treat this as zooming
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) {
return;
}
// scroll down to zoom in and up to zoom out
const newZoomLevel = this.zoom - (e.deltaY / 120) * 0.05;
this.executeZoom(
newZoomLevel,
this.canvasCoordinateToModelCoordinate(
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate),
),
);
}
mousemove(e: MouseEvent): void {
const mouseEventCoordinate = this.resolveMouseEventCoordinate(e);
this.cursorPosition = mouseEventCoordinate;
// Pan/Move
if (
this.rightClick ||
this.middleClick ||
(this.leftClick && this.interactionMode === DIAGRAM_INTERACTION_MODE.PAN)
) {
this.screenOffset = new Point(
this.screenOffset.x +
(mouseEventCoordinate.x - this.positionBeforeLastMove.x) / this.zoom,
this.screenOffset.y +
(mouseEventCoordinate.y - this.positionBeforeLastMove.y) / this.zoom,
);
this.positionBeforeLastMove = mouseEventCoordinate;
this.clearScreen();
this.drawAll();
} else if (this.leftClick) {
const eventPointInCanvasCoordinate =
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate);
const eventPointInModelCoordinate =
this.canvasCoordinateToModelCoordinate(eventPointInCanvasCoordinate);
switch (this.interactionMode) {
case DIAGRAM_INTERACTION_MODE.LAYOUT: {
// Resize class view
if (this.selectedClassCorner) {
// Make sure width and height are in range!
this.callbacks?.onPositionedRectangle_setRectangle?.(
this.selectedClassCorner,
new Rectangle(
eventPointInModelCoordinate.x -
this.selectedClassCorner.position.x,
eventPointInModelCoordinate.y -
this.selectedClassCorner.position.y,
),
);
// Refresh hash since ClassView rectangle is not observable
this.callbacks?.onPositionedRectangle_forceRefreshHash?.(
this.selectedClassCorner,
);
this.drawClassView(this.selectedClassCorner);
this.drawScreen();
}
// Move class view
if (!this.selectionStart && this.selectedClasses.length) {
if (!this.isReadOnly) {
let newMovingDeltaX = 0;
let newMovingDeltaY = 0;
this.selectedClasses.forEach((selectedClass, idx) => {
const selectedClassOldPosition =
this._selectedClassesInitialPositions.length > idx
? this._selectedClassesInitialPositions[idx]
: undefined;
if (selectedClassOldPosition) {
const newMovingX =
eventPointInCanvasCoordinate.x / this.zoom -
this.screenOffset.x -
(this.clickX - selectedClassOldPosition.oldPos.x);
const newMovingY =
eventPointInCanvasCoordinate.y / this.zoom -
this.screenOffset.y -
(this.clickY - selectedClassOldPosition.oldPos.y);
newMovingDeltaX = selectedClass.position.x - newMovingX;
newMovingDeltaY = selectedClass.position.y - newMovingY;
this.callbacks?.onPositionedRectangle_setPosition?.(
selectedClass,
new Point(newMovingX, newMovingY),
);
// Refresh hash since ClassView position is not observable
this.callbacks?.onPositionedRectangle_forceRefreshHash?.(
selectedClass,
);
}
});
this.potentiallyShiftRelationships(
this.diagram.associationViews,
this.selectedClasses,
newMovingDeltaX,
newMovingDeltaY,
);
this.potentiallyShiftRelationships(
this.diagram.propertyViews,
this.selectedClasses,
newMovingDeltaX,
newMovingDeltaY,
);
this.potentiallyShiftRelationships(
this.diagram.generalizationViews,
this.selectedClasses,
newMovingDeltaX,
newMovingDeltaY,
);
this.drawScreen();
}
}
// Change line (add a new point to the line)
if (this.selectedPoint) {
if (this.selectedPropertyOrAssociation) {
this.callbacks?.onRelationshipView_changePoint?.(
this.selectedPropertyOrAssociation,
this.selectedPoint,
eventPointInModelCoordinate,
);
} else if (this.selectedInheritance) {
this.callbacks?.onRelationshipView_changePoint?.(
this.selectedInheritance,
this.selectedPoint,
eventPointInModelCoordinate,
);
}
this.selectedPoint = eventPointInModelCoordinate;
this.drawScreen();
}
// Draw selection box
if (this.selectionStart) {
this.clearScreen();
this.drawAll();
const selectionStartPointInCanvasCoordinate =
this.modelCoordinateToCanvasCoordinate(this.selectionStart);
this.ctx.fillStyle = this.selectionBoxBorderColor;
this.ctx.fillRect(
selectionStartPointInCanvasCoordinate.x,
selectionStartPointInCanvasCoordinate.y,
eventPointInCanvasCoordinate.x -
selectionStartPointInCanvasCoordinate.x,
eventPointInCanvasCoordinate.y -
selectionStartPointInCanvasCoordinate.y,
);
this.ctx.strokeRect(
selectionStartPointInCanvasCoordinate.x,
selectionStartPointInCanvasCoordinate.y,
eventPointInCanvasCoordinate.x -
selectionStartPointInCanvasCoordinate.x,
eventPointInCanvasCoordinate.y -
selectionStartPointInCanvasCoordinate.y,
);
const selectionBoxWidth =
(eventPointInCanvasCoordinate.x -
selectionStartPointInCanvasCoordinate.x) /
this.zoom;
const selectionBoxHeight =
(eventPointInCanvasCoordinate.y -
selectionStartPointInCanvasCoordinate.y) /
this.zoom;
this.selection = new PositionedRectangle(
new Point(
selectionBoxWidth > 0
? this.selectionStart.x
: this.selectionStart.x + selectionBoxWidth,
selectionBoxHeight > 0
? this.selectionStart.y
: this.selectionStart.y + selectionBoxHeight,
),
new Rectangle(
Math.abs(selectionBoxWidth),
Math.abs(selectionBoxHeight),
),
);
this.setSelectedClasses([]);
for (const classView of this.diagram.classViews) {
if (
boxContains(this.selection, classView) ||
boxContains(classView, this.selection)
) {
this.setSelectedClasses([...this.selectedClasses, classView]);
}
}
}
break;
}
case DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP: {
if (this.selectionStart && this.startClassView) {
this.clearScreen();
this.drawBoundingBox();
const selectionStartPointInCanvasCoordinate =
this.modelCoordinateToCanvasCoordinate(this.selectionStart);
// Draw Line ------
this.ctx.moveTo(
selectionStartPointInCanvasCoordinate.x,
selectionStartPointInCanvasCoordinate.y,
);
this.ctx.lineTo(
eventPointInCanvasCoordinate.x,
eventPointInCanvasCoordinate.y,
);
this.ctx.stroke();
// Draw Line ------
this.drawDiagram();
}
break;
}
default:
break;
}
} else {
this.drawScreen();
const eventPointInCanvasCoordinate =
this.eventCoordinateToCanvasCoordinate(mouseEventCoordinate);
const eventPointInModelCoordinate =
this.canvasCoordinateToModelCoordinate(eventPointInCanvasCoordinate);
// Check for hovering state
this.setMouseOverClassView(undefined);
this.setMouseOverClassName(undefined);
this.setMouseOverClassCorner(undefined);
this.setMouseOverClassProperty(undefined);
this.setMouseOverPropertyHolderViewLabel(undefined);
for (const classView of this.diagram.classViews.slice().reverse()) {
if (
classView.contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
)
) {
this.setMouseOverClassView(classView);
// Check hover class corner
if (
buildBottomRightCornerBox(classView).contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
)
) {
this.setMouseOverClassCorner(classView);
}
// TODO: we probably should make this a shared function so we don't need to maintain
// this computation in both places: here and when we draw class view properties
const _class = classView.class.value;
let cursorY = classView.position.y + this.classViewSpaceY;
// account for height of stereotypes
cursorY =
cursorY +
(classView.hideStereotypes
? 0
: _class.stereotypes.length * this.lineHeight);
// Check hover class name
const classNameWidth = this.computeClassNameWidth(classView);
if (
new PositionedRectangle(
this.modelCoordinateToCanvasCoordinate(
new Point(
classView.position.x +
(classView.rectangle.width - classNameWidth) / 2,
cursorY,
),
),
new Rectangle(
classNameWidth,
this.lineHeight, // class name takes 1 line
),
).contains(
eventPointInCanvasCoordinate.x,
eventPointInCanvasCoordinate.y,
)
) {
this.setMouseOverClassName(classView);
}
// account for height of class name
cursorY += this.lineHeight;
// account for height of tagged values
cursorY =
cursorY +
(classView.hideTaggedValues
? 0
: _class.taggedValues.length * this.lineHeight);
// 2 spaces: 1 space (below for class name) and 1 space (above) for properties block
cursorY += this.classViewSpaceY * 2;
// Check hover class property
for (const property of getAllOwnClassProperties(_class)) {
if (!this.hasPropertyView(classView, property)) {
this.ctx.font = `${(this.fontSize - 1) * this.zoom}px ${
this.fontFamily
}`;
if (
new PositionedRectangle(
this.modelCoordinateToCanvasCoordinate(
new Point(
classView.position.x + this.classViewSpaceX,
cursorY,
),
),
new Rectangle(
this.drawClassViewProperty(
classView,
property,
// these dimensions mean nothing since we only measure here
0,
0,
true,
) * this.zoom,
this.lineHeight * this.zoom, // property takes 1 line
),
).contains(
eventPointInCanvasCoordinate.x,
eventPointInCanvasCoordinate.y,
)
) {
this.setMouseOverClassProperty(property);
}
cursorY = cursorY + this.lineHeight;
}
}
break;
}
}
const propertyHolderViews: PropertyHolderView[] = [
...this.diagram.propertyViews,
...this.diagram.associationViews,
];
for (const propertyHolderView of propertyHolderViews) {
const fullPath = RelationshipView.pruneUnnecessaryInsidePoints(
propertyHolderView.buildFullPath(),
propertyHolderView.from.classView.value,
propertyHolderView.to.classView.value,
);
const propertyInfoBox = 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',
),
propertyHolderView.to.classView.value,
propertyHolderView.property.value,
false,
);
if (
propertyInfoBox.contains(
eventPointInModelCoordinate.x,
eventPointInModelCoordinate.y,
)
) {
this.setMouseOverPropertyHolderViewLabel(propertyHolderView);
break;
}
}
}
}
}