All Downloads are FREE. Search and download functionalities are using the official Maven repository.

rs.scripts.diagram-viewer.0.9.2.source-code.entityGraph.renderer.ts Maven / Gradle / Ivy

The newest version!
import _           from 'lodash'
import $           from 'jquery'
import * as joint  from 'jointjs'
import dagre       from 'dagre'
import graphlib    from 'graphlib'

import * as util   from './util'
import * as erd    from './entityGraph.shapes'
import * as dg    from './dependsGraph.shapes'
import standardEntities from './standardEntities.json'

interface IEntityData {
  entities:           IEntity[]
}

interface IEntity {
  typeName:           string
  collectionName:     string
  attributes:         IAttribute[]
  relations:          IRelation[]
}

interface IAttribute {
  name:              string
  type:              string
}

interface IRelation {
  name:              string
  sourceCardinality: string
  targetCardinality: string
  targetRequired:    boolean
  refEntity:         string
  opposite:          string
}

interface Options {
  showStandardRelations:  boolean
  showAttributes:         boolean
  showStandardAttributes: boolean
}

export default class EntityGraphRenderer {
  readonly entityData: IEntityData
  readonly graph:     joint.dia.Graph
  readonly paper:     joint.dia.Paper
  options:            Options
  popupAttributes:    { [key:string]: erd.PopupShape } = {}
  popupRelations:     { [key:string]: erd.PopupShape } = {}
  links:              { [key:string]: erd.RelationShape } = {}
  currentEntities:    string[]
  entitiesPerProcess: { [key:string]: string[] }
  neighborsPerEntity: { [key:string]: joint.dia.Element[] } = {}
  hasDrawn:           boolean = false

  constructor($paper: JQuery, entityData: IEntityData, options: Options) {
    this.entityData = entityData
    this.graph = new joint.dia.Graph()
    this.paper = new joint.dia.Paper({
      el: $paper,
      model: this.graph,
      width: 300,
      height: 200,
      gridSize: 10,
      drawGrid: false,
      background: {
        color: 'rgba(255, 253, 235, 0.5)'
      },
      defaultAnchor: { name: 'perpendicular' },
      interactive: true
    })
    this.options = options
    this.currentEntities = []
    this.entitiesPerProcess = {}
    const self = this

     // this handles the popups for the relations: make them visible on a double click
    this.paper.on('link:pointerclick', (link, evt, x, y) => self.showRelationPopup(link, x, y))

    // This handles the popups for the entities: make them visible on a click
    this.paper.on('element:circle:pointerdown', (elementView: any) => self.showEntityPopup(elementView))

    // Hiding a pop-up when clicking on a cross
    this.paper.on('element:cross:pointerdown', (elementView: any) => self.hidePopup(elementView))

    this.paper.on('element:pointerdblclick', (elementView: joint.dia.ElementView, evt) => {
      this.greyoutStyle()
      const currentElement = elementView.model
      this.resetElementStyle(currentElement)
      this.resetNeighborsStyle(currentElement)
    })

    this.paper.on('blank:pointerclick', (evt) => this.resetStyle())

    $paper.on('diagram:toggle', (event: any, state: boolean) => {
      this.updateVisibility(state)
    })
    
    this.triggerMovingBoxes()
    
    this.graph.on('change:position', cell => {
      if (self.hasDrawn === false) return

      const parentBbox = self.paper.getArea()
      const cellBbox = cell.getBBox()
      if (parentBbox.containsPoint(cellBbox.origin()) &&
          parentBbox.containsPoint(cellBbox.topRight()) &&
          parentBbox.containsPoint(cellBbox.corner()) &&
          parentBbox.containsPoint(cellBbox.bottomLeft())) {
        // All the four corners of the child are inside the parent area.
        return
      }

      // Revert the child position.
      cell.set('position', cell.previous('position'))
    })
  }

  render() {
    console.log('Initializing entity graph with %d entities...', this.entityData.entities.length)
    
    util.time('Entity graph createCells', () => this.createCells(this.entityData.entities))
    const graph = util.time('Entity graph fillNeighbors', () => this.fillNeighbors())
    util.time('Entity graph layout', () => this.layout(graph))
    
    // Add hidden popups to graph (after layout!):
    util.time('Entity graph add popups', () => {
      this.graph.addCells(Object.values(this.popupAttributes))
      this.graph.addCells(Object.values(this.popupRelations))
    })

    this.hasDrawn = true
  }

  private updateVisibility(state: boolean) {
    this.paper.$el.toggleClass('hidden', !state)
    if (!this.hasDrawn) {
      this.render()
    }
  }

  // Create all cells in the graph: the entities and their associated relations
  private createCells(entities: IEntity[]) {

    const elems = this.createEntities(entities)
    entities
      .filter(entity => this.inCurrentEntities(entity.typeName))
      .forEach(entity => this.addRelations(entity, elems))
  }

