Graph Authoring
Example demonstrating visual graph authoring capabilities on in-memory RDF graph data.
/src/examples/PlaygroundGraphAuthoring.tsx
import * as React from 'react';
import * as Reactodia from '@reactodia/workspace';
import * as N3 from 'n3';
import { ExampleMetadataProvider, ExampleValidationProvider } from './ExampleMetadata';
import { ExampleToolbarMenu } from './ExampleCommon';
const Layouts = Reactodia.defineLayoutWorker(() => new Worker(
new URL('@reactodia/workspace/layout.worker', import.meta.url)
));
type TurtleDataSource =
| { type: 'url'; url: string }
| { type: 'data'; data: string };
export function PlaygroundGraphAuthoring() {
const {defaultLayout} = Reactodia.useWorker(Layouts);
const [dataSource, setDataSource] = React.useState<TurtleDataSource>({
type: 'url',
url: 'https://reactodia.github.io/resources/orgOntology.ttl',
});
const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => {
const {model, editor, getCommandBus, performLayout} = context;
editor.setAuthoringMode(true);
let turtleData: string;
if (dataSource.type === 'url') {
const response = await fetch(dataSource.url, {signal});
turtleData = await response.text();
} else {
turtleData = dataSource.data;
}
const dataProvider = new Reactodia.RdfDataProvider();
try {
dataProvider.addGraph(new N3.Parser().parse(turtleData));
} catch (err) {
throw new Error('Error parsing RDF graph data', {cause: err});
}
await model.importLayout({dataProvider, signal});
if (dataSource.type === 'url') {
const elements = [
model.createElement('http://www.w3.org/ns/org#Organization'),
model.createElement('http://www.w3.org/ns/org#FormalOrganization'),
model.createElement('http://www.w3.org/ns/org#hasMember'),
model.createElement('http://www.w3.org/ns/org#hasSubOrganization'),
model.createElement('http://www.w3.org/ns/org#subOrganizationOf'),
model.createElement('http://www.w3.org/ns/org#unitOf'),
];
model.history.execute(Reactodia.setElementExpanded(elements[0], true));
await Promise.all([
model.requestElementData(elements.map(el => el.iri)),
model.requestLinks(),
]);
await performLayout({signal});
} else {
getCommandBus(Reactodia.UnifiedSearchTopic)
.trigger('focus', {sectionKey: 'elementTypes'});
}
}, [dataSource]);
const [metadataProvider] = React.useState(() => new ExampleMetadataProvider());
const [validationProvider] = React.useState(() => new ExampleValidationProvider());
const [renameLinkProvider] = React.useState(() => new RenameSubclassOfProvider());
return (
<Reactodia.Workspace ref={onMount}
defaultLayout={defaultLayout}
metadataProvider={metadataProvider}
validationProvider={validationProvider}
renameLinkProvider={renameLinkProvider}>
<Reactodia.DefaultWorkspace
menu={
<>
<ToolbarActionOpenTurtleGraph onOpen={setDataSource} />
<ExampleToolbarMenu />
</>
}
/>
</Reactodia.Workspace>
);
}
class RenameSubclassOfProvider extends Reactodia.RenameLinkToLinkStateProvider {
override canRename(link: Reactodia.Link): boolean {
return link.typeId === 'http://www.w3.org/2000/01/rdf-schema#subClassOf';
}
}
function ToolbarActionOpenTurtleGraph(props: {
onOpen: (dataSource: TurtleDataSource) => void;
}) {
const {onOpen} = props;
return (
<Reactodia.ToolbarActionOpen
fileAccept='.ttl'
onSelect={async file => {
const turtleText = await file.text();
onOpen({type: 'data', data: turtleText});
}}>
Load RDF (Turtle) data
</Reactodia.ToolbarActionOpen>
);
}
ExampleMetadata.ts
/src/examples/ExampleMetadata.ts
import * as Reactodia from '@reactodia/workspace';
const owl = vocabulary('http://www.w3.org/2002/07/owl#', [
'Class',
'AnnotationProperty',
'DatatypeProperty',
'ObjectProperty',
]);
const rdfs = vocabulary('http://www.w3.org/2000/01/rdf-schema#', [
'comment',
'domain',
'range',
'seeAlso',
'subClassOf',
'subPropertyOf',
]);
const SIMULATED_DELAY: number = 200; /* ms */
export class ExampleMetadataProvider implements Reactodia.MetadataProvider {
private readonly propertyTypes = [owl.AnnotationProperty, owl.DatatypeProperty, owl.ObjectProperty];
private readonly editableTypes = new Set([owl.Class, ...this.propertyTypes]);
private readonly literalLanguages: ReadonlyArray<string> = ['de', 'en', 'es', 'ru', 'zh'];
getLiteralLanguages(): ReadonlyArray<string> {
return this.literalLanguages;
}
async createEntity(
type: Reactodia.ElementTypeIri,
options: { readonly signal?: AbortSignal }
): Promise<Reactodia.ElementModel> {
await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal});
const random32BitDigits = Math.floor((1 + Math.random()) * 0x100000000).toString(16).substring(1);
const typeLabel = Reactodia.Rdf.getLocalName(type) ?? 'Entity';
return {
id: `${type}_${random32BitDigits}` as Reactodia.ElementIri,
types: [type],
label: [Reactodia.Rdf.DefaultDataFactory.literal(`New ${typeLabel}`)],
properties: {},
};
}
async createRelation(
source: Reactodia.ElementModel,
target: Reactodia.ElementModel,
linkType: Reactodia.LinkTypeIri,
options: { readonly signal?: AbortSignal }
): Promise<Reactodia.LinkModel> {
await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal});
return {
sourceId: source.id,
targetId: target.id,
linkTypeId: linkType,
properties: {},
};
}
async canConnect(
source: Reactodia.ElementModel,
target: Reactodia.ElementModel | undefined,
linkType: Reactodia.LinkTypeIri | undefined,
options: { readonly signal?: AbortSignal }
): Promise<Reactodia.MetadataCanConnect[]> {
await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal});
const connections: Reactodia.MetadataCanConnect[] = [];
const addConnections = (
types: readonly Reactodia.ElementTypeIri[],
allOutLinks: readonly Reactodia.LinkTypeIri[],
allInLinks: readonly Reactodia.LinkTypeIri[]
) => {
const outLinks = linkType
? allOutLinks.filter(type => type === linkType)
: allOutLinks;
const inLinks = linkType
? allInLinks.filter(type => type === linkType)
: allInLinks;
if (types.length > 0 && (outLinks.length > 0 || inLinks.length > 0)) {
connections.push({ targetTypes: new Set(types), outLinks, inLinks });
}
};
if (hasType(source, owl.Class)) {
if (hasType(target, owl.Class)) {
addConnections([owl.Class], [rdfs.subClassOf], [rdfs.subClassOf]);
}
const targetPropertyTypes = this.propertyTypes.filter(type => hasType(target, type));
if (targetPropertyTypes.length > 0) {
addConnections(targetPropertyTypes, [], [rdfs.domain, rdfs.range]);
}
}
const sourcePropertyTypes = this.propertyTypes.filter(type => hasType(source, type));
if (sourcePropertyTypes.length > 0) {
for (const type of sourcePropertyTypes) {
if (hasType(target, type)) {
addConnections([type], [rdfs.subPropertyOf], [rdfs.subPropertyOf]);
}
}
if (hasType(target, owl.Class)) {
addConnections([owl.Class], [rdfs.domain, rdfs.range], []);
}
}
return connections;
}
async canModifyEntity(
entity: Reactodia.ElementModel,
options: { readonly signal?: AbortSignal; }
): Promise<Reactodia.MetadataCanModifyEntity> {
await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal});
const editable = entity.types.some(type => this.editableTypes.has(type));
return {
canChangeIri: entity.types.includes(owl.Class),
canEdit: editable,
canDelete: editable,
};
}
async canModifyRelation(
link: Reactodia.LinkModel,
source: Reactodia.ElementModel,
target: Reactodia.ElementModel,
options: { readonly signal?: AbortSignal; }
): Promise<Reactodia.MetadataCanModifyRelation> {
await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal});
switch (link.linkTypeId) {
case rdfs.domain:
case rdfs.range:
case rdfs.subClassOf:
case rdfs.subPropertyOf: {
return {
canChangeType: true,
canDelete: true,
};
}
default: {
return {};
}
}
}
async getEntityShape(
types: ReadonlyArray<Reactodia.ElementTypeIri>,
options: { readonly signal?: AbortSignal; }
): Promise<Reactodia.MetadataEntityShape> {
await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal});
const properties = new Map<Reactodia.PropertyTypeIri, Reactodia.MetadataPropertyShape>();
if (types.some(type => this.editableTypes.has(type))) {
properties.set(rdfs.comment, {
valueShape: {termType: 'Literal'},
});
properties.set(rdfs.seeAlso, {
valueShape: {termType: 'NamedNode'},
});
}
return {properties};
}
async filterConstructibleTypes(
types: ReadonlySet<Reactodia.ElementTypeIri>,
options: { readonly signal?: AbortSignal }
): Promise<ReadonlySet<Reactodia.ElementTypeIri>> {
await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal});
return new Set(Array.from(types).filter(type => this.editableTypes.has(type)));
}
}
export class ExampleValidationProvider implements Reactodia.ValidationProvider {
async validate(
event: Reactodia.ValidationEvent
): Promise<Reactodia.ValidationResult> {
const items: Array<Reactodia.ValidatedElement | Reactodia.ValidatedLink> = [];
if (event.target.types.includes(owl.Class)) {
event.state.links.forEach(e => {
if (e.type === 'relationAdd' && e.data.sourceId === event.target.id) {
items.push({
type: 'link',
target: e.data,
severity: 'error',
message: 'Cannot add any new link from a Class',
});
items.push({
type: 'element',
target: event.target.id,
severity: 'warning',
message: `Cannot create <${e.data.linkTypeId}> link from a Class`,
});
}
});
}
if (event.target.types.includes(owl.ObjectProperty)) {
if (!event.outboundLinks.some(link => link.linkTypeId === rdfs.subPropertyOf)) {
items.push({
type: 'element',
target: event.target.id,
severity: 'info',
message: 'It might be a good idea to make the property a sub-property of another',
});
}
}
await Reactodia.delay(SIMULATED_DELAY, {signal: event.signal});
return {items};
}
}
type VocabularyKeyType<K extends string> =
K extends Capitalize<K>
? Reactodia.ElementTypeIri
: Reactodia.LinkTypeIri & Reactodia.PropertyTypeIri;
type Vocabulary<Keys extends string[]> = {
readonly [K in Keys[number]]: VocabularyKeyType<K>;
};
function vocabulary<const Keys extends string[]>(prefix: string, keys: Keys): Vocabulary<Keys> {
const result: { [key: string]: string } = Object.create(null);
for (const key of keys) {
result[key] = prefix + key;
}
return result as Vocabulary<Keys>;
}
function hasType(model: Reactodia.ElementModel | undefined, type: Reactodia.ElementTypeIri) {
return Boolean(!model || model.types.includes(type));
}
ExampleCommon.tsx
/src/examples/ExampleCommon.tsx
import * as React from 'react';
import * as Reactodia from '@reactodia/workspace';
import { saveAs } from 'file-saver';
export function ExampleToolbarMenu() {
const {model, editor, overlay} = Reactodia.useWorkspace();
return (
<>
<Reactodia.ToolbarActionOpen
fileAccept='.json'
onSelect={async file => {
const preloadedElements = new Map<Reactodia.ElementIri, Reactodia.ElementModel>();
for (const element of model.elements) {
for (const data of Reactodia.iterateEntitiesOf(element)) {
preloadedElements.set(data.id, data);
}
}
const task = overlay.startTask({title: 'Importing a layout from file'});
try {
const json = await file.text();
const diagramLayout = JSON.parse(json);
await model.importLayout({
dataProvider: model.dataProvider,
diagram: diagramLayout,
preloadedElements,
validateLinks: true,
});
} catch (err) {
task.setError(new Error(
'Failed to load specified file with a diagram layout.',
{cause: err}
));
} finally {
task.end();
}
}}>
Open diagram from file
</Reactodia.ToolbarActionOpen>
<Reactodia.ToolbarActionSave mode='layout'
onSelect={() => {
const diagramLayout = model.exportLayout();
const layoutString = JSON.stringify(diagramLayout);
const blob = new Blob([layoutString], {type: 'application/json'});
const timestamp = new Date().toISOString().replaceAll(/[Z\s:-]/g, '');
saveAs(blob, `reactodia-diagram-${timestamp}.json`);
}}>
Save diagram to file
</Reactodia.ToolbarActionSave>
{editor.inAuthoringMode ? (
<Reactodia.ToolbarActionSave mode='authoring'
onSelect={() => {
const state = editor.authoringState;
console.log('Authoring state:', state);
alert('Please check browser console for result');
}}>
Persist changes to data
</Reactodia.ToolbarActionSave>
) : null}
<Reactodia.ToolbarActionClearAll />
<Reactodia.ToolbarActionExport kind='exportRaster' />
<Reactodia.ToolbarActionExport kind='exportSvg' />
<Reactodia.ToolbarActionExport kind='print' />
</>
);
}