/** * Entity Relationship Diagram (in TypeScript) * @author Levente Hunyadi * @version 1.0 * @remarks Copyright (C) 2022 Levente Hunyadi * @remarks Licensed under MIT, see https://opensource.org/licenses/MIT * @see https://hunyadi.info.hu/ **/ import { Arrow, DiagramCanvas } from "./diagram"; import { ElasticLayout, ElasticLayoutOptions } from "./elastic"; import { Movable, Pannable } from "./movable"; import SpectralLayout from "./spectral"; import TabPanel from "./tabpanel"; import Zoomable from "./zoomable"; import { toSVG, downloadSVG } from "./htmlsvg"; import { Toolbar } from "./toolbar"; // import styles from "./erdiagram.module.css"; declare interface TypeList { readonly item: EntityType; } declare interface TypeSet { readonly element: EntityType; } declare interface TypeDict { readonly key: EntityType; readonly value: EntityType; } type EntityType = string | TypeList | TypeSet | TypeDict; function entityTypeToString(type: EntityType): string { if (typeof type === "string") { return type; } else if ("item" in type) { return `List of [${entityTypeToString(type.item)}]`; } else if ("element" in type) { return `Set of [${entityTypeToString(type.element)}]`; } else if ("key" in type && "value" in type) { return `Dict of [${entityTypeToString(type.key)}] to [${entityTypeToString(type.value)}]`; } else { return `${undefined}`; } } declare interface EntityPropertyFeatures { readonly type: EntityType; readonly nullable: boolean; } declare interface PropertyDictionary { readonly [key: string]: EntityPropertyFeatures; } declare interface EntityKeys { readonly primary: string; } declare interface EntityFeatures { readonly properties: PropertyDictionary; readonly keys?: EntityKeys; } declare interface EntityDictionary { readonly [key: string]: EntityFeatures; } declare interface EntityPropertyAccess { readonly entity: string; readonly property: string; } declare interface EntityRelationship { readonly source: EntityPropertyAccess; readonly target: EntityPropertyAccess; } declare interface EntityRelationshipData { entities: EntityDictionary; relationships: EntityRelationship[]; } interface Renderable { get element(): Element; } class EntityPropertyElement implements Renderable { private elem: HTMLTableCellElement; constructor(private entity: EntityElement, propertyName: string) { this.elem = entity.element.querySelector(`td[data-property="${propertyName}"]`)!; } public get element(): HTMLElement { return this.elem; } public get parent(): EntityElement { return this.entity; } } /** * Represents an entity in an entity relationship diagram (ERD). */ class EntityElement implements Renderable { private elem: HTMLTableElement; constructor(public name: string, features: EntityFeatures) { this.elem = document.createElement("table"); //this.elem.classList.add(styles.entity); this.elem.classList.add("entity"); // generate HTML DOM representation of entity const body = document.createElement("tbody"); for (const [name, prop] of Object.entries(features.properties)) { const td = document.createElement("td"); if (features.keys && name == features.keys.primary) { td.classList.add("entity-key"); } td.dataset["property"] = name; const propName = `${name}`; const propType = `${entityTypeToString(prop.type)}`; let html = `${propName}: ${propType}`; if (prop.nullable) { html += ``; } td.innerHTML = html; const tr = document.createElement("tr"); tr.append(td); body.append(tr); } this.elem.insertAdjacentHTML("beforeend", `${this.name}`); this.elem.append(body); this.toggler.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this.compact(!this.toggler.classList.contains("collapsed")); }); } public get element(): HTMLElement { return this.elem; } private get head(): HTMLElement { return this.elem.querySelector("thead")!; } private get body(): HTMLElement { return this.elem.querySelector("tbody")!; } private get toggler(): HTMLElement { return this.elem.querySelector("thead>tr>th>span.toggle")!; } get heading(): HTMLElement { return this.head.querySelector("span.entity-name")!; } compact(state: boolean) { this.body.classList.toggle("hidden", state); this.toggler.classList.toggle("collapsed", state); } property(id: string): EntityPropertyElement { return new EntityPropertyElement(this, id); } } class EntityRelationshipElement { constructor(public source: EntityPropertyElement, public target: EntityPropertyElement) { } } class EntityDiagram { protected diagram: DiagramCanvas; protected entities = new Map(); protected relationships: EntityRelationshipElement[] = []; constructor(elem: HTMLElement, data: EntityRelationshipData) { this.validate(data); for (const [name, descriptor] of Object.entries(data.entities)) { const entity = new EntityElement(name, descriptor); this.entities.set(name, entity); } data.relationships.forEach(relationship => { const sourceEntity = this.entities.get(relationship.source.entity)!; const sourceProperty = sourceEntity.property(relationship.source.property); const targetEntity = this.entities.get(relationship.target.entity)!; const targetProperty = targetEntity.property(relationship.target.property); this.relationships.push(new EntityRelationshipElement(sourceProperty, targetProperty)); }); this.diagram = new DiagramCanvas(elem); const toolbar = new Toolbar(); toolbar.add("save-as-svg", "Save as SVG", () => { const svg = toSVG(this.diagram.element); downloadSVG(svg); }); elem.classList.add("diagram"); elem.append(toolbar.element); elem.append(this.diagram.element); } validate(data: EntityRelationshipData) { data.relationships.forEach(relationship => { const sourceEntity = data.entities[relationship.source.entity]; const targetEntity = data.entities[relationship.target.entity]; if (!sourceEntity || !targetEntity) { EntityDiagram.error(relationship, "entity", sourceEntity, targetEntity); } const sourceProperty = sourceEntity.properties[relationship.source.property]; const targetProperty = targetEntity.properties[relationship.target.property]; if (!sourceProperty || !targetProperty) { EntityDiagram.error(relationship, "property", sourceProperty, targetProperty); } }); } private static error(relationship: EntityRelationship, kind: string, source: object | undefined, target: object | undefined): never { let origin; if (!source) { origin = "source"; } else if (!target) { origin = "target"; } const context = `${relationship.source.entity}.${relationship.source.property} -> ${relationship.target.entity}.${relationship.target.property}`; throw TypeError(`${origin} ${kind} not found for relationship [${context}]`); } } class ElasticEntityDiagram extends EntityDiagram { constructor(elem: HTMLElement, data: EntityRelationshipData, options: ElasticLayoutOptions) { super(elem, data); const classList = this.diagram.element.classList; classList.add("canvaslike"); classList.add("elastic"); this.layout(options); } private layout(options: ElasticLayoutOptions): void { this.entities.forEach(entity => { entity.compact(true); this.diagram.addElement(entity.element); new Movable(entity.heading, entity.element); }); this.relationships.forEach(relationship => { this.diagram.addConnector(new Arrow(relationship.source.element, relationship.target.element)); }); // perform an initial layout const elements = Array.from(this.entities.values()).map(entity => { return entity.element; }); const edges = this.relationships.map(relationship => { return { source: relationship.source.parent.element, target: relationship.target.parent.element }; }); const spectralLayout = new SpectralLayout(elements, edges); const points = spectralLayout.calculate(); for (let [k, element] of elements.entries()) { element.style.left = 100 * points[k]!.x + "%"; element.style.top = 100 * points[k]!.y + "%"; } // refine initial layout let elasticLayout = new ElasticLayout( options, this.diagram.host, elements, (elem1, elem2) => { return this.diagram.isConnected(elem1, elem2); } ); elasticLayout.initialize(); } } class NavigableEntityDiagram extends EntityDiagram { private selector: HTMLSelectElement; constructor(elem: HTMLElement, data: EntityRelationshipData) { super(elem, data); this.diagram.element.classList.add("navigable"); this.selector = this.diagram.host.querySelector("select")!; this.entities.forEach(entity => { entity.compact(true); entity.heading.addEventListener("click", event => { event.preventDefault(); this.show(entity); }); const option = document.createElement("option"); option.innerText = entity.name; this.selector.append(option); }); this.selector.addEventListener("change", () => { const selected = this.entities.get(this.selector.value); if (selected) { this.display(selected); } }); if (this.entities.size > 0) { this.display(this.entities.values().next().value); } } show(entity: EntityElement): void { this.selector.value = entity.name; this.display(entity); } private display(entity: EntityElement): void { this.diagram.clear(); entity.compact(false); const leftPanel = this.diagram.host.querySelector(".left")!; const centerPanel = this.diagram.host.querySelector(".center")!; const rightPanel = this.diagram.host.querySelector(".right")!; const visible = new Set(); visible.add(entity); centerPanel.append(entity.element); this.diagram.addElement(entity.element); // add entities to diagram connected by a relationship this.relationships.forEach(relationship => { let updated = false; if (relationship.source.parent == entity && relationship.target.parent == entity) { updated = true; } else if (relationship.target.parent == entity) { const e = relationship.source.parent; if (!visible.has(e)) { leftPanel.append(e.element); this.diagram.addElement(e.element); visible.add(e); } updated = true; } else if (relationship.source.parent == entity) { const e = relationship.target.parent; if (!visible.has(e)) { rightPanel.append(e.element); this.diagram.addElement(e.element); visible.add(e); } updated = true; } if (updated) { this.diagram.addConnector(new Arrow(relationship.source.element, relationship.target.element)); } }); } } class SpectralEntityDiagram extends EntityDiagram { constructor(elem: HTMLElement, data: EntityRelationshipData) { super(elem, data); const classList = this.diagram.element.classList; classList.add("canvaslike"); classList.add("spectral"); new Zoomable(this.diagram.host, this.diagram.host); new Pannable(this.diagram.host, this.diagram.host); this.entities.forEach(entity => { entity.compact(true); this.diagram.addElement(entity.element); new Movable(entity.heading, entity.element); }); this.relationships.forEach(relationship => { this.diagram.addConnector(new Arrow(relationship.source.element, relationship.target.element)); }); const nodes = Array.from(this.entities.values()); const edges = this.relationships.map(relationship => { return { source: relationship.source.parent, target: relationship.target.parent }; }); const layout = new SpectralLayout(nodes, edges); const points = layout.calculate(); nodes.forEach((entity, index) => { const element = entity.element; const x = points[index]!.x; const y = points[index]!.y; element.style.left = (80 * x + 10) + "%"; element.style.top = (80 * y + 10) + "%"; }); } } /** * An interface that prevents name mangling for factory functions when TypeScript code is fed to the Closure Compiler. */ declare interface EntityRelationshipFactory { createElasticDiagram(elem: HTMLElement, data: EntityRelationshipData, options: ElasticLayoutOptions): ElasticEntityDiagram; createNavigableDiagram(elem: HTMLElement, data: EntityRelationshipData): NavigableEntityDiagram; createSpectralDiagram(elem: HTMLElement, data: EntityRelationshipData): SpectralEntityDiagram; } /** * The concrete implementation of the factory function interface. */ class EntityRelationshipFactoryImpl implements EntityRelationshipFactory { createElasticDiagram(elem: HTMLElement, data: EntityRelationshipData, options: ElasticLayoutOptions): ElasticEntityDiagram { return new ElasticEntityDiagram(elem, data, options); } createNavigableDiagram(elem: HTMLElement, data: EntityRelationshipData): NavigableEntityDiagram { return new NavigableEntityDiagram(elem, data); } createSpectralDiagram(elem: HTMLElement, data: EntityRelationshipData): SpectralEntityDiagram { return new SpectralEntityDiagram(elem, data); } } declare global { interface Window { erd: any, TabPanel: any; } } // export symbols to caller domain (necessary in Closure Compiler context) window["erd"] = new EntityRelationshipFactoryImpl(); window["TabPanel"] = TabPanel;