@reactodia/workspace
Version:
Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.
358 lines (322 loc) • 10.8 kB
text/typescript
import type { ReadonlyHashMap } from '@reactodia/hashmap';
import { ElementIri, ElementModel, LinkKey, LinkModel, LinkTypeIri } from '../data/model';
import {
DiagramContextV1, PlaceholderRelationType, TemplateState, SerializedTemplateState,
TemplateProperties,
} from '../data/schema';
import { Element, Link, LinkTypeVisibility } from '../diagram/elements';
import { Vector } from '../diagram/geometry';
/**
* Serialized diagram state in [JSON-LD](https://json-ld.org/) compatible format.
*
* @see {@link serializeDiagram}
*/
export interface SerializedDiagram {
'@context': any;
'@type': 'Diagram';
layoutData: SerializedLayout;
linkTypeOptions?: ReadonlyArray<SerializedLinkOptions>;
}
/**
* Serialized state for a single link type on a diagram.
*/
export interface SerializedLinkOptions {
'@type': 'LinkTypeOptions';
property: LinkTypeIri;
visible: boolean;
showLabel?: boolean;
}
/**
* Serialized diagram layout, composed of elements and links.
*/
export interface SerializedLayout {
'@type': 'Layout';
elements: ReadonlyArray<SerializedElement>;
links: ReadonlyArray<SerializedLink>;
}
type SerializedState<T> = T extends { toJSON(): infer S } ? Exclude<S, undefined> : never;
type JsonableElement = Element & { toJSON(): SerializedElement };
/**
* Static interface (contract) for serializable graph element
* classes derived from {@link Element}.
*
* **Example**:
* ```ts
* class MyElement extends Reactodia.Element {
* ...
* static readonly fromJSONType = 'MyElement';
* static fromJSON(state: SerializedMyElement): MyElement | undefined {
* ...
* }
* toJSON(): SerializedMyElement {
* ...
* }
* }
*
* interface SerializedMyElement extends Reactodia.SerializedElement {
* '@type': 'MyElement';
* ...
* }
*
* MyElement satisfies SerializableElementCell<MyElement>;
* ```
*/
export interface SerializableElementCell<T extends JsonableElement = JsonableElement> {
new (...args: any[]): T;
readonly fromJSONType: SerializedState<T>['@type'];
fromJSON(state: SerializedState<T>, options: ElementFromJsonOptions): T | undefined;
}
/**
* Options for {@link SerializableElementCell.fromJSON} method.
*/
export interface ElementFromJsonOptions {
readonly getInitialData: (iri: ElementIri) => ElementModel | undefined;
readonly mapTemplateState: (from: TemplateState) => TemplateState;
}
/**
* Serialized graph element state.
*/
export interface SerializedElement {
'@type': string;
'@id': string;
position: Vector;
elementState?: SerializedTemplateState;
}
type JsonableLink = Link & { toJSON(): SerializedLink };
/**
* Static interface (contract) for serializable graph link
* classes derived from {@link Link}.
*
* **Example**:
* ```ts
* class MyLink extends Reactodia.Link {
* ...
* static readonly fromJSONType = 'MyLink';
* static fromJSON(state: SerializedMyLink): MyLink | undefined {
* ...
* }
* toJSON(): SerializedMyLink {
* ...
* }
* }
*
* interface SerializedMyLink extends Reactodia.SerializedLink {
* '@type': 'MyLink';
* ...
* }
*
* MyLink satisfies SerializableLinkCell<MyLink>;
* ```
*/
export interface SerializableLinkCell<T extends JsonableLink = JsonableLink> {
new (...args: any[]): T;
readonly fromJSONType: SerializedState<T>['@type'];
fromJSON(state: SerializedState<T>, options: LinkFromJsonOptions): T | undefined;
}
/**
* Options for {@link SerializableLinkCell.fromJSON} method.
*/
export interface LinkFromJsonOptions {
readonly source: Element;
readonly target: Element;
readonly getInitialData: (key: LinkKey) => LinkModel | undefined;
readonly mapTemplateState: (from: TemplateState) => TemplateState;
}
/**
* Serialized graph link state.
*/
export interface SerializedLink {
'@type': string;
'@id': string;
source: { '@id': string };
target: { '@id': string };
vertices?: ReadonlyArray<Vector>;
linkState?: SerializedTemplateState;
}
/**
* Makes an empty serialized diagram state.
*/
export function emptyDiagram(): SerializedDiagram {
return {
'@context': DiagramContextV1,
'@type': 'Diagram',
layoutData: {
'@type': 'Layout',
elements: [],
links: [],
},
linkTypeOptions: [],
};
}
/**
* Raw diagram state to serialize.
*
* @see {@link deserializeDiagram}
*/
export interface DeserializedDiagram {
elements: ReadonlyArray<Element>;
links: ReadonlyArray<Link>;
linkTypeVisibility: ReadonlyMap<LinkTypeIri, LinkTypeVisibility>;
}
/**
* Exports diagram model state for [JSON-LD](https://json-ld.org/) compatible serialization.
*
* @see {@link deserializeDiagram}
*/
export function serializeDiagram(diagram: DeserializedDiagram): SerializedDiagram {
const {elements, links, linkTypeVisibility} = diagram;
let linkTypeOptions: SerializedLinkOptions[] | undefined;
if (linkTypeVisibility) {
linkTypeOptions = [];
for (const [linkTypeIri, visibility] of linkTypeVisibility) {
// Do not serialize default link type options
if (visibility !== 'visible' && linkTypeIri !== PlaceholderRelationType) {
linkTypeOptions.push({
'@type': 'LinkTypeOptions',
property: linkTypeIri,
visible: visibility !== 'hidden',
showLabel: visibility !== 'withoutLabel',
});
}
}
}
const serialized: SerializedDiagram = {
...emptyDiagram(),
layoutData: serializeLayout(elements, links),
linkTypeOptions: linkTypeOptions,
};
return serialized;
}
function serializeLayout(
modelElements: ReadonlyArray<Element>,
modelLinks: ReadonlyArray<Link>,
): SerializedLayout {
const elements: Array<SerializedElement> = [];
for (const element of modelElements) {
if (hasToJSON(element)) {
const state = element.toJSON();
if (isValidSerializedState(state)) {
elements.push(state as SerializedElement);
}
}
}
const links: Array<SerializedLink> = [];
for (const link of modelLinks) {
if (hasToJSON(link)) {
const state = link.toJSON();
if (isValidSerializedState(state)) {
links.push(state as SerializedLink);
}
}
}
return {'@type': 'Layout', elements, links};
}
/**
* Options for diagram deserialization.
*/
export interface DeserializeDiagramOptions {
readonly elementCellTypes: readonly SerializableElementCell[];
readonly linkCellTypes: readonly SerializableLinkCell[];
readonly preloadedElements?: ReadonlyMap<ElementIri, ElementModel>;
readonly preloadedLinks?: ReadonlyHashMap<LinkKey, LinkModel>;
readonly markLinksAsLayoutOnly?: boolean;
}
/**
* Imports diagram model state from the serialized form.
*
* @see {@link serializeDiagram}
*/
export function deserializeDiagram(
diagram: SerializedDiagram,
options: DeserializeDiagramOptions
): DeserializedDiagram {
const {layoutData, linkTypeOptions} = diagram;
const linkTypeVisibility = new Map<LinkTypeIri, LinkTypeVisibility>();
if (linkTypeOptions) {
for (const setting of linkTypeOptions) {
const {visible = true, showLabel = true} = setting;
const linkTypeId: LinkTypeIri = setting.property;
const visibility: LinkTypeVisibility = (
visible && showLabel ? 'visible' :
visible && !showLabel ? 'withoutLabel' :
'hidden'
);
linkTypeVisibility.set(linkTypeId, visibility);
}
}
const {elements, links} = deserializeLayout(layoutData, options);
return {elements, links, linkTypeVisibility};
}
interface DeserializedLayout {
elements: Element[];
links: Link[];
}
function deserializeLayout(
layout: SerializedLayout,
options: DeserializeDiagramOptions
): DeserializedLayout {
const {preloadedElements, preloadedLinks, markLinksAsLayoutOnly = false} = options;
const typeToElement = new Map<string, SerializableElementCell>();
for (const elementCellType of options.elementCellTypes) {
typeToElement.set(elementCellType.fromJSONType, elementCellType);
}
const elementOptions: ElementFromJsonOptions = {
getInitialData: iri => preloadedElements?.get(iri),
mapTemplateState: state => state,
};
const typeToLink = new Map<string, SerializableLinkCell>();
for (const linkCellType of options.linkCellTypes) {
typeToLink.set(linkCellType.fromJSONType, linkCellType);
}
const getInitialLinkData = (key: LinkKey) => preloadedLinks?.get(key);
const mapLinkTemplateState = (state: TemplateState) =>
markLayoutOnly(state, markLinksAsLayoutOnly);
const elements = new Map<string, Element>();
const links: Link[] = [];
for (const layoutElement of layout.elements) {
const elementClass = typeToElement.get(layoutElement['@type']);
if (elementClass) {
const element = elementClass.fromJSON(layoutElement, elementOptions);
if (element) {
elements.set(element.id, element);
}
}
}
for (const layoutLink of layout.links) {
const {source, target} = layoutLink;
const sourceElement = elements.get(source['@id']);
const targetElement = elements.get(target['@id']);
if (!(sourceElement && targetElement)) {
continue;
}
const linkClass = typeToLink.get(layoutLink['@type']);
if (linkClass) {
const link = linkClass.fromJSON(layoutLink, {
source: sourceElement,
target: targetElement,
getInitialData: getInitialLinkData,
mapTemplateState: mapLinkTemplateState,
});
if (link) {
links.push(link);
}
}
}
return {
elements: Array.from(elements.values()),
links,
};
}
export function markLayoutOnly(
linkState: TemplateState,
value: boolean
): TemplateState {
return linkState.set(TemplateProperties.LayoutOnly, value ? true : undefined);
}
function hasToJSON(instance: object): instance is { toJSON(): { ['@type']?: unknown } } {
const withToJson = instance as { toJSON?(): { ['@type']?: unknown } };
return Boolean(typeof withToJson.toJSON === 'function');
}
function isValidSerializedState(state: { ['@type']?: unknown }): boolean {
return typeof state === 'object' && state && typeof state['@type'] === 'string';
}