@reactodia/workspace
Version:
Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.
968 lines (861 loc) • 28.8 kB
text/typescript
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 {
PlaceholderDataProperty, TemplateState, type SerializedTemplateState,
} from '../data/schema';
import {
Element, ElementEvents, ElementProps,
Link, LinkEvents, LinkProps,
} from '../diagram/elements';
import { Command } from '../diagram/history';
import { DiagramModel } from '../diagram/model';
import type {
SerializedElement, SerializableElementCell, ElementFromJsonOptions,
SerializedLink, SerializableLinkCell, LinkFromJsonOptions,
} from './serializedDiagram';
/**
* 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;
}
/**
* Data graph entity represented by a diagram element and 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.
*
* @see {@link PlaceholderDataProperty}
*/
static placeholderData(iri: ElementIri): ElementModel {
return {
id: iri,
types: [],
properties: {
[PlaceholderDataProperty]: [],
},
};
}
/**
* Returns `true` if the `data` is an empty placeholder (not yet loaded) data,
* otherwise `false`.
*
* The entity data is considered to be a placeholder data if `data.properties`
* contains `PlaceholderDataProperty` key with a empty or non-empty values.
*
* @see {@link PlaceholderDataProperty}
*/
static isPlaceholderData(data: ElementModel): boolean {
return (
Object.prototype.hasOwnProperty.call(data.properties, PlaceholderDataProperty) &&
data.properties[PlaceholderDataProperty] !== undefined
);
}
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'});
}
static readonly fromJSONType = 'Element';
static fromJSON(
state: SerializedEntityElement,
options: ElementFromJsonOptions
): EntityElement | undefined {
const {'@id': id, iri, position, isExpanded, elementState} = state;
if (iri) {
const initialData = options.getInitialData(iri);
return new EntityElement({
id,
data: initialData ?? EntityElement.placeholderData(iri),
position,
expanded: isExpanded,
elementState: options.mapTemplateState(
TemplateState.fromJSON(elementState)
),
});
}
return undefined;
}
toJSON(): SerializedEntityElement {
return {
'@type': 'Element',
'@id': this.id,
iri: this.iri,
position: this.position,
elementState: this.elementState.toJSON(),
};
}
}
EntityElement satisfies SerializableElementCell<EntityElement>;
/**
* Serialized entity element state.
*
* @see {@link EntityElement}
*/
export interface SerializedEntityElement extends SerializedElement {
'@type': 'Element';
iri?: ElementIri;
/**
* @deprecated only deserialized to {@link TemplateProperties.Expanded}
* in {@link elementState} for compatibility
*/
isExpanded?: boolean;
}
/**
* 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>;
}
/**
* A group of multiple data graph entities represented by a diagram element.
*
* @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);
}
}
static readonly fromJSONType = 'ElementGroup';
static fromJSON(
state: SerializedEntityGroup,
options: ElementFromJsonOptions
): EntityGroup | undefined {
const {'@id': id, items, position, elementState} = state;
const groupItems: EntityGroupItem[] = [];
for (const item of items) {
const initialData = options.getInitialData(item.iri);
groupItems.push({
data: initialData ?? EntityElement.placeholderData(item.iri),
elementState: options.mapTemplateState(
TemplateState.fromJSON(item.elementState)
),
});
}
return new EntityGroup({
id,
items: groupItems,
position,
elementState: TemplateState.fromJSON(elementState),
});
}
toJSON(): SerializedEntityGroup {
return {
'@type': 'ElementGroup',
'@id': this.id,
items: this.items.map((item): SerializedEntityGroupItem => ({
'@type': 'ElementItem',
iri: item.data.id,
elementState: item.elementState?.toJSON(),
})),
position: this.position,
elementState: this.elementState.toJSON(),
};
}
}
EntityGroup satisfies SerializableElementCell<EntityGroup>;
/**
* Represents a single entity contained in the entity group.
*
* @see {@link EntityGroup.items}
*/
export interface EntityGroupItem {
readonly data: ElementModel;
readonly elementState?: TemplateState | undefined;
}
/**
* Serialized entity group state.
*
* @see {@link EntityGroup}
*/
export interface SerializedEntityGroup extends SerializedElement {
'@type': 'ElementGroup';
items: ReadonlyArray<SerializedEntityGroupItem>;
}
/**
* Serialized entity group item state.
*
* @see {@link EntityGroup}
* @see {@link SerializedEntityGroup}
*/
export interface SerializedEntityGroupItem {
'@type': 'ElementItem';
iri: ElementIri;
elementState?: SerializedTemplateState;
}
/**
* 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;
}
/**
* Data graph relation represented by a diagram link, 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});
}
static readonly fromJSONType = 'Link';
static fromJSON(
state: SerializedRelationLink,
options: LinkFromJsonOptions
): RelationLink | undefined {
const {'@id': id, property, vertices, linkState} = state;
const {source, target} = options;
const sourceIri = state.sourceIri ?? (
source instanceof EntityElement ? source.data.id : undefined
);
const targetIri = state.targetIri ?? (
target instanceof EntityElement ? target.data.id : undefined
);
if (sourceIri && targetIri) {
const key: LinkModel = {
linkTypeId: property,
sourceId: sourceIri,
targetId: targetIri,
properties: {},
};
const initialData = options.getInitialData(key);
return new RelationLink({
id,
sourceId: source.id,
targetId: target.id,
data: initialData ?? key,
vertices,
linkState: options.mapTemplateState(
TemplateState.fromJSON(linkState)
),
});
}
return undefined;
}
toJSON(): SerializedRelationLink {
return {
'@type': 'Link',
'@id': this.id,
property: this.typeId,
source: {'@id': this.sourceId},
target: {'@id': this.targetId},
sourceIri: this.data.sourceId,
targetIri: this.data.targetId,
vertices: [...this.vertices],
linkState: this.linkState.toJSON(),
};
}
}
RelationLink satisfies SerializableLinkCell<RelationLink>;
/**
* Serialized relation link state.
*
* @see {@link RelationLink}
*/
export interface SerializedRelationLink extends SerializedLink {
'@type': 'Link';
property: LinkTypeIri;
targetIri?: ElementIri;
sourceIri?: ElementIri;
}
/**
* 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>;
}
/**
* A group of multiple data graph relations represented by a diagram link.
*
* @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);
}
}
static readonly fromJSONType = 'LinkGroup';
static fromJSON(
state: SerializedRelationGroup,
options: LinkFromJsonOptions
): RelationGroup | undefined {
const {'@id': id, property, vertices, linkState} = state;
const {source, target} = options;
const groupItems: RelationGroupItem[] = [];
for (const item of state.items) {
const key: LinkModel = {
linkTypeId: state.property,
sourceId: item.sourceIri,
targetId: item.targetIri,
properties: {},
};
const initialData = options.getInitialData(key);
groupItems.push({
data: initialData ?? key,
linkState: options.mapTemplateState(
TemplateState.fromJSON(item.linkState)
),
});
}
return new RelationGroup({
id,
typeId: property,
sourceId: source.id,
targetId: target.id,
items: groupItems,
vertices,
linkState: options.mapTemplateState(
TemplateState.fromJSON(linkState)
),
});
}
toJSON(): SerializedRelationGroup {
return {
'@type': 'LinkGroup',
'@id': this.id,
property: this.typeId,
source: {'@id': this.sourceId},
target: {'@id': this.targetId},
items: this.items.map((item): SerializedRelationGroupItem => ({
'@type': 'LinkItem',
sourceIri: item.data.sourceId,
targetIri: item.data.targetId,
linkState: item.linkState?.toJSON(),
})),
vertices: [...this.vertices],
linkState: this.linkState.toJSON(),
};
}
}
RelationGroup satisfies SerializableLinkCell<RelationGroup>;
/**
* Represents a single relation contained in the relation group.
*
* @see {@link RelationGroup.items}
*/
export interface RelationGroupItem {
readonly data: LinkModel;
readonly linkState?: TemplateState | undefined;
}
/**
* Serialized relation group state.
*
* @see {@link RelationGroup}
*/
export interface SerializedRelationGroup extends SerializedLink {
'@type': 'LinkGroup';
property: LinkTypeIri;
items: ReadonlyArray<SerializedRelationGroupItem>;
}
/**
* Serialized relation group item state.
*
* @see {@link RelationGroup}
* @see {@link SerializedRelationGroup}
*/
export interface SerializedRelationGroupItem {
'@type': 'LinkItem';
targetIri: ElementIri;
sourceIri: ElementIri;
linkState?: SerializedTemplateState;
}
/**
* 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);
});
}