  private createEntities(entities: IEntity[]): { [key:string]: joint.dia.Element } {
    const elems: { [key:string]: joint.dia.Element } = {}
    entities
      .filter(entity => this.inCurrentEntities(entity.typeName))
      .forEach(entity => {
        const attributeNames = this.combineAttributes(entity, true)
        const attributeTypes = this.combineAttributes(entity, false)
        const shape = this.createEntityObject(entity, attributeNames, attributeTypes)
        elems[entity.typeName] = shape
        // Create corresponding popup
        const popup = this.createEntityPopup(entity, attributeNames, attributeTypes, shape.attr('popup/id'))
        this.popupAttributes[shape.attr('popup/id')] = popup
      })
    this.graph.addCells(Object.values(elems))
    Object.values(elems).forEach(shape => util.autoSize(shape, this.paper))
    return elems
  }

  // add relations between entities; add the modifier and creator relations
  private addRelations(entity: IEntity, elems: { [key:string]: joint.dia.Element }) {
    this.selectRelations(entity).forEach(relation => {
      if (relation.opposite !== null) { // if this relation has an opposite
        if (this.links[relation.refEntity + entity.typeName + relation.opposite] === undefined) // and if that opposite doesn't yet exist as a link
          this.createLink(entity, relation, elems, true) // create a double link
      }
      else // create a single link
        this.createLink(entity, relation, elems, false)
    })
  }

  // this function handles the creation of links, with some differences between uni- and bidirectional links
  private createLink(entity: IEntity, relation: IRelation, elems: { [key:string]: joint.dia.Element }, biDirectional: boolean) {
    const source = elems[entity.typeName]
    const target = elems[relation.refEntity]
    if (target === undefined) {
      console.warn(`Undefined target entity in relation from ${entity.typeName} to ${relation.refEntity}`)
      return
    } 
    const link = this.createLinkObject(source, target, entity, relation, biDirectional)
    const popup = this.createLinkPopup(entity, relation, biDirectional, link.attr('popup/id'))
    this.popupRelations[link.attr('popup/id')] = popup
    // Store existing links
    this.links[entity.typeName + relation.refEntity + relation.name] = link
    if (biDirectional)
      this.links[relation.refEntity + entity.typeName + relation.opposite] = link
  }

  private showEntityPopup(elementView: any) {
    const currentElement = elementView.model
    const position = currentElement.get('position')
    // Select popup corresponding to clicked attribute:
    const id = currentElement.attr('popup/id')
    const popupElement: erd.PopupShape = this.popupAttributes[id]
    // Show popup:
    if (popupElement.attr('body/visibility') === 'hidden') {
      popupElement.toFront()
      popupElement.position(position.x, position.y)
      popupElement.attr('body/visibility', 'visible')
      popupElement.attr('content/visibility', 'visible')
      popupElement.attr('line/visibility', 'visible')
      popupElement.attr('cross/visibility', 'visible')
      popupElement.attr('square/visibility', 'visible')
    }
  }

  private showRelationPopup(link: joint.dia.LinkView, x: number, y: number) {
    const relationShape: erd.RelationShape = link.model
     // Select the correct pop-up:
    const id = relationShape.attr('popup/id')
    const relationPopup: erd.PopupShape = this.popupRelations[id]
     // Show popup:
    if (relationPopup.attr('body/visibility') === 'hidden') {
      relationPopup.toFront()
      relationPopup.position(x, y)
      relationPopup.attr('body/visibility', 'visible')
      relationPopup.attr('content/visibility', 'visible')
      relationPopup.attr('cross/visibility', 'visible')
      relationPopup.attr('square/visibility', 'visible')
    }
  }

  private hidePopup(elementView: any) {
    const popup = elementView.model
    // Hide clicked popup:
    if (popup.attr('body/visibility') === 'visible') {
      popup.attr('body/visibility', 'hidden')
      popup.attr('content/visibility', 'hidden')
      popup.attr('line/visibility', 'hidden')
      popup.attr('cross/visibility', 'hidden')
      popup.attr('square/visibility', 'hidden')
    }
  }

  private resetNeighborsStyle(element: joint.dia.Element) {
    this.graph.getNeighbors(element).forEach(this.resetElementStyle)
    this.graph.getConnectedLinks(element, { inbound: true, outbound: true }).forEach(li => this.resetLinkStyle(li, true))
  }

  private resetElementStyle(element: joint.dia.Element) {
    if (element instanceof erd.PopupShape) return
    const isStandard = standardEntities.indexOf(element.attr("title/text").trim()) > -1
    element.attr('body/stroke', 'black');
    element.attr('body/fill', isStandard ? '#D6B700' : '#009ED0')
    element.attr('body/filter', dg.dropShadow)
    element.attr('circle/stroke', 'black')
    element.attr('circle/fill', 'white')
    element.attr('label/fill', 'white')
    
  }

