UNPKG

@reactodia/workspace

Version:

Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.

682 lines (601 loc) 20.8 kB
import { HashSet, type ReadonlyHashSet } from '@reactodia/hashmap'; import { Events, EventSource, PropertyChange } from '../coreUtils/events'; import { TranslatedText } from '../coreUtils/i18n'; import { ElementIri, ElementModel, ElementTypeIri, ElementTypeModel, LinkKey, LinkModel, LinkTypeIri, LinkTypeModel, PropertyTypeIri, PropertyTypeModel, equalLinks, hashLink, } from '../data/model'; import { Element, ElementEvents, ElementProps, ElementTemplateState, Link, LinkEvents, LinkProps, LinkTemplateState, } from '../diagram/elements'; import { Command } from '../diagram/history'; import { DiagramModel } from '../diagram/model'; /** * Event data for {@link EntityElement} events. * * @see {@link EntityElement} */ export interface EntityElementEvents extends ElementEvents { /** * Triggered on {@link EntityElement.data} property change. */ changeData: PropertyChange<EntityElement, ElementModel>; } /** * Properties for {@link EntityElement}. * * @see {@link EntityElement} */ export interface EntityElementProps extends ElementProps { data: ElementModel; } /** * Diagram element representing an graph entity referenced by an IRI. * * @category Core */ export class EntityElement extends Element { declare readonly events: Events<EntityElementEvents>; private _data: ElementModel; constructor(props: EntityElementProps) { super(props); this._data = props.data; } /** * Creates an empty (placeholder) data for the specified entity IRI. * * This data can be used to display an entity in the UI * until the actual data is loaded from a data provider. */ static placeholderData(iri: ElementIri): ElementModel { return { id: iri, types: [], properties: {}, }; } protected get entitySource(): EventSource<EntityElementEvents> { return this.source as EventSource<any>; } get iri() { return this._data.id; } get data(): ElementModel { return this._data; } setData(value: ElementModel) { const previous = this._data; if (previous === value) { return; } this._data = value; this.entitySource.trigger('changeData', {source: this, previous}); this.entitySource.trigger('requestedRedraw', {source: this, level: 'template'}); } } /** * Command to set {@link EntityElement.data entity element data}. * * @category Commands */ export function setEntityElementData( entity: EntityElement, data: ElementModel ): Command { return Command.create(TranslatedText.text('commands.set_entity_data.title'), () => { const previous = entity.data; entity.setData(data); return setEntityElementData(entity, previous); }); } /** * Event data for {@link EntityGroup} events. * * @see {@link EntityGroup} */ export interface EntityGroupEvents extends ElementEvents { /** * Triggered on {@link EntityGroup.items} property change. */ changeItems: PropertyChange<EntityGroup, ReadonlyArray<EntityGroupItem>>; } /** * Properties for {@link EntityGroup}. * * @see {@link EntityGroup} */ export interface EntityGroupProps extends ElementProps { items?: ReadonlyArray<EntityGroupItem>; } /** * Diagram element representing a group of multiple graph entities. * * @category Core */ export class EntityGroup extends Element { declare readonly events: Events<EntityGroupEvents>; private _items: ReadonlyArray<EntityGroupItem>; private _itemIris = new Set<ElementIri>(); constructor(props: EntityGroupProps) { super(props); this._items = props.items ?? []; this.updateItemIris(); } protected get entitySource(): EventSource<EntityGroupEvents> { return this.source as EventSource<any>; } get items(): ReadonlyArray<EntityGroupItem> { return this._items; } setItems(value: ReadonlyArray<EntityGroupItem>): void { const previous = this._items; if (previous === value) { return; } this._items = value; this.updateItemIris(); this.entitySource.trigger('changeItems', {source: this, previous}); this.entitySource.trigger('requestedRedraw', {source: this, level: 'template'}); } get itemIris(): ReadonlySet<ElementIri> { return this._itemIris; } private updateItemIris(): void { this._itemIris.clear(); for (const item of this._items) { this._itemIris.add(item.data.id); } } } /** * Represents a single entity contained in the entity group. * * @see {@link EntityGroup.items} */ export interface EntityGroupItem { readonly data: ElementModel; readonly elementState?: ElementTemplateState | undefined; } /** * Command to set {@link EntityGroup.items entity group items}. * * @category Commands */ export function setEntityGroupItems(group: EntityGroup, items: ReadonlyArray<EntityGroupItem>): Command { return Command.create(TranslatedText.text('commands.set_entity_group_items.title'), () => { const before = group.items; group.setItems(items); return setEntityGroupItems(group, before); }); } /** * Iterates over data for all entities of the target element. * * @category Core */ export function* iterateEntitiesOf(element: Element): Iterable<ElementModel> { if (element instanceof EntityElement) { yield element.data; } else if (element instanceof EntityGroup) { for (const item of element.items) { yield item.data; } } } /** * Event data for {@link RelationLink} events. * * @see {@link RelationLink} */ export interface RelationLinkEvents extends LinkEvents { /** * Triggered on {@link RelationLink.data} property change. */ changeData: PropertyChange<RelationLink, LinkModel>; } /** * Properties for {@link RelationLink}. * * @see {@link RelationLink} */ export interface RelationLinkProps extends LinkProps { data: LinkModel; } /** * Diagram link representing a graph relation, uniquely identified by * (source entity IRI, target entity IRI, link type IRI) tuple. * * @category Core */ export class RelationLink extends Link { declare readonly events: Events<RelationLinkEvents>; private _data: LinkModel; constructor(props: RelationLinkProps) { super(props); this._data = props.data; } protected get relationSource(): EventSource<RelationLinkEvents> { return this.source as EventSource<any>; } protected override getTypeId(): LinkTypeIri { return this._data.linkTypeId; } get data(): LinkModel { return this._data; } setData(value: LinkModel) { const previous = this._data; if (previous === value) { return; } this._data = value; this.relationSource.trigger('changeData', {source: this, previous}); this.relationSource.trigger('requestedRedraw', {source: this}); } withDirection(data: LinkModel): RelationLink { if (!(data.sourceId === this.data.sourceId || data.sourceId === this.data.targetId)) { throw new Error('New link source IRI is unrelated to original link'); } if (!(data.targetId === this.data.sourceId || data.targetId === this.data.targetId)) { throw new Error('New link target IRI is unrelated to original link'); } const sourceId = data.sourceId === this.data.sourceId ? this.sourceId : this.targetId; const targetId = data.targetId === this.data.targetId ? this.targetId : this.sourceId; return new RelationLink({sourceId, targetId, data}); } } /** * Command to set relation {@link RelationLink.data relation link data}. * * @category Commands */ export function setRelationLinkData( relation: RelationLink, data: LinkModel ): Command { return Command.create(TranslatedText.text('commands.set_relation_data.title'), () => { const previous = relation.data; relation.setData(data); return setRelationLinkData(relation, previous); }); } /** * Event data for {@link RelationGroup} events. * * @see {@link RelationGroup} */ export interface RelationGroupEvents extends LinkEvents { /** * Triggered on {@link RelationGroup.items} property change. */ changeItems: PropertyChange<RelationGroup, ReadonlyArray<RelationGroupItem>>; } /** * Properties for {@link RelationGroup}. * * @see {@link RelationGroup} */ export interface RelationGroupProps extends LinkProps { typeId: LinkTypeIri; items: ReadonlyArray<RelationGroupItem>; } /** * Diagram link representing a group of multiple graph relations. * * @category Core */ export class RelationGroup extends Link { declare readonly events: Events<RelationGroupEvents>; private readonly _typeId: LinkTypeIri; private _items: ReadonlyArray<RelationGroupItem>; private readonly _itemKeys = new HashSet<LinkKey>(hashLink, equalLinks); private readonly _sources = new Set<ElementIri>(); private readonly _targets = new Set<ElementIri>(); constructor(props: RelationGroupProps) { super(props); this._typeId = props.typeId; this._items = props.items ?? []; this.updateItemKeys(); } protected get relationSource(): EventSource<RelationGroupEvents> { return this.source as EventSource<any>; } protected override getTypeId(): LinkTypeIri { return this._typeId; } get items(): ReadonlyArray<RelationGroupItem> { return this._items; } setItems(value: ReadonlyArray<RelationGroupItem>): void { for (const item of value) { if (item.data.linkTypeId !== this._typeId) { throw new Error('RelationGroup should have only items with same type IRI'); } } const previous = this._items; if (previous === value) { return; } this._items = value; this.updateItemKeys(); this.relationSource.trigger('changeItems', {source: this, previous}); this.relationSource.trigger('requestedRedraw', {source: this}); } get itemKeys(): ReadonlyHashSet<LinkKey> { return this._itemKeys; } get itemSources(): ReadonlySet<ElementIri> { return this._sources; } get itemTargets(): ReadonlySet<ElementIri> { return this._targets; } private updateItemKeys(): void { this._itemKeys.clear(); this._sources.clear(); this._targets.clear(); for (const item of this._items) { this._itemKeys.add(item.data); this._sources.add(item.data.sourceId); this._targets.add(item.data.targetId); } } } /** * Represents a single relation contained in the relation group. * * @see {@link RelationGroup.items} */ export interface RelationGroupItem { readonly data: LinkModel; readonly linkState?: LinkTemplateState | undefined; } /** * Command to set {@link RelationGroup.items relation group items}. * * @category Commands */ export function setRelationGroupItems(group: RelationGroup, items: ReadonlyArray<RelationGroupItem>): Command { return Command.create(TranslatedText.text('commands.set_relation_group_items.title'), () => { const before = group.items; group.setItems(items); return setRelationGroupItems(group, before); }); } /** * Iterates over data for all relations of the target link. * * @category Core */ export function* iterateRelationsOf(link: Link): Iterable<LinkModel> { if (link instanceof RelationLink) { yield link.data; } else if (link instanceof RelationGroup) { for (const item of link.items) { yield item.data; } } } /** * Event data for {@link ElementType} events. * * @see {@link ElementType} */ export interface ElementTypeEvents { /** * Triggered on {@link ElementType.data} property change. */ changeData: PropertyChange<ElementType, ElementTypeModel | undefined>; } /** * Stores data of an entity type in the graph. * * @category Core */ export class ElementType { private readonly source = new EventSource<ElementTypeEvents>(); readonly events: Events<ElementTypeEvents> = this.source; readonly id: ElementTypeIri; private _data: ElementTypeModel | undefined; constructor(props: { id: ElementTypeIri; data?: ElementTypeModel; }) { const {id, data} = props; this.id = id; this.setData(data); } get data(): ElementTypeModel | undefined { return this._data; } setData(value: ElementTypeModel | undefined): void { if (value && value.id !== this.id) { throw new Error('ElementTypeModel.id does not match ElementType.id'); } const previous = this._data; if (previous === value) { return; } this._data = value; this.source.trigger('changeData', {source: this, previous}); } } /** * Event data for {@link PropertyType} events. * * @see {@link PropertyType} */ export interface PropertyTypeEvents { /** * Triggered on {@link PropertyType.data} property change. */ changeData: PropertyChange<PropertyType, PropertyTypeModel | undefined>; } /** * Stores data of a property type in the graph. * * @category Core */ export class PropertyType { private readonly source = new EventSource<PropertyTypeEvents>(); readonly events: Events<PropertyTypeEvents> = this.source; readonly id: PropertyTypeIri; private _data: PropertyTypeModel | undefined; constructor(props: { id: PropertyTypeIri; data?: PropertyTypeModel; }) { const {id, data} = props; this.id = id; this.setData(data); } get data(): PropertyTypeModel | undefined { return this._data; } setData(value: PropertyTypeModel | undefined): void { if (value && value.id !== this.id) { throw new Error('PropertyTypeModel.id does not match PropertyType.id'); } const previous = this._data; if (previous === value) { return; } this._data = value; this.source.trigger('changeData', {source: this, previous}); } } /** * Event data for {@link LinkType} events. * * @see {@link LinkType} */ export interface LinkTypeEvents { /** * Triggered on {@link LinkType.data} property change. */ changeData: PropertyChange<LinkType, LinkTypeModel | undefined>; } /** * Stores data of a link type in the graph. * * @category Core */ export class LinkType { private readonly source = new EventSource<LinkTypeEvents>(); readonly events: Events<LinkTypeEvents> = this.source; readonly id: LinkTypeIri; private _data: LinkTypeModel | undefined; constructor(props: { id: LinkTypeIri; data?: LinkTypeModel | undefined; }) { const {id, data} = props; this.id = id; this.setData(data); } get data(): LinkTypeModel | undefined { return this._data; } setData(value: LinkTypeModel | undefined): void { if (value && value.id !== this.id) { throw new Error('LinkTypeModel.id does not match LinkType.id'); } const previous = this._data; if (previous === value) { return; } this._data = value; this.source.trigger('changeData', {source: this, previous}); } } /** * Command to replace {@link EntityElement.data data} for all entities with target IRI on the diagram. * * If IRI in the new `data` is different from the `target`, the relations * connected to the entities will have their data changed as well to refer * to the same entities by the new IRI. * * @category Commands */ export function changeEntityData(model: DiagramModel, target: ElementIri, data: ElementModel): Command { const command = Command.create(TranslatedText.text('commands.change_entity.title'), () => { const previousIri = target; const newIri = data.id; const previousEntities = new Map<EntityElement, ElementModel>(); const previousEntityGroups = new Map<EntityGroup, ReadonlyArray<EntityGroupItem>>(); const previousRelations = new Map<RelationLink, LinkModel>(); const previousRelationGroups = new Map<RelationGroup, ReadonlyArray<RelationGroupItem>>(); const updateLinksToReferByNewIri = (element: Element) => { for (const link of model.getElementLinks(element)) { if (link instanceof RelationLink) { previousRelations.set(link, link.data); link.setData(mapRelationEndpoint(link.data, previousIri, newIri)); } else if (link instanceof RelationGroup) { if (link.itemSources.has(previousIri) || link.itemTargets.has(previousIri)) { previousRelationGroups.set(link, link.items); const items = link.items.map((item): RelationGroupItem => ({ ...item, data: mapRelationEndpoint(item.data, previousIri, newIri), })); link.setItems(items); } } } }; for (const element of model.elements) { if (element instanceof EntityElement) { if (element.iri === target) { previousEntities.set(element, element.data); element.setData(data); updateLinksToReferByNewIri(element); } } else if (element instanceof EntityGroup) { if (element.itemIris.has(target)) { previousEntityGroups.set(element, element.items); const nextItems = element.items.map((item): EntityGroupItem => item.data.id === target ? {...item, data} : item ); element.setItems(nextItems); updateLinksToReferByNewIri(element); } } } return Command.create(TranslatedText.text('commands.change_entity.title'), () => { for (const [element, previousData] of previousEntities) { element.setData(previousData); } for (const [element, previousItems] of previousEntityGroups) { element.setItems(previousItems); } for (const [link, previousData] of previousRelations) { link.setData(previousData); } for (const [link, previousItems] of previousRelationGroups) { link.setItems(previousItems); } return command; }); }); return command; } function mapRelationEndpoint(relation: LinkModel, oldIri: ElementIri, newIri: ElementIri): LinkModel { let data = relation; if (data.sourceId === oldIri) { data = {...data, sourceId: newIri}; } if (data.targetId === oldIri) { data = {...data, targetId: newIri}; } return data; } /** * Command to replace {@link RelationLink.data data} for all relations with same target identity. * * The relation identity should be the same for both `oldData` and `newData` * otherwise an error wil be thrown. * * @category Commands */ export function changeRelationData(model: DiagramModel, oldData: LinkModel, newData: LinkModel): Command { if (!equalLinks(oldData, newData)) { throw new Error('Cannot change typeId, sourceId or targetId when changing link data'); } return Command.create(TranslatedText.text('commands.change_relation.title'), () => { for (const link of model.links) { if (link instanceof RelationLink && equalLinks(link.data, oldData)) { link.setData(newData); } } return changeRelationData(model, newData, oldData); }); }