diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000000000000000000000000000000..fe7bb8528ce296deafe951cc56d1161436ba5d72 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:6007", + "webRoot": "${workspaceFolder}" + } + ] +} diff --git a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts index b60cae901b460fa549491631671748a5b89f0609..1835b16975232ce3a318310ba34bb7c08da85f77 100644 --- a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts +++ b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts @@ -28,6 +28,7 @@ export interface Edge { attributes: { [key: string]: unknown }; from: string; to: string; + id?: string; /* type: string; */ } @@ -63,7 +64,7 @@ export const graphQueryResultSlice = createSlice({ // Collect all the different nodetypes in the result const nodeTypes: string[] = []; action.payload.nodes.forEach((node) => { - // Note: works only for arangodb + // TODO FIXME!! Note: works only for arangodb const nodeType = node.id.split('/')[0]; if (!nodeTypes.includes(nodeType)) nodeTypes.push(nodeType); }); diff --git a/libs/shared/lib/mock-data/query-result/bigMockQueryResults.ts b/libs/shared/lib/mock-data/query-result/bigMockQueryResults.ts index 66d8770d4c89f89b497438bd222247a3dd6164f5..49bef38b7c9bc1d4e0c86bf77a1182e1d44cea30 100644 --- a/libs/shared/lib/mock-data/query-result/bigMockQueryResults.ts +++ b/libs/shared/lib/mock-data/query-result/bigMockQueryResults.ts @@ -10,7 +10,7 @@ * See testing plan for more details.*/ export const bigMockQueryResults = { - links: [ + edges: [ { from: 'airports/DFW', id: 'flights/268402', diff --git a/libs/shared/lib/mock-data/query-result/smallFlightsQueryResults.ts b/libs/shared/lib/mock-data/query-result/smallFlightsQueryResults.ts index 1fcaff929504104d7ff0d997645459b44ded283d..0d58608d9cc1fc7aa896c72fdd68510164f9d377 100644 --- a/libs/shared/lib/mock-data/query-result/smallFlightsQueryResults.ts +++ b/libs/shared/lib/mock-data/query-result/smallFlightsQueryResults.ts @@ -10,7 +10,7 @@ * See testing plan for more details.*/ export const smallFlightsQueryResults = { - links: [ + edges: [ { from: 'airports/JFK', to: 'airports/SFO', diff --git a/libs/shared/lib/mock-data/schema/simpleRaw.ts b/libs/shared/lib/mock-data/schema/simpleRaw.ts index df6855fffe7bba7f000a6df17a2e641d81db3090..5444e1f57b27731fedc78c3c3d33a1ff13029199 100644 --- a/libs/shared/lib/mock-data/schema/simpleRaw.ts +++ b/libs/shared/lib/mock-data/schema/simpleRaw.ts @@ -1,5 +1,5 @@ -import { SchemaUtils } from '@graphpolaris/shared/lib/schema/schema-utils'; -import { SchemaFromBackend } from '@graphpolaris/shared/lib/model/backend'; +import { SchemaFromBackend } from "../../schema"; +import { SchemaUtils } from "../../schema/schema-utils"; export const simpleSchemaRaw: SchemaFromBackend = { nodes: [ @@ -103,4 +103,4 @@ export const simpleSchemaRaw: SchemaFromBackend = { ], }; -export const simpleSchema = SchemaUtils.schemaBackend2Graphology(simpleSchemaRaw); +export const simpleSchema = SchemaUtils.schemaBackend2Graphology(simpleSchemaRaw); \ No newline at end of file diff --git a/libs/shared/lib/schema/model/graphology.ts b/libs/shared/lib/schema/model/graphology.ts index eb516f7255ab562823ddf92ad64c1e20c8baaa7f..e335f845a87d3ae5f810a46b50054cbdc3bffb47 100644 --- a/libs/shared/lib/schema/model/graphology.ts +++ b/libs/shared/lib/schema/model/graphology.ts @@ -4,9 +4,10 @@ import { SchemaAttribute, SchemaNode } from "./FromBackend"; /** Attribute type, consist of a name */ export type SchemaGraphologyNode = GAttributes & SchemaNode; +export type SchemaGraphologyEdge = GAttributes; export type SchemaGraphologyNodeEntry = NodeEntry<SchemaGraphologyNode>; export type SchemaGraphologyEdgeEntry = EdgeEntry<SchemaGraphologyNode, SchemaGraphologyNode>; -export class SchemaGraphology extends MultiGraph<SchemaGraphologyNode, GAttributes, GAttributes> { }; -export type SchemaGraph = SerializedGraph<SchemaGraphologyNode, GAttributes, GAttributes>; +export class SchemaGraphology extends MultiGraph<SchemaGraphologyNode, SchemaGraphologyEdge, GAttributes> { }; +export type SchemaGraph = SerializedGraph<SchemaGraphologyNode, SchemaGraphologyEdge, GAttributes>; diff --git a/libs/shared/lib/vis/nodelink/NodeLinkViewModel.tsx b/libs/shared/lib/vis/nodelink/NodeLinkViewModel.tsx index 6d5ab96ab6c2169e938ee2e45a518881b9af0fe1..8f72e455e4ce918257c2b2286552a67170cfb714 100644 --- a/libs/shared/lib/vis/nodelink/NodeLinkViewModel.tsx +++ b/libs/shared/lib/vis/nodelink/NodeLinkViewModel.tsx @@ -10,7 +10,7 @@ import * as d3 from 'd3'; import { jsPDF } from 'jspdf'; import ResultNodeLinkParserUseCase, { isNodeLinkResult, -} from './ResultNodeLinkParserUseCase'; +} from '../shared/ResultNodeLinkParserUseCase'; import { AttributeData, NodeAttributeData } from '../shared/InputDataTypes'; import { AttributeCategory } from '../shared/Types'; import { GraphQueryResult } from '../../data-access/store'; // TODO remove diff --git a/libs/shared/lib/vis/nodelink/nodelinkvis.tsx b/libs/shared/lib/vis/nodelink/nodelinkvis.tsx index 358b43873befe1ca23101975c318e2b7901a365c..09e3295b230b3aab4a2c7c24ff0f735105b7e7bb 100644 --- a/libs/shared/lib/vis/nodelink/nodelinkvis.tsx +++ b/libs/shared/lib/vis/nodelink/nodelinkvis.tsx @@ -11,7 +11,7 @@ import styles from './nodelinkvis.module.scss'; import * as PIXI from 'pixi.js'; import { GraphType, LinkType, NodeType } from './Types'; import NodeLinkViewModel from './NodeLinkViewModel'; -import ResultNodeLinkParserUseCase from './ResultNodeLinkParserUseCase'; +import ResultNodeLinkParserUseCase from '../shared/ResultNodeLinkParserUseCase'; import VisConfigPanelComponent from '../shared/VisConfigPanel/VisConfigPanel'; import NodeLinkConfigPanelComponent from './config-panel/NodeConfigPanelComponent'; import { use } from 'cytoscape'; diff --git a/libs/shared/lib/vis/paohvis/Types.tsx b/libs/shared/lib/vis/paohvis/Types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2192b5612ed1b71b71451c966dd86d053ddcada2 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/Types.tsx @@ -0,0 +1,131 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/** The data that is needed to make the complete Paohvis table. */ +export type PaohvisData = { + rowLabels: string[]; + hyperEdgeRanges: HyperEdgeRange[]; + maxRowLabelWidth: number; +}; + +/** The ranges of the hyperEdges consisting of the name of the range with all adequate hyperEdges. */ +export type HyperEdgeRange = { + rangeText: string; + hyperEdges: HyperEdgeI[]; +}; + +/** The index and name of a hyperEdge. */ +export type HyperEdgeI = { + indices: number[]; + frequencies: number[]; + nameToShow: string; +}; + +/** All information for the axes. This is used in the parser */ +export type PaohvisAxisInfo = { + selectedAttribute: Attribute; + relation: Relation; + isYAxisEntityEqualToRelationFrom: boolean; +}; + +/** The type of an Attribute. This is used to make the HyperEdgeRanges in the parser. */ +export type Attribute = { + name: string; + type: ValueType; + origin: AttributeOrigin; +}; + +/** The type of the value of an attribute. This is primarily used to make the HyperEdgeRanges in the parser. */ +export enum ValueType { + text = 'text', + bool = 'bool', + number = 'number', + noAttribute = 'No attribute', +} + +/** Attributes can come from entities or relations. This is primarily used to make the HyperEdgeRanges in the parser. */ +export enum AttributeOrigin { + relation = 'Relation', + entity = 'Entity', + noAttribute = 'No attribute', +} + +/** The type of a relation. This is primarily used to make the HyperEdges in the parser. */ +export type Relation = { + collection: string; + from: string; + to: string; +}; + +/** All information from the nodes. This is used in the parser. */ +export type PaohvisNodeInfo = { + // The rowlabels (ids from the nodes on the y-axis). + rowLabels: string[]; + // Dictionary for finding the index of a row. + yNodesIndexDict: Record<string, number>; + // Dictionary for finding the attributes that belong to the nodes on the x-axis. + xNodesAttributesDict: Record<string, any>; +}; + +/** The entities with names and attribute parameters from the schema. */ +export type EntitiesFromSchema = { + entityNames: string[]; + attributesPerEntity: Record<string, AttributeNames>; + relationsPerEntity: Record<string, string[]>; +}; + +/** The relations with (collection-)names and attribute parameters from the schema. */ +export type RelationsFromSchema = { + relationCollection: string[]; + relationNames: Record<string, string>; + attributesPerRelation: Record<string, AttributeNames>; +}; + +/** The names of attributes per datatype. */ +export type AttributeNames = { + textAttributeNames: string[]; + numberAttributeNames: string[]; + boolAttributeNames: string[]; +}; + +/** The options to order the nodes. */ +export enum NodeOrder { + alphabetical = 'Alphabetical', + degree = 'Degree (amount of hyperedges)', +} + +/** All information on the ordering of PAOHvis. */ +export type PaohvisNodeOrder = { + orderBy: NodeOrder; + isReverseOrder: boolean; +}; + +/** All PAOHvis filters grouped by the filters on nodes and edges. */ +export type PaohvisFilters = { + nodeFilters: FilterInfo[]; + edgeFilters: FilterInfo[]; +}; + +/** The information of one filter on PAOHvis. */ +export type FilterInfo = { + targetGroup: string; + attributeName: string; + value: any; + predicateName: string; +}; + +/** The options where you can filter on. */ +export enum FilterType { + xaxis = 'X-axis', + yaxis = 'Y-axis', + edge = 'Edges', +} + +/** Entities can come from the 'from' or the 'to' part of a relation. */ +export enum EntityOrigin { + from = 'From', + to = 'To', +} diff --git a/libs/shared/lib/vis/paohvis/components/HyperEdgesRange.tsx b/libs/shared/lib/vis/paohvis/components/HyperEdgesRange.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d46ce235d1741dba73b77835f872b0f6e8ceefb --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/HyperEdgesRange.tsx @@ -0,0 +1,159 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/* istanbul ignore file */ +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ +import { select } from 'd3'; +import React, { useEffect, useRef } from 'react'; +// import style from './PaohvisComponent.module.scss'; +import { HyperEdgeI } from '../Types'; + +type HyperEdgeRangeProps = { + index: number; + text: string; + hyperEdges: HyperEdgeI[]; + nRows: number; + colOffset: number; + xOffset: number; + yOffset: number; + rowHeight: number; + hyperedgeColumnWidth: number; + gapBetweenRanges: number; + + onMouseEnter: (col: number, nameToShow: string) => void; + onMouseLeave: (col: number) => void; +}; +export const HyperEdgeRange = (props: HyperEdgeRangeProps) => { + // calculate the width of the hyperEdgeRange. + const rectWidth = props.hyperEdges.length * props.hyperedgeColumnWidth + 2 * props.gapBetweenRanges; + + const rows: JSX.Element[] = []; + for (let i = 0; i < props.nRows; i++) + rows.push( + <g key={i} className={'row-' + i}> + <rect + key={i.toString()} + y={i * props.rowHeight} + width={rectWidth} + height={props.rowHeight} + fill={i % 2 === 0 ? '#e6e6e6' : '#f5f5f5'} + style={{ zIndex: -1 }} + /> + </g>, + ); + + let hyperedges = props.hyperEdges.map((hyperEdge, i) => ( + <HyperEdge + key={i} + col={i + props.colOffset} + connectedRows={hyperEdge.indices} + numbersOnRows={hyperEdge.frequencies} + nameToShow={hyperEdge.nameToShow} + rowHeight={props.rowHeight} + xOffset={ + props.hyperedgeColumnWidth / 2 + + i * props.hyperedgeColumnWidth + + props.gapBetweenRanges + } + radius={8} + onMouseEnter={props.onMouseEnter} + onMouseLeave={props.onMouseLeave} + /> + )); + + // * 3 because we have a gapBetweenRanges as padding + const xOffset = + props.xOffset + + (props.index * 3 + 1) * props.gapBetweenRanges + + props.colOffset * props.hyperedgeColumnWidth; + + return ( + <g transform={'translate(' + xOffset + ', +' + props.yOffset + ')'}> + <text + textDecoration={'underline'} + x="10" + y={props.rowHeight / 2} + dy=".35em" + style={{ fontWeight: 600, transform: 'translate3d(-10px, 15px, 0px) rotate(-30deg)' }} + > + {props.text} + </text> + <g transform={'translate(0,' + props.rowHeight + ')'}> + {rows} + {hyperedges} + </g> + </g> + ); +} + +type HyperEdgeProps = { + col: number; + connectedRows: number[]; + numbersOnRows: number[]; + rowHeight: number; + xOffset: number; + radius: number; + nameToShow: string; + onMouseEnter: (col: number, nameToShow: string) => void; + onMouseLeave: (col: number) => void; +}; + +const HyperEdge = (props: HyperEdgeProps) => { + const ref = useRef<SVGGElement>(null); + + useEffect(() => { + if (ref.current) { + /** Adds mouse events when the user hovers over and out of a hyperedge. */ + select(ref.current) + .on('mouseover', () => props.onMouseEnter(props.col, props.nameToShow)) + .on('mouseout', () => props.onMouseLeave(props.col)); + } + }, [ref.current]) + + + // render all circles on the correct position. + const circles = props.connectedRows.map((row, i) => { + return ( + <g key={'row' + row + ' col' + i}> + <circle + cx={0} + cy={row * props.rowHeight} + r={props.radius} + fill={'white'} + stroke={'black'} + strokeWidth={1} + /> + </g> + ); + }); + + // create a line between two circles. + const lines: JSX.Element[] = []; + let y1 = props.connectedRows[0] * props.rowHeight + props.radius; + let y2; + for (let i = 1; i < props.connectedRows.length; i++) { + y2 = props.connectedRows[i] * props.rowHeight - props.radius; + lines.push( + <line key={'line' + i} x1={0} y1={y1} x2={0} y2={y2} stroke={'black'} strokeWidth={1} />, + ); + y1 = props.connectedRows[i] * props.rowHeight + props.radius; + } + + const yOffset = props.rowHeight * 0.5; + + return ( + <g + ref={ref} + className={'col-' + props.col} + transform={'translate(' + props.xOffset + ',' + yOffset + ')'} + > + {lines} + {circles} + </g> + ); +} diff --git a/libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.scss b/libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.scss new file mode 100644 index 0000000000000000000000000000000000000000..dd4f3bbc24197e5a27599dbc1b421accff43ed89 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.scss @@ -0,0 +1,33 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + + +/* istanbul ignore file */ + + +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ + +.makePaohvisMenu { + display: flex; + gap: 1em; + margin: 1em; + .textFieldMakePaohvisMenu { + min-width: 120px; + } +} + +#reverseButtonLabel { + font-size: 17px; + color: inherit; +} + +#makeButton { + height: 40px; + margin-right: 5em; + font-weight: bold; +} \ No newline at end of file diff --git a/libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.tsx b/libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d19e1292c7a48c9035bfc87a7616e7a2772d3d4e --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.tsx @@ -0,0 +1,600 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/* istanbul ignore file */ +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ +import { Button, MenuItem, TextField, IconButton } from '@mui/material'; +import React, { ReactElement, useEffect } from 'react'; +import { + Attribute, + AttributeNames, + AttributeOrigin, + EntitiesFromSchema, + EntityOrigin, + NodeOrder, + PaohvisNodeOrder, + RelationsFromSchema, + ValueType, +} from '../Types'; +import { Sort } from '@mui/icons-material'; +import './MakePaohvisMenu.scss'; +import { useImmer } from 'use-immer'; +import { useGraphQueryResult, useSchemaGraph } from '@graphpolaris/shared/lib/data-access'; +import { calculateAttributesAndRelations, calculateAttributesFromRelation } from '../utils/utils'; +import calcEntitiesFromQueryResult from '../utils/CalcEntitiesFromQueryResult'; +import { isNodeLinkResult } from '../../shared/ResultNodeLinkParserUseCase'; +import { select } from 'd3'; + +/** The typing for the props of the Paohvis menu */ +type MakePaohvisMenuProps = { + makePaohvis: ( + entityVertical: string, + entityHorizontal: string, + relationName: string, + isEntityFromRelationFrom: boolean, + chosenAttribute: Attribute, + nodeOrder: PaohvisNodeOrder, + ) => void; +}; + +/** The variables in the state of the PAOHvis menu */ +type MakePaohvisMenuState = { + entityVertical: string; + entityHorizontal: string; + relationName: string; + isEntityVerticalEqualToRelationFrom: boolean; + attributeNameAndOrigin: string; + isSelectedAttributeFromEntity: boolean; + isButtonEnabled: boolean; + sortOrder: NodeOrder; + isReverseOrder: boolean; + + entitiesFromSchema: EntitiesFromSchema; + relationsFromSchema: RelationsFromSchema; + entitiesFromQueryResult: string[]; + + relationNameOptions: string[]; + attributeNameOptions: Record<string, string[]>; + relationValue: string, + attributeValue: string, +}; + +/** React component that renders a menu with input fields for adding a new Paohvis visualisation. */ +export const MakePaohvisMenu = (props: MakePaohvisMenuProps) => { + const graphQueryResult = useGraphQueryResult(); + const schema = useSchemaGraph(); + const [state, setState] = useImmer<MakePaohvisMenuState>({ + entityVertical: '', + entityHorizontal: '', + relationName: '', + isEntityVerticalEqualToRelationFrom: true, + attributeNameAndOrigin: '', + isSelectedAttributeFromEntity: false, + isButtonEnabled: false, + entitiesFromQueryResult: [], + sortOrder: NodeOrder.degree, + isReverseOrder: false, + entitiesFromSchema: { entityNames: [], attributesPerEntity: {}, relationsPerEntity: {} }, + relationsFromSchema: { + relationCollection: [], + relationNames: {}, + attributesPerRelation: {}, + }, + relationNameOptions: [], + attributeNameOptions: {}, + + relationValue: '?', + attributeValue: '?', + }); + + // + // FUNCTIONS + // + + /** + * Called when the entity field is changed. + * Calculates the `relationNameOptions`, resets the `entityHorizontal`, `relationName` and `attributeName` and sets the new state. + * @param {React.ChangeEvent<HTMLInputElement>} event The event that is given by the input field when a change event is fired. + * It has the value of the entity you clicked on. + */ + function onChangeEntity(event: React.ChangeEvent<HTMLInputElement>): void { + const newEntity = event.target.value; + setState((draft) => { + draft.relationNameOptions = []; + const relationNames: string[] = state.entitiesFromSchema.relationsPerEntity[newEntity]; + + // check if there are any relations + if (relationNames) { + // push all relation options to relationNameOptions + relationNames.forEach((relation) => { + let relationSplit = state.relationsFromSchema.relationNames[relation].split(':'); + + //check if relation is valid + if ( + draft.entitiesFromQueryResult.includes(relationSplit[0]) && + draft.entitiesFromQueryResult.includes(relationSplit[1]) + ) { + // check if relation is selfedge + if (relationSplit[0] == relationSplit[1]) { + const relationFrom = `${relation}:${EntityOrigin.from}`; + const relationTo = `${relation}:${EntityOrigin.to}`; + if ( + !draft.relationNameOptions.includes(relationFrom) && + !draft.relationNameOptions.includes(relationTo) + ) { + draft.relationNameOptions.push(relationFrom); + draft.relationNameOptions.push(relationTo); + } + } else draft.relationNameOptions.push(relation); + } + }); + // filter out duplicates + draft.relationNameOptions = draft.relationNameOptions.filter( + (n, i) => draft.relationNameOptions.indexOf(n) === i, + ); + } + + draft.relationValue = ''; + draft.attributeValue = ''; + + draft.entityVertical = newEntity; + draft.entityHorizontal = ''; + draft.relationName = ''; + draft.isEntityVerticalEqualToRelationFrom = true; + draft.attributeNameAndOrigin = ''; + draft.isButtonEnabled = false; + draft.attributeNameOptions = {}; + return draft; + }); + }; + + + /** + * Called when the relationName field is changed. + * Calculates the possible attribute values, resets the `attributeValue`, sets the `entityHorizontal`. + * @param {React.ChangeEvent<HTMLInputElement>} event The event that is given by the input field when a change event is fired. + * It has the value of the relation you clicked on. + */ + function onChangeRelationName(event: React.ChangeEvent<HTMLInputElement>): void { + setState((draft) => { + draft.relationName = event.target.value; + draft.attributeValue = ''; + draft.attributeNameAndOrigin = ''; + draft.isButtonEnabled = false; + + const newRelationSplit = event.target.value.split(':'); + const newRelation = newRelationSplit[0]; + + // This value should always be there + draft.attributeNameOptions[AttributeOrigin.noAttribute] = [ValueType.noAttribute]; + + const relationNames = draft.relationsFromSchema.relationNames[newRelation]; + if (relationNames == undefined) throw new Error('This entity does not exist in the schema.'); + + const relationSplit: string[] = relationNames.split(':'); + const entityVertical = draft.entityVertical; + + // check if relation is self edge + if (relationSplit[0] == relationSplit[1]) { + const entityOrigin = newRelationSplit[1]; + + if (!isValidEntityOrigin(entityOrigin)) + throw new Error( + `The entity "${entityVertical}" has an invalid entity origin: ${entityOrigin}.`, + ); + + draft.isEntityVerticalEqualToRelationFrom = entityOrigin == EntityOrigin.from; + draft.entityHorizontal = relationSplit[0]; + } + // check if entityVertical is the entity in the 'from' of the relation + else if (entityVertical == relationSplit[0]) { + draft.isEntityVerticalEqualToRelationFrom = true; + draft.entityHorizontal = relationSplit[1]; + } + // check if entityVertical is the entity in the 'to' of the relation + else if (entityVertical == relationSplit[1]) { + draft.isEntityVerticalEqualToRelationFrom = false; + draft.entityHorizontal = relationSplit[0]; + } else + throw new Error( + `The relationNames from this.relationsFromSchema for ${newRelation} is invalid`, + ); + + draft.attributeNameOptions[AttributeOrigin.entity] = []; + + if (draft.entitiesFromSchema.attributesPerEntity[draft.entityHorizontal]) { + let allAttributesOfEntity: AttributeNames = + draft.entitiesFromSchema.attributesPerEntity[draft.entityHorizontal]; + + let attributeNamesOfEntity: string[] = allAttributesOfEntity.textAttributeNames + .concat(allAttributesOfEntity.numberAttributeNames) + .concat(allAttributesOfEntity.boolAttributeNames); + draft.attributeNameOptions[AttributeOrigin.entity] = attributeNamesOfEntity; + } + + draft.attributeNameOptions[AttributeOrigin.relation] = []; + + if (draft.relationsFromSchema.attributesPerRelation[newRelation]) { + let allAttributesOfRelation: AttributeNames = + draft.relationsFromSchema.attributesPerRelation[newRelation]; + + let attributeNamesOfRelation: string[] = allAttributesOfRelation.textAttributeNames + .concat(allAttributesOfRelation.numberAttributeNames) + .concat(allAttributesOfRelation.boolAttributeNames); + draft.attributeNameOptions[AttributeOrigin.relation] = attributeNamesOfRelation; + } + + return draft; + }); + }; + + + /** + * Called when the attributeName field is changed. + * Sets the chosen attribute value, enables the "Make" button and sets the state. + * @param {React.ChangeEvent<HTMLInputElement>} event The event that is given by the input field when a change event is fired. + * It has the value of the attributeName you clicked on. + */ + function onChangeAttributeName(event: React.ChangeEvent<HTMLInputElement>): void { + const newAttribute = event.target.value; + setState((draft) => { + const isSelectedAttributeFromEntity = newAttribute.split(':')[1] == AttributeOrigin.entity; + draft.attributeValue = newAttribute; + + // render changes + draft.attributeNameAndOrigin = newAttribute; + draft.isSelectedAttributeFromEntity = isSelectedAttributeFromEntity; + draft.isButtonEnabled = true; + return draft; + }); + }; + + /** + * Called when the sort order field is changed. + * Sets the state by changing the sort order. + * @param {React.ChangeEvent<HTMLInputElement>} event The event that is given by the input field when a change event is fired. + */ + function onChangeSortOrder(event: React.ChangeEvent<HTMLInputElement>): void { + const newSortOrder: NodeOrder = event.target.value as NodeOrder; + const nodeOrders: string[] = Object.values(NodeOrder); + if (nodeOrders.includes(newSortOrder)) { + unflipReverseIconButton(); + + setState((draft) => { + // render changes + draft.sortOrder = newSortOrder; + draft.isReverseOrder = false; + return draft; + }); + } else throw new Error(newSortOrder + ' is not a sort order.'); + }; + + + /** + * Called when the user clicks the reverse order button. + * Sets the state and changes the svg of the button. + * @param {React.ChangeEvent<HTMLInputElement>} event The event that is given by the input field when a change event is fired. + */ + function onClickReverseOrder(): void { + //change the svg of the button to be the reversed variant + setState((draft) => { + draft.isReverseOrder = !draft.isReverseOrder; + const isReverseOrder = draft.isReverseOrder; + switch (draft.sortOrder) { + case NodeOrder.alphabetical: + if (isReverseOrder) flipReverseIconButtonLabel(); + else unflipReverseIconButtonLabel(); + break; + default: + if (isReverseOrder) flipReverseIconButton(); + else unflipReverseIconButton(); + break; + } + return draft; + }); + }; + + + /** + * Called when the user clicks the "Make" button. + * Checks if all fields are valid before calling `makePaohvis()`. + */ + function onClickMakeButton(): void { + const relationName = state.relationName.split(':')[0]; + + if ( + state.entitiesFromSchema.entityNames.includes(state.entityVertical) && + state.entitiesFromSchema.relationsPerEntity[state.entityVertical].includes(relationName) + ) { + const attributeNameAndOrigin: string[] = state.attributeNameAndOrigin.split(':'); + const attributeName: string = attributeNameAndOrigin[0]; + const attributeOrigin: AttributeOrigin = attributeNameAndOrigin[1] as AttributeOrigin; + const chosenAttribute: Attribute = { + name: attributeName, + type: getTypeOfAttribute(attributeName), + origin: attributeOrigin, + }; + + props.makePaohvis( + state.entityVertical, + state.entityHorizontal, + relationName, + state.isEntityVerticalEqualToRelationFrom, + chosenAttribute, + { + orderBy: state.sortOrder, + isReverseOrder: state.isReverseOrder, + }, + ); + } else { + setState((draft) => { + draft.isButtonEnabled = false; + return draft; + }); + throw new Error('Error: chosen entity or relation is invalid.'); + } + }; + + /** + * Gets the type of the value of the specified attribute by . + * @param attributeName The name of the specified attribute. + * @returns {ValueType} The type of the value of the specified attribute. + */ + function getTypeOfAttribute(attributeName: string): ValueType { + // get the correct AttributeNames for the attribute + const attributeNames = state.isSelectedAttributeFromEntity + ? state.entitiesFromSchema.attributesPerEntity[state.entityHorizontal] + : state.relationsFromSchema.attributesPerRelation[state.relationName.split(':')[0]]; + + // look up to which ValueType the attribute belongs + if (attributeNames.boolAttributeNames.includes(attributeName)) return ValueType.bool; + if (attributeNames.numberAttributeNames.includes(attributeName)) return ValueType.number; + if (attributeNames.textAttributeNames.includes(attributeName)) return ValueType.text; + if (attributeName == ValueType.noAttribute) return ValueType.noAttribute; + + throw new Error('Attribute ' + attributeName + ' does not exist'); + } + + /** + * Checks if the given string is a valid EntityOrigin. + * @param entityOrigin string that should be checked. + * @returns {boolean} returns true if the given string is a valid EntityOrigin. + */ + function isValidEntityOrigin(entityOrigin: string): boolean { + return entityOrigin == EntityOrigin.from || entityOrigin == EntityOrigin.to; + } + + /** + * Called when the user clicks the reverse order button when `Alphabetical` is selected. + * Changes the svg of the button to indicate that the order is reversed. + */ + function flipReverseIconButtonLabel(): void { + select('.reverseIconButton').select('#reverseButtonLabel').text('Z-A'); + } + /** + * Called when the user clicks the reverse order button when `Alphabetical` is selected. + * Changes the svg of the button back to normal. + */ + function unflipReverseIconButtonLabel(): void { + select('.reverseIconButton').select('#reverseButtonLabel').text('A-Z'); + } + /** + * Called when the user clicks the reverse order button when `Degree` is selected. + * Changes the svg of the button to indicate that the order is reversed. + */ + function flipReverseIconButton(): void { + select('.reverseIconButton').transition().style('transform', 'scaleY(-1)'); + } + /** + * Called when the user clicks the reverse order button when `Degree` is selected. + * Changes the svg of the button back to normal. + */ + function unflipReverseIconButton(): void { + select('.reverseIconButton').transition().style('transform', 'scaleY(1)'); + } + + // + // REACTIVITY + // + + useEffect(() => { + resetConfig(); + setState((draft) => { + draft.entitiesFromSchema = calculateAttributesAndRelations(schema); + draft.relationsFromSchema = calculateAttributesFromRelation(schema); + return draft; + }); + }, [schema]); + + + /** This method filters and makes a new Paohvis table. */ + useEffect(() => { + if (isNodeLinkResult(graphQueryResult)) { + resetConfig(); + setState((draft) => { + draft.entitiesFromQueryResult = calcEntitiesFromQueryResult(graphQueryResult); + return draft; + }); + } else { + console.error('Invalid query result!') + } + }, [graphQueryResult]); + + + /** This resets the configuration. Should be called when the possible entities and relations change. */ + function resetConfig(): void { + setState((draft) => { + draft.entityVertical = ''; + draft.entityHorizontal = ''; + draft.relationName = ''; + draft.isEntityVerticalEqualToRelationFrom = true; + draft.attributeNameAndOrigin = ''; + draft.isSelectedAttributeFromEntity = false; + draft.isButtonEnabled = false; + draft.entitiesFromQueryResult = []; + + draft.relationNameOptions = []; + draft.attributeNameOptions = {}; + return draft; + }); + } + + // + // RENDER + // + + // Retrieve the possible entity options. If none available, set helper message. + let entityMenuItems: ReactElement[]; + if (state.entitiesFromQueryResult.length > 0) + entityMenuItems = state.entitiesFromQueryResult.map((entity) => ( + <MenuItem key={entity} value={entity}> + {entity} + </MenuItem> + )); + else + entityMenuItems = [ + <MenuItem key="placeholder" value="" disabled> + No query data available + </MenuItem>, + ]; + + // Retrieve the possible relationName options. If none available, set helper message. + let relationNameMenuItems: ReactElement[]; + if (state.relationNameOptions.length > 0) + relationNameMenuItems = state.relationNameOptions.map((relation) => ( + <MenuItem key={relation} value={relation}> + {relation} + </MenuItem> + )); + else + relationNameMenuItems = [ + <MenuItem key="placeholder" value="" disabled> + First select an entity with one or more relations + </MenuItem>, + ]; + + // Retrieve all the possible attributeName options. If none available, set helper message. + let attributeNameMenuItems: ReactElement[] = []; + let attributeNameMenuItemsNoAttribute: ReactElement[] = []; + let attributeNameMenuItemsEntity: ReactElement[] = []; + let attributeNameMenuItemsRelation: ReactElement[] = []; + if (state.attributeNameOptions['Entity'] && state.attributeNameOptions['Relation']) { + attributeNameMenuItemsNoAttribute = state.attributeNameOptions['No attribute'].map( + (attribute) => ( + <MenuItem key={attribute} value={`${attribute}:NoAttribute`}> + {attribute} + </MenuItem> + ), + ); + attributeNameMenuItemsEntity = state.attributeNameOptions['Entity'].map((attribute) => ( + <MenuItem key={`${attribute}:Entity`} value={`${attribute}:Entity`}> + {`${attribute} : Entity`} + </MenuItem> + )); + attributeNameMenuItemsRelation = state.attributeNameOptions['Relation'].map( + (attribute) => ( + <MenuItem key={`${attribute}:Relation`} value={`${attribute}:Relation`}> + {`${attribute} : Relation`} + </MenuItem> + ), + ); + + attributeNameMenuItems = attributeNameMenuItemsNoAttribute + .concat(attributeNameMenuItemsEntity) + .concat(attributeNameMenuItemsRelation); + } else + attributeNameMenuItems = [ + <MenuItem key="placeholder" value="" disabled> + First select an relation with one or more attributes + </MenuItem>, + ]; + + // make sort order menu items + const sortOrderMenuItems: ReactElement[] = Object.values(NodeOrder).map((nodeOrder) => { + return ( + <MenuItem key={nodeOrder} value={nodeOrder}> + {nodeOrder} + </MenuItem> + ); + }); + + // make the reverse button + let reverseIcon: ReactElement; + switch (state.sortOrder) { + case NodeOrder.alphabetical: + reverseIcon = <span id="reverseButtonLabel">A-Z</span>; + break; + default: + reverseIcon = <Sort className={'reverseSortIcon'} />; + break; + } + + // return the whole MakePaohvisMenu + return ( + <div className="makePaohvisMenu"> + <TextField + select + className="textFieldMakePaohvisMenu" + id="standard-select-entity" + label="Entity" + value={state.entityVertical} + onChange={onChangeEntity} + > + {entityMenuItems} + </TextField> + <TextField + select + className="textFieldMakePaohvisMenu" + id="standard-select-relation" + label="Relation" + value={state.relationName} + onChange={onChangeRelationName} + > + {relationNameMenuItems} + </TextField> + <TextField + select + className="textFieldMakePaohvisMenu" + id="standard-select-attribute" + label="Attribute" + value={state.attributeNameAndOrigin} + onChange={onChangeAttributeName} + > + {attributeNameMenuItems} + </TextField> + <TextField + select + className="textFieldMakePaohvisMenu" + id="standard-select-sort-order" + label="Sort order" + value={state.sortOrder} + onChange={onChangeSortOrder} + > + {sortOrderMenuItems} + </TextField> + <IconButton + className={'reverseIconButton'} + color="inherit" + onClick={onClickReverseOrder} + > + {reverseIcon} + </IconButton> + + <Button + id="makeButton" + variant="contained" + disabled={!state.isButtonEnabled} + onClick={onClickMakeButton} + > + <span>Make</span> + </Button> + </div> + ); +} + +export default MakePaohvisMenu; diff --git a/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss b/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..d6ef0a52a6871a9352987bdc96ed2c3fa7758755 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss @@ -0,0 +1,57 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + + +/* istanbul ignore file */ + + +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ + +// Styling for the PaohvisFilterComponent +.container { + font-family: 'Open Sans', sans-serif; + display: flex; + flex-direction: column; + p { + font-size: 13px; + font-weight: 600; + color: #2d2d2d; + display: list-item; + } + .title { + color: #212020; + font-weight: 800; + line-height: 1.6em; + font-size: 16px; + margin-bottom: 0.4rem; + margin-top: 0.1; + padding-left: 10px; + } + .subtitle { + color: #212020; + font-weight: 700; + font-size: 14px; + padding-left: 10px; + padding-bottom: 0px; + margin-bottom: 0px; + } +} + +.selectContainer { + display: block; + justify-content: space-around; + select { + width: 6rem; + overflow: hidden; + text-overflow: ellipsis; + option { + width: 35px; + text-overflow: ellipsis; + } + } +} \ No newline at end of file diff --git a/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss.d.ts b/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..0139761e227918d250897db1938b8bafba18ecdf --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss.d.ts @@ -0,0 +1,7 @@ +declare const classNames: { + readonly container: 'container'; + readonly title: 'title'; + readonly subtitle: 'subtitle'; + readonly selectContainer: 'selectContainer'; +}; +export = classNames; diff --git a/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.tsx b/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6876e93637cb384fcffe381147ca5e6d82de8889 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.tsx @@ -0,0 +1,428 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/* istanbul ignore file */ +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ +import React, { ChangeEventHandler, ReactElement, useState, MouseEventHandler, useEffect } from 'react'; +import styles from './PaohvisFilterComponent.module.scss'; +import { AttributeNames, FilterType } from '../Types'; +import { useImmer } from 'use-immer'; +import { Button, MenuItem, TextField } from '@mui/material'; +import { useGraphQueryResult, useSchemaGraph } from '@graphpolaris/shared/lib/data-access'; +import { isNodeLinkResult } from '../../shared/ResultNodeLinkParserUseCase'; +import { isSchemaResult } from '../../shared/SchemaResultType'; +import { calculateAttributesAndRelations, calculateAttributesFromRelation } from '../utils/utils'; +import { boolPredicates, numberPredicates, textPredicates } from '../models/FilterPredicates'; + + +type PaohvisFilterProps = { + axis: FilterType; + entityVertical: string; + entityHorizontal: string; + relationName: string; + + filterPaohvis( + isTargetEntity: boolean, + filterTarget: string, + attributeName: string, + predicate: string, + compareValue: string, + ): void; + resetFilter(isTargetEntity: boolean): void; +}; + +type PaohvisFilterState = { + filterTarget: string; + attributeNameAndType: string; + attributeType: string; + predicate: string; + compareValue: string; + isFilterButtonEnabled: boolean; + predicateTypeList: string[]; + attributeNamesOptions: string[]; + + namesPerEntityOrRelation: string[]; + attributesPerEntityOrRelation: Record<string, AttributeNames>; + +}; + +/** Component for rendering the filters for PAOHvis */ +export const PaohvisFilterComponent = (props: PaohvisFilterProps) => { + const graphQueryResult = useGraphQueryResult(); + const schema = useSchemaGraph(); + const isTargetEntity = props.axis != FilterType.edge; + + const [state, setState] = useImmer<PaohvisFilterState>({ + filterTarget: '', + attributeNameAndType: '', + attributeType: '', + predicate: '', + compareValue: '', + isFilterButtonEnabled: false, + predicateTypeList: [], + attributeNamesOptions: [], + namesPerEntityOrRelation: [], + attributesPerEntityOrRelation: {}, + }); + + /** + * Updates the list of attributes that can be chosen from for the filter component. + * Takes all attributes from the filter target and converts them to the format 'name:type'. + */ + function updateAttributeNameOptions() { + const filterTarget = state.filterTarget; + const namesPerEntityOrRelation = state.namesPerEntityOrRelation; + const attributesPerEntityOrRelation = state.attributesPerEntityOrRelation; + + if (!namesPerEntityOrRelation.includes(filterTarget)) + throw new Error('The filter target does not exist in the schema'); + + setState(draft => { + // Add all possible options for attributes to attributeNamesOptions + // check all text attributes + if (attributesPerEntityOrRelation[filterTarget].textAttributeNames.length > 0) + attributesPerEntityOrRelation[filterTarget].textAttributeNames.map((attributeName) => + draft.attributeNamesOptions.push(`${attributeName}:text`), + ); + + // check all number attributes + if (attributesPerEntityOrRelation[filterTarget].numberAttributeNames.length > 0) + attributesPerEntityOrRelation[filterTarget].numberAttributeNames.map((attributeName) => + draft.attributeNamesOptions.push(`${attributeName}:number`), + ); + + // check all bool attributes + if (attributesPerEntityOrRelation[filterTarget].boolAttributeNames.length > 0) + attributesPerEntityOrRelation[filterTarget].boolAttributeNames.map((attributeName) => + draft.attributeNamesOptions.push(`${attributeName}:bool`), + ); + return draft; + }); + }; + + /** + * This is called when the name of the attribute is changed in a filter component. + * Based on the type of the chosen attribute, a different list with predicates is generated. + * @param event that called this eventhandler. + */ + function onChangeAttributeName(event: React.ChangeEvent<HTMLInputElement>): void { + const newAttributeNameAndType = event.target.value; + const newAttributeSplit = event.target.value.split(':'); + const newAttributeType = newAttributeSplit[1]; + + setState(draft => { + if (!containsFilterTargetChosenAttribute(newAttributeNameAndType)) + throw new Error( + 'The chosen attribute does not exist in the entity/relation that will be filtered', + ); + + switch (newAttributeType) { + case 'text': + draft.predicateTypeList = Object.keys(textPredicates); + break; + case 'number': + draft.predicateTypeList = Object.keys(numberPredicates); + break; + case 'bool': + draft.predicateTypeList = Object.keys(boolPredicates); + break; + } + + draft.attributeNameAndType = newAttributeNameAndType; + draft.attributeType = newAttributeType; + draft.predicate = ''; + draft.compareValue = ''; + draft.isFilterButtonEnabled = false; + return draft; + }); + }; + + /** +* This is called when the value of the predicate is changed in the filter component. +* @param event that called this eventhandler. +*/ + function onChangePredicate(event: React.ChangeEvent<HTMLInputElement>): void { + const newpredicate = event.target.value; + + if (!isPredicateValid(newpredicate)) throw new Error('The chosen predicate is invalid'); + + setState(draft => { + draft.predicate = newpredicate; + draft.compareValue = ''; + draft.isFilterButtonEnabled = false; + return draft; + }); + }; + + /** + * This is called when the value to compare the attribute with is changed in a filter component. + * Based on the type of the chosen attribute, the compareValue has some conditions on what type it should be. + * If attribute is numerical, the value has to be a number, etc. + * @param event that called this eventhandler. + */ + function onChangeCompareValue(event: React.ChangeEvent<HTMLInputElement>): void { + setState(draft => { + draft.compareValue = event.target.value; + draft.isFilterButtonEnabled = isFilterConfigurationValid(); + return draft; + }); + }; + + /** +* This is called when the "apply filter" button is clicked. +* It checks if the input is correct and sends the information for the filters to the PaohvisViewModelImpl. +*/ + function onClickFilterPaohvisButton(): void { + if (!isFilterConfigurationValid()) + throw new Error('Error: chosen attribute of predicate-value is invalid.'); + + props.filterPaohvis( + isTargetEntity, + state.filterTarget, + state.attributeNameAndType, + state.predicate, + state.compareValue, + ); + }; + + /** + * This resets the filters for the chosen filter component. + */ + function onClickResetFilter(): void { + setState(draft => { + draft.attributeNameAndType = ''; + draft.attributeType = ''; + draft.predicate = ''; + draft.compareValue = ''; + draft.isFilterButtonEnabled = false; + draft.predicateTypeList = []; + return draft; + }); + props.resetFilter(isTargetEntity); + }; + + /** + * Checks if the filter configuration is valid. + * @returns {boolean} true if the filter configuration is valid. + */ + function isFilterConfigurationValid(): boolean { + return ( + containsFilterTargetChosenAttribute(state.attributeNameAndType) && + isPredicateValid(state.predicate) && + isCompareValueTypeValid(state.compareValue) + ); + } + /** + * Checks if the given predicate is valid for the filter target. + * @param predicate that should be checked. + * @returns {boolean} true if the predicate is valid. + */ + function isPredicateValid(predicate: string): boolean { + return state.predicateTypeList.includes(predicate); + } + + /** + * This is used when the compareValue has changed. + * @param {string} compareValue is the value that will be used with the predicate. + * @returns {boolean} true if the chosen compareValue and chosen attribute are a valid combination. + */ + function isCompareValueTypeValid(compareValue: string): boolean { + const lowerCaseCompareValue: string = compareValue.toLowerCase(); + return ( + (state.attributeType == 'number' && !Number.isNaN(Number(compareValue))) || + (state.attributeType == 'bool' && (lowerCaseCompareValue == 'true' || lowerCaseCompareValue == 'false')) || + (state.attributeType == 'text' && compareValue.length > 0) + ); + } + + /** + * This determines whether the given attribute belongs to the filter target. + * @param attributeNameAndType is the chosen attribute in the format of 'name:type' + * @returns {boolean} true when the given attribute belongs to the filter target. + */ + function containsFilterTargetChosenAttribute(attributeNameAndType: string): boolean { + const filterTarget = state.filterTarget; + const split: string[] = attributeNameAndType.split(':'); + const attributeName = split[0]; + const attributeType = split[1]; + const attributesPerEntityOrRelation = state.attributesPerEntityOrRelation; + + switch (attributeType) { + case 'text': + return attributesPerEntityOrRelation[filterTarget].textAttributeNames.includes(attributeName,); + case 'number': + return attributesPerEntityOrRelation[filterTarget].numberAttributeNames.includes(attributeName,); + case 'bool': + return attributesPerEntityOrRelation[filterTarget].boolAttributeNames.includes(attributeName,); + default: + return false; + } + } + + /** + * This determines on which entity/relation the filter is going to be applied to. + * @param entityVertical is the entity type that belongs to the Y-axis of the Paohvis table. + * @param entityHorizontal is the entity type that belongs to the X-axis of the Paohvis table. + * @param relationName is the relation type that is that is displayed in the Paohvis table + */ + function determineFilterTarget( + entityVertical: string, + entityHorizontal: string, + relationName: string, + ): void { + setState(draft => { + if (props.axis === FilterType.yaxis) + draft.filterTarget = entityVertical; + else if (props.axis === FilterType.xaxis) + draft.filterTarget = entityHorizontal; + else draft.filterTarget = relationName; + return draft; + }); + } + + // + // REACT + // + + useEffect(() => { + if (isNodeLinkResult(graphQueryResult)) { + setState(draft => { + draft.filterTarget = ''; + draft.attributeNamesOptions = []; + return draft; + }) + } else { + console.error('Invalid query result!') + } + }, [graphQueryResult]); + + useEffect(() => { + // if (isSchemaResult(schema)) { + setState(draft => { + if (isTargetEntity) { + const entitiesFromSchema = calculateAttributesAndRelations(schema); + draft.namesPerEntityOrRelation = entitiesFromSchema.entityNames; + draft.attributesPerEntityOrRelation = entitiesFromSchema.attributesPerEntity; + } else { + const relationsFromSchema = calculateAttributesFromRelation(schema); + draft.namesPerEntityOrRelation = relationsFromSchema.relationCollection; + draft.attributesPerEntityOrRelation = relationsFromSchema.attributesPerRelation; + } + + draft.filterTarget = ''; + draft.attributeNamesOptions = []; + return draft; + }); + // } else { + // console.error('Invalid schema!') + // } + }, [schema]); + + + useEffect(() => { + determineFilterTarget(props.entityVertical, props.entityHorizontal, props.relationName); + if (state.filterTarget !== '') + updateAttributeNameOptions(); + }, [props]); + + // + // RENDER + // + + // Check if the given entity or relation is in the queryResult + let attributeNameMenuItems: ReactElement[] = []; + if (state.attributeNamesOptions.length > 0) { + attributeNameMenuItems = state.attributeNamesOptions.map((attributeNameAndType) => { + const attributeName = attributeNameAndType.split(':')[0]; + return ( + <MenuItem key={attributeName} value={attributeNameAndType}> + {attributeNameAndType} + </MenuItem> + ); + }); + } else + attributeNameMenuItems = [ + <MenuItem key="placeholder" value="" disabled> + First select an entity/relation with one or more attributes + </MenuItem>, + ]; + + let predicateTypeList: ReactElement[] = []; + if (state.predicateTypeList.length > 0) { + predicateTypeList = state.predicateTypeList.map((predicate) => ( + <MenuItem key={predicate} value={predicate}> + {predicate} + </MenuItem> + )); + } else + predicateTypeList = [ + <MenuItem key="placeholder" value="" disabled> + First select an attribute + </MenuItem>, + ]; + + return ( + <div> + <div className={styles.container}> + <p className={styles.title}>PAOHVis filters{props.axis}:</p> + <div className={styles.selectContainer}> + <p className={styles.subtitle}>{state.filterTarget}</p> + <div style={{ padding: 10, display: 'block', width: '100%' }}> + <TextField + select + id="standard-select-entity" + style={{ minWidth: 120, marginRight: 20 }} + label="Attribute" + value={state.attributeNameAndType} + onChange={onChangeAttributeName} + > + {attributeNameMenuItems} + </TextField> + <TextField + select + id="standard-select-relation" + style={{ minWidth: 120, marginRight: 20 }} + label="Relation" + value={state.predicate} + onChange={onChangePredicate} + > + {predicateTypeList} + </TextField> + <TextField + id="standard-select-relation" + style={{ minWidth: 120, marginRight: 20 }} + label="Value" + value={state.compareValue} + onChange={onChangeCompareValue} + > + { } + </TextField> + + <div style={{ height: 40, paddingTop: 10, marginBottom: 10 }}> + <Button + variant="contained" + disabled={!state.isFilterButtonEnabled} + onClick={onClickFilterPaohvisButton} + > + <span style={{ fontWeight: 'bold' }}>Apply filter</span> + </Button> + </div> + <div style={{ height: 40, marginBottom: 20 }}> + <Button + variant="contained" + onClick={onClickResetFilter} + > + <span style={{ fontWeight: 'bold' }}>Reset filter</span> + </Button> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/libs/shared/lib/vis/paohvis/components/RowLabelColumn.tsx b/libs/shared/lib/vis/paohvis/components/RowLabelColumn.tsx new file mode 100644 index 0000000000000000000000000000000000000000..82ca7b58be3a8bd9f80f5c0b256e122d6b658f4e --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/RowLabelColumn.tsx @@ -0,0 +1,82 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/* istanbul ignore file */ +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ + +import { select } from 'd3'; +import React, { useEffect, useRef } from 'react'; + +type RowLabelColumnProps = { + onMouseEnter: (row: number) => void; + onMouseLeave: (row: number) => void; + + titles: string[]; + width: number; + rowHeight: number; + yOffset: number; +}; +export const RowLabelColumn = (props: RowLabelColumnProps) => { + const titleRows = props.titles.map((title, i) => ( + <RowLabel + key={i} + index={i} + title={title} + width={props.width} + rowHeight={props.rowHeight} + yOffset={props.yOffset} + onMouseEnter={props.onMouseEnter} + onMouseLeave={props.onMouseLeave} + /> + )); + + return <g>{titleRows}</g>; +} + +type RowLabelProps = { + onMouseEnter: (row: number) => void; + onMouseLeave: (row: number) => void; + + title: string; + width: number; + rowHeight: number; + index: number; + yOffset: number; +}; + +const RowLabel = (props: RowLabelProps) => { + const ref = useRef<SVGGElement>(null); + + useEffect(() => { + if (ref.current === null) return; + select(ref.current) + .on('mouseover', () => props.onMouseEnter(props.index)) + .on('mouseout', () => props.onMouseLeave(props.index)); + }, [ref.current]); + + return ( + <g + ref={ref} + className={'row-' + props.index} + transform={ + 'translate(0,' + + (props.yOffset + props.rowHeight + props.index * props.rowHeight) + + ')' + } + > + <rect + width={props.width} + height={props.rowHeight} + fill={props.index % 2 === 0 ? '#e6e6e6' : '#f5f5f5'} + ></rect> + <text x="10" y={props.rowHeight / 2} dy=".35em" style={{ fontWeight: 600 }}> + {props.title} + </text> + </g> + ); +} diff --git a/libs/shared/lib/vis/paohvis/components/Tooltip.scss b/libs/shared/lib/vis/paohvis/components/Tooltip.scss new file mode 100644 index 0000000000000000000000000000000000000000..d62ff563ba2a837caf68b4232bfa9878a2fd1513 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/Tooltip.scss @@ -0,0 +1,20 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + + +/* istanbul ignore file */ + + +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ + +.tooltip { + visibility: 'hidden'; + position: 'absolute'; + padding: 10px; + font-weight: 'bold'; +} \ No newline at end of file diff --git a/libs/shared/lib/vis/paohvis/components/Tooltip.tsx b/libs/shared/lib/vis/paohvis/components/Tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..faf885025f6feedfac6ce22226a8be65cdcd9eb4 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/components/Tooltip.tsx @@ -0,0 +1,30 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/* istanbul ignore file */ +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ + +import React from 'react'; +import './Tooltip.scss'; +import { pointer, select } from 'd3'; + +export default class Tooltip extends React.Component { + onMouseOver(e: any) { + select('.tooltip') + .style('top', pointer(e)[1]) + .style('left', pointer(e)[0] + 5); + } + + render() { + return ( + <div style={{ position: 'absolute' }} className="tooltip"> + {' '} + </div> + ); + } +} diff --git a/libs/shared/lib/vis/paohvis/models/FilterPredicates.tsx b/libs/shared/lib/vis/paohvis/models/FilterPredicates.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71c4bf8cf22ee34666b0632539a72d1d329d8e18 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/models/FilterPredicates.tsx @@ -0,0 +1,33 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + + type TextPredicate = (a1: string, a2: string) => boolean; + type NumberPredicate = (a1: number, a2: number) => boolean; + type BoolPredicate = (a1: boolean, a2: boolean) => boolean; + + /** Predicates to be used on attributes that are strings. */ + export const textPredicates: Record<string, TextPredicate> = { + '==': (a1, a2) => a1 == a2, + '!=': (a1, a2) => a1 != a2, + contains: (a1, a2) => a1.includes(a2), + excludes: (a1, a2) => !a1.includes(a2), + }; + + /** Predicates to be used on attributes that are numbers. */ + export const numberPredicates: Record<string, NumberPredicate> = { + '==': (a1, a2) => a1 == a2, + '!=': (a1, a2) => a1 != a2, + '>=': (a1, a2) => a1 >= a2, + '>': (a1, a2) => a1 > a2, + '<=': (a1, a2) => a1 <= a2, + '<': (a1, a2) => a1 < a2, + }; + + /** Predicates to be used on attributes that are booleans. */ + export const boolPredicates: Record<string, BoolPredicate> = { + '==': (a1, a2) => a1 == a2, + '!=': (a1, a2) => a1 != a2, + }; \ No newline at end of file diff --git a/libs/shared/lib/vis/paohvis/models/PaohvisHolder.test.tsx b/libs/shared/lib/vis/paohvis/models/PaohvisHolder.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2811c96e5afe7ab8be01604e463dc137a6ac6bc --- /dev/null +++ b/libs/shared/lib/vis/paohvis/models/PaohvisHolder.test.tsx @@ -0,0 +1,32 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +import { describe, it, vi, expect } from 'vitest'; +import PaohvisListener from './PaohvisListener'; +import PaohvisHolder from './PaohvisHolder'; + +describe('PaohvisHolder', () => { + it('should add an remove listeners correctly', () => { + const holder: PaohvisHolder = new PaohvisHolder(); + const mockOnTableMade1 = vi.fn(); + const mockOnTableMade2 = vi.fn(); + const l1: PaohvisListener = { onTableMade: mockOnTableMade1 }; + const l2: PaohvisListener = { onTableMade: mockOnTableMade2 }; + holder.addListener(l1); + holder.addListener(l2); + + expect(mockOnTableMade1).toHaveBeenCalledTimes(0); + expect(mockOnTableMade2).toHaveBeenCalledTimes(0); + holder.onTableMade('', '', ''); + expect(mockOnTableMade1).toHaveBeenCalledTimes(1); + expect(mockOnTableMade2).toHaveBeenCalledTimes(1); + + holder.removeListener(l2); + holder.onTableMade('', '', ''); + expect(mockOnTableMade1).toHaveBeenCalledTimes(2); + expect(mockOnTableMade2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/shared/lib/vis/paohvis/models/PaohvisHolder.tsx b/libs/shared/lib/vis/paohvis/models/PaohvisHolder.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe857ef4d04a172135b0fe0904f29e899977a388 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/models/PaohvisHolder.tsx @@ -0,0 +1,59 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +import PaohvisListener from './PaohvisListener'; + +/** PaohvisHolder is an observer class for notifying listeners when a Paohvis table is made. */ +export default class PaohvisHolder { + private paohvisListeners: PaohvisListener[]; + private entityVertical: string; + private entityHorizontal: string; + private relationName: string; + + public constructor() { + this.paohvisListeners = []; + this.entityVertical = ''; + this.entityHorizontal = ''; + this.relationName = ''; + } + + /** + * This is called whenever a Paohvis table is made. + * @param entityVertical that is on the y-axis of the Paohis table. + * @param entityHorizontal that is on the x-axis of the Paohis table. + * @param relationName that is used as the hyperedge of the Paohis table. + */ + public onTableMade(entityVertical: string, entityHorizontal: string, relationName: string): void { + this.entityVertical = entityVertical; + this.entityHorizontal = entityHorizontal; + this.relationName = relationName; + + this.notifyListeners(); + } + + /** + * Adds a listener to the observer. + * @param listener The listener that we want to add. + */ + public addListener(listener: PaohvisListener): void { + this.paohvisListeners.push(listener); + } + + /** + * Removes a listener from the array of listeners. + * @param listener The listener that we want to remove. + */ + public removeListener(listener: PaohvisListener): void { + this.paohvisListeners.splice(this.paohvisListeners.indexOf(listener), 1); + } + + /** Notifies to all the listeners that a Paohvis table was made. */ + private notifyListeners(): void { + this.paohvisListeners.forEach((listener) => + listener.onTableMade(this.entityVertical, this.entityHorizontal, this.relationName), + ); + } +} diff --git a/libs/shared/lib/vis/paohvis/models/PaohvisListener.tsx b/libs/shared/lib/vis/paohvis/models/PaohvisListener.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a93293b035af6c113b2def109c2e07610fa4cee5 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/models/PaohvisListener.tsx @@ -0,0 +1,16 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/** Interface for the listener of the PaohvisHolder observer class. */ +export default interface PaohvisListener { + /** + * This is called whenever a Paohvis table is made. + * @param entityVertical that is on the y-axis of the Paohis table. + * @param entityHorizontal that is on the x-axis of the Paohis table. + * @param relationName that is used as the hyperedge of the Paohis table. + */ + onTableMade(entityVertical: string, entityHorizontal: string, relationName: string): void; +} diff --git a/libs/shared/lib/vis/paohvis/paohvis.module.scss b/libs/shared/lib/vis/paohvis/paohvis.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..009c546e97e7d866941a9674e17c1831846160d4 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/paohvis.module.scss @@ -0,0 +1,55 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +/* istanbul ignore file */ + +/* The comment above was added so the code coverage wouldn't count this file towards code coverage. + * We do not test components/renderfunctions/styling files. + * See testing plan for more details.*/ + +$expandButtonSize: 13px; + +:export { + expandButtonSize: $expandButtonSize; +} + +$tableFontFamily: Arial, Helvetica, sans-serif; +$tableFontSize: 0.875rem; +$tableFontWeight: 600; +:export { + tableFontFamily: $tableFontFamily; + tableFontSize: $tableFontSize; + tableFontWeight: $tableFontWeight; +} + +.container { + height: 100%; + position: relative; + .visContainer { + height: 100%; + overflow-y: auto; + transform: scale(-1, 1); + /** + * this fixes the horizontal scrollbar to the bottom of the visContainer, + * the transform of the visContainer and the transform of the child div should both be removed for it to work + * but the position of the svgs and paohvis are not good, so don't use it yet + * overflow-x: auto; + * width: 100%; + * display: flex; + */ + } + text { + font-family: $tableFontFamily; + font-size: $tableFontSize; + font-weight: $tableFontWeight; + } + #tableMessage { + margin: 1em; + } + #configPanelMessage { + margin: 1em; + } +} diff --git a/libs/shared/lib/vis/paohvis/paohvis.module.scss.d.ts b/libs/shared/lib/vis/paohvis/paohvis.module.scss.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfe7ee67e5808451cbe2502e65a466f89eb30fad --- /dev/null +++ b/libs/shared/lib/vis/paohvis/paohvis.module.scss.d.ts @@ -0,0 +1,9 @@ +declare const classNames: { + readonly expandButtonSize: 'expandButtonSize'; + readonly tableFontFamily: 'tableFontFamily'; + readonly tableFontSize: 'tableFontSize'; + readonly tableFontWeight: 'tableFontWeight'; + readonly container: 'container'; + readonly visContainer: 'visContainer'; +}; +export = classNames; diff --git a/libs/shared/lib/vis/paohvis/paohvis.stories.tsx b/libs/shared/lib/vis/paohvis/paohvis.stories.tsx index 59c0ff9750d11a1386fa2a4d0b5b6176bcfb029d..8799d2ec225e0dfe053977f8b92ab5b8bc8713d3 100644 --- a/libs/shared/lib/vis/paohvis/paohvis.stories.tsx +++ b/libs/shared/lib/vis/paohvis/paohvis.stories.tsx @@ -1,25 +1,130 @@ -import React from "react"; -import PaohVis from "./paohvis"; -import { ComponentStory, Meta } from "@storybook/react"; +import { + assignNewGraphQueryResult, + colorPaletteConfigSlice, + graphQueryResultSlice, + schemaSlice, + setSchema, +} from "../../data-access/store"; +import { GraphPolarisThemeProvider } from "../../data-access/theme"; +import { configureStore } from "@reduxjs/toolkit"; +import { Meta, ComponentStory } from "@storybook/react"; +import { Provider } from "react-redux"; -const Component: Meta<typeof PaohVis> = { +import Paohvis from "./paohvis"; +import { SchemaUtils } from "../../schema/schema-utils"; +import { bigMockQueryResults, simpleSchemaRaw, smallFlightsQueryResults } from "../../mock-data"; +import { simpleSchemaAirportRaw } from "../../mock-data/schema/simpleAirportRaw"; + +const Component: Meta<typeof Paohvis> = { /* 👇 The title prop is optional. * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading * to learn how to generate automatic titles */ - title: "Components/Visualizations/PaohVis", - component: PaohVis, - decorators: [(story) => <div style={{ padding: "3rem" }}>{story()}</div>], + title: "Components/Visualizations/Paohvis", + component: Paohvis, + decorators: [ + (story) => ( + <div + style={{ + width: '80%', + height: '80vh', + }} + > + <Provider store={Mockstore}> + <GraphPolarisThemeProvider> + {story()} + </GraphPolarisThemeProvider> + </Provider> + </div> + ), + ], }; -const Template: ComponentStory<typeof PaohVis> = (args) => ( - <PaohVis {...args} /> -); +const Mockstore = configureStore({ + reducer: { + schema: schemaSlice.reducer, + colorPaletteConfig: colorPaletteConfigSlice.reducer, + graphQueryResult: graphQueryResultSlice.reducer, + }, +}); -export const Primary = Template.bind({}); +export const TestWithData = { + args: { + loading: false, + rowHeight: 50, + hyperedgeColumnWidth: 50, + gapBetweenRanges: 10, + }, + play: async () => { + const dispatch = Mockstore.dispatch; + const schema = SchemaUtils.schemaBackend2Graphology({ + nodes: [ + { + name: '1', + attributes: [{ name: 'a', type: 'string' }], + }, + ], + edges: [ + { + name: '12', from: '1', to: '1', collection: '1c', attributes: [{ name: 'a', type: 'string' }], + }, + ], + }); -Primary.args = { - content: "Testing header #1", -}; + dispatch(setSchema(schema.export())); + dispatch( + assignNewGraphQueryResult({ + nodes: [ + { id: '1/a', attributes: { a: 's1' } }, + { id: '1/b1', attributes: { a: 's1' } }, + { id: '1/b2', attributes: { a: 's1' } }, + { id: '1/b3', attributes: { a: 's1' } }, + ], + edges: [ + { id: '1c/z1', from: '1/b1', to: '1/a', attributes: { a: 's1' } }, + // { from: 'b2', to: 'a', attributes: {} }, + // { from: 'b3', to: 'a', attributes: {} }, + { id: '1c/z1', from: '1/a', to: '1/b1', attributes: { a: 's1' } }, + ], + }) + ) + } +} + +export const TestWithAirportSimple = { + args: { + loading: false, + rowHeight: 50, + hyperedgeColumnWidth: 50, + gapBetweenRanges: 10, + }, + play: async () => { + const dispatch = Mockstore.dispatch; + const schema = SchemaUtils.schemaBackend2Graphology(simpleSchemaRaw); + + dispatch(setSchema(schema.export())); + dispatch( + assignNewGraphQueryResult(smallFlightsQueryResults) + ) + } +} + +export const TestWithAirport = { + args: { + loading: false, + rowHeight: 50, + hyperedgeColumnWidth: 50, + gapBetweenRanges: 10, + }, + play: async () => { + const dispatch = Mockstore.dispatch; + const schema = SchemaUtils.schemaBackend2Graphology(simpleSchemaAirportRaw); + + dispatch(setSchema(schema.export())); + dispatch( + assignNewGraphQueryResult(bigMockQueryResults) + ) + } +} export default Component; diff --git a/libs/shared/lib/vis/paohvis/paohvis.tsx b/libs/shared/lib/vis/paohvis/paohvis.tsx index d4797f6f275760911849475435e1e5444b021989..3826c893d49b5dd3946b53f4dcd6a9cac614c17b 100644 --- a/libs/shared/lib/vis/paohvis/paohvis.tsx +++ b/libs/shared/lib/vis/paohvis/paohvis.tsx @@ -1,19 +1,653 @@ -import styled from 'styled-components'; -interface Props { - content: string; + +import { useEffect, useRef, useState } from 'react'; +import styles from './paohvis.module.scss'; +import { Attribute, Relation, AttributeOrigin, EntitiesFromSchema, FilterInfo, FilterType, NodeOrder, PaohvisData, PaohvisNodeOrder, RelationsFromSchema, ValueType } from './Types'; +import { useImmer } from 'use-immer'; + +import { getWidthOfText } from '../../schema/schema-utils'; +import { useGraphQueryResult, useSchemaGraph } from '../../data-access'; +import { isNodeLinkResult } from '../shared/ResultNodeLinkParserUseCase'; +import { isSchemaResult } from '../shared/SchemaResultType'; +import { calculateAttributesAndRelations, calculateAttributesFromRelation } from './utils/utils'; +import VisConfigPanelComponent from '../shared/VisConfigPanel/VisConfigPanel'; +import { PaohvisFilterComponent } from './components/PaohvisFilterComponent'; +import Tooltip from './components/Tooltip'; +import { pointer, select, selectAll } from 'd3'; +import { HyperEdgeRange } from './components/HyperEdgesRange'; +import ToPaohvisDataParserUseCase from './utils/ToPaohvisDataParserUsecase'; +import MakePaohvisMenu from './components/MakePaohvisMenu'; +import { RowLabelColumn } from './components/RowLabelColumn'; + +type PaohvisViewModelState = { + rowHeight: number; + hyperedgeColumnWidth: number; + gapBetweenRanges: number; + + hyperedgesOnRow: number[][]; + allHyperEdges: number[][]; + + entitiesFromSchema: EntitiesFromSchema; + relationsFromSchema: RelationsFromSchema; + entityVertical: string; + entityHorizontal: string; + chosenRelation: string; + isEntityVerticalEqualToRelationFrom: boolean; + nodeOrder: { orderBy: NodeOrder, isReverseOrder: boolean }; + paohvisFilters: { nodeFilters: FilterInfo[], edgeFilters: FilterInfo[] }; + axisInfo: { + selectedAttribute: { name: string, type: ValueType, origin: AttributeOrigin }, + relation: { collection: string, from: string, to: string }, + isYAxisEntityEqualToRelationFrom: boolean, + }; + } -const Div = styled.div` - background-color: red; - font: 'Arial'; -`; +type Props = { + rowHeight: number; + hyperedgeColumnWidth: number; + gapBetweenRanges: number; + data?: PaohvisData; +}; + +export const PaohVis = (props: Props) => { + const svgRef = useRef<SVGSVGElement>(null); + const graphQueryResult = useGraphQueryResult(); + const schema = useSchemaGraph(); + + const [data, setData] = useState<PaohvisData>({ rowLabels: [], hyperEdgeRanges: [], maxRowLabelWidth: 0 },); + const [viewModel, setViewModel] = useImmer<PaohvisViewModelState>({ + rowHeight: 30, + hyperedgeColumnWidth: 20, + gapBetweenRanges: 5, + + hyperedgesOnRow: [], + allHyperEdges: [], + entitiesFromSchema: { entityNames: [], attributesPerEntity: {}, relationsPerEntity: {} }, + relationsFromSchema: { + relationCollection: [], relationNames: {}, attributesPerRelation: {}, + }, + + entityVertical: '', + entityHorizontal: '', + chosenRelation: '', + isEntityVerticalEqualToRelationFrom: true, + nodeOrder: { orderBy: NodeOrder.degree, isReverseOrder: false, }, + paohvisFilters: { nodeFilters: [], edgeFilters: [] }, + axisInfo: { + selectedAttribute: { + name: '', type: ValueType.noAttribute, origin: AttributeOrigin.noAttribute, + }, + relation: { collection: '', from: '', to: '' }, + isYAxisEntityEqualToRelationFrom: true, + } + }); + + // const [state, setState] = useState({ + // entityVertical: '', + // entityHorizontal: '', + // relation: '', + // isEntityFromRelationFrom: true, + // }); + + + + // + // Methods + // + + /** + * Filters the current PAOHvis visualization. + * @param {boolean} isTargetEntity Tells if the the filter you want to remove is applied to an entity or relation. + * @param {string} entityOrRelationType This is the type of the target. + * @param {string} attribute This is the chosen attribute of the target you want to filter on. + * @param {string} predicate This is the chosen predicate of how you want to compare the attribute. + * @param {string} compareValue This is the chosen value which you want to compare the attribute values to. + */ + function onClickFilterButton( + isTargetEntity: boolean, + entityOrRelationType: string, + attribute: string, + predicate: string, + compareValue: string, + ): void { + const attributeName: string = attribute.split(':')[0]; + const attributeType: string = attribute.split(':')[1]; + const lowerCaseCompareValue = compareValue.toLowerCase(); + let compareValueTyped: any; + // the compareValue must be typed based on the type of the attribute. + switch (attributeType) { + case ValueType.number: + if (!Number.isNaN(Number(compareValue))) compareValueTyped = Number(compareValue); + else throw new Error('Error: This is not a correct input'); + break; + case ValueType.bool: + if (lowerCaseCompareValue == 'true') compareValueTyped = true; + else if (lowerCaseCompareValue == 'false') compareValueTyped = false; + else throw new Error('Error: This is not a correct input'); + break; + default: + compareValueTyped = compareValue; + break; + } + const newFilter: FilterInfo = { + targetGroup: entityOrRelationType, + attributeName: attributeName, + value: compareValueTyped, + predicateName: predicate, + }; + + if (isTargetEntity) { + // The current filter is removed and then the new one is added. + // TODO: to keep all filters, delete this line and improve config menu to see all filters instead of only the last one. + setViewModel((draft) => { + draft.paohvisFilters.nodeFilters.pop(); + draft.paohvisFilters.nodeFilters.push(newFilter); + return draft; + }); + } else { + // The current filter is removed and then the new one is added. + // TODO: to keep all filters, delete this line and improve config menu to see all filters instead of only the last one. + setViewModel((draft) => { + draft.paohvisFilters.edgeFilters.pop(); + draft.paohvisFilters.edgeFilters.push(newFilter); + return draft; + }); + } + }; + + /** + * Resets the current chosen filter. + * @param {boolean} isTargetEntity Tells if the the filter you want to remove is applied to an entity or relation. + */ + function onClickResetButton(isTargetEntity: boolean): void { + setViewModel((draft) => { + if (isTargetEntity) draft.paohvisFilters.nodeFilters.pop(); + else draft.paohvisFilters.edgeFilters.pop(); + return draft; + }); + }; + + /** + * SHOULD NOT BE HERE: move to tooltip + * This calculates a new position based on the current position of the mouse. + * @param {React.MouseEvent} e This is the position of the mouse. + */ + function onMouseMoveToolTip(e: React.MouseEvent) { + select('.tooltip') + .style('top', pointer(e)[1] - 25 + 'px') + .style('left', pointer(e)[0] + 5 + 'px'); + }; + + /** + * Handles the visual changes when you enter a certain hyperEdge on hovering. + * @param colIndex This is the index which states in which column you are hovering. + * @param nameToShow This is the name of the entity which must be shown when you are hovering on this certain hyperEdge. + */ + function onMouseEnterHyperEdge(colIndex: number, nameToShow: string): void { + highlightAndFadeHyperEdges([colIndex]); + highlightAndFadeRows(new Set(viewModel.allHyperEdges[colIndex])); + showColName(nameToShow); + }; + + /** + * Handles the visual changes when you leave a certain hyperEdge on hovering. + * @param colIndex This is the index which states in which column you were hovering. + */ + function onMouseLeaveHyperEdge(colIndex: number): void { + unHighlightAndUnFadeHyperEdges([colIndex]); + unHighlightAndUnFadeRows(new Set(viewModel.allHyperEdges[colIndex])); + hideColName(); + }; + + + /** + * This makes sure that the correct rows are highlighted and the correct rows are faded. + * @param {Set<number>} rows This is the set with the numbers of the rows which must be highlighted. + */ + function highlightAndFadeRows(rows: Set<number>) { + rows.forEach((row) => highlightRow(row)); + + const rowsToFade = []; + for (let i = 0; i < data.rowLabels.length; i++) if (!rows.has(i)) rowsToFade.push(i); + rowsToFade.forEach((row) => fadeRow(row)); + } + + /** + * This makes sure that the correct rows are unhighlighted and the correct rows are unfaded. + * @param {Set<number>} rows This is the set with the numbers of the rows which must be unhighlighted. + */ + function unHighlightAndUnFadeRows(rows: Set<number>) { + rows.forEach((row) => unHighlightRow(row)); + + const rowsToUnFade = []; + for (let i = 0; i < data.rowLabels.length; i++) if (!rows.has(i)) rowsToUnFade.push(i); + rowsToUnFade.forEach((row) => unFadeRow(row)); + } + + /** + * This makes sure that the correct hyperEdges are highlighted and the correct rows are faded. + * @param {number[]} hyperEdges This is the list with the numbers of the hyperEdges which must be highlighted. + */ + function highlightAndFadeHyperEdges(hyperEdges: number[]) { + hyperEdges.forEach((hyperEdge) => highlightHyperedge(hyperEdge)); + + const colsToFade = []; + for (let i = 0; i < viewModel.allHyperEdges.length; i++) + if (!hyperEdges.includes(i)) colsToFade.push(i); + colsToFade.forEach((col) => fadeHyperedge(col)); + } + + /** + * This makes sure that the correct hyperEdges are unhighlighted and the correct rows are unfaded. + * @param {number[]} hyperEdges This is the list with the numbers of the hyperEdges which must be unhighlighted. + */ + function unHighlightAndUnFadeHyperEdges(hyperEdges: number[]) { + hyperEdges.forEach((hyperEdge) => unHighlightHyperedge(hyperEdge)); + + const colsToUnFade = []; + for (let i = 0; i < viewModel.allHyperEdges.length; i++) + if (!hyperEdges.includes(i)) colsToUnFade.push(i); + colsToUnFade.forEach((col) => unFadeHyperedge(col)); + } + + /** + * This highlights one row given its index. + * @param {number} rowIndex This is the index of the row that must be highlighted. + */ + function highlightRow(rowIndex: number) { + const row = selectAll('.row-' + rowIndex); + row.select('text').transition().duration(120).style('font-size', '15px'); + } + + /** + * This unhighlights one row given its index. + * @param {number} rowIndex This is the index of the row that must be unhighlighted. + */ + function unHighlightRow(rowIndex: number) { + const row = selectAll('.row-' + rowIndex); + row.select('text').transition().duration(120).style('font-size', '14px'); + } + + /** + * This highlights one hyperEdge given its index. + * @param {number} columnIndex This is the index of the hyperEdge that must be highlighted. + */ + function highlightHyperedge(columnIndex: number) { + const hyperedge = select('.col-' + columnIndex); + hyperedge.selectAll('circle').transition().duration(120).style('fill', 'orange'); + } + + /** + * This unhighlights one hyperEdge given its index. + * @param {number} columnIndex This is the index of the hyperEdge that must be unhighlighted. + */ + function unHighlightHyperedge(columnIndex: number) { + const hyperedge = select('.col-' + columnIndex); + hyperedge.selectAll('circle').transition().duration(120).style('fill', 'white'); + } + + /** + * This fades one row given its index. + * @param {number} rowIndex This is the index of the row that must be faded. + */ + function fadeRow(rowIndex: number) { + const row = selectAll('.row-' + rowIndex); + row.select('text').attr('opacity', '.3'); + } -const PoahVis = (props: Props) => { + /** + * This unfades one row given its index. + * @param {number} rowIndex This is the index of the row that must be unfaded. + */ + function unFadeRow(rowIndex: number) { + const row = selectAll('.row-' + rowIndex); + row.select('text').attr('opacity', '1'); + } + + /** + * This fades one hyperEdge given its index. + * @param {number} columnIndex This is the index of the hyperEdge that must be faded. + */ + function fadeHyperedge(columnIndex: number) { + const hyperedge = select('.col-' + columnIndex); + hyperedge.selectAll('circle').attr('opacity', '.3'); + hyperedge.selectAll('line').attr('opacity', '.3'); + } + + /** + * This unfades one hyperEdge given its index. + * @param {number} columnIndex This is the index of the hyperEdge that must be unfaded. + */ + function unFadeHyperedge(columnIndex: number) { + const hyperedge = select('.col-' + columnIndex); + hyperedge.selectAll('circle').attr('opacity', '1'); + hyperedge.selectAll('line').attr('opacity', '1'); + } + + /** + * This shows the name when hovering on a hyperEdge. + * @param {string} nameToShow This is the name that must be shown. + */ + function showColName(nameToShow: string): void { + select('.tooltip').text(nameToShow).style('visibility', 'visible'); + }; + + /** This hides the name when leaving a hyperEdge. */ + function hideColName(): void { + select('.tooltip').style('visibility', 'hidden'); + }; + + + /** + * Makes the new PAOHvis visualisation. + * @param {string} entityVertical This is the name of the vertical entity (so on the left). + * @param {string} entityHorizontal This is the name of the horizontal entity (so at the top). + * @param {string} relationName This is the (collection)-name of the relation. + * @param {boolean} isEntityVerticalEqualToRelationFrom Tells if the vertical entity is the from or to of the relation. + * @param {Attribute} chosenAttribute This is the attribute on which the PAOHvis must be grouped by. + * @param {PaohvisNodeOrder} nodeOrder Defines the sorting order of the PAOHvis visualisation. + */ + function onClickMakeButton( + entityVertical: string, + entityHorizontal: string, + relationName: string, + isEntityVerticalEqualToRelationFrom: boolean, + chosenAttribute: Attribute, + nodeOrder: PaohvisNodeOrder, + ): void { + setViewModel(draft => { + draft.entityVertical = entityVertical; + draft.entityHorizontal = entityHorizontal; + draft.chosenRelation = relationName; + draft.isEntityVerticalEqualToRelationFrom = isEntityVerticalEqualToRelationFrom; + draft.nodeOrder = nodeOrder; + + return draft; + }); + setAxisInfo(relationName, isEntityVerticalEqualToRelationFrom, chosenAttribute); + }; + + /** + * This method parses data, makes a new Paohvis Table and lets the view re-render. + */ + function makePaohvisTable() { + // set new data + const newData = new ToPaohvisDataParserUseCase(graphQueryResult).parseQueryResult(viewModel.axisInfo, viewModel.nodeOrder); + console.log(newData); + + setData(newData); + } + + + /** + * This method makes and sets a new PaohvisAxisInfo. + * @param relationName is the relation that will be used in the Paohvis table. + * @param isEntityVerticalEqualToRelationFrom is true when the entity on the y-axis belongs to the 'from' part of the relation. + * @param chosenAttribute is the chosen attribute that will be used to divide the HyperEdges into HyperEdgeRanges. + */ + function setAxisInfo( + relationName: string, + isEntityVerticalEqualToRelationFrom: boolean, + chosenAttribute: Attribute, + ): void { + const namesOfEntityTypes = viewModel.relationsFromSchema.relationNames[relationName].split(':'); + const relation: Relation = { + collection: relationName, + from: namesOfEntityTypes[0], + to: namesOfEntityTypes[1], + }; + + // set axisInfo + setViewModel(draft => { + draft.axisInfo = { + selectedAttribute: chosenAttribute, + relation: relation, + isYAxisEntityEqualToRelationFrom: isEntityVerticalEqualToRelationFrom, + }; + return draft; + }); + } + + /** + * Handles the visual changes when you enter a certain row on hovering. + * @param {number} rowIndex This is the index which states in which row you are hovering. + */ + function onMouseEnterRow(rowIndex: number): void { + const rowsToHighlight = new Set<number>(); + const colsToHighlight: number[] = []; + viewModel.hyperedgesOnRow[rowIndex].forEach((hyperedge) => { + colsToHighlight.push(hyperedge); + + viewModel.allHyperEdges[hyperedge].forEach((row) => { + rowsToHighlight.add(row); + }); + }); + + highlightAndFadeRows(rowsToHighlight); + highlightAndFadeHyperEdges(colsToHighlight); + }; + + /** + * Handles the visual changes when you leave a certain row on hovering. + * @param {number} rowIndex This is the index which states in which row you were hovering. + */ + function onMouseLeaveRow(rowIndex: number): void { + const rowsToUnHighlight = new Set<number>(); + const colsToUnHighlight: number[] = []; + viewModel.hyperedgesOnRow[rowIndex].forEach((hyperedge) => { + colsToUnHighlight.push(hyperedge); + + viewModel.allHyperEdges[hyperedge].forEach((row) => { + rowsToUnHighlight.add(row); + }); + }); + unHighlightAndUnFadeRows(rowsToUnHighlight); + unHighlightAndUnFadeHyperEdges(colsToUnHighlight); + }; + + // + // Reactivity + // + + useEffect(() => { + // if (isSchemaResult(schema)) { + setViewModel(draft => { + // When a schema is received, extract the entity names, and attributes per data type + draft.entitiesFromSchema = calculateAttributesAndRelations(schema); + draft.relationsFromSchema = calculateAttributesFromRelation(schema); + return draft; + }); + // } else { + // console.error('Invalid schema!') + // } + }, [schema]); + + + /** This method filters and makes a new Paohvis table. */ + useEffect(() => { + if (isNodeLinkResult(graphQueryResult)) { + makePaohvisTable(); + } else { + console.error('Invalid query result!') + } + }, [viewModel.paohvisFilters, graphQueryResult, viewModel.axisInfo, viewModel.nodeOrder]); + + + useEffect(() => { + /** + * Recalculates the hyperEdges and hyperEdgeRanges for the state. + */ + setViewModel(draft => { + // Create connecteHyperedgesInColumns, to quickly lookup which hyperedges are on a row + draft.allHyperEdges = []; + + data.hyperEdgeRanges.forEach((hyperEdgeRange) => { + hyperEdgeRange.hyperEdges.forEach((hyperEdge) => { + draft.allHyperEdges.push(hyperEdge.indices); + }); + }); + + draft.hyperedgesOnRow = data.rowLabels.map(() => []); + draft.allHyperEdges.forEach((hyperedge, i) => { + hyperedge.forEach((row) => { + draft.hyperedgesOnRow[row].push(i); + }); + }); + return draft; + }); + }, [data]); + + // + // RENDER + // + + const hyperEdgeRanges = data.hyperEdgeRanges; + const rowLabelColumnWidth = data.maxRowLabelWidth; + const hyperedgeColumnWidth = props.hyperedgeColumnWidth; + + //calculate yOffset + let maxColWidth = 0; + hyperEdgeRanges.forEach((hyperEdgeRange) => { + const textLength = getWidthOfText( + hyperEdgeRange.rangeText, + styles.tableFontFamily, + styles.tableFontSize, + styles.tableFontWeight, + ); + if (textLength > maxColWidth) maxColWidth = textLength; + }); + const columnLabelAngleInRadians = Math.PI / 6; + const yOffset = Math.sin(columnLabelAngleInRadians) * maxColWidth; + const expandButtonWidth = parseFloat(styles.expandButtonSize.split('px')[0]); + const margin = 5; + + //calc table width + let tableWidth = 0; + let tableWidthWithExtraColumnLabelWidth = 0; + hyperEdgeRanges.forEach((hyperEdgeRange) => { + const columnLabelWidth = + Math.cos(columnLabelAngleInRadians) * + getWidthOfText( + hyperEdgeRange.rangeText, + styles.tableFontFamily, + styles.tableFontSize, + styles.tableFontWeight, + ); + const columnWidth = + hyperEdgeRange.hyperEdges.length * hyperedgeColumnWidth + props.gapBetweenRanges * 3; + + tableWidth += columnWidth; + + if (columnLabelWidth > columnWidth) { + const currentTableWidthWithLabel = tableWidth + columnLabelWidth; + if (currentTableWidthWithLabel > tableWidthWithExtraColumnLabelWidth) + tableWidthWithExtraColumnLabelWidth = currentTableWidthWithLabel; + } + if (tableWidth > tableWidthWithExtraColumnLabelWidth) + tableWidthWithExtraColumnLabelWidth = tableWidth; + }); + tableWidthWithExtraColumnLabelWidth += rowLabelColumnWidth + expandButtonWidth + margin; + + //make all hyperEdgeRanges + let colOffset = 0; + let hyperEdgeRangeColumns: JSX.Element[] = []; + hyperEdgeRanges.forEach((hyperEdgeRange, i) => { + hyperEdgeRangeColumns.push( + <HyperEdgeRange + key={i} + index={i} + text={hyperEdgeRange.rangeText} + hyperEdges={hyperEdgeRange.hyperEdges} + nRows={data.rowLabels.length} + colOffset={colOffset} + xOffset={rowLabelColumnWidth} + yOffset={yOffset} + rowHeight={props.rowHeight} + hyperedgeColumnWidth={hyperedgeColumnWidth} + gapBetweenRanges={props.gapBetweenRanges} + onMouseEnter={onMouseEnterHyperEdge} + onMouseLeave={onMouseLeaveHyperEdge} + />, + ); + colOffset += hyperEdgeRange.hyperEdges.length; + }); + + //display message when there is no Paohvis table to display + let tableMessage = <span></span>; + let configPanelMessage = <div></div>; + if (data.rowLabels.length === 0) { + tableMessage = ( + <div id={styles.tableMessage}> Please choose a valid PAOHvis configuration </div> + ); + configPanelMessage = ( + <div id={styles.configPanelMessage}> Please make a PAOHvis table first </div> + ); + } + + // returns the whole PAOHvis visualisation panel return ( - <Div> - <h1>{props.content}</h1> - </Div> + <div className={styles.container}> + <div className={styles.visContainer}> + <div style={{ transform: 'scale(-1,1)' }}> + <div + style={{ width: '100%', height: '100%', overflow: 'auto' }} + onMouseMove={onMouseMoveToolTip} + > + <MakePaohvisMenu // render the MakePAOHvisMenu + makePaohvis={onClickMakeButton} + /> + {tableMessage} + <svg + ref={svgRef} + style={{ + width: tableWidthWithExtraColumnLabelWidth, + height: yOffset + (data.rowLabels.length + 1) * props.rowHeight, + }} + > + <RowLabelColumn // render the PAOHvis itself + onMouseEnter={onMouseEnterRow} + onMouseLeave={onMouseLeaveRow} + titles={data.rowLabels} + width={rowLabelColumnWidth} + rowHeight={viewModel.rowHeight} + yOffset={yOffset} + /> + {hyperEdgeRangeColumns} + </svg> + <Tooltip /> + </div> + </div> + </div> + <VisConfigPanelComponent> + {configPanelMessage} + <PaohvisFilterComponent // render the PaohvisFilterComponent with all three different filter options + axis={FilterType.yaxis} + filterPaohvis={onClickFilterButton} + resetFilter={onClickResetButton} + entityVertical={viewModel.entityVertical} + entityHorizontal={viewModel.entityHorizontal} + relationName={viewModel.chosenRelation} + /> + <PaohvisFilterComponent + axis={FilterType.xaxis} + filterPaohvis={onClickFilterButton} + resetFilter={onClickResetButton} + entityVertical={viewModel.entityVertical} + entityHorizontal={viewModel.entityHorizontal} + relationName={viewModel.chosenRelation} + /> + <PaohvisFilterComponent + axis={FilterType.edge} + filterPaohvis={onClickFilterButton} + resetFilter={onClickResetButton} + entityVertical={viewModel.entityVertical} + entityHorizontal={viewModel.entityHorizontal} + relationName={viewModel.chosenRelation} + /> + </VisConfigPanelComponent> + </div> ); }; -export default PoahVis; + +export default PaohVis; \ No newline at end of file diff --git a/libs/shared/lib/vis/paohvis/paohvisViewModel.tsx b/libs/shared/lib/vis/paohvis/paohvisViewModel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/libs/shared/lib/vis/paohvis/utils/AttributesFilterUseCase.tsx b/libs/shared/lib/vis/paohvis/utils/AttributesFilterUseCase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..725d7a88b226d1bb89d49e0382cc56c23fc793a4 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/utils/AttributesFilterUseCase.tsx @@ -0,0 +1,141 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +import { FilterInfo, PaohvisFilters } from '../Types'; +import { + AxisType, + isNotInGroup, +} from '../../shared/ResultNodeLinkParserUseCase'; +import { boolPredicates, numberPredicates, textPredicates } from '../models/FilterPredicates'; +import { Edge, GraphQueryResult, Node } from '@graphpolaris/shared/lib/data-access'; + +/** This is used to filter the data for Paohvis. */ +export default class AttributeFilterUsecase { + /** Applies all filters to the query result. */ + public static applyFilters( + queryResult: GraphQueryResult, + paohvisFilters: PaohvisFilters, + ): GraphQueryResult { + //apply the filters to the nodes + let filteredNodes = queryResult.nodes; + paohvisFilters.nodeFilters.forEach( + (filter) => (filteredNodes = this.filterAxis(filteredNodes, filter)), + ); + + //filter out the unused edges + const nodeIds = getIds(filteredNodes); + let filteredEdges = filterUnusedEdges(nodeIds, queryResult.edges); + + //apply the filters to the edges + paohvisFilters.edgeFilters.forEach( + (filter) => (filteredEdges = this.filterAxis(filteredEdges, filter)), + ); + + //filter out unused nodes + filteredNodes = filterUnusedNodes(filteredNodes, filteredEdges); + + return { ...queryResult, nodes: filteredNodes, edges: filteredEdges }; + } + + /** + * Gets the correct predicate that belongs to the predicateName and + * filters the given array on the specified target group on the given attribute with the predicate and given value, + * @param axisNodesOrEdges is the array that should be filtered. + * @param filter is the filter that should be applied. + * @returns the array filtered with the correct predicate. + */ + public static filterAxis<T extends Node | Edge>(axisNodesOrEdges: T[], filter: FilterInfo): T[] { + if (typeof filter.value == 'boolean') return filterBoolAttributes(axisNodesOrEdges, filter); + + if (typeof filter.value == 'number') return filterNumberAttributes(axisNodesOrEdges, filter); + + if (typeof filter.value == 'string') return filterTextAttributes(axisNodesOrEdges, filter); + + throw new Error('Filter on this type is not supported.'); + } +} + +/** Filters the given array on the designated boolean attribute with the given predicate. */ +function filterBoolAttributes<T extends Node | Edge>(axisNodesOrEdges: T[], filter: FilterInfo): T[] { + const predicate = boolPredicates[filter.predicateName]; + if (predicate == undefined) throw new Error('Predicate does not exist'); + + const resultNodesOrEdges = axisNodesOrEdges.filter((nodeOrEdge) => { + const currentAttribute = nodeOrEdge.attributes[filter.attributeName] as boolean; + return ( + isNotInGroup(nodeOrEdge, filter.targetGroup) || predicate(currentAttribute, filter.value) + ); + }); + + return resultNodesOrEdges; +} + +/** Filters the given array on the designated number attribute with the given predicate. */ +function filterNumberAttributes<T extends Node | Edge>(axisNodesOrEdges: T[], filter: FilterInfo): T[] { + const predicate = numberPredicates[filter.predicateName]; + if (predicate == undefined) throw new Error('Predicate does not exist'); + + const resultNodesOrEdges = axisNodesOrEdges.filter((nodeOrEdge) => { + const currentAttribute = nodeOrEdge.attributes[filter.attributeName] as number; + return ( + isNotInGroup(nodeOrEdge, filter.targetGroup) || predicate(currentAttribute, filter.value) + ); + }); + + return resultNodesOrEdges; +} + +/** Filters the given array on the designated string attribute with the given predicate. */ +function filterTextAttributes<T extends Node | Edge>(axisNodesOrEdges: T[], filter: FilterInfo): T[] { + const predicate = textPredicates[filter.predicateName]; + if (predicate == undefined) throw new Error('Predicate does not exist'); + + const resultNodesOrEdges = axisNodesOrEdges.filter((nodeOrEdge) => { + const currentAttribute = nodeOrEdge.attributes[filter.attributeName] as string; + return ( + isNotInGroup(nodeOrEdge, filter.targetGroup) || predicate(currentAttribute, filter.value) + ); + }); + + return resultNodesOrEdges; +} + +/** Gets the ids from the given nodes or edges array. */ +export function getIds(nodesOrEdges: AxisType[]): string[] { + const result: string[] = []; + nodesOrEdges.forEach((nodeOrEdge) => result.push(nodeOrEdge.id || 'unknown id')); + return result; +} + +/** + * Filters out the edges that are from or to a node that is not being used anymore. + * @param ids are the ids of the nodes. + * @param edges are the edges that should be filtered. + * @returns a filtered array where all edges are used. + */ +export function filterUnusedEdges(ids: string[], edges: Edge[]): Edge[] { + const filteredEdges: Edge[] = []; + edges.forEach( + (edge) => ids.includes(edge.from) && ids.includes(edge.to) && filteredEdges.push(edge), + ); + return filteredEdges; +} + +/** Filters out the nodes that are not used by any edge. */ +function filterUnusedNodes(nodes: Node[], edges: Edge[]): Node[] { + const filteredNodes: Node[] = []; + const frequencyDict: Record<string, number> = {}; + nodes.forEach((node) => (frequencyDict[node.id] = 0)); + + edges.forEach((edge) => { + frequencyDict[edge.from] += 1; + frequencyDict[edge.to] += 1; + }); + + nodes.forEach((node) => frequencyDict[node.id] > 0 && filteredNodes.push(node)); + + return filteredNodes; +} diff --git a/libs/shared/lib/vis/paohvis/utils/CalcEntitiesFromQueryResult.tsx b/libs/shared/lib/vis/paohvis/utils/CalcEntitiesFromQueryResult.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3474abc6c55673c189b8dfb80cfcd04a88846607 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/utils/CalcEntitiesFromQueryResult.tsx @@ -0,0 +1,23 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +import { GraphQueryResult } from "@graphpolaris/shared/lib/data-access"; +import { getGroupName } from "../../shared/ResultNodeLinkParserUseCase"; + + +/** + * This calculates all entities from the query result. + * @param {NodeLinkResultType} message This is the message with all the information about the entities and relations. + * @returns {string[]} All names of the entities which are in the query result. + */ +export default function calcEntitiesFromQueryResult(message: GraphQueryResult): string[] { + const entityTypesFromQueryResult: string[] = []; + message.nodes.forEach((node) => { + const group = getGroupName(node); + if (!entityTypesFromQueryResult.includes(group)) entityTypesFromQueryResult.push(group); + }); + return entityTypesFromQueryResult; +} diff --git a/libs/shared/lib/vis/paohvis/utils/CalcEntityAttrAndRelNamesFromSchemaUseCase.tsx b/libs/shared/lib/vis/paohvis/utils/CalcEntityAttrAndRelNamesFromSchemaUseCase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..834ec22a6c5c53dd3497506794c069f73617eca5 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/utils/CalcEntityAttrAndRelNamesFromSchemaUseCase.tsx @@ -0,0 +1,134 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ +import { SchemaGraph, SchemaGraphologyEdge } from '@graphpolaris/shared/lib/schema'; +import { + AttributeNames, + EntitiesFromSchema, + RelationsFromSchema, +} from '../Types'; + +/** Use case for retrieving entity names, relation names and attribute names from a schema result. */ +export default class CalcEntityAttrAndRelNamesFromSchemaUseCase { + /** + * Takes a schema result and calculates all the entity names, and relation names and attribute names per entity. + * Used by PAOHvis to show all possible options to choose from when adding a new PAOHvis visualisation or when filtering. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {EntitiesFromSchema} All entity names, and relation names and attribute names per entity. + */ + public static calculateAttributesAndRelations(schemaResult: SchemaGraph): EntitiesFromSchema { + const attributesPerEntity: Record<string, AttributeNames> = this.calculateAttributes(schemaResult,); + const relationsPerEntity: Record<string, string[]> = this.calculateRelations(schemaResult); + + return { + entityNames: schemaResult.nodes.filter(node => (node?.attributes?.name !== undefined)).map((node) => node.attributes!.name), + attributesPerEntity, + relationsPerEntity, + }; + } + + /** + * Takes a schema result and calculates all the attribute names per entity. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {Record<string, AttributeNames>} All attribute names per entity. + */ + public static calculateAttributes(schemaResult: SchemaGraph): Record<string, AttributeNames> { + const attributesPerEntity: Record<string, AttributeNames> = {}; + // Go through each entity. + schemaResult.nodes.forEach((node) => { + if (node.attributes === undefined || node.attributes.name === undefined) { + console.error('ERROR: Node has no attributes/name.', node); + return; + } + + // Extract the attribute names per datatype for each entity. + const textAttributeNames: string[] = []; + const boolAttributeNames: string[] = []; + const numberAttributeNames: string[] = []; + node.attributes.attributes.forEach((attr) => { + if (attr.type == 'string') textAttributeNames.push(attr.name); + else if (attr.type == 'int' || attr.type == 'float') numberAttributeNames.push(attr.name); + else boolAttributeNames.push(attr.name); + }); + + // Create a new object with the arrays with attribute names per datatype. + attributesPerEntity[node.attributes.name] = { + textAttributeNames, + boolAttributeNames, + numberAttributeNames, + }; + }); + return attributesPerEntity; + } + + /** + * Takes a schema result and calculates all the relation names per entity. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {Record<string, AttributeNames>} All relation (from and to) names per entity. + */ + public static calculateRelations(schemaResult: SchemaGraph): Record<string, string[]> { + const relationsPerEntity: Record<string, string[]> = {}; + // Go through each relation. + schemaResult.edges.forEach((edge) => { + if (edge.attributes === undefined || edge.attributes.name === undefined) { + console.error('ERROR: Edge has no attributes/name.', edge); + return; + } + + // Extract the from-node-name (collection name) from every relation. + if (relationsPerEntity[edge.attributes.from]) relationsPerEntity[edge.attributes.from].push(edge.attributes.collection); + else relationsPerEntity[edge.attributes.from] = [edge.attributes.collection]; + // Extract the to-node-name (collection name) from every relation. + if (relationsPerEntity[edge.attributes.to]) relationsPerEntity[edge.attributes.to].push(edge.attributes.collection); + else relationsPerEntity[edge.attributes.to] = [edge.attributes.collection]; + }); + return relationsPerEntity; + } + + /** + * Takes a schema result and calculates all the relation collection names, and relation names and attribute names per relation. + * Used by PAOHvis to show all possible options to choose from when adding a new PAOHvis visualisation or when filtering. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {EntitiesFromSchema} All entity names, and relation names and attribute names per entity. + */ + public static calculateAttributesFromRelation(schemaResult: SchemaGraph): RelationsFromSchema { + const relationCollections: string[] = []; + const attributesPerRelation: Record<string, AttributeNames> = {}; + const nameOfCollectionPerRelation: Record<string, string> = {}; + // Go through each relation. + schemaResult.edges.forEach((edge) => { + if (edge.attributes === undefined || edge.attributes.name === undefined) { + console.error('ERROR: Edge has no attributes/name.', edge); + return; + } + + if (!nameOfCollectionPerRelation[edge.attributes.collection]) { + relationCollections.push(edge.attributes.collection); + nameOfCollectionPerRelation[edge.attributes.collection] = `${edge.attributes.name}`; + } + // Extract the attribute names per datatype for each relation. + const textAttributeNames: string[] = []; + const boolAttributeNames: string[] = []; + const numberAttributeNames: string[] = []; + edge.attributes.attributes.forEach((attr: SchemaGraphologyEdge) => { + if (attr.type == 'string') textAttributeNames.push(attr.name); + else if (attr.type == 'int' || attr.type == 'float') numberAttributeNames.push(attr.name); + else boolAttributeNames.push(attr.name); + }); + + // Create a new object with the arrays with attribute names per datatype. + attributesPerRelation[edge.attributes.collection] = { + textAttributeNames, + boolAttributeNames, + numberAttributeNames, + }; + }); + return { + relationCollection: relationCollections, + relationNames: nameOfCollectionPerRelation, + attributesPerRelation: attributesPerRelation, + }; + } +} diff --git a/libs/shared/lib/vis/paohvis/utils/SortUseCase.tsx b/libs/shared/lib/vis/paohvis/utils/SortUseCase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..818384e1323c04f4cf5f88583294abfd35a35c4d --- /dev/null +++ b/libs/shared/lib/vis/paohvis/utils/SortUseCase.tsx @@ -0,0 +1,135 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +import { HyperEdgeI, HyperEdgeRange, NodeOrder, PaohvisNodeOrder, ValueType } from "../Types"; +import { Node } from '@graphpolaris/shared/lib/data-access'; + +/** + * This class is responsible for sorting the data for the ToPaohvisDataParser. + */ +export default class SortUseCase { + /** + * Sorts the given HyperEdgeRanges by their labels and sorts the indices for all HyperEdges in the HyperEdgeRanges. + * @param hyperEdgeRanges that should be sorted. + * @param type of the HyperEdgeRange labels. + */ + public static sortHyperEdgeRanges(hyperEdgeRanges: HyperEdgeRange[], type: ValueType): void { + this.sortHyperEdgeIndices(hyperEdgeRanges); + this.sortHyperEdgeRangesByLabels(hyperEdgeRanges, type); + } + + /** + * Sorts the given HyperEdgeRanges by their labels. + * They are sorted alphabetically when the labels are strings. + * They are sorted from lowest to highest when the labels are numbers. + * @param hyperEdgeRanges that should be sorted. + * @param type of the HyperEdgeRange labels. + */ + private static sortHyperEdgeRangesByLabels( + hyperEdgeRanges: HyperEdgeRange[], + type: ValueType, + ): void { + //sort all hyperedgeranges text + if (type == ValueType.number) { + //from lowest to highest + hyperEdgeRanges.sort((a, b) => compareNumericalLabels(a.rangeText, b.rangeText)); + } else { + //alphabetical + hyperEdgeRanges.sort((a, b) => a.rangeText.localeCompare(b.rangeText)); + } + } + + /** + * Sorts all indices for each HyperEdge in the HyperEdgeRanges from lowest to highest. + * @param hyperEdgeRanges that should be sorted. + */ + private static sortHyperEdgeIndices(hyperEdgeRanges: HyperEdgeRange[]): void { + hyperEdgeRanges.forEach((hyperEdgeRange) => + hyperEdgeRange.hyperEdges.forEach((hyperEdge) => hyperEdge.indices.sort((n1, n2) => n1 - n2)), + ); + } + + /** + * Sort the nodes in the given order. + * @param nodeOrder is the order in which the nodes should be sorted. + * @param nodes are the nodes that will be sorted + * @param hyperEdgeDegree is the dictionary where you can find how many edges connected from the node. + */ + public static sortNodes( + nodeOrder: PaohvisNodeOrder, + nodes: Node[], + hyperEdgeDegree: Record<string, number>, + ): void { + switch (nodeOrder.orderBy) { + //sort nodes on their degree (# number of hyperedges) (entities with most hyperedges first) + case NodeOrder.degree: + //TODO: sort when nodes have the same amount of edges + nodes.sort((node1, node2) => { + return hyperEdgeDegree[node2.id] - hyperEdgeDegree[node1.id]; + }); + break; + //sort nodes on their id alphabetically + case NodeOrder.alphabetical: + nodes.sort((node1, node2) => node1.id.localeCompare(node2.id)); + break; + default: + throw new Error('This node order does not exist'); + } + //reverse order + if (nodeOrder.isReverseOrder) nodes.reverse(); + } + + /** + * This is used to sort hyperEdges by appearance on y-axis and length of hyperEdges. + * @param hyperEdgeRanges that should be sorted. + */ + public static sortHyperEdges(hyperEdgeRanges: HyperEdgeRange[]): void { + hyperEdgeRanges.forEach((hyperEdgeRange) => { + hyperEdgeRange.hyperEdges.sort(this.compareByLineLength); + hyperEdgeRange.hyperEdges.sort(this.compareByAppearanceOnYAxis); + }); + } + + /** + * This is used to compare hyperedges by their linelength in the table. + * Can be used to sort hyperedges from hyperedges with a shorter lineLength to longer lineLength. + * @param hyperEdge1 first hyperedge to compare + * @param hyperEdge2 second hyperedge to compare + * @returns {number} a negative value if the first argument is less than the second argument, zero if they're equal, and a positive value otherwise. + */ + private static compareByLineLength(hyperEdge1: HyperEdgeI, hyperEdge2: HyperEdgeI): number { + const indices1 = hyperEdge1.indices; + const lineLength1 = indices1[indices1.length - 1] - indices1[0]; + + const indices2 = hyperEdge2.indices; + const lineLength2 = indices2[indices2.length - 1] - indices2[0]; + return lineLength1 - lineLength2; + } + + /** + * This is used to compare hyperedges by their appearance on the y-axis in the table. + * Can be used to sort hyperedges so that the hyperedges that appear higher on the y-axis should appear earlier on the x-axis. + * @param hyperEdge1 first hyperedge to compare + * @param hyperEdge2 second hyperedge to compare + * @returns {number} a negative value if the first argument is less than the second argument, zero if they're equal, and a positive value otherwise. + */ + private static compareByAppearanceOnYAxis(hyperEdge1: HyperEdgeI, hyperEdge2: HyperEdgeI): number { + const start1 = hyperEdge1.indices[0]; + const start2 = hyperEdge2.indices[0]; + return start1 - start2; + } +} + +/** + * This is used to compare numerical labels. + * Can be used to sort numerical labels from the label with the lowest number to the label with the highest number. + * @param numericalLabel1 first numerical label to compare + * @param numericalLabel2 second numerical label to compare + * @returns {number} a negative value if the first argument is less than the second argument, zero if they're equal, and a positive value otherwise. + */ +function compareNumericalLabels(numericalLabel1: string, numericalLabel2: string): number { + return parseFloat(numericalLabel1) - parseFloat(numericalLabel2); +} diff --git a/libs/shared/lib/vis/paohvis/utils/ToPaohvisDataParserUsecase.tsx b/libs/shared/lib/vis/paohvis/utils/ToPaohvisDataParserUsecase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..454ffe6c02e66304a2eb317c8140bda71b40ccd1 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/utils/ToPaohvisDataParserUsecase.tsx @@ -0,0 +1,355 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ + +import { AxisType, getGroupName } from '../../shared/ResultNodeLinkParserUseCase' +import { Edge, GraphQueryResult, Node } from '@graphpolaris/shared/lib/data-access'; + +import { AttributeOrigin, HyperEdgeI, HyperEdgeRange, PaohvisAxisInfo, PaohvisData, PaohvisFilters, PaohvisNodeInfo, PaohvisNodeOrder, Relation, ValueType } from '../Types'; +import AttributeFilterUsecase, { filterUnusedEdges, getIds } from './AttributesFilterUseCase'; +import SortUseCase from './SortUseCase'; +import { getWidthOfText, uniq } from './utils'; +import style from '../Paohvis.module.scss'; + +type Index = number; + +/** + * This parser is used to parse the incoming query result to the format that's needed to make the Paohvis table. + */ +export default class ToPaohvisDataParserUseCase { + private queryResult: GraphQueryResult; + private xAxisNodeGroup: string; + private yAxisNodeGroup: string; + private paohvisFilters: PaohvisFilters; + + public constructor(queryResult: GraphQueryResult) { + this.queryResult = queryResult; + this.xAxisNodeGroup = ''; + this.yAxisNodeGroup = ''; + this.paohvisFilters = { nodeFilters: [], edgeFilters: [] }; + } + /** + * Parses query results to the format that's needed to make a Paohvis table. + * @param axisInfo is the information that's needed to parse everything to the correct axis. + * @param nodeOrder is the order in which the nodes should be parsed. + * @returns the information that's needed to make a Paohvis table. + */ + public parseQueryResult(axisInfo: PaohvisAxisInfo, nodeOrder: PaohvisNodeOrder): PaohvisData { + this.setAxesNodeGroups(axisInfo); + + const filteredData = AttributeFilterUsecase.applyFilters( + this.queryResult, + this.paohvisFilters, + ); + + // filter unnecessary node groups and relations + const nodes = filteredData.nodes.filter((node) => { + const nodeType = getGroupName(node); + return nodeType === this.xAxisNodeGroup || nodeType === this.yAxisNodeGroup; + }); + const edges = filteredData.edges.filter( + (edge) => getGroupName(edge) === axisInfo.relation.collection, + ); + + // get hyperEdgeDegree + const hyperEdgeDegree: Record<string, number> = + ToPaohvisDataParserUseCase.GetHyperEdgeDegreeDict( + nodes, + edges, + axisInfo.isYAxisEntityEqualToRelationFrom, + ); + + //parse nodes + const rowInfo: PaohvisNodeInfo = ToPaohvisDataParserUseCase.parseNodes( + nodes, + hyperEdgeDegree, + nodeOrder, + this.yAxisNodeGroup, + this.xAxisNodeGroup, + ); + const maxLabelWidth = calcMaxRowLabelWidth(rowInfo.rowLabels); + + //parse hyperEdges + const filteredEdges = filterUnusedEdges(getIds(nodes), edges); + const resultHyperEdgeRanges = ToPaohvisDataParserUseCase.parseHyperEdgeRanges( + nodes, + filteredEdges, + axisInfo, + rowInfo, + ); + SortUseCase.sortHyperEdges(resultHyperEdgeRanges); + + return { + rowLabels: rowInfo.rowLabels, + hyperEdgeRanges: resultHyperEdgeRanges, + maxRowLabelWidth: maxLabelWidth, + }; + } + + /** + * Sets the x-axis and y-axis node groups from the given PaohvisAxisInfo in the parser. + * @param axisInfo is the new PaohvisAxisInfo that will be used. + */ + private setAxesNodeGroups(axisInfo: PaohvisAxisInfo): void { + const relation: Relation = axisInfo.relation; + if (axisInfo.isYAxisEntityEqualToRelationFrom) { + this.xAxisNodeGroup = relation.to; + this.yAxisNodeGroup = relation.from; + } else { + this.xAxisNodeGroup = relation.from; + this.yAxisNodeGroup = relation.to; + } + } + + /** + * This parses the nodes to get the information that's needed to parse the hyperedges. + * @param nodes are the nodes from the query result. + * @param hyperEdgeDegree is the dictionary where you can find how many edges connected from the node. + * @param nodeOrder is the order in which the nodes should be sorted. + * @param yAxisNodeType is the type of nodes that should be on the y-axis. + * @param xAxisNodeType is the type of nodes that should be on the x-axis. + * @returns the information that's needed to parse the hyperedges. + */ + private static parseNodes( + nodes: Node[], + hyperEdgeDegree: Record<string, number>, + nodeOrder: PaohvisNodeOrder, + yAxisNodeType: string, + xAxisNodeType: string, + ): PaohvisNodeInfo { + const rowNodes = filterRowNodes(nodes, hyperEdgeDegree, yAxisNodeType); + SortUseCase.sortNodes(nodeOrder, rowNodes, hyperEdgeDegree); + const rowLabels = getIds(rowNodes); + + //make dictionary for finding the index of a row + const yNodesIndexDict: Record<string, number> = {}; + let yNodeIndexCounter = 0; + for (let i = 0; i < rowNodes.length; i++) { + yNodesIndexDict[rowNodes[i].id] = yNodeIndexCounter; + yNodeIndexCounter++; + } + + const xNodesAttributesDict: Record<string, any> = getXNodesAttributesDict( + yAxisNodeType, + xAxisNodeType, + nodes, + ); + return { + rowLabels: rowLabels, + xNodesAttributesDict: xNodesAttributesDict, + yNodesIndexDict: yNodesIndexDict, + }; + } + + /** + * Makes a dictionary where you can find how many edges are connected to the nodes. + * @param nodes are the nodes where the edges should be counted for. + * @param edges should be used to count how many edges are connected to the nodes. + * @param isEntityRelationFrom is to decide if you need to count the from's or the to's of the edge. + * @returns a dictionary where you can find how many edges are connected to the nodes. + */ + private static GetHyperEdgeDegreeDict( + nodes: Node[], + edges: Edge[], + isEntityRelationFrom: boolean, + ): Record<string, number> { + const hyperEdgeDegreeDict: Record<string, number> = {}; + + //initialize dictionary + nodes.forEach((node) => (hyperEdgeDegreeDict[node.id] = 0)); + + //count node appearance frequencies + edges.forEach((edge) => { + if (isEntityRelationFrom) hyperEdgeDegreeDict[edge.from]++; + else hyperEdgeDegreeDict[edge.to]++; + }); + + return hyperEdgeDegreeDict; + } + + /** + * Parses the edges to make hyperedge ranges for the Paohvis table. + * @param nodes the unused nodes should already be filtered out. + * @param edges the unused edges should already be filtered out. + * @param axisInfo is the information that's needed to parse the edges to their respective hyperedge range. + * @param rowInfo is the information about the nodes that's needed to parse the edges to their respective hyperedge range. + * @returns the hyperedge ranges that will be used by the Paohvis table. + */ + private static parseHyperEdgeRanges( + nodes: Node[], + edges: Edge[], + axisInfo: PaohvisAxisInfo, + rowInfo: PaohvisNodeInfo, + ): HyperEdgeRange[] { + if (nodes.length == 0 || edges.length == 0) return []; + + const resultHyperEdgeRanges: HyperEdgeRange[] = []; + + //is used to find on which index of HyperEdgeRanges the edge should be added + const attributeRangesIndexDict: Record<string, Index> = {}; + let attributeRangeCounter = 0; + + //is used to find the index to which hyperedge it belongs to (in the array hyperEdgeRanges[i].hyperEdges) + //dict[hyperEdgeRangeLabel] gives a dict where you can find with dict[edge.to] the index where it belongs to + const hyperEdgesDict: Record<string, Record<string, Index>> = {}; + + const xAxisAttributeType: string = axisInfo.selectedAttribute.name; + + //is used to find the node attributes for the x-axis + const xNodesAttributesDict: Record<string, any> = rowInfo.xNodesAttributesDict; + + //is used to find the index of nodes in rowLabels + const yNodesIndexDict: Record<string, number> = rowInfo.yNodesIndexDict; + + let edgeDirection: string; + let edgeDirectionOpposite: string; + const isFromOnYAxis = axisInfo.isYAxisEntityEqualToRelationFrom; + const collection = axisInfo.relation.collection; + + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + ({ edgeDirection, edgeDirectionOpposite } = getEdgeDirections(isFromOnYAxis, edge)); + + let edgeIndexInResult: number = yNodesIndexDict[edgeDirection]; + + // check if the chosen attribute is an attribute of the edge or the node + let attribute: any; + if (axisInfo.selectedAttribute.origin == AttributeOrigin.relation) + attribute = edge.attributes[xAxisAttributeType]; + else attribute = xNodesAttributesDict[edgeDirectionOpposite][xAxisAttributeType]; + + // if no edge attribute was selected, then all edges will be placed in one big hyperEdgeRange + if (attribute == undefined) attribute = ValueType.noAttribute; + + if (attribute in attributeRangesIndexDict) { + const rangeIndex = attributeRangesIndexDict[attribute]; + const targetHyperEdges = resultHyperEdgeRanges[rangeIndex].hyperEdges; + const targetHyperEdgeIndex = hyperEdgesDict[attribute][edgeDirectionOpposite]; + + if (targetHyperEdgeIndex != undefined) { + // hyperedge group already exists so add edge to group + targetHyperEdges[targetHyperEdgeIndex].indices.push(edgeIndexInResult); + } else { + // create new hyperedge group + hyperEdgesDict[attribute][edgeDirectionOpposite] = targetHyperEdges.length; + + // 'add edge to new hyperedge group' + targetHyperEdges.push({ + indices: [edgeIndexInResult], + frequencies: newFrequencies(rowInfo.rowLabels), + nameToShow: edgeDirectionOpposite, + }); + } + } else { + // create new attribute range + attributeRangesIndexDict[attribute] = attributeRangeCounter; + attributeRangeCounter++; + + hyperEdgesDict[attribute] = {}; + hyperEdgesDict[attribute][edgeDirectionOpposite] = 0; + + let label: string; + if (xAxisAttributeType != ValueType.noAttribute && xAxisAttributeType != '') + label = attribute.toString(); + else label = 'No attribute was selected'; + const hyperEdge: HyperEdgeI = { + indices: [edgeIndexInResult], + frequencies: newFrequencies(rowInfo.rowLabels), + nameToShow: edgeDirectionOpposite, + }; + const hyperEdgeRange: HyperEdgeRange = { + rangeText: label, + hyperEdges: [hyperEdge], + }; + resultHyperEdgeRanges.push(hyperEdgeRange); + } + } + + SortUseCase.sortHyperEdgeRanges(resultHyperEdgeRanges, axisInfo.selectedAttribute.type); + + //calc how many duplicate edges are in each hyperedge + resultHyperEdgeRanges.forEach((hyperEdgeRange) => { + hyperEdgeRange.hyperEdges.forEach((hyperedge) => { + hyperedge.indices.forEach((index) => { + hyperedge.frequencies[index] += 1; + }); + }); + }); + + //filter out duplicate indices from all hyperedges + resultHyperEdgeRanges.forEach((hyperEdgeRange) => { + hyperEdgeRange.hyperEdges.forEach((hyperedge) => { + hyperedge.indices = uniq(hyperedge.indices); + }); + }); + + return resultHyperEdgeRanges; + } + + /** Sets new PaohvisFilters. */ + public setPaohvisFilters(filters: PaohvisFilters): void { + this.paohvisFilters = filters; + } +} + +/** Calculates the width that's needed for the rowlabels. */ +function calcMaxRowLabelWidth(rowLabels: string[]) { + const margin = 10; + let maxLabelWidth = 0; + rowLabels.forEach((rowLabel) => { + const textWidth: number = getWidthOfText( + rowLabel + ' ', + style.tableFontFamily, + style.tableFontSize, + style.tableFontWeight, + ); + if (textWidth > maxLabelWidth) maxLabelWidth = textWidth; + }); + return maxLabelWidth + margin; +} + +/** Gets a dictionary where you can find the attributes that belong to the nodes on teh x-axis. */ +function getXNodesAttributesDict(yAxisNodeType: string, xAxisNodeType: string, nodes: Node[]) { + const resultXNodesAttributesDict: Record<string, any> = {}; + if (yAxisNodeType == xAxisNodeType) + nodes.forEach((node) => (resultXNodesAttributesDict[node!.id] = node.attributes)); + else + nodes.forEach((node) => { + if (getGroupName(node) == xAxisNodeType) + resultXNodesAttributesDict[node.id] = node.attributes; + }); + return resultXNodesAttributesDict; +} + +/** + * Gets which direction the edge will be read from. The direction can be read from: + * 'from => to' or 'to => from'. + */ +function getEdgeDirections(isFromToRelation: boolean, edge: Edge) { + let edgeDirection: string; + let edgeDirectionOpposite: string; + if (isFromToRelation) { + edgeDirection = edge.from; + edgeDirectionOpposite = edge.to; + } else { + edgeDirection = edge.to; + edgeDirectionOpposite = edge.from; + } + return { edgeDirection, edgeDirectionOpposite }; +} + +/** This makes an array filled with zeroes, which is used to count the frequencies of edges. */ +function newFrequencies(rowLabels: string[]): number[] { + return Array(rowLabels.length).fill(0); +} + +/** Filters out nodes that have no edges and nodes that are not the specified type. */ +function filterRowNodes( + nodes: Node[], + hyperEdgeDegree: Record<string, number>, + rowNodeType: string, +): Node[] { + return nodes.filter((node) => hyperEdgeDegree[node.id] > 0 && getGroupName(node) == rowNodeType); +} diff --git a/libs/shared/lib/vis/paohvis/utils/utils.tsx b/libs/shared/lib/vis/paohvis/utils/utils.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4476bb68fb12c1bfa5e97caa5f57cbb47f540950 --- /dev/null +++ b/libs/shared/lib/vis/paohvis/utils/utils.tsx @@ -0,0 +1,158 @@ +import { log } from "console"; +import { SchemaAttribute, SchemaGraph, SchemaGraphologyEdge, SchemaGraphologyNode } from "../../../schema"; +import { AttributeNames, EntitiesFromSchema, RelationsFromSchema } from "../Types"; + +/** + * Takes a schema result and calculates all the entity names, and relation names and attribute names per entity. + * Used by PAOHvis to show all possible options to choose from when adding a new PAOHvis visualisation or when filtering. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {EntitiesFromSchema} All entity names, and relation names and attribute names per entity. + */ +export function calculateAttributesAndRelations(schemaResult: SchemaGraph): EntitiesFromSchema { + const attributesPerEntity: Record<string, AttributeNames> = calculateAttributes( + schemaResult, + ); + const relationsPerEntity: Record<string, string[]> = calculateRelations(schemaResult); + + return { + entityNames: schemaResult.nodes.map((node) => node?.attributes?.name || 'ERROR'), + attributesPerEntity, + relationsPerEntity, + }; +} + + +/** + * Takes a schema result and calculates all the attribute names per entity. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {Record<string, AttributeNames>} All attribute names per entity. + */ +export function calculateAttributes(schemaResult: SchemaGraph): Record<string, AttributeNames> { + const attributesPerEntity: Record<string, AttributeNames> = {}; + // Go through each entity. + schemaResult.nodes.forEach((node) => { + if (node?.attributes?.name === undefined) { + console.error('ERROR: Node has no name attribute or name.', node); + return; + } + + // Extract the attribute names per datatype for each entity. + const textAttributeNames: string[] = []; + const boolAttributeNames: string[] = []; + const numberAttributeNames: string[] = []; + + node.attributes.attributes.forEach((attr) => { + if (attr.type == 'string') textAttributeNames.push(attr.name); + else if (attr.type == 'int' || attr.type == 'float') numberAttributeNames.push(attr.name); + else boolAttributeNames.push(attr.name); + }); + + // Create a new object with the arrays with attribute names per datatype. + attributesPerEntity[node.attributes.name] = { + textAttributeNames, + boolAttributeNames, + numberAttributeNames, + }; + }); + return attributesPerEntity; +} + +/** +* Takes a schema result and calculates all the relation names per entity. +* @param {SchemaResultType} schemaResult A new schema result from the backend. +* @returns {Record<string, AttributeNames>} All relation (from and to) names per entity. +*/ +export function calculateRelations(schemaResult: SchemaGraph): Record<string, string[]> { + const relationsPerEntity: Record<string, string[]> = {}; + // Go through each relation. + schemaResult.edges.forEach((edge) => { + if (edge?.attributes === undefined) { + console.error('ERROR: Edge has no attribute.', edge); + return; + } + + // Extract the from-node-name (collection name) from every relation. + if (relationsPerEntity[edge.attributes.from]) relationsPerEntity[edge.attributes.from].push(edge.attributes.collection); + else relationsPerEntity[edge.attributes.from] = [edge.attributes.collection]; + // Extract the to-node-name (collection name) from every relation. + if (relationsPerEntity[edge.attributes.to]) relationsPerEntity[edge.attributes.to].push(edge.attributes.collection); + else relationsPerEntity[edge.attributes.to] = [edge.attributes.collection]; + }); + return relationsPerEntity; +} + +/** + * Takes a schema result and calculates all the relation collection names, and relation names and attribute names per relation. + * Used by PAOHvis to show all possible options to choose from when adding a new PAOHvis visualisation or when filtering. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {EntitiesFromSchema} All entity names, and relation names and attribute names per entity. + */ +export function calculateAttributesFromRelation(schemaResult: SchemaGraph): RelationsFromSchema { + const relationCollections: string[] = []; + const attributesPerRelation: Record<string, AttributeNames> = {}; + const nameOfCollectionPerRelation: Record<string, string> = {}; + // Go through each relation. + schemaResult.edges.forEach((edge) => { + if (edge?.attributes === undefined) { + console.error('ERROR: Edge has no attribute.', edge); + return; + } + + if (!nameOfCollectionPerRelation[edge.attributes.collection]) { + relationCollections.push(edge.attributes.collection); + nameOfCollectionPerRelation[edge.attributes.collection] = `${edge.attributes.from}:${edge.attributes.to}:${edge.attributes.name}`; + } + // Extract the attribute names per datatype for each relation. + const textAttributeNames: string[] = []; + const boolAttributeNames: string[] = []; + const numberAttributeNames: string[] = []; + edge.attributes.attributes.forEach((attr: SchemaAttribute) => { + if (attr.type == 'string') textAttributeNames.push(attr.name); + else if (attr.type == 'int' || attr.type == 'float') numberAttributeNames.push(attr.name); + else boolAttributeNames.push(attr.name); + }); + + // Create a new object with the arrays with attribute names per datatype. + attributesPerRelation[edge.attributes.collection] = { + textAttributeNames, + boolAttributeNames, + numberAttributeNames, + }; + }); + return { + relationCollection: relationCollections, + relationNames: nameOfCollectionPerRelation, + attributesPerRelation: attributesPerRelation, + }; +} + +/** + * Filters duplicate elements from array with a hashtable. + * From https://stackoverflow.com/questions/9229645/remove-duplicate-values-from-js-array */ +export function uniq(element: number[]) { + const seen: Record<number, boolean> = {}; + return element.filter(function (item) { + return seen.hasOwnProperty(item) ? false : (seen[item] = true); + }); +} + +/** +* Calculate the width of the specified text. +* @param txt Text input as string. +* @param fontname Name of the font. +* @param fontsize Size of the fond in px. +* @param fontWeight The weight of the font. +* @returns {number} Width of the textfield in px. +*/ +export const getWidthOfText = ( + txt: string, + fontname: string, + fontsize: string, + fontWeight = 'normal', +): number => { + let c = document.createElement('canvas'); + let ctx = c.getContext('2d') as CanvasRenderingContext2D; + let fontspec = fontWeight + ' ' + fontsize + ' ' + fontname; + if (ctx.font !== fontspec) ctx.font = fontspec; + return ctx.measureText(txt).width; +}; \ No newline at end of file diff --git a/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx b/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx index 21e3687a7b4b51d1d391c7ff5dc5bd116aeca715..04ea7bf0c8887aeb015a14a35123cc2a9ba57fac 100644 --- a/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx @@ -19,7 +19,7 @@ import ToPlotDataParserUseCase from './utils/ToPlotDataParserUseCase'; import CalcXYMinMaxUseCase from './utils/CalcXYMinMaxUseCase'; import { ClassNameMap } from '@mui/material'; -import { NodeLinkResultType, isNodeLinkResult } from '../nodelink/ResultNodeLinkParserUseCase'; +import { NodeLinkResultType, isNodeLinkResult } from '../shared/ResultNodeLinkParserUseCase'; import CalcEntityAttrNamesFromSchemaUseCase from './utils/CalcEntityAttrNamesFromSchemaUseCase'; import CalcEntityAttrNamesFromResultUseCase from './utils/CalcEntityAttrNamesFromResultUseCase'; import { isSchemaResult } from '../shared/SchemaResultType'; diff --git a/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx index 5c23d9dfb13c01b10bade50d12c23f7ee9eeeab6..4f257a6af7a3753be55996c9f374bc2d77226e4a 100644 --- a/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx @@ -4,7 +4,7 @@ import { EntityWithAttributes, FSSConfigPanelProps, } from './Types'; -import { Link, Node } from '../../nodelink/ResultNodeLinkParserUseCase'; +import { Link, Node } from '../../shared/ResultNodeLinkParserUseCase'; import SemanticSubstratesViewModel from '../SemanticSubstratesViewModel'; /** Viewmodel for rendering config input fields for Faceted Semantic Substrate attributes. */ diff --git a/libs/shared/lib/vis/semanticsubstrates/configpanel/Types.tsx b/libs/shared/lib/vis/semanticsubstrates/configpanel/Types.tsx index 7956ba611639ffd0fd78c8540dc6337f7b5f0efa..3a7462d70c02e0b0505ecfe11b0962d9fa7c4a3e 100644 --- a/libs/shared/lib/vis/semanticsubstrates/configpanel/Types.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/configpanel/Types.tsx @@ -4,7 +4,7 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ -import { NodeLinkResultType } from "../../nodelink/ResultNodeLinkParserUseCase"; +import { NodeLinkResultType } from "../../shared/ResultNodeLinkParserUseCase"; import SemanticSubstratesViewModel from "../SemanticSubstratesViewModel"; /* An entity that has an attribute (Either a node with attributes or an edges with attributes) diff --git a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx index 92de4a05f6f11a94ae4f1a65b5c1c93ac0b8c1fb..f5149f537fa7466c34a26b14beb913e7e7a59211 100644 --- a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx @@ -4,7 +4,7 @@ import { } from '@graphpolaris/shared/lib/data-access/store'; import { useEffect, useRef, useState } from 'react'; import { AxisLabel, EntitiesFromSchema, MinMaxType, PlotSpecifications, PlotType, RelationType } from './Types'; -import { NodeLinkResultType, isNodeLinkResult } from '../nodelink/ResultNodeLinkParserUseCase'; +import { NodeLinkResultType, isNodeLinkResult } from '../shared/ResultNodeLinkParserUseCase'; import styles from './semanticsubstrates.module.scss'; import AddPlotButtonComponent from './subcomponents/AddPlotButtonComponent'; import SVGCheckboxesWithSemanticSubstrLabel from './subcomponents/SVGCheckBoxComponent'; @@ -179,8 +179,7 @@ const SemanticSubstrates = () => { * @param {PlotSpecifications[]} plotSpecs The plotspecs to filter the new plots with. * @param {NodeLinkResultType} queryResult The query result to apply the plot specs to. */ - function applyNewPlotSpecifications( - ): void { + function applyNewPlotSpecifications(): void { // Parse the incoming data to plotdata with the auto generated plot specifications const { plots, relations } = ToPlotDataParserUseCase.parseQueryResult( diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx index 076e744ecf7aabf67d97a8992ef0eca000acbe3a..f53468bc4fe77f355f9d5cbb3dcee2ebf4cdff93 100644 --- a/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx @@ -8,7 +8,7 @@ /* The comment above was added so the code coverage wouldn't count this file towards code coverage. * We do not test components/renderfunctions/styling files. * See testing plan for more details.*/ -import { Link, Node } from '../../nodelink/ResultNodeLinkParserUseCase'; +import { Link, Node } from '../../shared/ResultNodeLinkParserUseCase'; import { Button, MenuItem, Popover, TextField } from '@mui/material'; import React, { ReactElement } from 'react'; import { EntitiesFromSchema } from '../Types'; diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx index 87c1071af31f929269fa822e8d1fbb9484ffbe16..dac2a940f3786c24c4cf4de7660a6d2292f55dd8 100644 --- a/libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx @@ -4,7 +4,7 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ -import { NodeLinkResultType } from "../../nodelink/ResultNodeLinkParserUseCase"; +import { NodeLinkResultType } from "../../shared/ResultNodeLinkParserUseCase"; import { AxisLabel, PlotSpecifications } from "../Types"; /** UseCase for calculating default plots from node link query result data. */ diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx index 698cdc1592d1e2b0c3df3f5044bfddb2f4c80bd7..7120f0e0395e7ca7f23eaf5a90e7b97e56579af3 100644 --- a/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx @@ -4,7 +4,7 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ -import { NodeLinkResultType } from "../../nodelink/ResultNodeLinkParserUseCase"; +import { NodeLinkResultType } from "../../shared/ResultNodeLinkParserUseCase"; import { EntitiesFromSchema } from "../Types"; diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx index ba1e50404eb104291c86ca190b5d462230370aa6..7c09b475f5987a3d1a02e08a2a2b92788e32fac7 100644 --- a/libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx @@ -4,7 +4,7 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ import { GraphQueryResult } from '@graphpolaris/shared/lib/data-access'; -import { NodeLinkResultType, Node, ParseToUniqueEdges } from '../../nodelink/ResultNodeLinkParserUseCase'; +import { NodeLinkResultType, Node, ParseToUniqueEdges } from '../../shared/ResultNodeLinkParserUseCase'; import { AxisLabel, PlotInputData, diff --git a/libs/shared/lib/vis/shared/InputDataTypes.tsx b/libs/shared/lib/vis/shared/InputDataTypes.tsx index b675a1fe3c08b3c9d7a7e27c5607149e753ad906..fdc0a35a50a34e0802ef853ad87b393bb1f76754 100644 --- a/libs/shared/lib/vis/shared/InputDataTypes.tsx +++ b/libs/shared/lib/vis/shared/InputDataTypes.tsx @@ -6,10 +6,10 @@ import { AttributeCategory } from './Types'; /** Schema type, consist of nodes and edges */ -export type Schema = { - edges: Edge[]; - nodes: Node[]; -}; +// export type Schema = { DEPRECATED USE SCHEMAGRAPH INSTEAD +// edges: Edge[]; +// nodes: Node[]; +// }; /** Attribute type, consist of a name */ export type Attribute = { diff --git a/libs/shared/lib/vis/nodelink/ResultNodeLinkParserUseCase.tsx b/libs/shared/lib/vis/shared/ResultNodeLinkParserUseCase.tsx similarity index 91% rename from libs/shared/lib/vis/nodelink/ResultNodeLinkParserUseCase.tsx rename to libs/shared/lib/vis/shared/ResultNodeLinkParserUseCase.tsx index dc8cbdca68e19128d7a12f40d922e1631f321488..eadcc972c5380100828af81049c25e40e2978218 100644 --- a/libs/shared/lib/vis/nodelink/ResultNodeLinkParserUseCase.tsx +++ b/libs/shared/lib/vis/shared/ResultNodeLinkParserUseCase.tsx @@ -3,8 +3,8 @@ * Utrecht University within the Software Project course. * © Copyright Utrecht University (Department of Information and Computing Sciences) */ -import { GraphType, LinkType, NodeType } from './Types'; -import { Edge, GraphQueryResult } from '../../data-access/store'; +import { GraphType, LinkType, NodeType } from '../nodelink/Types'; +import { Edge, Node, GraphQueryResult } from '../../data-access/store'; /** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */ /** @@ -13,31 +13,34 @@ import { Edge, GraphQueryResult } from '../../data-access/store'; * © Copyright Utrecht University (Department of Information and Computing Sciences) */ /** A node link data-type for a query result object from the backend. */ -export type NodeLinkResultType = { - nodes: Node[]; - edges: Link[]; - mlEdges?: Link[]; -}; +// export type NodeLinkResultType = { DEPRECATED USE GraphQueryResult +// nodes: Node[]; +// edges: Link[]; +// mlEdges?: Link[]; +// }; /** Typing for nodes and links in the node-link result. Nodes and links should always have an id and attributes. */ -export interface AxisType { - id: string; - attributes: Record<string, any>; - mldata?: Record<string, string[]> | number; // This is shortest path data . This name is needs to be changed together with backend TODO: Change this. -} +// export interface AxisType { +// id: string; +// attributes: Record<string, any>; +// mldata?: Record<string, string[]> | number; // This is shortest path data . This name is needs to be changed together with backend TODO: Change this. +// } /** Typing for a node in the node-link result */ -export type Node = AxisType; +// export type Node = AxisType; /** Typing for a link in the node-link result */ -export interface Link extends AxisType { - from: string; - to: string; -} +// export interface Link extends AxisType { +// from: string; +// to: string; +// } + +export type AxisType = Node | Edge; /** Gets the group to which the node/edge belongs */ export function getGroupName(axisType: AxisType): string { - return axisType.id.split('/')[0]; + // FIXME: only works in arangodb + return (axisType.id as string).split('/')[0]; } /** Returns true if the given id belongs to the target group. */ @@ -54,7 +57,7 @@ export function isNotInGroup( */ export function isNodeLinkResult( jsonObject: any -): jsonObject is NodeLinkResultType { +): jsonObject is GraphQueryResult { if ( typeof jsonObject === 'object' && jsonObject !== null && @@ -77,7 +80,7 @@ export function isNodeLinkResult( /** Returns a record with a type of the nodes as key and a number that represents how many times this type is present in the nodeLinkResult as value. */ export function getNodeTypes( - nodeLinkResult: NodeLinkResultType + nodeLinkResult: GraphQueryResult ): Record<string, number> { const types: Record<string, number> = {};