From 71a6a03eae40345e004378ab782ced8daf12ce1f Mon Sep 17 00:00:00 2001 From: Stuart Hendren Date: Sat, 18 Mar 2023 14:12:51 +0000 Subject: [PATCH] feat: changes from attributes to metadata in graph model The JSON graph spec no longer uses attributes so changing to metadata. This will ease the tranistion for clients between JSON Graph and the GraphModel. BREAKING CHANGE: Node and Edge no longer have attributes - use metadata instead. --- apps/docs/src/Decoration.stories.tsx | 38 +-- apps/docs/src/GraphToolbar.stories.tsx | 14 +- apps/docs/src/JSONGraph.stories.tsx | 6 +- apps/docs/src/NodeViewer.stories.tsx | 22 +- apps/docs/src/RdfGraph.stories.tsx | 4 +- apps/docs/src/exampleData.ts | 23 +- package-lock.json | 2 +- package.json | 2 +- packages/graph-json/src/JsonGraph.test.ts | 14 +- packages/graph-json/src/JsonGraph.ts | 25 +- packages/graph-rdf/src/RdfGraph.test.ts | 36 +-- packages/graph-rdf/src/RdfGraph.ts | 35 +-- packages/graph-rdf/src/utils/labels.test.ts | 14 +- packages/graph-rdf/src/utils/labels.ts | 10 +- .../graph-rdf/src/utils/processors.test.ts | 54 ++-- packages/graph-rdf/src/utils/processors.ts | 16 +- packages/graph/src/graph/ContentModel.test.ts | 122 +++++---- packages/graph/src/graph/ContentModel.ts | 232 ++++++++++++------ .../graph/src/graph/DecoratorModel.test.ts | 22 +- packages/graph/src/graph/DecoratorModel.ts | 25 +- packages/graph/src/graph/GraphModel.test.ts | 22 +- packages/graph/src/graph/GraphModel.ts | 20 +- packages/graph/src/graph/data/Generator.ts | 15 +- packages/graph/src/graph/data/shapes.ts | 28 --- .../graph/src/graph/decorators/size.test.ts | 39 +-- packages/graph/src/graph/decorators/size.ts | 53 ++-- packages/graph/src/graph/types/index.ts | 121 +++++++-- packages/graph/src/test/setup.ts | 15 +- .../GraphDebugControl/GraphDebugControl.tsx | 9 +- .../GraphToolbar/GraphToolbar.test.tsx | 4 +- .../src/components/GraphToolbar/SizeBy.tsx | 32 ++- .../components/NodeViewer/NodeViewer.test.tsx | 22 +- .../src/components/NodeViewer/NodeViewer.tsx | 26 +- .../src/graph/renderer/CytoscapeRenderer.tsx | 3 +- packages/react/src/test/exampleData.ts | 23 +- packages/react/src/test/setup.tsx | 23 +- 36 files changed, 627 insertions(+), 544 deletions(-) delete mode 100644 packages/graph/src/graph/data/shapes.ts diff --git a/apps/docs/src/Decoration.stories.tsx b/apps/docs/src/Decoration.stories.tsx index 91b5ce0..978f8ac 100644 --- a/apps/docs/src/Decoration.stories.tsx +++ b/apps/docs/src/Decoration.stories.tsx @@ -2,6 +2,7 @@ import { ContentModel, DecoratorModel, Generator, Graph, GraphModel, ModelNode, + Node, NodeDecoration, sizeNodeBy } from '@committed/components-graph' @@ -47,8 +48,8 @@ export const TypedDecoration: React.FC = () => { new GraphModel(ContentModel.fromRaw(exampleGraphData), { decoratorModel: DecoratorModel.createDefault({ nodeDecorators: [ - (node: ModelNode): Partial => { - const type = node.attributes['type'] + (node: Node): Partial => { + const type = node.metadata['type'] if (type === 'person') return { color: '$colors$info' } if (type === 'place') return { color: '$colors$success' } return { color: '#00FF00' } @@ -85,41 +86,41 @@ export const CustomIcons: React.FC = () => { { n1: { id: 'n1', - attributes: {}, + metadata: {}, label: faker.name.fullName(), icon: 'https://i.pravatar.cc/100', size: 100, }, n2: { id: 'n2', - attributes: {}, + metadata: {}, label: faker.name.fullName(), icon: 'https://i.pravatar.cc/100', size: 100, }, n3: { id: 'n3', - attributes: {}, + metadata: {}, label: faker.name.fullName(), size: 100, }, n4: { id: 'n4', - attributes: {}, + metadata: {}, label: faker.name.fullName(), icon: 'https://i.pravatar.cc/100', size: 100, }, n5: { id: 'n5', - attributes: {}, + metadata: {}, label: faker.name.fullName(), icon: 'https://i.pravatar.cc/100', size: 100, }, n6: { id: 'n6', - attributes: {}, + metadata: {}, label: faker.name.fullName(), icon: 'https://i.pravatar.cc/100', size: 100, @@ -129,51 +130,58 @@ export const CustomIcons: React.FC = () => { e1: { id: 'e1', label: '', - attributes: {}, + metadata: {}, source: 'n1', target: 'n2', + directed: true }, e2: { id: 'e2', label: '', - attributes: {}, + metadata: {}, source: 'n2', target: 'n3', + directed: true }, e3: { id: 'e3', label: '', - attributes: {}, + metadata: {}, source: 'n6', target: 'n4', + directed: true }, e4: { id: 'e4', label: '', - attributes: {}, + metadata: {}, source: 'n5', target: 'n6', + directed: true }, e5: { id: 'e5', label: '', - attributes: {}, + metadata: {}, source: 'n3', target: 'n5', + directed: true }, e6: { id: 'e6', label: '', - attributes: {}, + metadata: {}, source: 'n3', target: 'n4', + directed: true }, e7: { id: 'e7', label: '', - attributes: {}, + metadata: {}, source: 'n1', target: 'n4', + directed: true }, } ) diff --git a/apps/docs/src/GraphToolbar.stories.tsx b/apps/docs/src/GraphToolbar.stories.tsx index 7767033..86485c7 100644 --- a/apps/docs/src/GraphToolbar.stories.tsx +++ b/apps/docs/src/GraphToolbar.stories.tsx @@ -99,8 +99,8 @@ const typesLayout: CustomGraphLayout = { const rowHeight = 75 const byType = Object.values( model.nodes.reduce>((acc, next) => { - acc[(next.attributes.type ?? 'unknown') as string] = ( - acc[(next.attributes.type ?? 'unknown') as string] ?? [] + acc[(next.metadata.type ?? 'unknown') as string] = ( + acc[(next.metadata.type ?? 'unknown') as string] ?? [] ).concat(next) return acc }, {}) @@ -223,33 +223,33 @@ export const CustomLayout: Story = () => { e1: { id: 'e1', label: 'Type 1', - attributes: { + metadata: { type: 'type1', }, }, e2: { id: 'e2', label: 'Type 2', - attributes: { + metadata: { type: 'type2', }, }, e3: { id: 'e3', label: 'Type 3', - attributes: { + metadata: { type: 'type3', }, }, e4: { id: 'e4', label: 'Type 1 (2)', - attributes: { + metadata: { type: 'type1', }, }, }, - edges: {}, + edges: [], }) ) ) diff --git a/apps/docs/src/JSONGraph.stories.tsx b/apps/docs/src/JSONGraph.stories.tsx index 4c999dc..a643484 100644 --- a/apps/docs/src/JSONGraph.stories.tsx +++ b/apps/docs/src/JSONGraph.stories.tsx @@ -1,10 +1,10 @@ import { Alert, AlertContent, AlertTitle, Flex } from '@committed/components' -import { ContentModel, cytoscapeRenderer, Graph, GraphModel, GraphToolbar, ModelNode, NodeViewer } from '@committed/components-graph' +import { ContentModel, cytoscapeRenderer, Graph, GraphModel, GraphToolbar, Node, NodeViewer } from '@committed/components-graph' import { Json, JsonExample } from '@committed/graph-json' import { Meta, Story } from '@storybook/react' import React, { useEffect, useState } from 'react' -import { Template } from './StoryUtil' import RJSON from "relaxed-json" +import { Template } from './StoryUtil' const { smallGraph, largeGraph } = JsonExample @@ -53,7 +53,7 @@ const StoryTemplate: Story<{ } }, [setModel, setAlert, json]) - const [node, setNode] = useState(undefined) + const [node, setNode] = useState(undefined) return ( <> diff --git a/apps/docs/src/NodeViewer.stories.tsx b/apps/docs/src/NodeViewer.stories.tsx index b037923..1d2e922 100644 --- a/apps/docs/src/NodeViewer.stories.tsx +++ b/apps/docs/src/NodeViewer.stories.tsx @@ -1,12 +1,10 @@ import { Button } from '@committed/components' -import { Generator, ModelNode } from '@committed/graph' +import { cytoscapeRenderer, Graph, NodeViewer } from '@committed/components-graph' +import { Generator, Node } from '@committed/graph' import { useBoolean } from '@committed/hooks' import { Meta, Story } from '@storybook/react' -import React, { useState } from 'react' -import { cytoscapeRenderer } from '@committed/components-graph' -import { Graph } from '@committed/components-graph' -import { NodeViewer } from '@committed/components-graph' import faker from 'faker' +import React, { useState } from 'react' export default { title: 'Components/NodeViewer', @@ -16,7 +14,7 @@ export default { export const Default: Story> = () => { const [model, setModel] = useState(Generator.randomGraph) - const [node, setNode] = useState( + const [node, setNode] = useState( Object.values(model.getCurrentContent().nodes)[0] ) const graph = ( @@ -42,10 +40,10 @@ export const Default: Story> = () => { export const WithAttributes: Story = () => { const [open, { setTrue: setOpen, setFalse: setClosed }] = useBoolean(false) - const node: ModelNode = { + const node: Node = { id: 'test', label: 'example node', - attributes: { + metadata: { employer: 'Committed', }, } @@ -59,10 +57,10 @@ export const WithAttributes: Story = () => { export const NoAttributes: Story = () => { const [open, { setTrue: setOpen, setFalse: setClosed }] = useBoolean(false) - const node: ModelNode = { + const node: Node = { id: 'test', label: 'example node', - attributes: {}, + metadata: {}, } return ( <> @@ -84,10 +82,10 @@ export const NoNode: Story = () => { export const ManyAttributes: Story = () => { const [open, { setTrue: setOpen, setFalse: setClosed }] = useBoolean(false) - const node: ModelNode = { + const node: Node = { id: 'test', label: 'example node', - attributes: { + metadata: { firstName: faker.name.firstName(), middleName: faker.name.middleName(), lastName: faker.name.lastName(), diff --git a/apps/docs/src/RdfGraph.stories.tsx b/apps/docs/src/RdfGraph.stories.tsx index 61afd1d..4026ee9 100644 --- a/apps/docs/src/RdfGraph.stories.tsx +++ b/apps/docs/src/RdfGraph.stories.tsx @@ -1,5 +1,5 @@ import { Alert, AlertContent, AlertTitle, Flex } from '@committed/components' -import { ContentModel, cytoscapeRenderer, Graph, GraphModel, GraphToolbar, ModelNode, NodeViewer } from '@committed/components-graph' +import { ContentModel, cytoscapeRenderer, Graph, GraphModel, GraphToolbar, Node, NodeViewer } from '@committed/components-graph' import { Rdf, RdfUtil } from '@committed/graph-rdf' import { Meta, Story } from '@storybook/react' import React, { useEffect, useState } from 'react' @@ -133,7 +133,7 @@ const StoryTemplate: Story<{ } }, [setModel, setAlert, rdf]) - const [node, setNode] = useState(undefined) + const [node, setNode] = useState(undefined) return ( <> diff --git a/apps/docs/src/exampleData.ts b/apps/docs/src/exampleData.ts index ea1619b..ef91714 100644 --- a/apps/docs/src/exampleData.ts +++ b/apps/docs/src/exampleData.ts @@ -1,47 +1,40 @@ import { ModelEdge, ModelNode } from '@committed/components-graph' const exampleNodesArr: ModelNode[] = [ - { id: 'n1', attributes: { type: 'person' } }, - { id: 'n2', attributes: { type: 'person' } }, - { id: 'n3', attributes: { type: 'person' } }, - { id: 'n4', attributes: { type: 'place' } }, - { id: 'n5', attributes: { type: 'place' } }, - { id: 'n6', attributes: { type: 'place' } }, + { id: 'n1', metadata: { type: 'person' } }, + { id: 'n2', metadata: { type: 'person' } }, + { id: 'n3', metadata: { type: 'person' } }, + { id: 'n4', metadata: { type: 'place' } }, + { id: 'n5', metadata: { type: 'place' } }, + { id: 'n6', metadata: { type: 'place' } }, ] const exampleEdgesArr: ModelEdge[] = [ { id: 'e1', source: 'n1', target: 'n2', - attributes: {}, }, { id: 'e2', source: 'n1', target: 'n3', - attributes: {}, }, { id: 'e3', source: 'n3', target: 'n4', - attributes: {}, }, { id: 'e5', source: 'n5', target: 'n5', - attributes: {}, }, ] export const exampleGraphData = { nodes: exampleNodesArr.reduce>((prev, next) => { - prev[next.id] = next - return prev - }, {}), - edges: exampleEdgesArr.reduce>((prev, next) => { - prev[next.id] = next + prev[next.id as string] = next return prev }, {}), + edges: exampleEdgesArr } diff --git a/package-lock.json b/package-lock.json index fdc53a6..8db9e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "devDependencies": { "@commitlint/cli": "^13.1.0", "@commitlint/config-conventional": "^13.1.0", - "@committed/components": "^8.1.3", + "@committed/components": "8.1.3", "@qiwi/multi-semantic-release": "^3.16.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", diff --git a/package.json b/package.json index 15094cc..f997915 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "devDependencies": { "@commitlint/cli": "^13.1.0", "@commitlint/config-conventional": "^13.1.0", - "@committed/components": "^8.1.3", + "@committed/components": "8.1.3", "@qiwi/multi-semantic-release": "^3.16.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", diff --git a/packages/graph-json/src/JsonGraph.test.ts b/packages/graph-json/src/JsonGraph.test.ts index 32f8011..b84aa31 100644 --- a/packages/graph-json/src/JsonGraph.test.ts +++ b/packages/graph-json/src/JsonGraph.test.ts @@ -1,13 +1,13 @@ +import { Node } from '@committed/graph' import { largeGraph, smallGraph, veryLargeGraph } from 'examples' import { buildGraph, Graph as JSONGraph } from 'JsonGraph' -import { ModelNode } from '@committed/graph' it('Create from json graph spec graph values', () => { const contentModel = buildGraph(smallGraph) expect(Object.keys(contentModel.nodes)).toHaveLength(4) expect(Object.keys(contentModel.edges)).toHaveLength(2) - const node = contentModel.getNode('nissan') as ModelNode + const node = contentModel.getNode('nissan') as Node expect(node?.label).toBe('Nissan') const edge = contentModel.getEdgesLinkedToNode(node.id)[0] @@ -15,7 +15,7 @@ it('Create from json graph spec graph values', () => { expect(edge.source).toBe(node.id) expect(edge.target).toBe('infiniti') expect(edge.label).toBe('has_luxury_division') - expect(edge.attributes.relation).toBe('has_luxury_division') + expect(edge.metadata.relation).toBe('has_luxury_division') }) it('Create from json graph', () => { @@ -35,10 +35,10 @@ it('Create from json graph', () => { expect(Object.keys(contentModel.nodes)).toHaveLength(3) expect(Object.keys(contentModel.edges)).toHaveLength(2) expect(contentModel.getNode('n1')).toBeDefined() - expect(contentModel.getNode('n1')?.attributes.a1).toBe(true) - expect(contentModel.getNode('n1')?.attributes.a2).toBe(10) - expect(contentModel.getNode('n1')?.attributes.a3).toBe('test') - expect((contentModel.getNode('n1')?.attributes.a4 as any).object).toBe( + expect(contentModel.getNode('n1')?.metadata.a1).toBe(true) + expect(contentModel.getNode('n1')?.metadata.a2).toBe(10) + expect(contentModel.getNode('n1')?.metadata.a3).toBe('test') + expect((contentModel.getNode('n1')?.metadata.a4 as any).object).toBe( 'example' ) expect(contentModel.getNode('n2')).toBeDefined() diff --git a/packages/graph-json/src/JsonGraph.ts b/packages/graph-json/src/JsonGraph.ts index 9329082..0a0a21d 100644 --- a/packages/graph-json/src/JsonGraph.ts +++ b/packages/graph-json/src/JsonGraph.ts @@ -1,5 +1,5 @@ +import { ContentModel, Edge, Node } from '@committed/graph' import { v4 } from 'uuid' -import { ContentModel, ModelEdge, ModelNode } from '@committed/graph' /// JSON GRAPH Interfaces - only describes what we need from /// https://github.com/jsongraph/json-graph-specification/blob/master/json-graph-schema_v2.json @@ -50,15 +50,15 @@ function getGraphData(data: Graph | GraphData): Graph { } function getNodes(graphData: Graph) { - return Object.entries(graphData.nodes).reduce>( + return Object.entries(graphData.nodes).reduce>( (acc, entry) => { - const node: ModelNode = { id: entry[0], attributes: {} } + const node: Node = { id: entry[0], metadata: {} } const jsonNode: GraphNode = entry[1] if (jsonNode.label !== undefined) { node.label = jsonNode.label } if (jsonNode.metadata !== undefined) { - node.attributes = jsonNode.metadata + node.metadata = jsonNode.metadata } acc[node.id] = node return acc @@ -68,13 +68,14 @@ function getNodes(graphData: Graph) { } function getEdges(graphData: Graph) { - return Object.values(graphData.edges).reduce>( + return Object.values(graphData.edges).reduce>( (acc, jsonEdge) => { - const edge: ModelEdge = { + const edge: Edge = { id: jsonEdge.id ?? v4(), source: jsonEdge.source, target: jsonEdge.target, - attributes: {}, + directed: true, + metadata: {}, } if (jsonEdge.id !== undefined) { edge.id = jsonEdge.id @@ -83,10 +84,13 @@ function getEdges(graphData: Graph) { edge.label = jsonEdge.label ?? jsonEdge.relation } if (jsonEdge.metadata !== undefined) { - edge.attributes = jsonEdge.metadata + edge.metadata = jsonEdge.metadata } if (jsonEdge.relation !== undefined) { - edge.attributes.relation = jsonEdge.relation + edge.metadata.relation = jsonEdge.relation + } + if (jsonEdge.directed !== undefined) { + edge.directed = jsonEdge.directed } acc[edge.id] = edge return acc @@ -99,6 +103,5 @@ export function buildGraph(data: GraphData | Graph): ContentModel { const graphData = getGraphData(data) const nodes = getNodes(graphData) const edges = getEdges(graphData) - - return ContentModel.fromRaw({ nodes, edges }) + return new ContentModel(nodes, edges) } diff --git a/packages/graph-rdf/src/RdfGraph.test.ts b/packages/graph-rdf/src/RdfGraph.test.ts index 705c4b6..1b58733 100644 --- a/packages/graph-rdf/src/RdfGraph.test.ts +++ b/packages/graph-rdf/src/RdfGraph.test.ts @@ -1,4 +1,4 @@ -import { ModelNode } from '@committed/graph' +import { Node } from '@committed/graph' import { decorated, sample, small } from 'test/data' import { buildGraph, LiteralObject, LiteralOption } from './RdfGraph' @@ -9,12 +9,12 @@ it('Create from ttl string', () => { const node = contentModel.getNode( 'https://example.org/data/transaction/123' - ) as ModelNode + ) as Node - const processedAt = node?.attributes[ + const processedAt = node?.metadata[ 'https://example.org/ont/transaction-log/processedAt' ] as LiteralObject - const statusCode = node?.attributes[ + const statusCode = node?.metadata[ 'https://example.org/ont/transaction-log/statusCode' ] as LiteralObject expect(processedAt.value).toBe('2015-10-16T10:22:23') @@ -34,9 +34,9 @@ it('Create from ttl string using prefixes', () => { expect(Object.keys(contentModel.nodes)).toHaveLength(16) expect(Object.keys(contentModel.edges)).toHaveLength(13) - const node = contentModel.getNode('txn:123') as ModelNode - const processedAt = node?.attributes['log:processedAt'] as LiteralObject - const statusCode = node?.attributes['log:statusCode'] as LiteralObject + const node = contentModel.getNode('txn:123') as Node + const processedAt = node?.metadata['log:processedAt'] as LiteralObject + const statusCode = node?.metadata['log:statusCode'] as LiteralObject expect(processedAt.value).toBe('2015-10-16T10:22:23') expect(processedAt.dataType).toBe('xsd:dateTime') expect(statusCode.value).toBe('200') @@ -54,11 +54,11 @@ it('Can process literals to rdf literal string', () => { usePrefixId: true, literals: LiteralOption.AS_STRING, }) - const node = contentModel.getNode('txn:123') as ModelNode - expect(node?.attributes['log:processedAt']).toBe( + const node = contentModel.getNode('txn:123') as Node + expect(node?.metadata['log:processedAt']).toBe( `"2015-10-16T10:22:23"^^http://www.w3.org/2001/XMLSchema#dateTime` ) - expect(node?.attributes['log:statusCode']).toBe( + expect(node?.metadata['log:statusCode']).toBe( '"200"^^http://www.w3.org/2001/XMLSchema#integer' ) }) @@ -68,11 +68,11 @@ it('Can process literals to value only', () => { usePrefixId: true, literals: LiteralOption.VALUE_ONLY, }) - const node = contentModel.getNode('txn:123') as ModelNode - expect(node?.attributes['log:processedAt']).toEqual( + const node = contentModel.getNode('txn:123') as Node + expect(node?.metadata['log:processedAt']).toEqual( new Date('2015-10-16T10:22:23') ) - expect(node?.attributes['log:statusCode']).toBe(200) + expect(node?.metadata['log:statusCode']).toBe(200) }) it('Can parse simple example by adding missing prefixes', () => { @@ -88,9 +88,9 @@ it('Can parse simple example by adding missing prefixes', () => { expect(Object.keys(contentModel.nodes)).toHaveLength(7) expect(Object.keys(contentModel.edges)).toHaveLength(5) - const node = contentModel.getNode(':John') as ModelNode - expect(node.attributes['type']).toBe(':Man') - const name = node?.attributes[':name'] as LiteralObject + const node = contentModel.getNode(':John') as Node + expect(node.metadata['type']).toBe(':Man') + const name = node?.metadata[':name'] as LiteralObject expect(name.value).toBe('John') expect(name.dataType).toBe('xsd:string') @@ -106,7 +106,7 @@ it('Can parse simple example by adding missing prefixes', () => { .getEdgesLinkedToNode(':event') .find((e) => e.label === ':has_time_span')?.target as string const blankNode = contentModel.getNode(blankNodeId) - const attribute = blankNode?.attributes[ + const attribute = blankNode?.metadata[ ':at_some_time_within_date' ] as LiteralObject expect(attribute.value).toBe('2018-01-12') @@ -150,5 +150,5 @@ it('Decorates by default', () => { const node = contentModel.getNode('A') expect(node?.size).toBeUndefined() - expect(node?.attributes['cd:color']).toBe('#FFBB00') + expect(node?.metadata['cd:color']).toBe('#FFBB00') }) diff --git a/packages/graph-rdf/src/RdfGraph.ts b/packages/graph-rdf/src/RdfGraph.ts index 4dfe8a7..fb3a90c 100644 --- a/packages/graph-rdf/src/RdfGraph.ts +++ b/packages/graph-rdf/src/RdfGraph.ts @@ -1,4 +1,4 @@ -import { ContentModel, ModelEdge, ModelNode } from '@committed/graph' +import { ContentModel, Edge, Node, NodeDecoration } from '@committed/graph' import { DataFactory, Literal, @@ -35,7 +35,7 @@ export const DECORATION_NODE_STROKE_COLOR_IRI = export const DECORATION_NODE_STROKE_SIZE_IRI = DECORATION_IRI + DECORATION_NODE_STROKE_SIZE -const DECORATORS: Record = { +const DECORATORS: Record = { [DECORATION_ITEM_LABEL_IRI]: DECORATION_ITEM_LABEL, [DECORATION_ITEM_SIZE_IRI]: DECORATION_ITEM_SIZE, [DECORATION_ITEM_COLOR_IRI]: DECORATION_ITEM_COLOR, @@ -54,6 +54,8 @@ const rdfsLabel = DataFactory.namedNode( 'http://www.w3.org/2000/01/rdf-schema#label' ) +const TYPE_KEY = 'type' as const + export interface LiteralObject { value: string /** a valid rdf:datatype */ @@ -95,9 +97,9 @@ export interface RdfOptions { /** Add additional prefixes to the used */ additionalPrefixes?: Record /** Node processor to apply to nodes after conversion */ - nodeProcessor?: (node: ModelNode) => ModelNode + nodeProcessor?: (node: Node) => Node /** Edge processor to apply to edges after conversion */ - edgeProcessor?: (edge: ModelEdge) => ModelEdge + edgeProcessor?: (edge: Edge) => Edge } export const DEFAULT_RDF_OPTIONS: RdfOptions = { @@ -120,9 +122,9 @@ interface GraphBuilderOptions } class GraphBuilder { - private readonly nodes: Record = {} - private readonly edges: Record = {} - private readonly attributes: Triple[] = [] + private readonly nodes: Record = {} + private readonly edges: Record = {} + private readonly metadata: Triple[] = [] private readonly triples: Triple[] private readonly options: GraphBuilderOptions @@ -134,11 +136,11 @@ class GraphBuilder { public build(): ContentModel { this.triples.forEach((t) => this.addTriple(t)) - this.attributes.forEach((t) => this.addAttribute(t)) + this.metadata.forEach((t) => this.addAttribute(t)) this.processNodes() this.processEdges() - return ContentModel.fromRaw({ nodes: this.nodes, edges: this.edges }) + return new ContentModel(this.nodes, this.edges) } private processNodes() { @@ -161,7 +163,7 @@ class GraphBuilder { private addTriple(t: Triple): void { if (t.object.termType === 'Literal') { - this.attributes.push(t) + this.metadata.push(t) } else { this.addNode(t.subject) if ( @@ -182,7 +184,7 @@ class GraphBuilder { this.nodes[id] = { id, label: term.value, - attributes: {}, + metadata: {}, } } } @@ -194,14 +196,15 @@ class GraphBuilder { source: this.toNodeId(t.subject), target: this.toNodeId(t.object), label: this.toNodeId(t.predicate), - attributes: {}, + metadata: {}, + directed: true, } } private addType(t: Triple): void { const item = this.nodes[this.toNodeId(t.subject)] - if (item !== undefined && item.attributes !== undefined) { - item.attributes.type = this.toNodeId(t.object) + if (item !== undefined && item.metadata !== undefined) { + item.metadata[TYPE_KEY] = this.toNodeId(t.object) } } @@ -216,8 +219,8 @@ class GraphBuilder { this.addDecorator(t) } else { const item = this.nodes[this.toNodeId(t.subject)] - if (item !== undefined && item.attributes !== undefined) { - item.attributes[this.toNodeId(t.predicate)] = this.toLiteralAttribute( + if (item !== undefined && item.metadata !== undefined) { + item.metadata[this.toNodeId(t.predicate)] = this.toLiteralAttribute( t.object as Literal ) } diff --git a/packages/graph-rdf/src/utils/labels.test.ts b/packages/graph-rdf/src/utils/labels.test.ts index 32ff04d..1998c70 100644 --- a/packages/graph-rdf/src/utils/labels.test.ts +++ b/packages/graph-rdf/src/utils/labels.test.ts @@ -1,4 +1,4 @@ -import { labelWithPrefix, labelWithFragment } from './labels' +import { labelWithFragment, labelWithPrefix } from './labels' it('Can label by prefixed id', () => { const decorator = labelWithPrefix({ @@ -7,13 +7,13 @@ it('Can label by prefixed id', () => { }) expect( - decorator({ id: 'https://example.org/data/test', attributes: {} }).label + decorator({ id: 'https://example.org/data/test', metadata: {} }).label ).toBe('data:test') expect( - decorator({ id: 'https://example.org/demo#TEST', attributes: {} }).label + decorator({ id: 'https://example.org/demo#TEST', metadata: {} }).label ).toBe('demo:TEST') expect( - decorator({ id: 'https://example.org/other', attributes: {} }).label + decorator({ id: 'https://example.org/other', metadata: {} }).label ).toBe('https://example.org/other') }) @@ -21,12 +21,12 @@ it('Can label by id fragment', () => { const decorator = labelWithFragment() expect( - decorator({ id: 'https://example.org/data/test', attributes: {} }).label + decorator({ id: 'https://example.org/data/test', metadata: {} }).label ).toBe('test') expect( - decorator({ id: 'https://example.org/demo#TEST', attributes: {} }).label + decorator({ id: 'https://example.org/demo#TEST', metadata: {} }).label ).toBe('TEST') expect( - decorator({ id: 'https://example.org/other', attributes: {} }).label + decorator({ id: 'https://example.org/other', metadata: {} }).label ).toBe('other') }) diff --git a/packages/graph-rdf/src/utils/labels.ts b/packages/graph-rdf/src/utils/labels.ts index ff5c2fb..f969453 100644 --- a/packages/graph-rdf/src/utils/labels.ts +++ b/packages/graph-rdf/src/utils/labels.ts @@ -1,10 +1,10 @@ -import { ModelItem } from '@committed/graph' +import { Item } from '@committed/graph' const labelNodeBy = - (mapping: (item: T) => string) => + (mapping: (item: T) => string) => (item: T) => ({ label: mapping(item) }) -const idTo = (mapping: (id: string) => string) => (item: ModelItem) => +const idTo = (mapping: (id: string) => string) => (item: Item) => mapping(item.id) export const prefixedId = @@ -39,7 +39,7 @@ export const fragmentId = (id: string): string => { * @param prefixes map of prefix to full URI * @returns item decorator */ -export const labelWithPrefix = ( +export const labelWithPrefix = ( prefixes: Record ): ((item: T) => { label: string }) => labelNodeBy(idTo(prefixedId(prefixes))) @@ -50,6 +50,6 @@ export const labelWithPrefix = ( * * @returns item decorator */ -export const labelWithFragment = (): ((item: T) => { +export const labelWithFragment = (): ((item: T) => { label: string }) => labelNodeBy(idTo(fragmentId)) diff --git a/packages/graph-rdf/src/utils/processors.test.ts b/packages/graph-rdf/src/utils/processors.test.ts index 9bab03f..f1bb323 100644 --- a/packages/graph-rdf/src/utils/processors.test.ts +++ b/packages/graph-rdf/src/utils/processors.test.ts @@ -1,80 +1,74 @@ -import { ModelEdge, ModelNode } from '@committed/graph' +import { Edge, Node } from '@committed/graph' import { cleanProcessor } from './processors' it('Can label by fragment id', () => { expect( - cleanProcessor({ + cleanProcessor({ id: 'https://example.org/data/test', - attributes: {}, + metadata: {}, }).label ).toBe('test') expect( - cleanProcessor({ + cleanProcessor({ id: 'https://example.org/demo#TEST', - attributes: {}, + metadata: {}, }).label ).toBe('TEST') - expect(cleanProcessor({ id: 'test', attributes: {} }).label).toBe( - 'test' - ) + expect(cleanProcessor({ id: 'test', metadata: {} }).label).toBe('test') expect( - cleanProcessor({ id: 'test', label: 'label', attributes: {} }) - .label + cleanProcessor({ id: 'test', label: 'label', metadata: {} }).label ).toBe('label') }) it('Can clean attribute labels', () => { expect( - cleanProcessor({ + cleanProcessor({ id: 'https://example.org/data/test', - attributes: { 'https://example.org/data/test': 'test' }, - }).attributes + metadata: { 'https://example.org/data/test': 'test' }, + }).metadata ).toStrictEqual({ test: 'test' }) expect( - cleanProcessor({ + cleanProcessor({ id: 'https://example.org/demo#TEST', - attributes: { + metadata: { 'https://example.org/demo#TEST': 'TEST', }, - }).attributes + }).metadata ).toStrictEqual({ TEST: 'TEST' }) expect( - cleanProcessor({ id: 'test', attributes: { test: 'test' } }) - .attributes + cleanProcessor({ id: 'test', metadata: { test: 'test' } }).metadata ).toStrictEqual({ test: 'test' }) }) it('Can process type', () => { expect( - cleanProcessor({ + cleanProcessor({ id: 'https://example.org/demo#TEST', - attributes: { type: 'https://example.org/demo#TEST' }, - }).attributes + metadata: { type: 'https://example.org/demo#TEST' }, + }).metadata ).toStrictEqual({ type: 'TEST' }) }) it('Can rewite label if equal id or matches edge predicate format', () => { expect( - cleanProcessor({ + cleanProcessor({ id: 'https://example.org/data/test', label: 'https://example.org/data/test', - attributes: {}, + metadata: {}, }).label ).toBe('test') expect( - cleanProcessor({ + cleanProcessor({ id: 'https://example.org/demo#source|https://example.org/demo#TEST|https://example.org/demo#target', source: 'https://example.org/demo#source', target: 'https://example.org/demo#target', label: 'https://example.org/demo#TEST', - attributes: {}, + metadata: {}, + directed: true, }).label ).toBe('TEST') - expect(cleanProcessor({ id: 'test', attributes: {} }).label).toBe( - 'test' - ) + expect(cleanProcessor({ id: 'test', metadata: {} }).label).toBe('test') expect( - cleanProcessor({ id: 'test', label: 'label', attributes: {} }) - .label + cleanProcessor({ id: 'test', label: 'label', metadata: {} }).label ).toBe('label') }) diff --git a/packages/graph-rdf/src/utils/processors.ts b/packages/graph-rdf/src/utils/processors.ts index 0cccc1d..ec858f3 100644 --- a/packages/graph-rdf/src/utils/processors.ts +++ b/packages/graph-rdf/src/utils/processors.ts @@ -1,4 +1,4 @@ -import { ModelAttributeSet, ModelEdge, ModelNode } from '@committed/graph' +import { Edge, Metadata, Node } from '@committed/graph' import { fragmentId } from './labels' /** @@ -6,7 +6,7 @@ import { fragmentId } from './labels' * @param item Opinionated function to process rdf nodes and edges for a cleaner presentation in the graph * @returns */ -export const cleanProcessor = (item: T): T => { +export const cleanProcessor = (item: T): T => { if (typeof item.label === 'string' && item.id.includes(`|${item.label}|`)) { item.label = fragmentId(item.label) } @@ -15,15 +15,15 @@ export const cleanProcessor = (item: T): T => { item.label = fragmentId(item.id) } - if (typeof item.attributes.type === 'string') { - item.attributes.type = fragmentId(item.attributes.type) + if (typeof item.metadata.type === 'string') { + item.metadata.type = fragmentId(item.metadata.type) } - const attributes: ModelAttributeSet = {} - Object.keys(item.attributes).forEach((key) => { - attributes[fragmentId(key)] = item.attributes[key] + const metadata: Metadata = {} + Object.keys(item.metadata).forEach((key) => { + metadata[fragmentId(key)] = item.metadata[key] }) - item.attributes = attributes + item.metadata = metadata return item } diff --git a/packages/graph/src/graph/ContentModel.test.ts b/packages/graph/src/graph/ContentModel.test.ts index cb0f700..33df61f 100644 --- a/packages/graph/src/graph/ContentModel.test.ts +++ b/packages/graph/src/graph/ContentModel.test.ts @@ -3,8 +3,8 @@ import { ContentModel } from './ContentModel' let contentModel: ContentModel -const attribute = 'att1' -const attributeValue = 'att1val' +const key = 'att1' +const value = 'att1val' beforeEach(() => { contentModel = ContentModel.createEmpty() @@ -27,44 +27,40 @@ it('Throws editing non-existing nodes', () => { expect(() => contentModel.editNode({ id: 'nonexisting', - attributes: {}, + metadata: {}, }) ).toThrow() expect(() => - contentModel.addNodeAttribute('nonexisting', 'attribute', 'value') + contentModel.addNodeMetadata('nonexisting', 'key', 'value') ).toThrow() expect(() => - contentModel.editNodeAttribute('nonexisting', 'attribute', 'value') + contentModel.editNodeMetadata('nonexisting', 'key', 'value') ).toThrow() - expect(() => - contentModel.removeNodeAttribute('nonexisting', 'attribute') - ).toThrow() + expect(() => contentModel.removeNodeMetadata('nonexisting', 'key')).toThrow() }) it('Throws editing non-existing edges', () => { expect(() => contentModel.editEdge({ id: 'nonexisting', - attributes: {}, + metadata: {}, source: 'nonexisting', target: 'nonexisting', }) ).toThrow() expect(() => - contentModel.addEdgeAttribute('nonexisting', 'attribute', 'value') + contentModel.addEdgeMetadata('nonexisting', 'key', 'value') ).toThrow() expect(() => - contentModel.editEdgeAttribute('nonexisting', 'attribute', 'value') + contentModel.editEdgeMetadata('nonexisting', 'key', 'value') ).toThrow() - expect(() => - contentModel.removeEdgeAttribute('nonexisting', 'attribute') - ).toThrow() + expect(() => contentModel.removeEdgeMetadata('nonexisting', 'key')).toThrow() }) it('Getting non-existing node', () => { @@ -75,11 +71,11 @@ it('Getting non-existing edge', () => { expect(contentModel.getEdge('nonexisting')).toBeUndefined() }) -it('Doesnt contain non-existing node', () => { +it('Does not contain non-existing node', () => { expect(contentModel.containsNode('nonexisting')).toBe(false) }) -it('Doesnt contain non-existing edge', () => { +it('Does not contain non-existing edge', () => { expect(contentModel.containsEdge('nonexisting')).toBe(false) }) @@ -104,39 +100,37 @@ it('Add fully unspecified node', () => { it('Add node attribute', () => { contentModel = contentModel .addNode(node1) - .addNodeAttribute(node1.id, attribute, attributeValue) + .addNodeMetadata(node1.id, key, value) const node = contentModel.getNode(node1.id) expect(node).toBeTruthy() - expect(node!.attributes[attribute]).toBe(attributeValue) + expect(node!.metadata[key]).toBe(value) }) it('Edit node attribute', () => { const newAttributeValue = 'att1val2' contentModel = contentModel .addNode(node1) - .addNodeAttribute(node1.id, attribute, attributeValue) - .editNodeAttribute(node1.id, attribute, newAttributeValue) + .addNodeMetadata(node1.id, key, value) + .editNodeMetadata(node1.id, key, newAttributeValue) const node = contentModel.getNode(node1.id) expect(node).toBeTruthy() - expect(node!.attributes[attribute]).toBe(newAttributeValue) + expect(node!.metadata[key]).toBe(newAttributeValue) }) it('Edit node attribute should throw if missing', () => { contentModel = contentModel.addNode(node1) - expect(() => - contentModel.editNodeAttribute(node1.id, attribute, attributeValue) - ).toThrow() + expect(() => contentModel.editNodeMetadata(node1.id, key, value)).toThrow() }) it('Remove node attribute', () => { contentModel = contentModel .addNode(node1) - .addNodeAttribute(node1.id, attribute, attributeValue) - .removeNodeAttribute(node1.id, attribute) + .addNodeMetadata(node1.id, key, value) + .removeNodeMetadata(node1.id, key) const node = contentModel.getNode(node1.id) expect(node).toBeTruthy() - expect(node!.attributes[attribute]).toBeFalsy() + expect(node!.metadata[key]).toBeFalsy() }) it('Remove node', () => { @@ -186,10 +180,10 @@ it('Add edge attribute', () => { .addNode(node1) .addNode(node2) .addEdge(edge1) - .addEdgeAttribute(edge1.id, attribute, attributeValue) + .addEdgeMetadata(edge1.id, key, value) const edge = contentModel.getEdge(edge1.id) expect(edge).toBeTruthy() - expect(edge!.attributes[attribute]).toBe(attributeValue) + expect(edge!.metadata[key]).toBe(value) }) it('Edit edge attribute', () => { @@ -198,19 +192,17 @@ it('Edit edge attribute', () => { .addNode(node1) .addNode(node2) .addEdge(edge1) - .addEdgeAttribute(edge1.id, attribute, attributeValue) - .editEdgeAttribute(edge1.id, attribute, newAttributeValue) + .addEdgeMetadata(edge1.id, key, value) + .editEdgeMetadata(edge1.id, key, newAttributeValue) const edge = contentModel.getEdge(edge1.id) expect(edge).toBeTruthy() - expect(edge!.attributes[attribute]).toBe(newAttributeValue) + expect(edge!.metadata[key]).toBe(newAttributeValue) }) it('Edit edge attribute throws if missing', () => { contentModel = contentModel.addNode(node1).addNode(node2).addEdge(edge1) - expect(() => - contentModel.editEdgeAttribute(edge1.id, attribute, attributeValue) - ).toThrow() + expect(() => contentModel.editEdgeMetadata(edge1.id, key, value)).toThrow() }) it('Remove edge attribute', () => { @@ -218,11 +210,11 @@ it('Remove edge attribute', () => { .addNode(node1) .addNode(node2) .addEdge(edge1) - .addEdgeAttribute(edge1.id, attribute, attributeValue) - .removeEdgeAttribute(edge1.id, attribute) + .addEdgeMetadata(edge1.id, key, value) + .removeEdgeMetadata(edge1.id, key) const node = contentModel.getNode(node1.id) expect(node).toBeTruthy() - expect(node!.attributes[attribute]).toBeFalsy() + expect(node!.metadata[key]).toBeFalsy() }) it('Remove edge', () => { @@ -277,7 +269,7 @@ it('Gets all edges linked to node', () => { }) it('Create from raw empty', () => { - contentModel = ContentModel.fromRaw({ nodes: {}, edges: {} }) + contentModel = ContentModel.fromRaw({ nodes: {}, edges: [] }) expect(Object.keys(contentModel.nodes)).toHaveLength(0) expect(Object.keys(contentModel.edges)).toHaveLength(0) }) @@ -285,7 +277,7 @@ it('Create from raw empty', () => { it('Create from raw valid values', () => { contentModel = ContentModel.fromRaw({ nodes: { [node1.id]: node1, [node2.id]: node2 }, - edges: { [edge1.id]: edge1 }, + edges: [edge1], }) expect(Object.keys(contentModel.nodes)).toHaveLength(2) expect(Object.keys(contentModel.edges)).toHaveLength(1) @@ -296,49 +288,49 @@ it('Throws creating with missing edge target', () => { () => (contentModel = ContentModel.fromRaw({ nodes: { [node1.id]: node1 }, - edges: { [edge1.id]: edge1 }, + edges: [edge1], })) ).toThrow() }) -it('has no attributes when empty', () => { - expect(Object.keys(contentModel.getNodeAttributes())).toHaveLength(0) - expect(Object.keys(contentModel.getEdgeAttributes())).toHaveLength(0) +it('has no metadata when empty', () => { + expect(Object.keys(contentModel.getNodeMetadataTypes())).toHaveLength(0) + expect(Object.keys(contentModel.getEdgeMetadataTypes())).toHaveLength(0) }) -it('Can get node attributes', () => { +it('Can get node metadata', () => { contentModel = contentModel .addNode(node1) - .addNodeAttribute(node1.id, attribute, attributeValue) + .addNodeMetadata(node1.id, key, value) .addNode(node2) - .addNodeAttribute(node2.id, attribute, 10) + .addNodeMetadata(node2.id, key, 10) - const existingAttributeId = Object.keys(node1.attributes)[0] + const existingKey = Object.keys(node1.metadata)[0] - const attributeTypes = contentModel.getNodeAttributes() - const attributeIds = Object.keys(attributeTypes) - expect(attributeIds).toHaveLength(2) - expect(attributeIds).toContain(attribute) - expect(attributeIds).toContain(existingAttributeId) + const metadataSets = contentModel.getNodeMetadataTypes() + const metadataKeys = Object.keys(metadataSets) + expect(metadataKeys).toHaveLength(2) + expect(metadataKeys).toContain(key) + expect(metadataKeys).toContain(existingKey) - expect(attributeTypes[attribute].size).toBe(2) - expect(attributeTypes[attribute].has('string')).toBeTruthy() - expect(attributeTypes[attribute].has('number')).toBeTruthy() + expect(metadataSets[key].size).toBe(2) + expect(metadataSets[key].has('string')).toBeTruthy() + expect(metadataSets[key].has('number')).toBeTruthy() - expect(attributeTypes[existingAttributeId].size).toBe(1) - expect(attributeTypes[attribute].has('string')).toBeTruthy() + expect(metadataSets[existingKey].size).toBe(1) + expect(metadataSets[key].has('string')).toBeTruthy() }) it('Can get edge attributes', () => { contentModel = contentModel.addNode(node1).addNode(node2).addEdge(edge1) - const attributeTypes = contentModel.getEdgeAttributes() - const attributeIds = Object.keys(attributeTypes) + const metadataTypes = contentModel.getEdgeMetadataTypes() + const metadataKeys = Object.keys(metadataTypes) - const existingAttributeId = Object.keys(edge1.attributes)[0] - expect(attributeIds).toHaveLength(1) - expect(attributeIds).toContain(Object.keys(edge1.attributes)[0]) + const existingKey = Object.keys(edge1.metadata)[0] + expect(metadataKeys).toHaveLength(1) + expect(metadataKeys).toContain(Object.keys(edge1.metadata)[0]) - expect(attributeTypes[existingAttributeId].size).toBe(1) - expect(attributeTypes[existingAttributeId].has('string')).toBeTruthy() + expect(metadataTypes[existingKey].size).toBe(1) + expect(metadataTypes[existingKey].has('string')).toBeTruthy() }) diff --git a/packages/graph/src/graph/ContentModel.ts b/packages/graph/src/graph/ContentModel.ts index b18776c..60cf4fe 100644 --- a/packages/graph/src/graph/ContentModel.ts +++ b/packages/graph/src/graph/ContentModel.ts @@ -1,17 +1,44 @@ import { v4 } from 'uuid' import { - ModelAttributeSet, - ModelAttributeTypes, + Edge, + Item, + MetadataTypes, ModelEdge, ModelGraphData, - ModelItem, ModelNode, + Node, } from './types' export class ContentModel { + private static toNode(modelNode: ModelNode): Node { + return { id: modelNode.id ?? v4(), metadata: {}, ...modelNode } + } + + public static toEdge(modelEdge: ModelEdge): Edge { + return { + id: modelEdge.id ?? v4(), + directed: true, + metadata: {}, + ...modelEdge, + } + } + public static fromRaw(data: ModelGraphData): ContentModel { - const model = new ContentModel(data.nodes, data.edges) - Object.values(model.edges).forEach((e) => model.checkEdgeNodes(e)) + const nodes = Object.keys(data.nodes).reduce>( + (obj, id) => { + const node = ContentModel.toNode({ ...data.nodes[id], id }) + obj[node.id] = node + return obj + }, + {} + ) + const edges = data.edges.reduce>((obj, modelEdge) => { + const edge = ContentModel.toEdge(modelEdge) + obj[edge.id] = edge + return obj + }, {}) + const model = new ContentModel(nodes, edges) + model.validate() return model } @@ -20,11 +47,12 @@ export class ContentModel { } constructor( - readonly nodes: Record, - readonly edges: Record - ) { - this.nodes = nodes - this.edges = edges + readonly nodes: Record, + readonly edges: Record + ) {} + + validate() { + Object.values(this.edges).forEach((e) => this.checkEdgeNodes(e)) } containsNode(id: string): boolean { @@ -35,11 +63,14 @@ export class ContentModel { return this.getEdge(id) != null } - getNode(id: string): ModelNode | undefined { + getNode(id: string): Node | undefined { return this.nodes[`${id}`] } - private getExistingNode(id: string): ModelNode { + private getExistingNode(id: string | undefined): Node { + if (id == null) { + throw new Error(`Node id must be given`) + } const node = this.getNode(id) if (node == null) { throw new Error(`Node [${id}] does not exist`) @@ -47,46 +78,49 @@ export class ContentModel { return node } - getEdgesLinkedToNode(nodeId: string): ModelEdge[] { + getEdgesLinkedToNode(nodeId: string): Edge[] { return Object.values(this.edges).filter( (e) => e.target === nodeId || e.source === nodeId ) } - addNode(node: Partial): ContentModel { - if (node.id != null && this.containsNode(node.id)) { - throw new Error(`Cannot add node already in graph (${node.id})`) - } - const newNode: ModelNode = { - id: node.id ?? v4(), - attributes: node.attributes ?? {}, - ...node, + addNode(modelNode: ModelNode): ContentModel { + if (modelNode.id != null && this.containsNode(modelNode.id)) { + throw new Error(`Cannot add node already in graph (${modelNode.id})`) } - const nodes = { ...this.nodes, [newNode.id]: newNode } + const node = ContentModel.toNode(modelNode) + const nodes = { ...this.nodes, [node.id]: node } return new ContentModel(nodes, this.edges) } editNode(node: ModelNode): ContentModel { - this.getExistingNode(node.id) + const existing = this.getExistingNode(node.id) return new ContentModel( - { ...this.nodes, ...{ [node.id]: node } }, + { ...this.nodes, [existing.id]: ContentModel.toNode(node) }, this.edges ) } + /** + * @deprecated use addNodeMetadata + */ addNodeAttribute( id: string, attributeName: string, attributeValue: V ): ContentModel { + return this.addNodeMetadata(id, attributeName, attributeValue) + } + + addNodeMetadata(id: string, key: string, value: V): ContentModel { const node = this.getExistingNode(id) - const newAttributes = { - ...node.attributes, - ...{ [attributeName]: attributeValue }, + const newMetadata = { + ...node.metadata, + ...{ [key]: value }, } const changedNode = { - ...this.getExistingNode(id), - ...{ attributes: newAttributes }, + ...node, + metadata: newMetadata, } return this.editNode(changedNode) } @@ -114,59 +148,62 @@ export class ContentModel { return new ContentModel(withoutNode, this.edges) } + /** + * @deprecated use editNodeMetadata + */ editNodeAttribute( id: string, attributeName: string, attributeValue: V ): ContentModel { - if (this.getExistingNode(id).attributes[`${attributeName}`] == null) { - throw new Error(`Node [${id}] does not have attribute ${attributeName}`) + return this.editNodeMetadata(id, attributeName, attributeValue) + } + + editNodeMetadata(id: string, key: string, value: V): ContentModel { + if (this.getExistingNode(id).metadata[`${key}`] == null) { + throw new Error(`Node [${id}] does not have metadata ${key}`) } - return this.addNodeAttribute(id, attributeName, attributeValue) + return this.addNodeMetadata(id, key, value) } + /** + * @deprecated use removeNodeMetadata + */ removeNodeAttribute(id: string, attributeName: string): ContentModel { + return this.removeNodeMetadata(id, attributeName) + } + + removeNodeMetadata(id: string, key: string): ContentModel { const node = this.getExistingNode(id) - const remainingAttributes = { ...node.attributes } - delete remainingAttributes[`${attributeName}`] - const n = { ...node, ...{ attributes: remainingAttributes } } + const remainingMetadata = { ...node.metadata } + delete remainingMetadata[`${key}`] + const n = { ...node, metadata: remainingMetadata } return this.editNode(n) } - addEdge( - edge: Omit & { - id?: string - attributes?: ModelAttributeSet - } - ): ContentModel { - this.checkEdgeNodes(edge) - if (edge.id != null && this.containsEdge(edge.id)) { - throw new Error(`Cannot add edge already in graph (${edge.id})`) - } - const newEdge: ModelEdge = { - id: edge.id ?? v4(), - attributes: edge.attributes ?? {}, - ...edge, + addEdge(modelEdge: ModelEdge): ContentModel { + this.checkEdgeNodes(modelEdge) + if (modelEdge.id != null && this.containsEdge(modelEdge.id)) { + throw new Error(`Cannot add edge already in graph (${modelEdge.id})`) } + const newEdge = ContentModel.toEdge(modelEdge) const edges = { ...this.edges, [newEdge.id]: newEdge } return new ContentModel(this.nodes, edges) } - checkEdgeNodes( - edge: Omit & { - id?: string - attributes?: ModelAttributeSet - } - ): void { + checkEdgeNodes(edge: ModelEdge): void { this.getExistingNode(edge.source) this.getExistingNode(edge.target) } - getEdge(id: string): ModelEdge | undefined { + getEdge(id: string): Edge | undefined { return this.edges[`${id}`] } - private getExistingEdge(id: string): ModelEdge { + private getExistingEdge(id: string | undefined): Edge { + if (id == null) { + throw new Error('Edge id must be provided') + } const edge = this.getEdge(id) if (edge == null) { throw new Error(`Edge [${id}] does not exist`) @@ -176,46 +213,67 @@ export class ContentModel { editEdge(edge: ModelEdge): ContentModel { // ensure edge exists - this.getExistingEdge(edge.id) + const existing = this.getExistingEdge(edge.id) return new ContentModel(this.nodes, { ...this.edges, - ...{ [edge.id]: edge }, + [existing.id]: ContentModel.toEdge(edge), }) } + /** + * @deprecated use addEdgeMetadata + */ addEdgeAttribute( id: string, attributeName: string, attributeValue: V ): ContentModel { + return this.addEdgeMetadata(id, attributeName, attributeValue) + } + + addEdgeMetadata(id: string, key: string, value: V): ContentModel { const edge = this.getExistingEdge(id) - const newAttributes = { - ...edge.attributes, - ...{ [attributeName]: attributeValue }, + const newMetadata = { + ...edge.metadata, + [key]: value, } const changedEdge = { ...this.getExistingEdge(id), - ...{ attributes: newAttributes }, + metadata: newMetadata, } return this.editEdge(changedEdge) } + /** + * @deprecated use editEdgeMetadata + */ editEdgeAttribute( id: string, attributeName: string, attributeValue: V ): ContentModel { - if (this.getExistingEdge(id).attributes[`${attributeName}`] == null) { - throw new Error(`Edge [${id}] does not have attribute ${attributeName}`) + return this.editEdgeMetadata(id, attributeName, attributeValue) + } + + editEdgeMetadata(id: string, key: string, value: V): ContentModel { + if (this.getExistingEdge(id).metadata[`${key}`] == null) { + throw new Error(`Edge [${id}] does not have attribute ${key}`) } - return this.addEdgeAttribute(id, attributeName, attributeValue) + return this.addEdgeMetadata(id, key, value) } + /** + * @deprecated use removeEdgeMetadata + */ removeEdgeAttribute(id: string, attributeName: string): ContentModel { + return this.removeEdgeMetadata(id, attributeName) + } + + removeEdgeMetadata(id: string, attributeName: string): ContentModel { const edge = this.getExistingEdge(id) - const remainingAttributes = { ...edge.attributes } - delete remainingAttributes[`${attributeName}`] - const e = { ...edge, ...{ attributes: remainingAttributes } } + const remainingMetadata = { ...edge.metadata } + delete remainingMetadata[`${attributeName}`] + const e = { ...edge, metadata: remainingMetadata } return this.editEdge(e) } @@ -229,22 +287,36 @@ export class ContentModel { return new ContentModel(this.nodes, withoutEdge) } - private getAttributes(items: ModelItem[]): ModelAttributeTypes { - return items.reduce((attributeTypes, item) => { - Object.entries(item.attributes).forEach((a) => { - const attributeType = attributeTypes[a[0]] ?? new Set() - attributeType.add(typeof a[1]) - attributeTypes[a[0]] = attributeType + private getMetadataSets(items: Item[]): MetadataTypes { + return items.reduce((metadataSets, item) => { + Object.entries(item.metadata).forEach((a) => { + const metadataKey = metadataSets[a[0]] ?? new Set() + metadataKey.add(typeof a[1]) + metadataSets[a[0]] = metadataKey }) - return attributeTypes + return metadataSets }, {}) } - getNodeAttributes(): ModelAttributeTypes { - return this.getAttributes(Object.values(this.nodes)) + /** + * @deprecated use getNodeMetadataSets + */ + getNodeAttributes(): MetadataTypes { + return this.getNodeMetadataTypes() + } + + getNodeMetadataTypes(): MetadataTypes { + return this.getMetadataSets(Object.values(this.nodes)) + } + + /** + * @deprecated use getEdgeMetadataSets + */ + getEdgeAttributes(): MetadataTypes { + return this.getEdgeMetadataTypes() } - getEdgeAttributes(): ModelAttributeTypes { - return this.getAttributes(Object.values(this.edges)) + getEdgeMetadataTypes(): MetadataTypes { + return this.getMetadataSets(Object.values(this.edges)) } } diff --git a/packages/graph/src/graph/DecoratorModel.test.ts b/packages/graph/src/graph/DecoratorModel.test.ts index b53d4d6..c7c7d7f 100644 --- a/packages/graph/src/graph/DecoratorModel.test.ts +++ b/packages/graph/src/graph/DecoratorModel.test.ts @@ -1,23 +1,25 @@ import { DecoratorModel } from './DecoratorModel' import { + Edge, EdgeDecoration, EdgeDecorator, ModelEdge, ModelNode, + Node, NodeDecoration, NodeDecorator, } from './types' let decoratorModel: DecoratorModel -const nodeWithoutDecoration: ModelNode = { +const nodeWithoutDecoration: Node = { id: 'node1', - attributes: { + metadata: { employer: 'Committed', }, } -const nodeWithDecoration: ModelNode = { +const nodeWithDecoration: Node = { ...nodeWithoutDecoration, color: 'yellow', label: 'Node 1', @@ -38,16 +40,17 @@ const nodeDecoratorDecoration: NodeDecoration = { strokeSize: 5, } -const edgeWithoutDecoration: ModelEdge = { +const edgeWithoutDecoration: Edge = { id: 'edge1', - attributes: { + metadata: { role: 'client', }, source: 'node1', target: 'node2', + directed: true, } -const edgeWithDecoration: ModelEdge = { +const edgeWithDecoration: Edge = { ...edgeWithoutDecoration, color: 'yellow', label: 'Node 1', @@ -66,6 +69,7 @@ const edgeDecoratorDecoration: EdgeDecoration = { sourceArrow: false, targetArrow: false, style: 'dashed', + curve: 'bezier', } const nodeDecorator: NodeDecorator = () => nodeDecoratorDecoration @@ -131,7 +135,7 @@ it('Does not alter non-decoration node properties', () => { nodeWithDecoration, ])[0] expect(decoratedNode.id).toBe(nodeWithDecoration.id) - expect(decoratedNode.attributes).toBe(nodeWithDecoration.attributes) + expect(decoratedNode.metadata).toBe(nodeWithDecoration.metadata) }) it('Does not applies default decoration to unstyled node', () => { @@ -196,6 +200,7 @@ it('Create default sets default edge decoration for theme', () => { expect(decoratorModel.getDefaultEdgeDecorator()()).toMatchInlineSnapshot(` { "color": "$colors$textSecondary", + "curve": "haystack", "opacity": 1, "size": 2, "sourceArrow": false, @@ -218,6 +223,7 @@ it('Can supply partial decorator function for default', () => { expect(decoration).toMatchInlineSnapshot(` { "color": "$colors$success", + "curve": "haystack", "opacity": 1, "size": 10, "sourceArrow": false, @@ -239,7 +245,7 @@ it('Does not alter non-decoration edge properties', () => { ])[0] expect(decoratedEdge.id).toBe(edgeWithDecoration.id) - expect(decoratedEdge.attributes).toBe(edgeWithDecoration.attributes) + expect(decoratedEdge.metadata).toBe(edgeWithDecoration.metadata) expect(decoratedEdge.source).toBe(edgeWithDecoration.source) expect(decoratedEdge.target).toBe(edgeWithDecoration.target) }) diff --git a/packages/graph/src/graph/DecoratorModel.ts b/packages/graph/src/graph/DecoratorModel.ts index 555ff76..b7367bc 100644 --- a/packages/graph/src/graph/DecoratorModel.ts +++ b/packages/graph/src/graph/DecoratorModel.ts @@ -2,12 +2,12 @@ import { DecoratedEdge, DecoratedNode, DecorationFunction, + Edge, EdgeDecoration, EdgeDecorationFunction, EdgeDecorator, ItemDecoration, - ModelEdge, - ModelNode, + Node, NodeDecoration, NodeDecorationFunction, NodeDecorator, @@ -30,6 +30,7 @@ export class DecoratorModel { targetArrow: false, opacity: 1, style: 'solid', + curve: 'haystack', } static readonly idAsLabelNode: NodeDecorator = (item) => { @@ -119,10 +120,10 @@ export class DecoratorModel { /** * Adds a function to the given nodes that provides the decoration overrides for the node - * @param modelNodes to be decorated + * @param nodes to be decorated */ - getDecoratedNodes(modelNodes: ModelNode[]): DecoratedNode[] { - return Object.values(modelNodes).map((node) => { + getDecoratedNodes(nodes: Node[]): DecoratedNode[] { + return Object.values(nodes).map((node) => { return { getDecorationOverrides: () => this.getNodeDecorationOverrides(node), ...node, @@ -132,10 +133,10 @@ export class DecoratorModel { /** * Adds a function to the given edges that provides the decoration overrides for the edge - * @param modelEdges to be decorated + * @param edges to be decorated */ - getDecoratedEdges(modelEdges: ModelEdge[]): DecoratedEdge[] { - return Object.values(modelEdges).map((edge) => { + getDecoratedEdges(edges: Edge[]): DecoratedEdge[] { + return Object.values(edges).map((edge) => { return { getDecorationOverrides: () => this.getEdgeDecorationOverrides(edge), ...edge, @@ -331,9 +332,9 @@ export class DecoratorModel { return decorators.filter((d) => d !== decorator) } - private getNodeDecorationOverrides(node: ModelNode): Partial { + private getNodeDecorationOverrides(node: Node): Partial { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, attributes, ...nodeStyle } = node + const { id, metadata, ...nodeStyle } = node const decor = Object.assign( {}, ...this.nodeDecorators.map((d) => d(node)) @@ -346,9 +347,9 @@ export class DecoratorModel { } } - private getEdgeDecorationOverrides(edge: ModelEdge): Partial { + private getEdgeDecorationOverrides(edge: Edge): Partial { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, attributes, source, target, ...edgeStyle } = edge + const { id, metadata, source, target, ...edgeStyle } = edge const decor = Object.assign( {}, ...this.edgeDecorators.map((d) => d(edge)) diff --git a/packages/graph/src/graph/GraphModel.test.ts b/packages/graph/src/graph/GraphModel.test.ts index 79c0459..1bda0f9 100644 --- a/packages/graph/src/graph/GraphModel.test.ts +++ b/packages/graph/src/graph/GraphModel.test.ts @@ -1,4 +1,4 @@ -import { edge1, node1, node2, exampleGraph } from 'test/setup' +import { edge1, exampleGraph, node1, node2 } from 'test/setup' import { ContentModel } from './ContentModel' import { DecoratorModel } from './DecoratorModel' import { GraphModel } from './GraphModel' @@ -179,8 +179,8 @@ it('Clearing empty queue does nothing', () => { }) it('has no attributes when empty', () => { - expect(Object.keys(graphModel.getNodeAttributes())).toHaveLength(0) - expect(Object.keys(graphModel.getEdgeAttributes())).toHaveLength(0) + expect(Object.keys(graphModel.getNodeMetadataTypes())).toHaveLength(0) + expect(Object.keys(graphModel.getEdgeMetadataTypes())).toHaveLength(0) }) it('Can get node attributes', () => { @@ -189,10 +189,10 @@ it('Can get node attributes', () => { graphModel.getCurrentContent().addNode(node1).addNode(node2).addEdge(edge1) ) - const attributeTypes = graphModel.getNodeAttributes() - const attributeIds = Object.keys(attributeTypes) - expect(attributeIds).toHaveLength(1) - expect(attributeIds).toContain(Object.keys(node1.attributes)[0]) + const metadataTypes = graphModel.getNodeMetadataTypes() + const metadataKeys = Object.keys(metadataTypes) + expect(metadataKeys).toHaveLength(1) + expect(metadataKeys).toContain(Object.keys(node1.metadata)[0]) }) it('Can get edge attributes', () => { @@ -201,8 +201,8 @@ it('Can get edge attributes', () => { graphModel.getCurrentContent().addNode(node1).addNode(node2).addEdge(edge1) ) - const attributeTypes = graphModel.getEdgeAttributes() - const attributeIds = Object.keys(attributeTypes) - expect(attributeIds).toHaveLength(1) - expect(attributeIds).toContain(Object.keys(edge1.attributes)[0]) + const metadataTypes = graphModel.getEdgeMetadataTypes() + const metadataKeys = Object.keys(metadataTypes) + expect(metadataKeys).toHaveLength(1) + expect(metadataKeys).toContain(Object.keys(edge1.metadata)[0]) }) diff --git a/packages/graph/src/graph/GraphModel.ts b/packages/graph/src/graph/GraphModel.ts index 8b71c6f..f16b386 100644 --- a/packages/graph/src/graph/GraphModel.ts +++ b/packages/graph/src/graph/GraphModel.ts @@ -5,10 +5,10 @@ import { SelectionModel } from './SelectionModel' import { DecoratedEdge, DecoratedNode, + Edge, GraphCommand, - ModelAttributeTypes, - ModelEdge, - ModelNode, + MetadataTypes, + Node, } from './types' /** The GraphModel is a declarative, immutable definition of graph state @@ -16,7 +16,7 @@ import { The GraphModel defines: - Nodes/edges (ContentModel) - No requirement for ontology, typing etc - - Attributes + - Metadata - Decoration (DecoratorModel) - May define custom node/edge Decorators based on any property on the node/edge - Selection (SelectionModel) @@ -141,7 +141,7 @@ export class GraphModel { Array.from(this.getSelection().nodes) .map((n) => this.contentModel.getNode(n)) .filter((n) => n != null) - .map((n) => n as ModelNode) + .map((n) => n as Node) ) } @@ -150,7 +150,7 @@ export class GraphModel { Array.from(this.getSelection().edges) .map((e) => this.contentModel.getEdge(e)) .filter((e) => e != null) - .map((e) => e as ModelEdge) + .map((e) => e as Edge) ) } @@ -170,12 +170,12 @@ export class GraphModel { return this.decoratorModel.getDecoratedEdges([edge])[0] } - getNodeAttributes(): ModelAttributeTypes { - return this.contentModel.getNodeAttributes() + getNodeMetadataTypes(): MetadataTypes { + return this.contentModel.getNodeMetadataTypes() } - getEdgeAttributes(): ModelAttributeTypes { - return this.contentModel.getEdgeAttributes() + getEdgeMetadataTypes(): MetadataTypes { + return this.contentModel.getEdgeMetadataTypes() } getCurrentLayout(): LayoutModel { diff --git a/packages/graph/src/graph/data/Generator.ts b/packages/graph/src/graph/data/Generator.ts index f20d8ca..28bea09 100644 --- a/packages/graph/src/graph/data/Generator.ts +++ b/packages/graph/src/graph/data/Generator.ts @@ -1,14 +1,13 @@ import { ContentModel } from '../ContentModel' import { GraphModel } from '../GraphModel' -import { ModelEdge, ModelNode } from '../types' +import { Edge, Node, NodeShape, NodeShapes } from '../types' import { colors } from './colors' import { names } from './names' -import { shapes } from './shapes' const randomItem = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)] // nosonar - secure random not required -export const randomNode = (model: ContentModel): ModelNode | undefined => { +export const randomNode = (model: ContentModel): Node | undefined => { const nodes = Object.values(model.nodes) if (nodes.length === 0) { return @@ -18,7 +17,7 @@ export const randomNode = (model: ContentModel): ModelNode | undefined => { const randomNumber = (): number => Math.ceil(Math.random() * 100) -const randomEdge = (model: ContentModel): ModelEdge | undefined => { +const randomEdge = (model: ContentModel): Edge | undefined => { const edges = Object.values(model.edges) if (edges.length === 0) { return @@ -30,14 +29,14 @@ const randomColor = (): string => { return randomItem(colors) } -const randomShape = (): string => { - return randomItem(shapes) +const randomShape = () => { + return randomItem(Object.keys(NodeShapes) as NodeShape[]) } export const addRandomNode = ( model: GraphModel, count = 1, - options?: Partial | (() => Partial) + options?: Partial | (() => Partial) ): GraphModel => { let content = model.getCurrentContent() for (let i = 0; i < count; i++) { @@ -47,7 +46,7 @@ export const addRandomNode = ( content = content.addNode({ label: `${firstName} ${lastName}`, ...(typeof options === 'function' ? options() : options), - attributes: { + metadata: { firstName, lastName, age, diff --git a/packages/graph/src/graph/data/shapes.ts b/packages/graph/src/graph/data/shapes.ts deleted file mode 100644 index ff2636e..0000000 --- a/packages/graph/src/graph/data/shapes.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const shapes = [ - 'rectangle', - 'roundrectangle', - 'ellipse', - 'triangle', - 'pentagon', - 'hexagon', - 'heptagon', - 'octagon', - 'star', - 'barrel', - 'diamond', - 'vee', - 'rhomboid', - 'polygon', - 'tag', - 'round-rectangle', - 'round-triangle', - 'round-diamond', - 'round-pentagon', - 'round-hexagon', - 'round-heptagon', - 'round-octagon', - 'round-tag', - 'cut-rectangle', - 'bottom-round-rectangle', - 'concave-hexagon', -] diff --git a/packages/graph/src/graph/decorators/size.test.ts b/packages/graph/src/graph/decorators/size.test.ts index 410d17f..74ca6e8 100644 --- a/packages/graph/src/graph/decorators/size.test.ts +++ b/packages/graph/src/graph/decorators/size.test.ts @@ -1,51 +1,54 @@ import { ContentModel } from '../' -import { ModelEdge, ModelNode } from '../types' -import { sizeEdgeByAttribute, sizeNodeByAttribute } from './size' +import { Edge, Node } from '../types' +import { sizeEdgeByMetadata, sizeNodeByMetadata } from './size' -export const node1: ModelNode = { +export const node1: Node = { id: 'node1', - attributes: { + metadata: { sizeBy: '50', }, } -export const node2: ModelNode = { +export const node2: Node = { id: 'node2', - attributes: { + metadata: { sizeBy: 100, }, } -export const node3: ModelNode = { +export const node3: Node = { id: 'node3', - attributes: {}, + metadata: {}, } -export const edge1: ModelEdge = { +export const edge1: Edge = { id: 'edge1', - attributes: { + metadata: { sizeBy: '1', }, source: 'node1', target: 'node2', + directed: true, } -export const edge2: ModelEdge = { +export const edge2: Edge = { id: 'edge2', - attributes: { + metadata: { sizeBy: 2, }, source: 'node1', target: 'node2', + directed: false, } -export const edge3: ModelEdge = { +export const edge3: Edge = { id: 'edge3', - attributes: { + metadata: { sizeBy: '3', }, source: 'node1', target: 'node2', + directed: true, } const contentModel = ContentModel.createEmpty() @@ -57,7 +60,7 @@ const contentModel = ContentModel.createEmpty() .addEdge(edge3) it('Can size node by attribute', () => { - const sizeByAttribute = sizeNodeByAttribute(contentModel, 'sizeBy') + const sizeByAttribute = sizeNodeByMetadata(contentModel, 'sizeBy') expect(sizeByAttribute(node1).size).toBe(10) expect(sizeByAttribute(node2).size).toBe(200) @@ -65,7 +68,7 @@ it('Can size node by attribute', () => { }) it('Can size node by attribute', () => { - const sizeByAttribute = sizeNodeByAttribute(contentModel, 'sizeBy', [1, 10]) + const sizeByAttribute = sizeNodeByMetadata(contentModel, 'sizeBy', [1, 10]) expect(sizeByAttribute(node1).size).toBe(1) expect(sizeByAttribute(node2).size).toBe(10) @@ -73,7 +76,7 @@ it('Can size node by attribute', () => { }) it('Can size edge by attribute', () => { - const sizeByAttribute = sizeEdgeByAttribute(contentModel, 'sizeBy', [1, 2]) + const sizeByAttribute = sizeEdgeByMetadata(contentModel, 'sizeBy', [1, 2]) expect(sizeByAttribute(edge1).size).toBe(1) expect(sizeByAttribute(edge2).size).toBe(1.5) @@ -81,7 +84,7 @@ it('Can size edge by attribute', () => { }) it('Can size edge by attribute', () => { - const sizeByAttribute = sizeEdgeByAttribute(contentModel, 'sizeBy') + const sizeByAttribute = sizeEdgeByMetadata(contentModel, 'sizeBy') expect(sizeByAttribute(edge1).size).toBe(1) expect(sizeByAttribute(edge2).size).toBe(3) diff --git a/packages/graph/src/graph/decorators/size.ts b/packages/graph/src/graph/decorators/size.ts index bf07922..e4519f2 100644 --- a/packages/graph/src/graph/decorators/size.ts +++ b/packages/graph/src/graph/decorators/size.ts @@ -1,11 +1,5 @@ import { ContentModel } from 'graph' -import { - EdgeDecorator, - ModelEdge, - ModelItem, - ModelNode, - NodeDecorator, -} from '../types' +import { Edge, EdgeDecorator, Item, Node, NodeDecorator } from '../types' type Range = [min: number, max: number] @@ -17,18 +11,15 @@ interface NumericMapping { * Wraps a function from nodes to numbers to map it to the size of the node */ export const sizeNodeBy = - ( - mapping: NumericMapping, - range: Range = [10, 200] - ): NodeDecorator => - (node: ModelNode) => ({ size: scale(...mapping(node), range) }) + (mapping: NumericMapping, range: Range = [10, 200]): NodeDecorator => + (node: Node) => ({ size: scale(...mapping(node), range) }) /** * Wraps a function from edges to numbers to map it to the size of the node */ export const sizeEdgeBy = - (mapping: NumericMapping, range: Range = [1, 5]): EdgeDecorator => - (edge: ModelEdge) => ({ size: scale(...mapping(edge), range) }) + (mapping: NumericMapping, range: Range = [1, 5]): EdgeDecorator => + (edge: Edge) => ({ size: scale(...mapping(edge), range) }) const scale = ( input: number | undefined, @@ -46,25 +37,19 @@ const scale = ( return percent * (tMax - tMin) + tMin } -function getMinAndMax( - items: ModelItem[], - attributeKey: string -): [min: number, max: number] { +function getMinAndMax(items: Item[], key: string): [min: number, max: number] { const values = items - .map((item) => parseFloat(item.attributes[attributeKey] as string)) + .map((item) => parseFloat(item.metadata[key] as string)) .filter((v) => !isNaN(v)) const min = Math.min(...values) const max = Math.max(...values) return [min, max] } -function attribute( - items: T[], - id: string -): NumericMapping { +function metadata(items: T[], id: string): NumericMapping { const source = getMinAndMax(items, id) return (item: T) => { - const value = parseFloat(item.attributes[id] as string) + const value = parseFloat(item.metadata[id] as string) if (isNaN(value)) { return [undefined, source] } else { @@ -75,34 +60,34 @@ function attribute( /** * - * Creates a decorator to size the nodes by an attribute value + * Creates a decorator to size the nodes by a metadata value * * @param contentModel the current content model - * @param attributeId the id of the attribute + * @param key the key of the metadata * @param min optional minimum size * @param max optional minimum size * @returns node decorator */ -export const sizeNodeByAttribute = ( +export const sizeNodeByMetadata = ( contentModel: ContentModel, - attributeId: string, + key: string, range?: Range ): NodeDecorator => - sizeNodeBy(attribute(Object.values(contentModel.nodes), attributeId), range) + sizeNodeBy(metadata(Object.values(contentModel.nodes), key), range) /** * - * Creates a decorator to size the edges by an attribute value + * Creates a decorator to size the edges by a metadata value * * @param contentModel the current content model - * @param attributeId the id of the attribute + * @param key the key of the metadata * @param min optional minimum size * @param max optional minimum size * @returns edge decorator */ -export const sizeEdgeByAttribute = ( +export const sizeEdgeByMetadata = ( contentModel: ContentModel, - attributeId: string, + key: string, range?: Range ): EdgeDecorator => - sizeEdgeBy(attribute(Object.values(contentModel.edges), attributeId), range) + sizeEdgeBy(metadata(Object.values(contentModel.edges), key), range) diff --git a/packages/graph/src/graph/types/index.ts b/packages/graph/src/graph/types/index.ts index 56afd2c..f039fa1 100644 --- a/packages/graph/src/graph/types/index.ts +++ b/packages/graph/src/graph/types/index.ts @@ -1,27 +1,45 @@ import { GraphModel } from '../GraphModel' -export interface ModelItem { +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ModelNode extends Partial { + id?: string + metadata?: Metadata +} +export interface ModelEdge extends Partial { + source: string + target: string + id?: string + metadata?: Metadata + directed?: boolean +} + +export interface Item { id: string - attributes: ModelAttributeSet + metadata: Metadata } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ModelNode extends ModelItem, Partial {} +export interface Node extends Item, Partial {} -export interface ModelEdge extends ModelItem, Partial { +export interface Edge extends Item, Partial { source: string target: string + directed: boolean } -export type ModelAttributeSet = Record +export type Metadata = Record -export type ModelAttributeValue = unknown +export type MetadataValue = unknown -export type ModelAttributeTypes = Record> +export type MetadataTypes = Record> export type ModelGraphData = { - nodes: Record - edges: Record + nodes: Record> + edges: ModelEdge[] +} + +export type GraphData = { + nodes: Record + edges: Record } export interface ItemDecoration { @@ -43,6 +61,7 @@ export interface EdgeDecoration extends ItemDecoration { sourceArrow: boolean targetArrow: boolean style: EdgeStyle + curve: CurveStyle } export interface DecorationFunction> { @@ -60,23 +79,70 @@ export interface EdgeDecorationCreator { getDecorationOverrides: EdgeDecorationFunction } -export type DecoratedNode = ModelNode & NodeDecorationCreator +export type DecoratedNode = Node & NodeDecorationCreator -export type DecoratedEdge = ModelEdge & EdgeDecorationCreator +export type DecoratedEdge = Edge & EdgeDecorationCreator export type NodeDecorator = { - (node: ModelNode): Partial + (node: Node): Partial id?: string } export type EdgeDecorator = { - (edge: ModelEdge): Partial + (edge: Edge): Partial id?: string } -export type NodeShape = string - -export type EdgeStyle = string +export const NodeShapes = { + rectangle: 'Rectangle', + ellipse: 'Ellipse', + triangle: 'Triangle', + pentagon: 'Pentagon', + hexagon: 'Hexagon', + heptagon: 'Heptagon', + octagon: 'Octagon', + star: 'Star', + barrel: 'Barrel', + diamond: 'Diamond', + vee: 'Vee', + rhomboid: 'Rhomboid', + tag: 'Tag', + 'round-rectangle': 'Rounded rectangle', + 'round-triangle': 'Rounded triangle', + 'round-diamond': 'Rounded diamond', + 'round-pentagon': 'Rounded pentagon', + 'round-hexagon': 'Rounded hexagon', + 'round-heptagon': 'Rounded heptagon', + 'round-octagon': 'Rounded octagon', + 'round-tag': 'Rounded tag', + 'cut-rectangle': 'Cut rectangle', + 'bottom-round-rectangle': 'Bottom rounded rectangle', + 'concave-hexagon': 'Concave hexagon', + // Omit polygon as requires further specification + // polygon: 'Polygon', +} as const + +export type NodeShape = keyof typeof NodeShapes + +export const EdgeStyles = { + solid: 'Solid', + dotted: 'Dotted', + dashed: 'Dashed', +} as const + +export type EdgeStyle = keyof typeof EdgeStyles + +export const CurveStyles = { + haystack: 'Haystack', + straight: 'Straight', + 'straight-triangle': 'Triangle', + bezier: 'Bezier', + 'unbundled-bezier': 'Unbundled-bezier', + segments: 'Segments', + taxi: 'Taxi', +} as const + +export type CurveStyle = keyof typeof CurveStyles export interface CustomGraphLayout { name: string @@ -88,15 +154,18 @@ export interface CustomGraphLayout { stopLayout(): void } -export type PresetGraphLayout = - | 'force-directed' - | 'circle' - | 'grid' - | 'cola' - | 'hierarchical' - | 'concentric' - | 'breadth-first' - | 'cose' +export const PresetGraphLayouts = { + 'force-directed': 'Force-directed', + circle: 'Circle', + grid: 'Grid', + cola: 'Cola', + hierarchical: 'Hierarchical', + concentric: 'Concentric', + 'breadth-first': 'Breadth-first', + cose: 'Cose', +} as const + +export type PresetGraphLayout = keyof typeof PresetGraphLayouts // eslint-disable-next-line @typescript-eslint/ban-types export type GraphLayout = PresetGraphLayout | (string & {}) | CustomGraphLayout diff --git a/packages/graph/src/test/setup.ts b/packages/graph/src/test/setup.ts index 111cff5..0b2f663 100644 --- a/packages/graph/src/test/setup.ts +++ b/packages/graph/src/test/setup.ts @@ -1,10 +1,10 @@ -import { ModelNode, ModelEdge, ContentModel, GraphModel } from 'index' +import { ContentModel, Edge, GraphModel, Node } from 'index' // TEST DATA -export const node1: ModelNode = { +export const node1: Node = { id: 'node1', - attributes: { + metadata: { employer: 'Committed', }, color: 'yellow', @@ -16,9 +16,9 @@ export const node1: ModelNode = { strokeSize: 2, } -export const node2: ModelNode = { +export const node2: Node = { id: 'node2', - attributes: { + metadata: { employer: 'Government', }, color: 'green', @@ -30,13 +30,14 @@ export const node2: ModelNode = { strokeSize: 3, } -export const edge1: ModelEdge = { +export const edge1: Edge = { id: 'edge1', - attributes: { + metadata: { role: 'client', }, source: node1.id, target: node2.id, + directed: true, } export const exampleGraph = GraphModel.applyContent( diff --git a/packages/react/src/components/GraphDebugControl/GraphDebugControl.tsx b/packages/react/src/components/GraphDebugControl/GraphDebugControl.tsx index c8f81fb..a51a29d 100644 --- a/packages/react/src/components/GraphDebugControl/GraphDebugControl.tsx +++ b/packages/react/src/components/GraphDebugControl/GraphDebugControl.tsx @@ -6,7 +6,12 @@ import { Select, SelectItem, } from '@committed/components' -import { Generator, GraphModel, PresetGraphLayout } from '@committed/graph' +import { + Generator, + GraphModel, + PresetGraphLayout, + PresetGraphLayouts, +} from '@committed/graph' import React from 'react' import { defaultLayouts } from '../../graph' @@ -96,7 +101,7 @@ export const GraphDebugControl: React.FC = ({ > {Object.keys(defaultLayouts).map((l) => ( - {l} + {PresetGraphLayouts[l as PresetGraphLayout]} ))} diff --git a/packages/react/src/components/GraphToolbar/GraphToolbar.test.tsx b/packages/react/src/components/GraphToolbar/GraphToolbar.test.tsx index 1b07f3e..4c27543 100644 --- a/packages/react/src/components/GraphToolbar/GraphToolbar.test.tsx +++ b/packages/react/src/components/GraphToolbar/GraphToolbar.test.tsx @@ -29,8 +29,8 @@ const typesLayout: CustomGraphLayout = { const rowHeight = 75 const byType = Object.values( model.nodes.reduce>((acc, next) => { - acc[(next.attributes.type ?? 'unknown') as string] = ( - acc[(next.attributes.type ?? 'unknown') as string] ?? [] + acc[(next.metadata.type ?? 'unknown') as string] = ( + acc[(next.metadata.type ?? 'unknown') as string] ?? [] ).concat(next) return acc }, {}) diff --git a/packages/react/src/components/GraphToolbar/SizeBy.tsx b/packages/react/src/components/GraphToolbar/SizeBy.tsx index 4043782..d704151 100644 --- a/packages/react/src/components/GraphToolbar/SizeBy.tsx +++ b/packages/react/src/components/GraphToolbar/SizeBy.tsx @@ -10,7 +10,7 @@ import { MenuSubTrigger, VariantProps, } from '@committed/components' -import { GraphModel, sizeNodeByAttribute } from '@committed/graph' +import { GraphModel, sizeNodeByMetadata } from '@committed/graph' import React, { useCallback, useMemo } from 'react' function capitalize(key: string) { @@ -30,7 +30,7 @@ export type SizeByProps = CSSProps & } /** - * A GraphToolbar sub-component adds controls for sizing by an attribute + * A GraphToolbar sub-component adds controls for sizing by an key */ export const SizeBy: React.VFC = ({ model, @@ -40,13 +40,13 @@ export const SizeBy: React.VFC = ({ }) => { const nodeAttributes: string[] = useMemo( () => - Object.entries(model.getNodeAttributes()) + Object.entries(model.getNodeMetadataTypes()) .filter((a) => a[1].has('number')) .map((a) => a[0]), [model] ) - const selectedNodeAttributes: string | undefined = useMemo( + const selectedMetadataKey: string | undefined = useMemo( () => model .getDecorators() @@ -56,25 +56,21 @@ export const SizeBy: React.VFC = ({ ) const handleSizeNodeByDecoration = useCallback( - (attribute: string): void => { + (key: string): void => { let decoratorModel = model.getDecorators() - if (selectedNodeAttributes) { - decoratorModel = decoratorModel.removeNodeDecoratorById( - selectedNodeAttributes - ) + if (selectedMetadataKey) { + decoratorModel = + decoratorModel.removeNodeDecoratorById(selectedMetadataKey) } - if (attribute !== 'none') { - const newSizeByNode = sizeNodeByAttribute( - model.getCurrentContent(), - attribute - ) - newSizeByNode.id = PREFIX + attribute + if (key !== 'none') { + const newSizeByNode = sizeNodeByMetadata(model.getCurrentContent(), key) + newSizeByNode.id = PREFIX + key decoratorModel = decoratorModel.addNodeDecorator(newSizeByNode) } onModelChange(GraphModel.applyDecoration(model, decoratorModel)) }, - [model, onModelChange, selectedNodeAttributes] + [model, onModelChange, selectedMetadataKey] ) if (nodeAttributes.length === 0) { @@ -87,8 +83,8 @@ export const SizeBy: React.VFC = ({ { - const node: ModelNode = { +export const WithMetadata: React.FC = () => { + const node: Node = { id: 'test', label: 'example node', - attributes: { + metadata: { employer: 'Committed', }, } return } -export const NoAttributes: React.FC = () => { - const node: ModelNode = { +export const NoMetadata: React.FC = () => { + const node: Node = { id: 'test', label: 'example node', - attributes: {}, + metadata: {}, } return } export const NoNode: React.FC = () => -it('renders light with attributes', () => { - const { asFragment } = renderLight() +it('renders light with metadata', () => { + const { asFragment } = renderLight() expect(asFragment()).toBeDefined() }) -it('renders dark with no attributes', () => { - const { asFragment } = renderDark() +it('renders dark with no metadata', () => { + const { asFragment } = renderDark() expect(asFragment()).toBeDefined() }) it('renders with no node', () => { diff --git a/packages/react/src/components/NodeViewer/NodeViewer.tsx b/packages/react/src/components/NodeViewer/NodeViewer.tsx index ada009a..913cf4a 100644 --- a/packages/react/src/components/NodeViewer/NodeViewer.tsx +++ b/packages/react/src/components/NodeViewer/NodeViewer.tsx @@ -1,18 +1,19 @@ import { + Box, Dialog, DialogContent, DialogTitle, Input, - Box, + Stack, } from '@committed/components' -import { ModelNode } from '@committed/graph' +import { Node } from '@committed/graph' import React from 'react' import { EmptyState } from '../EmptyState' type NodeModalProps = React.ComponentProps & Pick, 'defaultClose'> & { /** The node to show */ - node?: ModelNode + node?: Node } /** @@ -28,22 +29,15 @@ export const NodeViewer: React.FC = ({ {node != null ? ( - <> + {node.label} - {Object.keys(node.attributes).length === 0 ? ( + {Object.keys(node.metadata).length === 0 ? ( ) : null} - {Object.entries(node.attributes).map( - ([attributeId, attributeValue]) => ( - - ) - )} - + {Object.entries(node.metadata).map(([key, value]) => ( + + ))} + ) : ( )} diff --git a/packages/react/src/graph/renderer/CytoscapeRenderer.tsx b/packages/react/src/graph/renderer/CytoscapeRenderer.tsx index 680b1a1..684d2ce 100644 --- a/packages/react/src/graph/renderer/CytoscapeRenderer.tsx +++ b/packages/react/src/graph/renderer/CytoscapeRenderer.tsx @@ -154,7 +154,8 @@ const toEdgeCyStyle = (e: Partial): Css.Edge | undefined => { const s: Css.Edge = { 'line-color': e.color, 'target-arrow-color': e.color, - 'line-style': e.style as Css.LineStyle, + 'line-style': e.style as Css.Edge['line-style'], + 'curve-style': e.curve as Css.Edge['curve-style'], opacity: e.opacity, } diff --git a/packages/react/src/test/exampleData.ts b/packages/react/src/test/exampleData.ts index 5e6c073..462850b 100644 --- a/packages/react/src/test/exampleData.ts +++ b/packages/react/src/test/exampleData.ts @@ -1,47 +1,40 @@ import { ModelEdge, ModelNode } from '@committed/graph' const exampleNodesArr: ModelNode[] = [ - { id: 'n1', attributes: { type: 'person' } }, - { id: 'n2', attributes: { type: 'person' } }, - { id: 'n3', attributes: { type: 'person' } }, - { id: 'n4', attributes: { type: 'place' } }, - { id: 'n5', attributes: { type: 'place' } }, - { id: 'n6', attributes: { type: 'place' } }, + { id: 'n1', metadata: { type: 'person' } }, + { id: 'n2', metadata: { type: 'person' } }, + { id: 'n3', metadata: { type: 'person' } }, + { id: 'n4', metadata: { type: 'place' } }, + { id: 'n5', metadata: { type: 'place' } }, + { id: 'n6', metadata: { type: 'place' } }, ] const exampleEdgesArr: ModelEdge[] = [ { id: 'e1', source: 'n1', target: 'n2', - attributes: {}, }, { id: 'e2', source: 'n1', target: 'n3', - attributes: {}, }, { id: 'e3', source: 'n3', target: 'n4', - attributes: {}, }, { id: 'e5', source: 'n5', target: 'n5', - attributes: {}, }, ] export const exampleGraphData = { nodes: exampleNodesArr.reduce>((prev, next) => { - prev[next.id] = next - return prev - }, {}), - edges: exampleEdgesArr.reduce>((prev, next) => { - prev[next.id] = next + prev[next.id as string] = next return prev }, {}), + edges: exampleEdgesArr, } diff --git a/packages/react/src/test/setup.tsx b/packages/react/src/test/setup.tsx index 6df76e5..5bfb3bb 100644 --- a/packages/react/src/test/setup.tsx +++ b/packages/react/src/test/setup.tsx @@ -1,14 +1,9 @@ import { ThemeProvider } from '@committed/components' +import { ContentModel, GraphModel, NodeShape } from '@committed/graph' import '@testing-library/jest-dom/extend-expect' import { render, RenderOptions, RenderResult } from '@testing-library/react' import userEvent from '@testing-library/user-event' import React from 'react' -import { - ModelNode, - ModelEdge, - ContentModel, - GraphModel, -} from '@committed/graph' import ResizeObserver from 'resize-observer-polyfill' // Use the polyfill for the ResizeObserver. @@ -105,9 +100,9 @@ export { userEvent } // TEST DATA -export const node1: ModelNode = { +export const node1 = { id: 'node1', - attributes: { + metadata: { employer: 'Committed', }, color: 'yellow', @@ -115,13 +110,13 @@ export const node1: ModelNode = { size: 10, strokeColor: 'black', opacity: 1, - shape: 'ellipse', + shape: 'ellipse' as NodeShape, strokeSize: 2, } -export const node2: ModelNode = { +export const node2 = { id: 'node2', - attributes: { + metadata: { employer: 'Government', }, color: 'green', @@ -129,13 +124,13 @@ export const node2: ModelNode = { size: 12, strokeColor: 'black', opacity: 0.9, - shape: 'rectangle', + shape: 'rectangle' as NodeShape, strokeSize: 3, } -export const edge1: ModelEdge = { +export const edge1 = { id: 'edge1', - attributes: { + metadata: { role: 'client', }, source: node1.id,