UNPKG

@finos/legend-extension-dsl-diagram

Version:
1,478 lines (1,385 loc) 129 kB
/** * 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, 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, rotatePointX, rotatePointY, } from '../graph/helpers/DSL_Diagram_Helper.js'; import { AssociationView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_AssociationView.js'; import { class_addProperty, class_addSuperType, } from '@finos/legend-application-studio'; import { classView_setHideProperties, classView_setHideStereotypes, classView_setHideTaggedValues, diagram_addClassView, diagram_addGeneralizationView, diagram_addPropertyView, diagram_deleteAssociationView, diagram_deleteClassView, diagram_deleteGeneralizationView, diagram_deletePropertyView, diagram_setAssociationViews, diagram_setClassViews, diagram_setGeneralizationViews, diagram_setPropertyViews, findOrBuildPoint, positionedRectangle_forceRefreshHash, positionedRectangle_setPosition, positionedRectangle_setRectangle, relationshipEdgeView_setOffsetX, relationshipEdgeView_setOffsetY, relationshipView_changePoint, relationshipView_simplifyPath, relationshipView_setPath, } from '../stores/studio/DSL_Diagram_GraphModifierHelper.js'; export 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; 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) { makeObservable(this, { isReadOnly: observable, enableLayoutAutoAdjustment: observable, interactionMode: observable, relationshipMode: observable, zoom: observable, mouseOverClassView: observable, mouseOverClassName: observable, mouseOverClassCorner: observable, mouseOverClassProperty: observable, mouseOverPropertyHolderViewLabel: observable, selectionStart: observable, selectedClassCorner: observable, selectedClasses: observable, selectedPropertyOrAssociation: observable, selectedInheritance: observable, leftClick: observable, middleClick: observable, rightClick: observable, changeMode: action, setIsReadOnly: action, setEnableLayoutAutoAdjustment: action, setMouseOverClassView: action, setMouseOverClassName: action, setMouseOverClassCorner: action, setMouseOverClassProperty: action, setMouseOverPropertyHolderViewLabel: action, setSelectionStart: action, setSelectedClassCorner: action, setSelectedClasses: action, setSelectedPropertyOrAssociation: action, setSelectedInheritance: action, setLeftClick: action, setMiddleClick: action, setRightClick: action, setZoomLevel: action, align: action, }); this.diagram = diagram; // Container and canvas this.div = div; this.div.childNodes.forEach((node) => this.div.removeChild(node)); // Clear the <div> container this.canvas = document.createElement('canvas'); this.canvasDimension = new Rectangle( this.div.offsetWidth, this.div.offsetHeight, ); this.canvasCenter = new Point( this.canvasDimension.width / 2, this.canvasDimension.height / 2, ); this.canvas.width = this.canvasDimension.width; this.canvas.height = this.canvasDimension.height; this.canvas.style.position = 'absolute'; this.canvas.style.left = '0'; this.canvas.style.top = '0'; this.div.appendChild(this.canvas); this.ctx = this.canvas.getContext('2d') 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), ); 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, ) ) { class_addSuperType( startClassView.class.value, GenericTypeExplicitReference.create( new GenericType(targetClassView.class.value), ), ); } // only add an inheritance relationship view if the start class // has already had the target class as its supertype if ( startClassView.class.value.generalizations.find( (generalization) => generalization.value.rawType === targetClassView.class.value, ) ) { const gview = new GeneralizationView( this.diagram, startClassView, targetClassView, ); diagram_addGeneralizationView(this.diagram, gview); return gview; } return undefined; }; break; } case DIAGRAM_RELATIONSHIP_EDIT_MODE.PROPERTY: { this.handleAddRelationship = ( startClassView: 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, ); class_addProperty(startClassView.class.value, property); // only create property view if the classviews are different // else we end up with a weird rendering where the property view // is not targetable if (startClassView !== targetClassView) { const pView = new PropertyView( this.diagram, PropertyExplicitReference.create(property), startClassView, targetClassView, ); diagram_addPropertyView(this.diagram, pView); return pView; } return undefined; }; break; } default: throw new UnsupportedOperationError( `Can't switch to relationship mode '${relationshipMode}': unsupported mode`, ); } } } align(op: 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) => positionedRectangle_setPosition( view, new Point(leftBound, view.position.y), ), ); break; } case DIAGRAM_ALIGNER_OPERATOR.ALIGN_CENTER: { const center = this.selectedClasses.reduce( (val, view) => val + view.position.x + view.rectangle.width / 2, 0, ) / this.selectedClasses.length; this.selectedClasses.forEach((view) => positionedRectangle_setPosition( view, new Point(center - view.rectangle.width / 2, view.position.y), ), ); break; } case DIAGRAM_ALIGNER_OPERATOR.ALIGN_RIGHT: { const rightBound = this.selectedClasses.reduce( (val, view) => Math.max(val, view.position.x + view.rectangle.width), -Number.MAX_SAFE_INTEGER, ); this.selectedClasses.forEach((view) => positionedRectangle_setPosition( view, new Point(rightBound - view.rectangle.width, view.position.y), ), ); break; } case DIAGRAM_ALIGNER_OPERATOR.ALIGN_TOP: { const topBound = this.selectedClasses.reduce( (val, view) => Math.min(val, view.position.y), Number.MAX_SAFE_INTEGER, ); this.selectedClasses.forEach((view) => positionedRectangle_setPosition( view, new Point(view.position.x, topBound), ), ); break; } case DIAGRAM_ALIGNER_OPERATOR.ALIGN_MIDDLE: { const middle = this.selectedClasses.reduce( (val, view) => val + view.position.y + view.rectangle.height / 2, 0, ) / this.selectedClasses.length; this.selectedClasses.forEach((view) => positionedRectangle_setPosition( view, new Point(view.position.x, middle - view.rectangle.height / 2), ), ); break; } case DIAGRAM_ALIGNER_OPERATOR.ALIGN_BOTTOM: { const bottomBound = this.selectedClasses.reduce( (val, view) => Math.max(val, view.position.y + view.rectangle.height), -Number.MAX_SAFE_INTEGER, ); this.selectedClasses.forEach((view) => positionedRectangle_setPosition( view, new Point(view.position.x, bottomBound - view.rectangle.height), ), ); break; } case DIAGRAM_ALIGNER_OPERATOR.SPACE_HORIZONTALLY: { const sorted = this.selectedClasses.toSorted( (a, b) => a.position.x - b.position.x, ); // NOTE: handle special case where there are only 2 views, make them adjacent if (sorted.length === 2) { const previousView = sorted[0] as ClassView; const currentView = sorted[1] as ClassView; positionedRectangle_setPosition( currentView, new Point( previousView.position.x + previousView.rectangle.width, currentView.position.y, ), ); } else { const spacings = []; for (let idx = 1; idx < sorted.length; idx++) { const previousView = sorted[idx - 1] 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; positionedRectangle_setPosition( currentView, new Point( previousView.position.x + previousView.rectangle.width + averageSpacing, currentView.position.y, ), ); } } break; } case DIAGRAM_ALIGNER_OPERATOR.SPACE_VERTICALLY: { const sorted = this.selectedClasses.toSorted( (a, b) => a.position.y - b.position.y, ); // NOTE: handle special case where there are only 2 views, make them adjacent if (sorted.length === 2) { const previousView = sorted[0] as ClassView; const currentView = sorted[1] as ClassView; positionedRectangle_setPosition( currentView, new Point( currentView.position.x, previousView.position.y + previousView.rectangle.height, ), ); } else { const spacings = []; for (let idx = 1; idx < sorted.length; idx++) { const previousView = sorted[idx - 1] 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; positionedRectangle_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), ); positionedRectangle_setPosition( newClassView, classViewModelCoordinate ?? this.canvasCoordinateToModelCoordinate( new Point( this.virtualScreen.position.x + this.virtualScreen.rectangle.width / 2, this.virtualScreen.position.y + this.virtualScreen.rectangle.height / 2, ), ), ); diagram_addClassView(this.diagram, newClassView); // Refresh hash since ClassView position is not observable // NOTE: here we refresh after adding the class view to the diagram, that way the diagram hash is refreshed positionedRectangle_forceRefreshHash(newClassView); this.diagram.classViews .filter((classView) => classView.class.value !== addedClass) .forEach((classView) => { const _class = classView.class.value; // Add supertype if ( addedClass.generalizations .map((generalization) => generalization.value.rawType) .includes(_class) ) { diagram_addGeneralizationView( this.diagram, new GeneralizationView(this.diagram, newClassView, classView), ); } if ( _class.generalizations .map((generalization) => generalization.value.rawType) .includes(addedClass) ) { diagram_addGeneralizationView( this.diagram, new GeneralizationView(this.diagram, classView, newClassView), ); } // Add property view getAllOwnClassProperties(addedClass).forEach((property) => { if (property.genericType.value.rawType === _class) { diagram_addPropertyView( this.diagram, new PropertyView( this.diagram, PropertyExplicitReference.create(property), newClassView, classView, ), ); } }); getAllOwnClassProperties(_class).forEach((property) => { if (property.genericType.value.rawType === addedClass) { diagram_addPropertyView( this.diagram, new PropertyView( this.diagram, PropertyExplicitReference.create(property), classView, newClassView, ), ); } }); }); this.drawClassView(newClassView); this.drawScreen(); return newClassView; } return undefined; } 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