  private resetLinkStyle(link: joint.dia.Link, highlight: boolean = false) {
    if (link instanceof erd.PopupShape) return
    link.attr('line/stroke', 'black')
    link.attr('line/strokeWidth', highlight ? 3.5 : 2)
    link.attr('line/sourceMarker/style', 'fill:#333333;stroke:#333333;stroke-width:0.264583;')
    link.attr('line/targetMarker/style', 'fill:#333333;stroke:#333333;stroke-width:0.264583;')
  }

  private greyoutStyle() {
    this.paper.model.getElements().forEach(el => {
      if (el instanceof erd.PopupShape) return
      el.attr('body/stroke', '#888888')
      el.attr('body/fill', '#cfcfcf')
      el.attr('circle/stroke', '#888888')
      el.attr('circle/fill', '#cfcfcf')
      el.attr('label/fill', '#848484')
      el.removeAttr('body/filter')
    })
    this.paper.model.getLinks().forEach(li => {
      if (li instanceof erd.PopupShape) return
      li.attr('line/stroke', '#888888')
      li.attr('line/strokeWidth', 1)
      li.attr('line/sourceMarker/style', 'fill:#888888;stroke:#888888;stroke-width:0.264583')
      li.attr('line/targetMarker/style', 'fill:#888888;stroke:#888888;stroke-width:0.264583')
    })
  }

  private resetStyle() {
    this.paper.model.getElements().forEach(this.resetElementStyle)
    this.paper.model.getLinks().forEach(li => this.resetLinkStyle(li))
  }

  private getElement(arg: joint.dia.Link.EndJSON): joint.dia.Element {
    return  this.graph.getCell(arg.id!)
  }

   // Make one string with all attribute names/types of an entity
  combineAttributes(entity: IEntity, name: boolean): string {
    const attributeConcat: string[] = []
    // Check if we want to show standard attributes and filter them out if not
    entity.attributes.forEach(attribute => {
      if (this.options.showStandardAttributes || !(attribute.name === 'created_at' || attribute.name === 'modified_at'))
        attributeConcat.push(name ? attribute.name.concat(':   '): attribute.type)
    })
    return '\n'.concat(attributeConcat.join('\n'))
  }

  // Make one string with all process that use this entity
  makeEntityProcessList(entity: IEntity): string {
    const processConcat: string[] = []
    Object.keys(this.entitiesPerProcess).forEach(process => {
      if (this.entitiesPerProcess[process].indexOf(entity.typeName) > -1)
        processConcat.push(process)
    })
    return (processConcat.length > 0) ? '\n'.concat(processConcat.join('\n')) : ''
  }

  createEntityObject(entity: IEntity, attributeNames: string, attributeTypes: string): erd.EntityShape {
    const shape = new erd.EntityShape()
    shape.attr({
      body: {
        // Make standard entities orange and the remaining entities blue
        fill: this.isStandard(entity.typeName) ? '#D6B700' : '#009ED0',
      },
      title: { text: entity.typeName.concat('\t\t\t\t\t\t') },
      // Newlines and tabs added for space between the name and the attributes of an entity and the popup button
      // Show attributes when showAttributes boolean is true
      names: { text: (this.options.showAttributes) ? attributeNames: '' },
      types: { text: (this.options.showAttributes) ? attributeTypes: '' },
      line: {
        stroke: (this.options.showAttributes) ? 'black': 'transparent',
      },
      popup: {
        // Name to identify corresponding popup:
        id: entity.typeName
      }
    })
    return shape
  }

  private isStandard(typeName: string): boolean {
    return standardEntities.indexOf(typeName) > -1
  }

  createEntityPopup(entity: IEntity, attributeNames: string, attributeTypes: string, popupID: string){
    const popup = new erd.PopupShape({id: popupID})
    popup.attr({
      body: { fill: 'white' },
      title: { text: entity.typeName.concat('\t\t\t\t\t\t') },
      // Newlines and tabs added for space between the name and the attributes of an entity and the popup button
      names: {
        text: (this.options.showStandardAttributes) ? attributeNames : attributeNames.concat('\ncreated_at:   \nmodified_at:   ')
      },
      types: {
        text: (this.options.showStandardAttributes) ? attributeTypes : attributeTypes.concat('\ndatetime\ndatetime')
      },
      processes: { text: this.makeEntityProcessList(entity) },
      line: { stroke: 'black' },
    })
    return popup
  }

  inCurrentEntities(typeName: string): boolean {
    return this.currentEntities.length === 0
        || this.currentEntities.indexOf(typeName) > -1
  }

