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)
}
}