UNPKG

@reactodia/workspace

Version:

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

1,126 lines (1,019 loc) 39.9 kB
import type { ReadonlyHashMap } from '@reactodia/hashmap'; import { AbortScope } from '../coreUtils/async'; import { AnyEvent, EventSource, Events } from '../coreUtils/events'; import { Translation, TranslatedText } from '../coreUtils/i18n'; import { ElementIri, ElementModel, ElementTypeIri, LinkKey, LinkModel, LinkTypeModel, LinkTypeIri, PropertyTypeIri, equalLinks, } from '../data/model'; import { TemplateState } from '../data/schema'; import { EmptyDataProvider } from '../data/decorated/emptyDataProvider'; import { DataProvider } from '../data/dataProvider'; import * as Rdf from '../data/rdf/rdfModel'; import { setLinkState } from '../diagram/commands'; import { Link, LinkTypeVisibility } from '../diagram/elements'; import { Rect, getContentFittingBox } from '../diagram/geometry'; import { Command } from '../diagram/history'; import { DiagramModel, DiagramModelEvents, DiagramModelOptions, GraphStructure, } from '../diagram/model'; import { AnnotationElement, AnnotationLink } from './annotationCells'; import { EntityElement, EntityGroup, EntityGroupItem, RelationLink, RelationGroup, RelationGroupItem, ElementType, ElementTypeEvents, PropertyType, PropertyTypeEvents, LinkType, LinkTypeEvents, iterateEntitiesOf, setEntityGroupItems, setRelationGroupItems, setRelationLinkData, } from './dataElements'; import { DataFetcher, ChangeOperationsEvent, FetchOperation, FetchOperationTargetType, FetchOperationTypeToTarget, } from './dataFetcher'; import { type DataLocaleProvider, DefaultDataLocaleProvider } from './dataLocaleProvider'; import { SerializedDiagram, SerializedLinkOptions, emptyDiagram, serializeDiagram, deserializeDiagram, markLayoutOnly, SerializableElementCell, SerializableLinkCell, } from './serializedDiagram'; import { DataGraph } from './dataGraph'; /** * Event data for {@link DataDiagramModel} events. * * @see {@link DataDiagramModel} */ export interface DataDiagramModelEvents extends DiagramModelEvents { /** * Triggered on any event from an element type in the graph. */ elementTypeEvent: AnyEvent<ElementTypeEvents>; /** * Triggered on any event from a link type in the graph. */ linkTypeEvent: AnyEvent<LinkTypeEvents>; /** * Triggered on any event from a property type in the graph. */ propertyTypeEvent: AnyEvent<PropertyTypeEvents>; /** * Triggered on start of the diagram "create new" or "import" operations. * * @see {@link DataDiagramModel.createNewDiagram} * @see {@link DataDiagramModel.importLayout} */ loadingStart: { readonly source: DataDiagramModel }; /** * Triggered on successful completion of diagram loading operation. */ loadingSuccess: { readonly source: DataDiagramModel }; /** * Triggered on failed completion of diagram loading operation. */ loadingError: { readonly source: DataDiagramModel; readonly error: unknown; }; /** * Triggered on {@link DataDiagramModel.operations} property change. */ changeOperations: ChangeOperationsEvent; /** * Triggered when a link would be created on the diagram from a data provider. * * It is possible to discard the link and avoid creating it by calling `cancel()` * method on the event object. * * This event is triggered only when a link is created from a data provider and * won't be triggered on explicit call for the model to create links. */ createLoadedLink: { readonly source: DataDiagramModel; readonly model: LinkModel; cancel(): void; }; } /** * Provides entity graph content: elements and connected links, * as well as element, link and property types. * * @category Core */ export interface DataGraphStructure extends GraphStructure { /** * Gets an element type by its {@link ElementType.id} in the graph if exists. * * Element types are added to the graph as requested by * {@link DataDiagramModel.createElementType} so the data (e.g. labels) can be * fetched from a data provider. * * @see {@link DataDiagramModel.createElementType} */ getElementType(elementTypeIri: ElementTypeIri): ElementType | undefined; /** * Gets an link type by its {@link LinkType.id} in the graph if exists. * * Link types are added to the graph as requested by {@link DataDiagramModel.createLinkType} * so the data (e.g. labels) can be fetched from a data provider. * * @see {@link DataDiagramModel.createLinkType} */ getLinkType(linkTypeIri: LinkTypeIri): LinkType | undefined; /** * Gets an property type by its {@link PropertyType.id} in the graph if exists. * * Property types are added to the graph as requested by * {@link DataDiagramModel.createPropertyType} so the data (e.g. labels) can be * fetched from a data provider. * * @see {@link DataDiagramModel.createPropertyType} */ getPropertyType(propertyTypeIri: PropertyTypeIri): PropertyType | undefined; } /** @hidden */ export interface DataDiagramModelOptions extends DiagramModelOptions {} /** * Asynchronously fetches and stores the entity diagram content: * graph elements and links, as well as element, link and property types; * maintains selection and the current language to display the data. * * Additionally, the diagram model provides the means to undo/redo commands * via {@link DataDiagramModel.history history}. * * @category Core */ export class DataDiagramModel extends DiagramModel implements DataGraphStructure { declare readonly events: Events<DataDiagramModelEvents>; private readonly translation: Translation; private dataGraph = new DataGraph(); private loadingScope: AbortScope | undefined; private _dataProvider: DataProvider; private _locale: DataLocaleProvider; private fetcher: DataFetcher; private discardingTask = Promise.resolve(); /** @hidden */ constructor(options: DataDiagramModelOptions) { super(options); this.translation = options.translation; this._dataProvider = new EmptyDataProvider(); this.fetcher = new DataFetcher(this.graph, this.dataGraph, this._dataProvider); this._locale = new DefaultDataLocaleProvider({ model: this, translation: this.translation, }); this.subscribeGraph(); } private get extendedSource(): EventSource<DataDiagramModelEvents> { return this.source as EventSource<any>; } /** * Returns the data provider that is associated with the current diagram * via creating new or importing an existing layout. * * This provider is used to fetch entity graph data on-demand. * * By default, it is set to {@link EmptyDataProvider} instance without any graph data. * * @see {@link createNewDiagram} * @see {@link importLayout} */ get dataProvider(): DataProvider { return this._dataProvider; } protected getTermFactory(): Rdf.DataFactory { return this._dataProvider.factory; } /** * Provides the methods to format the graph data according to the current language. */ get locale(): DataLocaleProvider { return this._locale; } /** * Returns an immutable snapshot of current fetch operations. */ get operations(): ReadonlyArray<FetchOperation> { return this.fetcher.operations; } /** * Returns a reason (thrown error) why latest fetch operation for specific target * failed, if any; otherwise returns `undefined`. */ getOperationFailReason<T extends FetchOperationTargetType>( type: T, target: FetchOperationTypeToTarget[T] ): unknown { return this.fetcher.getFailReason(type, target); } protected override resetGraph(): void { this.dataGraph = new DataGraph(); super.resetGraph(); this.loadingScope?.abort(); this.fetcher.dispose(); } protected override subscribeGraph() { this.graphListener.listen(this.dataGraph.events, 'elementTypeEvent', e => { this.extendedSource.trigger('elementTypeEvent', e); }); this.graphListener.listen(this.dataGraph.events, 'linkTypeEvent', e => { this.extendedSource.trigger('linkTypeEvent', e); }); this.graphListener.listen(this.dataGraph.events, 'propertyTypeEvent', e => { this.extendedSource.trigger('propertyTypeEvent', e); }); super.subscribeGraph(); } private setDataProvider(dataProvider: DataProvider) { this._dataProvider = dataProvider; this.fetcher = new DataFetcher(this.graph, this.dataGraph, dataProvider); this.graphListener.listen(this.fetcher.events, 'changeOperations', e => { this.extendedSource.trigger('changeOperations', e); }); } /** * Clears up the diagram and associates a new data provider for it. * * This method discards all current diagram state (elements, links and other data) * and resets the command history. * * @see {@link importLayout} */ async createNewDiagram(params: { /** * Data provider to associate with the diagram. * * This provider will be used to fetch entity graph data on-demand * for the diagram. */ dataProvider: DataProvider; /** * Formatter for the graph data on the diagram. * * If not specified, the {@link DefaultDataLocaleProvider} is used. * * @see {@link DataDiagramModel.locale} */ locale?: DataLocaleProvider; /** * Cancellation signal. */ signal?: AbortSignal; }): Promise<void> { const {dataProvider, signal} = params; return this.importLayout({dataProvider, signal}); } /** * Restores diagram content from previously exported state and associates * a new data provider for the diagram. * * This method discards all current diagram state (elements, links and other data) * and resets the command history. * * @see {@link createNewDiagram} * @see {@link exportLayout} */ async importLayout(params: { /** * Data provider to associate with the diagram. * * This provider will be used to fetch entity graph data on-demand * for the diagram. */ dataProvider: DataProvider; /** * Formatter for the graph data on the diagram. * * If not specified, the {@link DefaultDataLocaleProvider} is used. * * @see {@link DataDiagramModel.locale} */ locale?: DataLocaleProvider; /** * Diagram state to restore (elements and their positions, * links with visibility settings, etc). * * If specified, current diagram content will be replaced by one * from the state, otherwise the diagram will be cleared up only. */ diagram?: SerializedDiagram; /** * Element cell types to deserialize from the imported diagram state. * * Any element cell type not from this list will be silently ignored. * * **Unstable**: this feature is likely to be changed in the future. * * @default [EntityElement, EntityGroup, AnnotationElement] */ elementCellTypes?: readonly SerializableElementCell[]; /** * Link cell types to deserialize from the imported diagram state. * * Any link cell type not from this list will be silently ignored. * * **Unstable**: this feature is likely to be changed in the future. * * @default [RelationLink, RelationGroup, AnnotationLink] */ linkCellTypes?: readonly SerializableLinkCell[]; /** * Pre-cached data for the entities which should be used instead of * being requested from the data provider on import. */ preloadedElements?: ReadonlyMap<ElementIri, ElementModel>; /** * Pre-cached data for the relations which should be used instead of * being requested from the data provider on import. */ preloadedLinks?: ReadonlyHashMap<LinkKey, LinkModel>; /** * Whether links for the between imported elements should be requested * from the data provider on import. * * @default false */ validateLinks?: boolean; /** * Whether to fetch known link types on import and automatically hide * all unused link types. * * @default false */ hideUnusedLinkTypes?: boolean; /** * Cancellation signal. */ signal?: AbortSignal; }): Promise<void> { const { dataProvider, locale, diagram = emptyDiagram(), elementCellTypes = [EntityElement, EntityGroup, AnnotationElement], linkCellTypes = [RelationLink, RelationGroup, AnnotationLink], preloadedElements, preloadedLinks, validateLinks = false, hideUnusedLinkTypes = false, signal: parentSignal, } = params; await this.discardingTask; this.resetGraph(); this.setDataProvider(dataProvider); this._locale = locale ?? new DefaultDataLocaleProvider({ model: this, translation: this.translation, }); this.loadingScope = new AbortScope(parentSignal); this.extendedSource.trigger('loadingStart', {source: this}); const signal = this.loadingScope.signal; try { signal.throwIfAborted(); this.setLinkSettings(diagram.linkTypeOptions ?? []); this.createGraphElements({ diagram, elementCellTypes, linkCellTypes, preloadedElements, preloadedLinks, markLinksAsLayoutOnly: validateLinks, }); if (hideUnusedLinkTypes) { const linkTypes = await this.dataProvider.knownLinkTypes({signal}); signal.throwIfAborted(); const knownLinkTypes = this.initLinkTypes(linkTypes); this.hideUnusedLinkTypes(knownLinkTypes); } this.subscribeGraph(); const elementIrisToRequestData: ElementIri[] = []; for (const element of this.graph.getElements()) { for (const entity of iterateEntitiesOf(element)) { if (!(preloadedElements && preloadedElements.has(entity.id))) { elementIrisToRequestData.push(entity.id); } } } const requestingModels = this.requestElementData(elementIrisToRequestData); const requestingLinks = params.validateLinks ? this.requestLinks() : Promise.resolve(); await Promise.all([requestingModels, requestingLinks]); this.history.reset(); this.extendedSource.trigger('loadingSuccess', {source: this}); } catch (error) { this.extendedSource.trigger('loadingError', {source: this, error}); throw new Error('Reactodia: failed to import a layout', {cause: error}); } finally { this.loadingScope?.abort(); this.loadingScope = undefined; } } /** * Discards all diagram content and resets associated data provider to en empty one. */ discardLayout(): void { const previous = this.discardingTask; // Run discard in a microtask to avoid warnings due to synchronous // React state updates when called from a lifecycle method, // e.g. from a dispose callback in a useEffect() this.discardingTask = Promise.resolve() .then(async () => { await previous; this.resetGraph(); this.setDataProvider(new EmptyDataProvider()); this.extendedSource.trigger('loadingStart', {source: this}); this.subscribeGraph(); this.history.reset(); this.extendedSource.trigger('loadingSuccess', {source: this}); }) .catch(err => { console.warn('Error while discarding a layout', err); }); } /** * Exports current diagram state to a serializable object. * * The exported state includes element and link geometry, template state, * references to described entities and relations (via IRIs). * Additionally, link type visibility settings are exported as well. * * @see {@link importLayout} */ exportLayout(): SerializedDiagram { const knownLinkTypes = new Set( this.graph.getLinks() .filter(link => link instanceof RelationLink || link instanceof RelationGroup) .map(link => link.typeId) ); const linkTypeVisibility = new Map<LinkTypeIri, LinkTypeVisibility>(); for (const linkTypeIri of knownLinkTypes) { linkTypeVisibility.set(linkTypeIri, this.getLinkVisibility(linkTypeIri)); } return serializeDiagram({ elements: this.graph.getElements(), links: this.graph.getLinks(), linkTypeVisibility, }); } private initLinkTypes(linkTypes: LinkTypeModel[]): LinkType[] { const types: LinkType[] = []; for (const data of linkTypes) { const linkType = new LinkType({id: data.id, data}); this.dataGraph.addLinkType(linkType); types.push(linkType); } return types; } private setLinkSettings(settings: ReadonlyArray<SerializedLinkOptions>) { for (const setting of settings) { const {visible = true, showLabel = true} = setting; const linkTypeId: LinkTypeIri = setting.property; const visibility: LinkTypeVisibility = ( visible && showLabel ? 'visible' : visible && !showLabel ? 'withoutLabel' : 'hidden' ); this.setLinkVisibility(linkTypeId, visibility); } } private createGraphElements(params: { diagram: SerializedDiagram; elementCellTypes: readonly SerializableElementCell[]; linkCellTypes: readonly SerializableLinkCell[]; preloadedElements?: ReadonlyMap<ElementIri, ElementModel>; preloadedLinks?: ReadonlyHashMap<LinkKey, LinkModel>; markLinksAsLayoutOnly: boolean; }): void { const { diagram, elementCellTypes, linkCellTypes, preloadedElements, preloadedLinks, markLinksAsLayoutOnly, } = params; const { elements, links, linkTypeVisibility, } = deserializeDiagram(diagram, { elementCellTypes, linkCellTypes, preloadedElements, preloadedLinks, markLinksAsLayoutOnly, }); const batch = this.history.startBatch( TranslatedText.text('data_diagram_model.import_layout.command') ); for (const [linkTypeIri, visibility] of linkTypeVisibility) { this.setLinkVisibility(linkTypeIri, visibility); } for (const link of links) { if (link instanceof RelationLink || link instanceof RelationGroup) { this.createLinkType(link.typeId); } } for (const element of elements) { this.addElement(element); } for (const link of links) { this.addLink(link); } batch.store(); } private hideUnusedLinkTypes(knownLinkTypes: ReadonlyArray<LinkType>): void { const usedTypes = new Set<LinkTypeIri>(); for (const link of this.graph.getLinks()) { if (link instanceof RelationLink || link instanceof RelationGroup) { usedTypes.add(link.typeId); } } for (const linkType of knownLinkTypes) { if (!usedTypes.has(linkType.id)) { this.setLinkVisibility(linkType.id, 'hidden'); } } } /** * Requests to load all {@link EntityElement entities} with * {@link EntityElement.isPlaceholderData placeholder data} and all * {@link RelationLink relations} connected to them. * * @see {@link DataDiagramModel.requestElementData} * @see {@link DataDiagramModel.requestLinks} */ async requestData(): Promise<void> { const targets = new Set<ElementIri>(); for (const element of this.elements) { for (const entity of iterateEntitiesOf(element)) { if (EntityElement.isPlaceholderData(entity)) { targets.add(entity.id); } } } const elements = Array.from(targets); await Promise.all([ this.fetcher.fetchElementData(targets), this.requestLinks({addedElements: elements}), ]); } /** * Requests to fetch the data for the specified elements from a data provider. */ requestElementData(elementIris: ReadonlyArray<ElementIri>): Promise<void> { return this.fetcher.fetchElementData(new Set<ElementIri>(elementIris)); } /** * Requests to fetch links between all elements on the diagram from a data provider. */ requestLinks(options: RequestLinksOptions = {}): Promise<void> { const {addedElements, linkTypes} = options; const primaryIris: ElementIri[] = []; for (const element of this.graph.getElements()) { for (const entity of iterateEntitiesOf(element)) { primaryIris.push(entity.id); } } const secondaryIris = addedElements ?? primaryIris; if (primaryIris.length === 0 || secondaryIris.length === 0) { return Promise.resolve(); } return this.fetcher .fetchLinks(primaryIris, secondaryIris, linkTypes) .then(links => this.onLinkInfoLoaded(links)); } /** * @deprecated Use {@link DataDiagramModel.requestLinks} instead. */ requestLinksOfType(linkTypeIds?: ReadonlyArray<LinkTypeIri>): Promise<void> { return this.requestLinks({linkTypes: linkTypeIds}); } /** * Creates or gets an existing entity element on the diagram. * * If element is specified as an IRI only, then the * {@link EntityElement.placeholderData placeholder data} will be used. * * If multiple entity elements with the same IRI is on the diagram, * the first one in the order will be returned. * * The operation puts a command to the {@link DiagramModel.history command history}. */ createElement(elementIriOrModel: ElementIri | ElementModel): EntityElement { const elementIri = typeof elementIriOrModel === 'string' ? elementIriOrModel : elementIriOrModel.id; const elements = this.elements.filter((el): el is EntityElement => el instanceof EntityElement && el.iri === elementIri ); if (elements.length > 0) { // usually there should be only one element return elements[0]; } let data = typeof elementIriOrModel === 'string' ? EntityElement.placeholderData(elementIri) : elementIriOrModel; data = {...data, id: data.id}; const element = new EntityElement({data}); this.addElement(element); return element; } private onLinkInfoLoaded(links: LinkModel[]) { let allowToCreate: boolean; const cancel = () => { allowToCreate = false; }; const batch = this.history.startBatch(); for (const linkModel of links) { allowToCreate = true; this.extendedSource.trigger('createLoadedLink', {source: this, model: linkModel, cancel}); if (allowToCreate) { this.createLinks(linkModel); } } batch.discard(); } /** * Creates or gets an existing links for the specified link model. * * Multiple links may exists for the same link model because in some cases * there could be multiple source or target elements with the same IRI. * * Each existing link for the same link model will be updated with the specified data, * link state property `urn:reactodia:layoutOnly` ({@link TemplateProperties.LayoutOnly}) * will be discarded if set. * * The operation puts a command to the {@link DiagramModel.history command history}. */ createLinks(data: LinkModel): Array<RelationLink | RelationGroup> { const sources = this.graph.getElements().filter((el): el is EntityElement | EntityGroup => el instanceof EntityElement && el.iri === data.sourceId || el instanceof EntityGroup && el.itemIris.has(data.sourceId) ); const targets = this.graph.getElements().filter((el): el is EntityElement | EntityGroup => el instanceof EntityElement && el.iri === data.targetId || el instanceof EntityGroup && el.itemIris.has(data.targetId) ); const batch = this.history.startBatch( TranslatedText.text('data_diagram_model.create_links.command') ); const links: Array<RelationLink | RelationGroup> = []; for (const source of sources) { for (const target of targets) { const link = this.createRelation(source, target, data); links.push(link); } } batch.store(); return links; } private createRelation( source: EntityElement | EntityGroup, target: EntityElement | EntityGroup, data: LinkModel ): RelationLink | RelationGroup { const existingLinks = Array.from( this.graph.iterateLinks(source.id, target.id, data.linkTypeId) ); for (const link of existingLinks) { if (link instanceof RelationLink && equalLinks(link.data, data)) { this.history.execute(setLinkState(link, markLayoutOnly(link.linkState, false))); this.history.execute(setRelationLinkData(link, data)); return link; } else if (link instanceof RelationGroup && link.itemKeys.has(data)) { const items = link.items.map((item): RelationGroupItem => equalLinks(item.data, data) ? { ...item, data, linkState: markLayoutOnly(item.linkState ?? TemplateState.empty, false) } : item ); this.history.execute(setRelationGroupItems(link, items)); return link; } } for (const link of existingLinks) { if (link.typeId === link.typeId) { if (link instanceof RelationLink) { const items: RelationGroupItem[] = [ {data: link.data, linkState: link.linkState}, {data}, ]; const group = new RelationGroup({ sourceId: source.id, targetId: target.id, typeId: data.linkTypeId, items, }); this.removeLink(link.id); this.addLink(group); return group; } if (link instanceof RelationGroup) { const items: RelationGroupItem[] = [...link.items, {data}]; this.history.execute(setRelationGroupItems(link, items)); return link; } } } const link = new RelationLink({ sourceId: source.id, targetId: target.id, data, }); this.addLink(link); return link; } getElementType(elementTypeIri: ElementTypeIri): ElementType | undefined { return this.dataGraph.getElementType(elementTypeIri); } /** * Creates or gets an existing element type in the graph. * * If element type does not exists in the graph yet, it will be created * and the data for it will be requested for it from the data provider. */ createElementType(elementTypeIri: ElementTypeIri): ElementType { const existing = this.dataGraph.getElementType(elementTypeIri); if (existing) { return existing; } const elementType = new ElementType({id: elementTypeIri}); this.dataGraph.addElementType(elementType); this.fetcher.fetchElementType(elementType); return elementType; } getLinkType(linkTypeIri: LinkTypeIri): LinkType | undefined { return this.dataGraph.getLinkType(linkTypeIri); } /** * Creates or gets an existing link type in the graph. * * If link type does not exists in the graph yet, it will be created * and the data for it will be requested for it from the data provider. */ createLinkType(linkTypeIri: LinkTypeIri): LinkType { const existing = this.dataGraph.getLinkType(linkTypeIri); if (existing) { return existing; } const linkType = new LinkType({id: linkTypeIri}); this.dataGraph.addLinkType(linkType); this.fetcher.fetchLinkType(linkType); return linkType; } getPropertyType(propertyTypeIri: PropertyTypeIri): PropertyType | undefined { return this.dataGraph.getPropertyType(propertyTypeIri); } /** * Creates or gets an existing property type in the graph. * * If property type does not exists in the graph yet, it will be created * and the data for it will be requested for it from the data provider. */ createPropertyType(propertyIri: PropertyTypeIri): PropertyType { const existing = this.dataGraph.getPropertyType(propertyIri); if (existing) { return existing; } const property = new PropertyType({id: propertyIri}); this.dataGraph.addPropertyType(property); this.fetcher.fetchPropertyType(property); return property; } /** * Groups multiple entity elements into an entity group element. * * Specified entity elements are removed from the diagram and * a single entity group element with these entities is created * at the center of the bounding box between them. * * Relation links from/to specified elements are re-grouped to * form relation group links the same way. * * The operation puts a command to the {@link DiagramModel.history command history}. * * @see {@link ungroupAll} * @see {@link ungroupSome} */ group(entities: ReadonlyArray<EntityElement>): EntityGroup { const batch = this.history.startBatch( TranslatedText.text('data_diagram_model.group_entities.command') ); const entityIds = new Set<ElementIri>(); for (const entity of entities) { entityIds.add(entity.data.id); } const items: EntityGroupItem[] = []; const links = new Set<Link>(); for (const entity of entities) { items.push({ data: entity.data, elementState: entity.elementState, }); for (const link of this.getElementLinks(entity)) { links.add(link); } } for (const link of links) { this.removeLink(link.id); } // Remove entities only after collecting links for each // otherwise some links might get removed before for (const entity of entities) { this.removeElement(entity.id); } const box = getContentFittingBox(entities, [], { getElementSize: () => undefined, }); const group = new EntityGroup({ items, position: Rect.center(box), }); this.addElement(group); this.recreateLinks(links); batch.store(); return group; } /** * Ungroups one or many entity group elements into all contained entity elements. * * Specified entity group elements are removed from the diagram and * all contained entity elements are created at the same position as * the owner group. * * Relation links from/to ungrouped entities are re-grouped to * form relation group links the same way. * * The operation puts a command to the {@link DiagramModel.history command history}. * * @see {@link group} * @see {@link ungroupSome} */ ungroupAll(groups: ReadonlyArray<EntityGroup>): EntityElement[] { const batch = this.history.startBatch( TranslatedText.text('data_diagram_model.ungroup_entities.command') ); const ungrouped: EntityElement[] = []; const links = new Set<Link>(); for (const group of groups) { for (const link of this.getElementLinks(group)) { links.add(link); } this.removeElement(group.id); for (const item of group.items) { const entity = new EntityElement({ data: item.data, position: group.position, elementState: item.elementState, }); this.addElement(entity); ungrouped.push(entity); } } // Restore links only after all elements of a group has been // added to ensure both source and target of a link exists this.recreateLinks(links); batch.store(); return ungrouped; } /** * Ungroups some entities from an entity group element. * * Specified entity group is modified to remove target entities * and re-create them at the same position as the group. * If only one or less entities are left in the group, * the group will be completely ungrouped instead. * * Relation links from/to ungrouped entities are re-grouped to * form relation group links the same way. * * The operation puts a command to the {@link DiagramModel.history command history}. * * @see {@link group} * @see {@link ungroupAll} */ ungroupSome(group: EntityGroup, entities: ReadonlySet<ElementIri>): EntityElement[] { const leftGrouped = group.items.filter(item => !entities.has(item.data.id)); if (leftGrouped.length <= 1) { return this.ungroupAll([group]); } const batch = this.history.startBatch( TranslatedText.text('data_diagram_model.ungroup_entities.command') ); const links = new Set<Link>(); for (const link of this.getElementLinks(group)) { if (link instanceof RelationLink) { if (entities.has(link.data.sourceId) || entities.has(link.data.targetId)) { links.add(link); } } else if (link instanceof RelationGroup) { if (link.items.some(item => entities.has(item.data.sourceId) || entities.has(item.data.targetId))) { links.add(link); } } } for (const link of links) { this.removeLink(link.id); } const ungroupedElements: EntityElement[] = []; for (const item of group.items) { if (entities.has(item.data.id)) { const entity = new EntityElement({ data: item.data, position: group.position, elementState: item.elementState, }); this.addElement(entity); ungroupedElements.push(entity); } } batch.history.execute(setEntityGroupItems(group, leftGrouped)); this.recreateLinks(links); batch.store(); return ungroupedElements; } /** * Re-creates a set of relations or relation groups to automatically * group relations with the same link type connected to entity groups. * * The operation puts a command to the {@link DiagramModel.history command history}. */ regroupLinks(links: ReadonlyArray<RelationLink | RelationGroup>): void { const batch = this.history.startBatch( TranslatedText.text('data_diagram_model.regroup_relations.command') ); for (const link of links) { this.removeLink(link.id); } this.recreateLinks(new Set(links)); batch.store(); } private recreateLinks(links: ReadonlySet<Link>): void { for (const link of links) { if (link instanceof RelationLink) { for (const created of this.createLinks(link.data)) { if (created instanceof RelationLink) { this.history.execute(setLinkState(created, link.linkState)); } } } else if (link instanceof RelationGroup) { for (const {data} of link.items) { this.createLinks(data); } } } } } /** * Options for {@link DataDiagramModel.requestLinks}. * * @see {@link DataDiagramModel.requestLinks} * @see {@link restoreLinksBetweenElements} */ export interface RequestLinksOptions { /** * If specified, skips fetching links between existing elements on the diagram * and only adds links between all elements and the specified set. * * It is recommended to specify this set if possible to allow incremental * link loading (avoid fetching already added links). */ addedElements?: ReadonlyArray<ElementIri>; /** * If specified, instructs the data provider to only return links with one * of the specified types. */ linkTypes?: ReadonlyArray<LinkTypeIri>; } /** * Command effect to request data for the specified entity elements on the diagram * from a data provider. * * @category Commands * @see {@link DataDiagramModel.requestElementData} */ export function requestElementData(model: DataDiagramModel, elementIris: ReadonlyArray<ElementIri>): Command { return Command.effect( TranslatedText.text('data_diagram_model.request_entities.command'), () => void model.requestElementData(elementIris) ); } /** * Command effect to request links between elements on the diagram * from a data provider. * * @category Commands * @see {@link DataDiagramModel.requestLinks} */ export function restoreLinksBetweenElements( model: DataDiagramModel, options: RequestLinksOptions = {} ): Command { return Command.effect( TranslatedText.text('data_diagram_model.request_relations.command'), () => void model.requestLinks(options) ); } export function getAllPresentEntities(graph: DataGraphStructure): Set<ElementIri> { const presentOnDiagram = new Set<ElementIri>(); for (const element of graph.elements) { for (const entity of iterateEntitiesOf(element)) { presentOnDiagram.add(entity.id); } } return presentOnDiagram; }