  selectRelations(entity: IEntity): IRelation[]{
    const include = (relation: IRelation) => {
      const isStandardRelation = relation.name === 'MODIFIER' || relation.name === 'CREATOR'
      return (!isStandardRelation || this.options.showStandardRelations)  && this.inCurrentEntities(relation.refEntity)
    }
    return entity.relations.filter(include)
  }

  createLinkObject(source: joint.dia.Element, target: joint.dia.Element, entity: IEntity, relation: IRelation, biDirectional: boolean): erd.RelationShape {
    const required = relation.targetRequired
    const link = new erd.RelationShape()
    link.source(source)
    link.target(target)
    link.router('normal')     // normal, manhattan, metro, orthogonal, oneSide
    link.connector('rounded') // normal, jumpover, rounded, smooth
    link.addTo(this.graph)
    link.attr({
      line: {
        sourceMarker: this.getMarker(relation.sourceCardinality, biDirectional, required),
        targetMarker: this.getMarker(relation.targetCardinality, true, required),
      },
      popup: {
        // Name to identify cooresponding popup:
        id: entity.typeName + relation.refEntity + relation.name
      }
    })
    return link
  }

  private getMarker(cardinality: string, ingoing: boolean, required: boolean): any {
    if (cardinality === 'One' && ingoing === true && required)
      return {
        type: 'rect',
        style: 'fill:#333333;stroke:#333333;stroke-width:0.264583',
        width: '2',
        height: '24',
        x: '6',
        y: '-12',
      }
    else if (cardinality === 'Many' && ingoing === true && required)
      return {
        type: 'path',
        style: 'fill:#333333;stroke:#333333;stroke-width:0.264583;',
        d: 'M 0,-14.155208 11.377083,-2.6458333 V -14.155208 h 2.38125 v 28.310417 h -2.38125 V 2.6458337 L 0,14.155209 V 11.509375 L 11.377083,-3.3333333e-7 0,-11.509375 Z',
      }
    else if (cardinality === 'Many')
      return {
        type: 'path',
        style: 'fill:#333333;stroke:#333333;stroke-width:0.264583;',
        d: 'M 0,-14.155208 13.758333,-3.3333333e-7 0,14.155209 V 11.509375 L 11.1125,-3.3333333e-7 0,-11.509375 Z',
      }
    else 
      return {
        type: 'rect',
        style: 'display:none;'
      }
  }

  createLinkPopup(entity: IEntity, relation: IRelation, biDirectional: boolean, popupID: string): erd.PopupShape {
    const uniDirectionalText: string = 'From ' + entity.typeName + ' to ' + relation.refEntity + ': ' + relation.name + '\t\t\t\t\t\t'
    const biDirectionalText: string = uniDirectionalText.concat('\n' + 'From ' + relation.refEntity + ' to ' + entity.typeName + ': ' + relation.opposite)
    const popup = new erd.PopupShape({ id: popupID })
    popup.attr({
      body: { fill: 'white' },
      // Newlines and tabs added for space between the name and the close button
      processes: { text: (biDirectional? biDirectionalText: uniDirectionalText) },
    })
    return popup
  }

  fillNeighbors(): joint.dia.Graph {
    const elements = this.graph.getElements()
    const elementNames = elements.map(e => e.attr('popup/ID'))
    elements.forEach(e => {
      const neighbors = this.graph.getNeighbors(e)
      this.neighborsPerEntity[e.attr('popup/id')] = neighbors
    })
    const url = new URL(window.location.href)
    const currentPage = url.searchParams.get('p')!
    if (elementNames.indexOf(currentPage) !== -1) {
      const resultGraph = new joint.dia.Graph()
      this.neighborsPerEntity[currentPage].forEach(e => e.addTo(resultGraph))
      return resultGraph
    }
    else {
      return this.graph
    }
  }

  // this function enables the movement in the diagram and links it to the button 'move boxes'
  triggerMovingBoxes() {
    const button = $('#btn-movingBoxes')
    const container = $('#btn-movingBoxes')
    function updateState() {
      const state = button.hasClass('on')
      container.toggleClass('hidden', !state)
    }
    button.click(() => {
      button.toggleClass('on')
      button.hasClass('on')? this.paper.setInteractivity(true): this.paper.setInteractivity(false)
      updateState()
    })
    button.toggleClass('on', true)
    updateState()
  }

  layout(graph: joint.dia.Graph) {
    const bbox = joint.layout.DirectedGraph.layout(graph, {
      dagre,
      graphlib,
      nodeSep:     40,
      edgeSep:     20,
      rankSep:     60,
      rankDir:     'TB',
      ranker:      'longest-path', // network-simplex, tight-tree, longest-path
      setVertices: true,
    })
    const margin = 100
    this.paper.setDimensions(bbox.width + margin * 2, bbox.height + margin * 2)
    this.paper.translate(margin, margin)
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy