@reactodia/workspace
Version:
Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.
220 lines (189 loc) • 7.62 kB
text/typescript
import { OrderedMap } from '../coreUtils/collections';
import { EventSource, Events, AnyEvent, AnyListener, PropertyChange } from '../coreUtils/events';
import { LinkTypeIri } from '../data/model';
import {
Element, ElementEvents, Link, LinkEvents, LinkTypeVisibility,
} from './elements';
export interface GraphEvents {
changeCells: CellsChangedEvent;
elementEvent: AnyEvent<ElementEvents>;
linkEvent: AnyEvent<LinkEvents>;
changeLinkVisibility: PropertyChange<LinkTypeIri, LinkTypeVisibility>;
}
/**
* Event data for diagram cells changed event.
*/
export interface CellsChangedEvent {
/**
* If `true`, it should be assumed that many elements or links
* were added or removed at the same time, and any caches based
* on the diagram content should be completely re-computed.
*/
readonly updateAll: boolean;
/**
* Specific element was added or removed.
*/
readonly changedElement?: Element;
/**
* Specific links were added or removed.
*/
readonly changedLinks?: ReadonlyArray<Link>;
}
export class Graph {
private readonly source = new EventSource<GraphEvents>();
readonly events: Events<GraphEvents> = this.source;
private readonly elements = new OrderedMap<Element>();
private readonly links = new OrderedMap<Link>();
private readonly elementLinks = new WeakMap<Element, Link[]>();
private readonly EMPTY_LINKS: Link[] = [];
private readonly linkTypeVisibility = new Map<LinkTypeIri, LinkTypeVisibility>();
getElements() { return this.elements.items; }
getLinks() { return this.links.items; }
getLink(linkId: string): Link | undefined {
return this.links.get(linkId);
}
getElementLinks(element: Element): ReadonlyArray<Link> {
return this.elementLinks.get(element) ?? [];
}
*iterateLinks(sourceId: string, targetId: string, linkTypeId?: LinkTypeIri): Iterable<Link> {
const source = this.getElement(sourceId);
const target = this.getElement(targetId);
if (!(source && target)) {
return;
}
const sourceLinks = this.elementLinks.get(source) ?? this.EMPTY_LINKS;
const targetLinks = this.elementLinks.get(target) ?? this.EMPTY_LINKS;
const linksToIterate = sourceLinks.length <= targetLinks.length ? sourceLinks : targetLinks;
for (const link of linksToIterate) {
if (
link.sourceId === sourceId &&
link.targetId === targetId &&
(!linkTypeId || link.typeId === linkTypeId)
) {
yield link;
}
}
}
sourceOf(link: Link) {
return this.getElement(link.sourceId);
}
targetOf(link: Link) {
return this.getElement(link.targetId);
}
reorderElements(compare: (a: Element, b: Element) => number) {
this.elements.reorder(compare);
}
getElement(elementId: string): Element | undefined {
return this.elements.get(elementId);
}
addElement(element: Element): void {
if (this.getElement(element.id)) {
throw new Error(`Element '${element.id}' already exists.`);
}
element.events.onAny(this.onElementEvent);
this.elements.push(element.id, element);
this.source.trigger('changeCells', {updateAll: false, changedElement: element});
}
private onElementEvent: AnyListener<ElementEvents> = (data) => {
this.source.trigger('elementEvent', {data});
};
removeElement(elementId: string): void {
const element = this.elements.get(elementId);
if (element) {
const options = {silent: true};
// clone links to prevent modifications during iteration
const changedLinks = [...this.getElementLinks(element)];
for (const link of changedLinks) {
this.removeLink(link.id, options);
}
this.elements.delete(elementId);
element.events.offAny(this.onElementEvent);
this.source.trigger('changeCells', {updateAll: false, changedElement: element, changedLinks});
}
}
addLink(link: Link): void {
if (this.getLink(link.id)) {
throw new Error(`Link already exists: ${link.id}`);
}
const source = this.sourceOf(link);
if (!source) {
throw new Error(`Link source not found: ${link.sourceId}`);
}
const target = this.targetOf(link);
if (!target) {
throw new Error(`Link target not found: ${link.targetId}`);
}
let sourceLinks = this.elementLinks.get(source);
if (!sourceLinks) {
sourceLinks = [];
this.elementLinks.set(source, sourceLinks);
}
sourceLinks.push(link);
if (link.sourceId !== link.targetId) {
let targetLinks = this.elementLinks.get(target);
if (!targetLinks) {
targetLinks = [];
this.elementLinks.set(target, targetLinks);
}
targetLinks.push(link);
}
link.events.onAny(this.onLinkEvent);
this.links.push(link.id, link);
this.source.trigger('changeCells', {updateAll: false, changedLinks: [link]});
}
private onLinkEvent: AnyListener<LinkEvents> = (data) => {
this.source.trigger('linkEvent', {data});
};
removeLink(linkId: string, options?: { silent?: boolean }) {
const link = this.links.delete(linkId);
if (link) {
link.events.offAny(this.onLinkEvent);
this.removeLinkReferences(link);
if (!(options && options.silent)) {
this.source.trigger('changeCells', {updateAll: false, changedLinks: [link]});
}
}
}
private removeLinkReferences(link: Link): void {
const source = this.getElement(link.sourceId);
if (source) {
const sourceLinks = this.elementLinks.get(source);
if (sourceLinks) {
removeLinkFrom(sourceLinks, link);
if (sourceLinks.length === 0) {
this.elementLinks.delete(source);
}
}
}
const target = this.getElement(link.targetId);
if (target) {
const targetLinks = this.elementLinks.get(target);
if (targetLinks) {
removeLinkFrom(targetLinks, link);
if (targetLinks.length === 0) {
this.elementLinks.delete(target);
}
}
}
}
get linkVisibility(): ReadonlyMap<LinkTypeIri, LinkTypeVisibility> {
return this.linkTypeVisibility;
}
getLinkVisibility(linkTypeId: LinkTypeIri): LinkTypeVisibility {
return this.linkTypeVisibility.get(linkTypeId) ?? 'visible';
}
setLinkVisibility(linkTypeId: LinkTypeIri, value: LinkTypeVisibility): void {
const previous = this.getLinkVisibility(linkTypeId);
if (value === previous) {
return;
}
this.linkTypeVisibility.set(linkTypeId, value);
this.source.trigger('changeLinkVisibility', {source: linkTypeId, previous});
}
}
function removeLinkFrom(links: Link[], link: Link): void {
const index = links.indexOf(link);
if (index >= 0) {
links.splice(index, 1);
}
}