/**
 * Copyright (c) 2020-present, Goldman Sachs
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {
  uuid,
  noop,
  UnsupportedOperationError,
  IllegalStateError,
  guaranteeNonNullable,
  findLast,
  uniqBy,
} from '@finos/legend-shared';
import {
  type AbstractProperty,
  type GenericTypeReference,
  Class,
  Enumeration,
  PrimitiveType,
  DerivedProperty,
  PackageableElementExplicitReference,
  PropertyExplicitReference,
  GenericTypeExplicitReference,
  GenericType,
  Property,
  Multiplicity,
  getAllSuperclasses,
  getAllOwnClassProperties,
  generateMultiplicityString,
  getRawGenericType,
  AggregationKind,
} from '@finos/legend-graph';
import { action, makeObservable, observable } from 'mobx';
import type { Diagram } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_Diagram.js';
import { Rectangle } from '../graph/metamodel/pure/packageableElements/diagram/geometry/DSL_Diagram_Rectangle.js';
import { Point } from '../graph/metamodel/pure/packageableElements/diagram/geometry/DSL_Diagram_Point.js';
import { PositionedRectangle } from '../graph/metamodel/pure/packageableElements/diagram/geometry/DSL_Diagram_PositionedRectangle.js';
import { ClassView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_ClassView.js';
import type { PropertyHolderView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_PropertyHolderView.js';
import { GeneralizationView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_GeneralizationView.js';
import { RelationshipView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_RelationshipView.js';
import { PropertyView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_PropertyView.js';
import {
  boxContains,
  buildBottomRightCornerBox,
  getBottomRightCornerPoint,
  getElementPosition,
  layoutDiagram,
  rotatePointX,
  rotatePointY,
} from '../graph/helpers/DSL_Diagram_Helper.js';
import { AssociationView } from '../graph/metamodel/pure/packageableElements/diagram/DSL_Diagram_AssociationView.js';
import type { RelationshipViewEnd } from '../graph-manager/index.js';

export class DiagramAnalysisResult {
  readonly uuid = uuid();
  title!: string;
  description?: string | undefined;
  diagram!: Diagram;
}

export interface DiagramRendererCallbacks {
  // Domain mutations (from legend-application-studio)
  onClass_addSuperType?: (
    subclass: Class,
    superType: GenericTypeReference,
  ) => void;
  onClass_addProperty?: (ownerClass: Class, property: Property) => void;

  // Diagram mutations - ClassView
  onDiagram_setClassViews?: (diagram: Diagram, classViews: ClassView[]) => void;
  onDiagram_addClassView?: (diagram: Diagram, classView: ClassView) => void;
  onDiagram_deleteClassView?: (diagram: Diagram, classView: ClassView) => void;

  // Diagram mutations - AssociationView
  onDiagram_setAssociationViews?: (
    diagram: Diagram,
    associationViews: AssociationView[],
  ) => void;
  onDiagram_deleteAssociationView?: (
    diagram: Diagram,
    associationView: AssociationView,
  ) => void;

  // Diagram mutations - GeneralizationView
  onDiagram_setGeneralizationViews?: (
    diagram: Diagram,
    generalizationViews: GeneralizationView[],
  ) => void;
  onDiagram_addGeneralizationView?: (
    diagram: Diagram,
    view: GeneralizationView,
  ) => void;
  onDiagram_deleteGeneralizationView?: (
    diagram: Diagram,
    view: GeneralizationView,
  ) => void;

  // Diagram mutations - PropertyView
  onDiagram_setPropertyViews?: (
    diagram: Diagram,
    propertyViews: PropertyView[],
  ) => void;
  onDiagram_addPropertyView?: (diagram: Diagram, view: PropertyView) => void;
  onDiagram_deletePropertyView?: (diagram: Diagram, view: PropertyView) => void;

  // ClassView display mutations
  onClassView_setHideProperties?: (classView: ClassView, hide: boolean) => void;
  onClassView_setHideStereotypes?: (
    classView: ClassView,
    hide: boolean,
  ) => void;
  onClassView_setHideTaggedValues?: (
    classView: ClassView,
    hide: boolean,
  ) => void;

  // RelationshipViewEnd mutations
  onRelationshipEdgeView_setOffsetX?: (
    edge: RelationshipViewEnd,
    offsetX: number,
  ) => void;
  onRelationshipEdgeView_setOffsetY?: (
    edge: RelationshipViewEnd,
    offsetY: number,
  ) => void;

  // RelationshipView path mutations
  onRelationshipView_changePoint?: (
    relationship: RelationshipView,
    oldPoint: Point,
    newPoint: Point,
  ) => void;
  onRelationshipView_simplifyPath?: (relationship: RelationshipView) => void;
  onRelationshipView_setPath?: (
    relationship: RelationshipView,
    path: Point[],
  ) => void;

  // PositionedRectangle mutations
  onPositionedRectangle_setRectangle?: (
    classView: ClassView,
    rectangle: Rectangle,
  ) => void;
  onPositionedRectangle_setPosition?: (
    classView: ClassView,
    position: Point,
  ) => void;
  onPositionedRectangle_forceRefreshHash?: (
    positionedRectangle: PositionedRectangle,
  ) => void;

  onFindOrBuildPoint?: (
    relationship: RelationshipView,
    x: number,
    y: number,
    zoom: number,
    editable: boolean,
  ) => Point | undefined;
}

export enum DIAGRAM_INTERACTION_MODE {
  LAYOUT,
  PAN,
  ZOOM_IN,
  ZOOM_OUT,
  ADD_RELATIONSHIP,
  ADD_CLASS,
}

export enum DIAGRAM_RELATIONSHIP_EDIT_MODE {
  // ASSOCIATION,
  PROPERTY,
  INHERITANCE,
  NONE,
}

export enum DIAGRAM_ALIGNER_OPERATOR {
  ALIGN_LEFT,
  ALIGN_CENTER,
  ALIGN_RIGHT,
  ALIGN_TOP,
  ALIGN_MIDDLE,
  ALIGN_BOTTOM,
  SPACE_HORIZONTALLY,
  SPACE_VERTICALLY,
}

const MIN_ZOOM_LEVEL = 0.05; // 5%
const FIT_ZOOM_PADDING = 10;
export const DIAGRAM_ZOOM_LEVELS = [
  50, 75, 90, 100, 110, 125, 150, 200, 250, 300, 400,
];

const getPropertyDisplayName = (property: AbstractProperty): string =>
  (property instanceof DerivedProperty ? '/ ' : '') + property.name;

export class DiagramRenderer {
  diagram: Diagram;

  isReadOnly: boolean;
  enableLayoutAutoAdjustment: boolean;

  div: HTMLDivElement;
  canvas: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;

  // Rendering elements
  canvasDimension: Rectangle; // dimension of the canvas, i.e. the dimension of the container element that hosts the canvas
  canvasCenter: Point;
  /**
   * The screen or artboard that contains all parts of the diagram. It's important to understand that this is indeed a virtual screen
   * because it is constructed by constructing the smallest possible bounding rectangle around the diagram. As such, information about
   * the screen is not persisted (in the protocol JSON)
   */
  virtualScreen: PositionedRectangle;
  /**
   * This refers the offset of the virtual screen with respect to the canvas. We have 2 types of coordinate:
   * `stored` (in the JSON protocol of class and relationship views) vs. `rendering`.
   *
   * There are 2 important facts about stored coordinates:
   * 1. Zoom is not taken into account (unlike rendering coordinates which change as we zoom)
   * 2. They are with respect to the canvas, not the screen (because the screen is virtual - see above)
   *
   * As such, when we debug, let's say we have a position (x,y), if we want to find that coordinate in the coordiante system of the canvas, we have to
   * add the offset, so the coordinate of (x, y) is (x + screenOffset.x, y + screenOffset.y) when we refer to the canvas coordinate system
   * So if we turn on debug mode and try to move the top left corner of the screen to the `offset crosshair` the screen coordinate system should align
   * with the canvas coordinate system. Of course due to centering and moving the screen around there is still an offset between the 2 coordinate system,
   * but we know for a fact that the top left of the screen will have stored coordinate (0,0)
   */
  screenOffset: Point;
  zoom: number;

  // edit modes
  // NOTE: we keep the edit mode separated like this
  // becase we anticipate more complex interactions in the future
  interactionMode: DIAGRAM_INTERACTION_MODE;
  relationshipMode: DIAGRAM_RELATIONSHIP_EDIT_MODE;

  // UML specific shapes
  triangle: [Point, Point, Point];
  diamond: [Point, Point, Point, Point];

  // Font
  fontFamily: string;
  fontSize: number;
  lineHeight: number;

  // Wrap text
  truncateText: boolean;
  maxLineLength: number;
  // TODO: we might want to do text wrapping as well

  // Spacing
  screenPadding: number; // the padding of the diagram (art board)
  classViewSpaceX: number;
  classViewSpaceY: number;
  propertySpacing: number;

  // Screen Grid (for debugging)
  showScreenGrid: boolean; // show the screen coordinate system
  showScreenBoxGuide: boolean; // show the screen border box and center
  screenGridAxisTickLength: number;
  screenGridSpacingX: number;
  screenGridSpacingY: number;
  screenGridLineWidth: number;
  screenGridLineColor: string;
  screenGridLabelTextColor: string;
  screenGuideLineColor: string;
  screenGuideLabelTextColor: string;

  // Canvas Grid (for debugging)
  showCanvasGrid: boolean; // show the canvas coordinate system
  showCanvasBoxGuide: boolean; // show the canvas border box and center
  showScreenOffsetGuide: boolean; // show the offset of the screen with respect to the canvas
  canvasGridAxisTickLength: number;
  canvasGridSpacingX: number;
  canvasGridSpacingY: number;
  canvasGridLineWidth: number;
  canvasGridLineColor: string;
  canvasGridLabelTextColor: string;
  canvasGuideLineColor: string;
  canvasGuideLabelTextColor: string;
  screenOffsetGuideLineColor: string;
  screenOffsetGuideLabelTextColor: string;

  // Line
  defaultLineWidth: number;

  // Color
  defaultLineColor: string;
  canvasColor: string;
  backgroundColor: string;
  classViewFillColor: string;
  classViewHeaderTextColor: string;
  classViewPropertyTextColor: string;
  classViewPrimitivePropertyTextColor: string;
  classViewDerivedPropertyTextColor: string;
  relationshipViewTextColor: string;
  propertyViewOwnedDiamondColor: string;
  propertyViewSharedDiamondColor: string;
  generalizationViewInheritanceTriangeFillColor: string;
  selectionBoxBorderColor: string;

  // Selection
  selection?: PositionedRectangle | undefined;
  selectionStart?: Point | undefined;
  selectedClassCorner?: ClassView | undefined; // the class view which we currently select the bottom right corner
  selectedClassProperty?:
    | { property: AbstractProperty; selectionPoint: Point }
    | undefined;
  selectedClasses: ClassView[];
  selectedPropertyOrAssociation?: PropertyHolderView | undefined;
  selectedInheritance?: GeneralizationView | undefined;
  selectedPoint?: Point | undefined;

  private _selectedClassesInitialPositions: {
    classView: ClassView;
    oldPos: Point;
  }[];

  // Relationship
  startClassView?: ClassView | undefined;
  handleAddRelationship?:
    | ((start: ClassView, target: ClassView) => RelationshipView | undefined)
    | undefined;

  mouseOverClassCorner?: ClassView | undefined;
  mouseOverClassName?: ClassView | undefined;
  mouseOverClassView?: ClassView | undefined;
  mouseOverClassProperty?: AbstractProperty | undefined;
  mouseOverPropertyHolderViewLabel?: PropertyHolderView | undefined;
  cursorPosition: Point;
  callbacks?: DiagramRendererCallbacks | undefined;

  leftClick: boolean;
  middleClick: boolean;
  rightClick: boolean;
  clickX: number;
  clickY: number;
  positionBeforeLastMove: Point;

  // interactions
  onAddClassViewClick: (point: Point) => void = noop();
  onClassViewRightClick: (classView: ClassView, point: Point) => void = noop();

  onBackgroundDoubleClick?: ((point: Point) => void) | undefined;
  onClassViewDoubleClick?:
    | ((classView: ClassView, point: Point) => void)
    | undefined;
  onClassNameDoubleClick?:
    | ((classView: ClassView, point: Point) => void)
    | undefined;
  onClassPropertyDoubleClick?:
    | ((
        property: AbstractProperty,
        point: Point,
        propertyHolderView: PropertyHolderView | undefined,
      ) => void)
    | undefined;

  handleEditClassView: (classView: ClassView) => void = noop();
  handleEditProperty: (
    property: AbstractProperty,
    point: Point,
    propertyHolderView: PropertyHolderView | undefined,
  ) => void = noop();
  handleAddSimpleProperty: (classView: ClassView) => void = noop();

  constructor(
    div: HTMLDivElement,
    diagram: Diagram,
    callbacks?: DiagramRendererCallbacks,
  ) {
    makeObservable(this, {
      isReadOnly: observable,
      enableLayoutAutoAdjustment: observable,
      interactionMode: observable,
      relationshipMode: observable,
      zoom: observable,
      mouseOverClassView: observable,
      mouseOverClassName: observable,
      mouseOverClassCorner: observable,
      mouseOverClassProperty: observable,
      mouseOverPropertyHolderViewLabel: observable,
      selectionStart: observable,
      selectedClassCorner: observable,
      selectedClasses: observable,
      selectedPropertyOrAssociation: observable,
      selectedInheritance: observable,
      leftClick: observable,
      middleClick: observable,
      rightClick: observable,
      changeMode: action,
      setIsReadOnly: action,
      setEnableLayoutAutoAdjustment: action,
      setMouseOverClassView: action,
      setMouseOverClassName: action,
      setMouseOverClassCorner: action,
      setMouseOverClassProperty: action,
      setMouseOverPropertyHolderViewLabel: action,
      setSelectionStart: action,
      setSelectedClassCorner: action,
      setSelectedClasses: action,
      setSelectedPropertyOrAssociation: action,
      setSelectedInheritance: action,
      setLeftClick: action,
      setMiddleClick: action,
      setRightClick: action,
      setZoomLevel: action,
      align: action,
    });

    this.diagram = diagram;
    this.callbacks = callbacks;

    // Container and canvas
    this.div = div;
    this.div.childNodes.forEach((node) => this.div.removeChild(node)); // Clear the <div> container
    this.canvas = document.createElement('canvas');
    this.canvasDimension = new Rectangle(
      this.div.offsetWidth,
      this.div.offsetHeight,
    );
    this.canvasCenter = new Point(
      this.canvasDimension.width / 2,
      this.canvasDimension.height / 2,
    );
    this.canvas.width = this.canvasDimension.width;
    this.canvas.height = this.canvasDimension.height;
    this.canvas.style.position = 'absolute';
    this.canvas.style.left = '0';
    this.canvas.style.top = '0';
    this.div.appendChild(this.canvas);
    this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
    this.screenOffset = new Point(0, 0);
    this.virtualScreen = new PositionedRectangle(
      new Point(0, 0),
      new Rectangle(0, 0),
    );
    this.zoom = 1;

    // UML specific shapes
    this.triangle = [new Point(0, 0), new Point(-15, -10), new Point(-15, 10)];
    this.diamond = [
      new Point(0, 0),
      new Point(-10, -5),
      new Point(-20, 0),
      new Point(-10, 5),
    ];

    // Font
    // this.fontFamily = 'Roboto Mono'; // intentionally choose a monospaced font so it's easier for calculation (such as text wrapping)
    this.fontFamily = 'Arial'; // convert this back to non-monospaced font for now since monospaced fonts look rather off
    this.fontSize = 12;
    this.lineHeight = 14;

    // Wrap text
    this.truncateText = true;
    this.maxLineLength = 40;

    // Screen Grid (for debugging purpose)
    this.showScreenGrid = false;
    this.showScreenBoxGuide = true;
    this.screenGridAxisTickLength = 50;
    this.screenGridSpacingX = 100;
    this.screenGridSpacingY = 100;
    this.screenGridLineWidth = 0.5;

    // Canvas Grid
    this.showCanvasGrid = false;
    this.showCanvasBoxGuide = true;
    this.showScreenOffsetGuide = true;
    this.canvasGridAxisTickLength = 50;
    this.canvasGridSpacingX = 100;
    this.canvasGridSpacingY = 100;
    this.canvasGridLineWidth = 0.5;

    // Line
    this.defaultLineWidth = 1;

    // Color
    this.screenGridLineColor = 'rgba(61,126,154,0.56)';
    this.screenGridLabelTextColor = 'rgba(61,126,154,0.56)';
    this.screenGuideLineColor = 'red';
    this.screenGuideLabelTextColor = 'red';
    this.canvasGridLineColor = 'green';
    this.canvasGridLabelTextColor = 'green';
    this.canvasGuideLineColor = 'orange';
    this.canvasGuideLabelTextColor = 'orange';
    this.screenOffsetGuideLineColor = 'purple';
    this.screenOffsetGuideLabelTextColor = 'purple';
    this.defaultLineColor = 'rgb(0,0,0)';
    this.canvasColor = 'rgb(220,220,220)';
    this.backgroundColor = 'rgb(255,255,255)';
    this.classViewFillColor = 'rgb(185,185,185)';
    this.classViewHeaderTextColor = 'rgb(0,0,0)';
    this.classViewPropertyTextColor = 'rgb(0,0,0)';
    this.classViewPrimitivePropertyTextColor = 'rgb(255,255,255)';
    this.classViewDerivedPropertyTextColor = 'rgb(100,100,100)';
    this.relationshipViewTextColor = 'rgb(0,0,0)';
    this.propertyViewSharedDiamondColor = 'rgb(255,255,255)';
    this.propertyViewOwnedDiamondColor = 'rgb(0,0,0)';
    this.generalizationViewInheritanceTriangeFillColor = 'rgb(255,255,255)';
    this.selectionBoxBorderColor = 'rgba(0,0,0, 0.02)';

    // Preferences
    this.interactionMode = DIAGRAM_INTERACTION_MODE.LAYOUT;
    this.relationshipMode = DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE;
    this.isReadOnly = false;
    this.enableLayoutAutoAdjustment = false;
    this.screenPadding = 20;
    this.classViewSpaceX = 10;
    this.classViewSpaceY = 4;
    this.propertySpacing = 10;

    // Event handlers
    this.selectionStart = undefined;
    this.selection = undefined;
    this.selectedClasses = [];
    this._selectedClassesInitialPositions = [];
    this.cursorPosition = new Point(0, 0);
    this.leftClick = false;
    this.middleClick = false;
    this.rightClick = false;
    this.clickX = 0;
    this.clickY = 0;
    this.positionBeforeLastMove = new Point(0, 0);
    this.div.onwheel = this.mousewheel.bind(this);
    this.div.onmousedown = this.mousedown.bind(this);
    this.div.onkeydown = this.keydown.bind(this);
    this.div.ondblclick = this.mousedblclick.bind(this);
    this.div.onmouseup = this.mouseup.bind(this);
    this.div.onmousemove = this.mousemove.bind(this);
  }

  setIsReadOnly(val: boolean): void {
    this.isReadOnly = val;
  }

  setEnableLayoutAutoAdjustment(val: boolean): void {
    this.enableLayoutAutoAdjustment = val;
  }

  setMouseOverClassView(val: ClassView | undefined): void {
    this.mouseOverClassView = val;
  }

  setMouseOverClassName(val: ClassView | undefined): void {
    this.mouseOverClassName = val;
  }

  setMouseOverClassCorner(val: ClassView | undefined): void {
    this.mouseOverClassCorner = val;
  }

  setMouseOverClassProperty(val: AbstractProperty | undefined): void {
    this.mouseOverClassProperty = val;
  }

  setMouseOverPropertyHolderViewLabel(
    val: PropertyHolderView | undefined,
  ): void {
    this.mouseOverPropertyHolderViewLabel = val;
  }

  setSelectionStart(val: Point | undefined): void {
    this.selectionStart = val;
  }

  setSelectedClassCorner(val: ClassView | undefined): void {
    this.selectedClassCorner = val;
  }

  setSelectedClasses(val: ClassView[]): void {
    this.selectedClasses = val;
  }

  setSelectedPropertyOrAssociation(val: PropertyHolderView | undefined): void {
    this.selectedPropertyOrAssociation = val;
  }

  setSelectedInheritance(val: GeneralizationView | undefined): void {
    this.selectedInheritance = val;
  }

  setLeftClick(val: boolean): void {
    this.leftClick = val;
  }

  setMiddleClick(val: boolean): void {
    this.middleClick = val;
  }

  setRightClick(val: boolean): void {
    this.rightClick = val;
  }

  setZoomLevel(val: number): void {
    this.zoom = val;
  }

  render(options?: { initial?: boolean | undefined }): void {
    this.diagram.classViews.forEach((classView) =>
      this.ensureClassViewMeetMinDimensions(classView),
    );

    // Auto-layout on initial render when all class views still sit at the origin,
    // which indicates the diagram was just generated and needs re-positioning
    if (
      options?.initial &&
      this.diagram.classViews.length > 1 &&
      this.diagram.classViews.every(
        (cv) => cv.position.x === 0 && cv.position.y === 0,
      )
    ) {
      layoutDiagram(this.diagram);
    }

    this.refresh();

    if (options?.initial) {
      this.autoRecenter();

      // only zoom to fit if needed
      if (
        this.canvas.width <
          this.virtualScreen.rectangle.width +
            this.screenPadding * 2 +
            FIT_ZOOM_PADDING * 2 ||
        this.canvas.height <
          this.virtualScreen.rectangle.height +
            this.screenPadding * 2 +
            FIT_ZOOM_PADDING * 2
      ) {
        this.zoomToFit();
      }
    }
  }

  refresh(): void {
    this.refreshCanvas();
    this.drawScreen();
  }

  refreshCanvas(): void {
    this.canvasDimension = new Rectangle(
      this.div.offsetWidth,
      this.div.offsetHeight,
    );
    this.canvasCenter = new Point(
      this.canvasDimension.width / 2,
      this.canvasDimension.height / 2,
    );
    this.canvas.width = this.canvasDimension.width;
    this.canvas.height = this.canvasDimension.height;
  }

  clearScreen(): void {
    this.ctx.fillStyle = this.canvasColor;
    this.ctx.fillRect(
      0,
      0,
      this.canvasDimension.width,
      this.canvasDimension.height,
    );
  }

  private drawScreen(): void {
    this.manageVirtualScreen();
    this.clearScreen();
    this.drawAll();
  }

  autoRecenter(): void {
    this.center(
      this.virtualScreen.position.x + this.virtualScreen.rectangle.width / 2,
      this.virtualScreen.position.y + this.virtualScreen.rectangle.height / 2,
    );
  }

  /**
   * Reset the screen offset
   */
  private center(x: number, y: number): void {
    this.screenOffset = new Point(
      -x + this.canvasCenter.x,
      -y + this.canvasCenter.y,
    );
    this.refresh();
  }

  changeMode(
    editMode: DIAGRAM_INTERACTION_MODE,
    relationshipMode: DIAGRAM_RELATIONSHIP_EDIT_MODE,
  ): void {
    switch (editMode) {
      case DIAGRAM_INTERACTION_MODE.LAYOUT:
      case DIAGRAM_INTERACTION_MODE.PAN:
      case DIAGRAM_INTERACTION_MODE.ZOOM_IN:
      case DIAGRAM_INTERACTION_MODE.ZOOM_OUT:
      case DIAGRAM_INTERACTION_MODE.ADD_CLASS: {
        if (relationshipMode !== DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE) {
          throw new IllegalStateError(
            `Can't change to '${editMode}' mode: relationship mode should not be specified in layout mode`,
          );
        }
        break;
      }
      case DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP: {
        if (relationshipMode === DIAGRAM_RELATIONSHIP_EDIT_MODE.NONE) {
          throw new IllegalStateError(
            `Can't switch to relationship mode: relationship mode is not specified`,
          );
        }
        break;
      }
      default:
        throw new UnsupportedOperationError(
          `Can't switch to mode '${editMode}': unsupported mode`,
        );
    }

    this.interactionMode = editMode;
    this.relationshipMode = relationshipMode;

    if (editMode === DIAGRAM_INTERACTION_MODE.ADD_RELATIONSHIP) {
      switch (relationshipMode) {
        case DIAGRAM_RELATIONSHIP_EDIT_MODE.INHERITANCE: {
          this.handleAddRelationship = (
            startClassView: ClassView,
            targetClassView: ClassView,
          ): RelationshipView | undefined => {
            if (
              // Do not allow creating self-inheritance
              startClassView.class.value !== targetClassView.class.value &&
              // Avoid creating inhertance that already existed
              !getAllSuperclasses(startClassView.class.value).includes(
                targetClassView.class.value,
              ) &&
              // Avoid loop (might be expensive)
              !getAllSuperclasses(targetClassView.class.value).includes(
                startClassView.class.value,
              )
            ) {
              this.callbacks?.onClass_addSuperType?.(
                startClassView.class.value,
                GenericTypeExplicitReference.create(
                  new GenericType(targetClassView.class.value),
                ),
              );
            }
            // only add an inheritance relationship view if the start class
            // has already had the target class as its supertype
            if (
              startClassView.class.value.generalizations.find(
                (generalization) =>
                  generalization.value.rawType === targetClassView.class.value,
              )
            ) {
              const gview = new GeneralizationView(
                this.diagram,
                startClassView,
                targetClassView,
              );
              this.callbacks?.onDiagram_addGeneralizationView?.(
                this.diagram,
                gview,
              );
              return gview;
            }
            return undefined;
          };
          break;
        }
        case DIAGRAM_RELATIONSHIP_EDIT_MODE.PROPERTY: {
          this.handleAddRelationship = (
            startClassView: ClassView,
            targetClassView: ClassView,
          ): PropertyView | undefined => {
            const property = new Property(
              `property_${startClassView.class.value.properties.length + 1}`,
              Multiplicity.ONE,
              GenericTypeExplicitReference.create(
                new GenericType(targetClassView.class.value),
              ),
              startClassView.class.value,
            );
            this.callbacks?.onClass_addProperty?.(
              startClassView.class.value,
              property,
            );
            // only create property view if the classviews are different
            // else we end up with a weird rendering where the property view
            // is not targetable
            if (startClassView !== targetClassView) {
              const pView = new PropertyView(
                this.diagram,
                PropertyExplicitReference.create(property),
                startClassView,
                targetClassView,
              );
              this.callbacks?.onDiagram_addPropertyView?.(this.diagram, pView);
              return pView;
            }
            return undefined;
          };
          break;
        }
        default:
          throw new UnsupportedOperationError(
            `Can't switch to relationship mode '${relationshipMode}': unsupported mode`,
          );
      }
    }
  }

  align(op: DIAGRAM_ALIGNER_OPERATOR): void {
    if (this.selectedClasses.length < 2) {
      return;
    }
    switch (op) {
      case DIAGRAM_ALIGNER_OPERATOR.ALIGN_LEFT: {
        const leftBound = this.selectedClasses.reduce(
          (val, view) => Math.min(val, view.position.x),
          Number.MAX_SAFE_INTEGER,
        );
        this.selectedClasses.forEach((view) => {
          this.callbacks?.onPositionedRectangle_setPosition?.(
            view,
            new Point(leftBound, view.position.y),
          );
        });
        break;
      }
      case DIAGRAM_ALIGNER_OPERATOR.ALIGN_CENTER: {
        const center =
          this.selectedClasses.reduce(
            (val, view) => val + view.position.x + view.rectangle.width / 2,
            0,
          ) / this.selectedClasses.length;
        this.selectedClasses.forEach((view) =>
          this.callbacks?.onPositionedRectangle_setPosition?.(
            view,
            new Point(center - view.rectangle.width / 2, view.position.y),
          ),
        );
        break;
      }

      case DIAGRAM_ALIGNER_OPERATOR.ALIGN_RIGHT: {
        const rightBound = this.selectedClasses.reduce(
          (val, view) => Math.max(val, view.position.x + view.rectangle.width),
          -Number.MAX_SAFE_INTEGER,
        );
        this.selectedClasses.forEach((view) =>
          this.callbacks?.onPositionedRectangle_setPosition?.(
            view,
            new Point(rightBound - view.rectangle.width, view.position.y),
          ),
        );
        break;
      }

      case DIAGRAM_ALIGNER_OPERATOR.ALIGN_TOP: {
        const topBound = this.selectedClasses.reduce(
          (val, view) => Math.min(val, view.position.y),
          Number.MAX_SAFE_INTEGER,
        );
        this.selectedClasses.forEach((view) =>
          this.callbacks?.onPositionedRectangle_setPosition?.(
            view,
            new Point(view.position.x, topBound),
          ),
        );
        break;
      }

      case DIAGRAM_ALIGNER_OPERATOR.ALIGN_MIDDLE: {
        const middle =
          this.selectedClasses.reduce(
            (val, view) => val + view.position.y + view.rectangle.height / 2,
            0,
          ) / this.selectedClasses.length;
        this.selectedClasses.forEach((view) =>
          this.callbacks?.onPositionedRectangle_setPosition?.(
            view,
            new Point(view.position.x, middle - view.rectangle.height / 2),
          ),
        );
        break;
      }

      case DIAGRAM_ALIGNER_OPERATOR.ALIGN_BOTTOM: {
        const bottomBound = this.selectedClasses.reduce(
          (val, view) => Math.max(val, view.position.y + view.rectangle.height),
          -Number.MAX_SAFE_INTEGER,
        );
        this.selectedClasses.forEach((view) =>
          this.callbacks?.onPositionedRectangle_setPosition?.(
            view,
            new Point(view.position.x, bottomBound - view.rectangle.height),
          ),
        );
        break;
      }

      case DIAGRAM_ALIGNER_OPERATOR.SPACE_HORIZONTALLY: {
        const sorted = this.selectedClasses.toSorted(
          (a, b) => a.position.x - b.position.x,
        );
        // NOTE: handle special case where there are only 2 views, make them adjacent
        if (sorted.length === 2) {
          const previousView = sorted[0] as ClassView;
          const currentView = sorted[1] as ClassView;
          this.callbacks?.onPositionedRectangle_setPosition?.(
            currentView,
            new Point(
              previousView.position.x + previousView.rectangle.width,
              currentView.position.y,
            ),
          );
        } else {
          const spacings = [];
          for (let idx = 1; idx < sorted.length; idx++) {
            const previousView = sorted[idx - 1] as ClassView;
            const currentView = sorted[idx] as ClassView;
            spacings.push(
              currentView.position.x -
                (previousView.position.x + previousView.rectangle.width),
            );
          }
          const averageSpacing =
            spacings.reduce((val, distance) => val + distance, 0) /
            spacings.length;
          for (let idx = 1; idx < sorted.length; idx++) {
            const previousView = sorted[idx - 1] as ClassView;
            const currentView = sorted[idx] as ClassView;
            this.callbacks?.onPositionedRectangle_setPosition?.(
              currentView,
              new Point(
                previousView.position.x +
                  previousView.rectangle.width +
                  averageSpacing,
                currentView.position.y,
              ),
            );
          }
        }
        break;
      }
      case DIAGRAM_ALIGNER_OPERATOR.SPACE_VERTICALLY: {
        const sorted = this.selectedClasses.toSorted(
          (a, b) => a.position.y - b.position.y,
        );
        // NOTE: handle special case where there are only 2 views, make them adjacent
        if (sorted.length === 2) {
          const previousView = sorted[0] as ClassView;
          const currentView = sorted[1] as ClassView;
          this.callbacks?.onPositionedRectangle_setPosition?.(
            currentView,
            new Point(
              currentView.position.x,
              previousView.position.y + previousView.rectangle.height,
            ),
          );
        } else {
          const spacings = [];
          for (let idx = 1; idx < sorted.length; idx++) {
            const previousView = sorted[idx - 1] as ClassView;
            const currentView = sorted[idx] as ClassView;
            spacings.push(
              currentView.position.y -
                (previousView.position.y + previousView.rectangle.height),
            );
          }
          const averageSpacing =
            spacings.reduce((val, distance) => val + distance, 0) /
            spacings.length;
          for (let idx = 1; idx < sorted.length; idx++) {
            const previousView = sorted[idx - 1] as ClassView;
            const currentView = sorted[idx] as ClassView;
            this.callbacks?.onPositionedRectangle_setPosition?.(
              currentView,
              new Point(
                currentView.position.x,
                previousView.position.y +
                  previousView.rectangle.height +
                  averageSpacing,
              ),
            );
          }
        }
        break;
      }
      default:
        break;
    }

    this.drawScreen();
  }

  truncateTextWithEllipsis(val: string, limit = this.maxLineLength): string {
    const ellipsis = '...';
    return val.length > limit
      ? `${val.substring(0, limit + 1 - ellipsis.length)}${ellipsis}`
      : val;
  }

  canvasCoordinateToModelCoordinate(point: Point): Point {
    return new Point(
      (point.x - this.canvasCenter.x) / this.zoom -
        this.screenOffset.x +
        this.canvasCenter.x,
      (point.y - this.canvasCenter.y) / this.zoom -
        this.screenOffset.y +
        this.canvasCenter.y,
    );
  }

  modelCoordinateToCanvasCoordinate(point: Point): Point {
    return new Point(
      (point.x - this.canvasCenter.x + this.screenOffset.x) * this.zoom +
        this.canvasCenter.x,
      (point.y - this.canvasCenter.y + this.screenOffset.y) * this.zoom +
        this.canvasCenter.y,
    );
  }

  /**
   * Mouse events' coordinate x,y are relative to the viewport, not the canvas element
   * so we need to calculate them relative to the canvas element
   */
  resolveMouseEventCoordinate(e: MouseEvent): Point {
    if (e.target instanceof HTMLElement) {
      const targetEl = getElementPosition(e.target);
      return new Point(targetEl.x + e.offsetX, targetEl.y + e.offsetY);
    }
    // NOTE: this is a fallback, should not happen
    // since all mouse events shoud target the canvas element
    return new Point(e.x, e.y);
  }

  eventCoordinateToCanvasCoordinate(point: Point): Point {
    return new Point(
      point.x - this.divPosition.x + this.div.scrollLeft,
      point.y - this.divPosition.y + this.div.scrollTop,
    );
  }

  canvasCoordinateToEventCoordinate(point: Point): Point {
    return new Point(
      point.x - this.div.scrollLeft + this.divPosition.x,
      point.y - this.div.scrollTop + this.divPosition.y,
    );
  }

  hasPropertyView(classView: ClassView, property: AbstractProperty): boolean {
    return (
      this.diagram.propertyViews.filter(
        (p) =>
          p.property.value === property && p.from.classView.value === classView,
      ).length > 0
    );
  }

  get divPosition(): Point {
    return getElementPosition(this.div);
  }

  private manageVirtualScreen(): void {
    if (this.diagram.classViews.length) {
      const firstClassView = guaranteeNonNullable(this.diagram.classViews[0]);
      let minX = firstClassView.position.x;
      let minY = firstClassView.position.y;
      let maxX = firstClassView.position.x + firstClassView.rectangle.width;
      let maxY = firstClassView.position.y + firstClassView.rectangle.height;
      for (const classView of this.diagram.classViews) {
        minX = Math.min(minX, classView.position.x);
        minY = Math.min(minY, classView.position.y);
        maxX = Math.max(maxX, classView.position.x + classView.rectangle.width);
        maxY = Math.max(
          maxY,
          classView.position.y + classView.rectangle.height,
        );
      }
      const relationshipViews = (
        this.diagram.associationViews as RelationshipView[]
      )
        .concat(this.diagram.generalizationViews)
        .concat(this.diagram.propertyViews);
      for (const relationshipView of relationshipViews) {
        const fullPath = RelationshipView.pruneUnnecessaryInsidePoints(
          relationshipView.buildFullPath(),
          relationshipView.from.classView.value,
          relationshipView.to.classView.value,
        );
        if (relationshipView instanceof PropertyView) {
          const box = this.drawLinePropertyText(
            // NOTE: by the way we compute the full path, it would guarantee
            // to always have at least 2 points
            guaranteeNonNullable(
              fullPath[fullPath.length - 2],
              'Diagram path expected to have at least 2 points',
            ),
            guaranteeNonNullable(
              fullPath[fullPath.length - 1],
              'Diagram path expected to have at least 2 points',
            ),
            relationshipView.to.classView.value,
            relationshipView.property.value,
            false,
          );
          minX = Math.min(minX, box.position.x);
          minY = Math.min(minY, box.position.y);
          maxX = Math.max(maxX, getBottomRightCornerPoint(box).x);
          maxY = Math.max(maxY, getBottomRightCornerPoint(box).y);
        }
        // if (relationshipView.from.name) {
        //   var box = this.displayText(fullPath[1], fullPath[0], relationshipView.from.classView, relationshipView.property, this.ctx);
        //   minX = Math.min(minX, box.x);
        //   minY = Math.min(minY, box.y);
        //   maxX = Math.max(maxX, box.x2);
        //   maxY = Math.max(maxY, box.y2);
        // }
        for (const point of relationshipView.path) {
          minX = Math.min(minX, point.x);
          minY = Math.min(minY, point.y);
          maxX = Math.max(maxX, point.x);
          maxY = Math.max(maxY, point.y);
        }
      }
      this.virtualScreen = new PositionedRectangle(
        new Point(minX, minY),
        new Rectangle(maxX - minX, maxY - minY),
      );
    } else {
      this.setZoomLevel(1);
      this.screenOffset = new Point(0, 0);
      this.virtualScreen = new PositionedRectangle(
        new Point(
          this.canvasDimension.width / 2,
          this.canvasDimension.height / 2,
        ),
        new Rectangle(0, 0),
      );
    }
  }

  /**
   * Here we zoom with respect to the point the mouse is currently pointing at.
   * The idea is fairly simple. We convert the coordinate of the zoom point
   * to the model coordinate and find a way to alter `screenOffset` in response
   * to change in zoom level to ensure the model coordinate stays constant.
   */
  private executeZoom(newZoomLevel: number, point: Point): void {
    // NOTE: we cap the minimum zoom level to avoid negative zoom
    newZoomLevel = Math.max(newZoomLevel, MIN_ZOOM_LEVEL);

    const currentZoomLevel = this.zoom;
    this.setZoomLevel(newZoomLevel);

    this.screenOffset = new Point(
      ((point.x - this.canvasCenter.x) * (currentZoomLevel - newZoomLevel) +
        this.screenOffset.x * currentZoomLevel) /
        newZoomLevel,
      ((point.y - this.canvasCenter.y) * (currentZoomLevel - newZoomLevel) +
        this.screenOffset.y * currentZoomLevel) /
        newZoomLevel,
    );

    this.clearScreen();
    this.drawAll();
  }

  private zoomPoint(zoomLevel: number, zoomPoint: Point): void {
    this.executeZoom(zoomLevel, zoomPoint);
  }

  zoomCenter(zoomLevel: number): void {
    // NOTE: we cap the minimum zoom level to avoid negative zoom
    this.setZoomLevel(Math.max(zoomLevel, MIN_ZOOM_LEVEL));
    this.clearScreen();
    this.drawAll();
  }

  zoomToFit(): void {
    this.autoRecenter();
    this.zoomCenter(
      Math.max(
        Math.min(
          this.canvas.width /
            (this.virtualScreen.rectangle.width +
              this.screenPadding * 2 +
              FIT_ZOOM_PADDING * 2),
          this.canvas.height /
            (this.virtualScreen.rectangle.height +
              this.screenPadding * 2 +
              FIT_ZOOM_PADDING * 2),
        ),
        MIN_ZOOM_LEVEL,
      ),
    );
  }

  /**
   * Add a classview to current diagram and draw it.
   * This function is intended to be used with drag and drop, hence the position paramter, which must be relative to the screen/window
   */
  addClassView(
    addedClass: Class,
    classViewModelCoordinate?: Point,
  ): ClassView | undefined {
    if (!this.isReadOnly) {
      // NOTE: Using `uuid` might be overkill since the `id` is only required to be unique
      // across the diagram, but maintaining a counter has its own downside
      // NOTE: checking for collision to guarantee stability, especially since class view is usually manually added
      const existingIds = this.diagram.classViews.map(
        (classView) => classView.id,
      );
      let id = uuid();
      while (existingIds.includes(id)) {
        id = uuid();
      }
      const newClassView = new ClassView(
        this.diagram,
        id,
        PackageableElementExplicitReference.create(addedClass),
      );
      this.callbacks?.onPositionedRectangle_setPosition?.(
        newClassView,
        classViewModelCoordinate ??
          this.canvasCoordinateToModelCoordinate(
            new Point(
              this.virtualScreen.position.x +
                this.virtualScreen.rectangle.width / 2,
              this.virtualScreen.position.y +
                this.virtualScreen.rectangle.height / 2,
            ),
          ),
      );

      this.callbacks?.onDiagram_addClassView?.(this.diagram, newClassView);
      // Refresh hash since ClassView position is not observable
      // NOTE: here we refresh after adding the class view to the diagram, that way the diagram hash is refreshed

      this.callbacks?.onPositionedRectangle_forceRefreshHash?.(newClassView);
      this.diagram.classViews
        .filter((classView) => classView.class.value !== addedClass)
        .forEach((classView) => {
          const _class = classView.class.value;
          // Add supertype
          if (
            addedClass.generalizations
              .map((generalization) => generalization.value.rawType)
              .includes(_class)
          ) {
            this.callbacks?.onDiagram_addGeneralizationView?.(
              this.diagram,
              new GeneralizationView(this.diagram, newClassView, classView),
            );
          }
          if (
            _class.generalizations
              .map((generalization) => generalization.value.rawType)
              .includes(addedClass)
          ) {
            this.callbacks?.onDiagram_addGeneralizationView?.(
              this.diagram,
              new GeneralizationView(this.diagram, classView, newClassView),
            );
          }
          // Add property view
          getAllOwnClassProperties(addedClass).forEach((property) => {
            if (property.genericType.value.rawType === _class) {
              this.callbacks?.onDiagram_addPropertyView?.(
                this.diagram,

                new PropertyView(
                  this.diagram,
                  PropertyExplicitReference.create(property),
                  newClassView,
                  classView,
                ),
              );
            }
          });
          getAllOwnClassProperties(_class).forEach((property) => {
            if (property.genericType.value.rawType === addedClass) {
              this.callbacks?.onDiagram_addPropertyView?.(
                this.diagram,
                new PropertyView(
                  this.diagram,
                  PropertyExplicitReference.create(property),
                  classView,
                  newClassView,
                ),
              );
            }
          });
        });
      this.drawClassView(newClassView);
      this.drawScreen();
      return newClassView;
    }
    return undefined;
  }

  private drawBoundingBox(): void {
    this.ctx.fillStyle = this.backgroundColor;
    this.ctx.lineWidth = 1;
    this.ctx.fillRect(
      (this.virtualScreen.position.x +
        this.screenOffset.x -
        this.canvasCenter.x -
        this.screenPadding) *
        this.zoom +
        this.canvasCenter.x,
      (this.virtualScreen.position.y +
        this.screenOffset.y -
        this.canvasCenter.y -
        this.screenPadding) *
        this.zoom +
        this.canvasCenter.y,
      (this.virtualScreen.rectangle.width + this.screenPadding * 2) * this.zoom,
      (this.virtualScreen.rectangle.height + this.screenPadding * 2) *
        this.zoom,
    );
    this.ctx.strokeRect(
      (this.virtualScreen.position.x +
        this.screenOffset.x -
        this.canvasCenter.x -
        this.screenPadding) *
        this.zoom +
        this.canvasCenter.x,
      (this.virtualScreen.position.y +
        this.screenOffset.y -
        this.canvasCenter.y -
        this.screenPadding) *
        this.zoom +
        this.canvasCenter.y,
      (this.virtualScreen.rectangle.width + this.screenPadding * 2) * this.zoom,
      (this.virtualScreen.rectangle.height + this.screenPadding * 2) *
        this.zoom,
    );
  }

  private drawDiagram(): void {
    this.diagram.associationViews.forEach((associationView) =>
      this.drawPropertyOrAssociation(associationView),
    );
    this.diagram.generalizationViews.forEach((generalizationView) =>
      this.drawInheritance(generalizationView),
    );
    this.diagram.propertyViews.forEach((propertyView) =>
      this.drawPropertyOrAssociation(propertyView),
    );
    this.diagram.classViews.forEach((classView) =>
      this.drawClassView(classView),
    );
    if (this.showCanvasGrid) {
      this.drawCanvasGrid();
    }
    if (this.showScreenGrid) {
      this.drawScreenGrid();
    }
  }

  drawAll(): void {
    this.drawBoundingBox();
    this.drawDiagram();
  }

  private drawScreenGrid(): void {
    const startX =
      (this.virtualScreen.position.x +
        this.screenOffset.x -
        this.canvasCenter.x) *
        this.zoom +
      this.canvasCenter.x;
    const startY =
      (this.virtualScreen.position.y +
        this.screenOffset.y -
        this.canvasCenter.y) *
        this.zoom +
      this.canvasCenter.y;
    const width =
      (this.virtualScreen.rectangle.width + this.screenPadding * 2) * this.zoom;
    const height =
      (this.virtualScreen.rectangle.height + this.screenPadding * 2) *
      this.zoom;
    this.ctx.beginPath();
    this.ctx.fillStyle = this.screenGridLabelTextColor;
    this.ctx.strokeStyle = this.screenGridLineColor;
    this.ctx.lineWidth = this.screenGridLineWidth;
    this.ctx.font = `${(this.fontSize - 1) * this.zoom}px ${this.fontFamily}`;
    const labelPadding = 5;
    // draw vertical grid lines
    let gridX = 0;
    for (
      let x = startX;
      x < startX + width - this.screenPadding * 2;
      x += this.screenGridSpacingX * this.zoom
    ) {
      this.ctx.fillText(
        `${gridX}`,
        x + labelPadding * this.zoom,
        startY -
          (this.screenGridAxisTickLength + this.screenPadding) * this.zoom,
      );
      this.ctx.fillText(
        `[${Math.round(this.virtualScreen.position.x + gridX)}]`,
        x + labelPadding * this.zoom,
        startY -
          (this.screenGridAxisTickLength +
            this.screenPadding -
            this.lineHeight) *
            this.zoom,
      );
      this.ctx.moveTo(
        x,
        startY -
          (this.screenGridAxisTickLength + this.screenPadding) * this.zoom,
      );
      this.ctx.lineTo(x, startY + height - this.screenPadding * this.zoom);
      this.ctx.stroke();
      gridX += this.screenGridSpacingX;
    }
    // draw horizontal grid lines
    let gridY = 0;
    for (
      let y = startY;
      y < startY + height - this.screenPadding * 2;
      y += this.screenGridSpacingY * this.zoom
    ) {
      this.ctx.fillText(
        `${gridY}`,
        startX -
          (this.screenGridAxisTickLength + this.screenPadding) * this.zoom,
        y + labelPadding * this.zoom,
      );
      this.ctx.fillText(
        `[${Math.round(this.virtualScreen.position.y + gridY)}]`,
        startX -
          (this.screenGridAxisTickLength + this.screenPadding) * this.zoom,
        y + (labelPadding + this.lineHeight) * this.zoom,
      );
      this.ctx.moveTo(
        startX -
          (this.screenGridAxisTickLength + this.screenPadding) * this.zoom,
        y,
      );
      this.ctx.lineTo(startX + width - this.screenPadding * this.zoom, y);
      this.ctx.stroke();
      gridY += this.screenGridSpacingY;
    }
    // draw screen padding border grid line
    if (this.showScreenBoxGuide) {
      this.ctx.beginPath();
      this.ctx.fillStyle = this.screenGuideLabelTextColor;
      this.ctx.strokeStyle = this.screenGuideLineColor;
      this.ctx.moveTo(startX, startY);
      this.ctx.lineTo(
        startX,
        startY + height - this.screenPadding * 2 * this.zoom,
      );
      this.ctx.stroke();
      this.ctx.lineTo(
        startX,
        startY + height - this.screenPadding * 2 * this.zoom,
      );
      this.ctx.lineTo(
        startX + width - this.screenPadding * 2 * this.zoom,
        startY + height - this.screenPadding * 2 * this.zoom,
      );
      this.ctx.stroke();
      this.ctx.lineTo(
        startX + width - this.screenPadding * 2 * this.zoom,
        startY + height - this.screenPadding * 2 * this.zoom,
      );
      this.ctx.lineTo(
        startX + width - this.screenPadding * 2 * this.zoom,
        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;
        }
      }
    }
  }
}
