From a49259dbe1280e657e8d060e00c0e15c4bd9fdb8 Mon Sep 17 00:00:00 2001 From: Milho001 <l.milhomemfrancochristino@uu.nl> Date: Tue, 20 Jun 2023 09:58:27 +0000 Subject: [PATCH] feat(paohvis & semsub): inclusion of paohvis and a basic/partial implementation of semantic substrates --- .vscode/launch.json | 15 + libs/shared/lib/data-access/api/query.ts | 2 +- .../store/graphQueryResultSlice.ts | 4 +- .../data-access/store/querybuilderSlice.ts | 24 +- .../lib/data-access/store/schemaSlice.spec.ts | 35 +- .../lib/data-access/store/schemaSlice.ts | 21 +- .../query-result/bigMockQueryResults.ts | 2 +- .../query-result/mockLargeQueryResults.ts | 2 + .../query-result/smallFlightsQueryResults.ts | 2 +- .../lib/mock-data/schema/moviesSchemaRaw.ts | 2 +- .../mock-data/schema/northwindSchemaRaw.ts | 2 +- .../lib/mock-data/schema/simpleAirportRaw.ts | 43 + libs/shared/lib/mock-data/schema/simpleRaw.ts | 6 +- libs/shared/lib/model/backend/index.ts | 1 - libs/shared/lib/model/general.ts | 26 - libs/shared/lib/model/graphology.ts | 10 - libs/shared/lib/model/index.ts | 2 - libs/shared/lib/model/reactflow.ts | 1 - .../graph/graphology/JSONParser.tsx | 85 -- libs/shared/lib/querybuilder/index.ts | 3 +- .../BackendQueryFormat.tsx | 16 + .../querybuilder/model/graphology/index.ts | 2 + .../{graph => model}/graphology/model.ts | 2 +- .../{graph => model}/graphology/utils.ts | 4 +- libs/shared/lib/querybuilder/model/index.ts | 4 + .../lib/querybuilder/model/logic/index.ts | 1 + .../{graph => model}/logic/queryFunctions.tsx | 0 .../{graph => model}/reactflow/handles.tsx | 0 .../lib/querybuilder/model/reactflow/index.ts | 4 + .../{graph => model}/reactflow/model.tsx | 0 .../{graph => model}/reactflow/pillHandles.ts | 0 .../{graph => model}/reactflow/utils.ts | 0 .../panel/querybuilder.stories.tsx | 5 +- .../lib/querybuilder/panel/querybuilder.tsx | 10 +- .../panel/shemaquerybuilder.stories.tsx | 6 +- .../attributepill-full.stories.tsx | 6 +- .../attributepill/attributepill.tsx | 3 +- .../attributepill/select-component.tsx | 2 +- .../entitypill/entitypill-full.stories.tsx | 6 +- .../entitypill/entitypill.stories.tsx | 2 +- .../customFlowPills/entitypill/entitypill.tsx | 3 +- .../functionpill/functionpill.stories.tsx | 6 +- .../functionpill/functionpill.tsx | 8 +- .../modifierpill/mopdifierpill.stories.tsx | 7 +- ...sx => relation-full_reactflow.stories.tsx} | 5 +- .../relationpill/relationpill copy.txt | 242 ---- .../relationpill.module copy.scss | 304 ----- .../relationpill/relationpill.stories.tsx | 2 +- .../relationpill/relationpill.tsx | 5 +- .../lib/querybuilder/query-utils/index.ts | 2 +- .../querybuilder/query-utils/query-utils.ts | 11 +- libs/shared/lib/schema/index.ts | 2 + .../schema.ts => schema/model/FromBackend.ts} | 18 +- libs/shared/lib/schema/model/graphology.ts | 13 + libs/shared/lib/schema/model/index.ts | 3 + .../Types.tsx => model/reactflow.tsx} | 8 +- .../shared/lib/schema/panel/SchemaComponent.t | 184 --- .../schema/pills/nodes/entity/entity-node.tsx | 13 +- ...attribute-analytics-popup-menu.stories.tsx | 12 +- .../popup/attribute-analytics-popup-menu.tsx | 2 +- .../nodes/popup/node-quality-entity-popup.tsx | 2 +- .../popup/node-quality-relation-popup.tsx | 2 +- ...attribute-analytics-popup-menu.stories.tsx | 12 +- .../attribute-analytics-popup-menu.tsx | 2 +- .../nodes/popup/popupmenus/filterbar.tsx | 2 +- .../popupmenus/node-quality-entity-popup.tsx | 2 +- .../node-quality-relation-popup.tsx | 2 +- .../nodes/popup/popupmenus/searchbar.tsx | 2 +- .../pills/nodes/relation/relation-node.tsx | 14 +- .../schema-usecases-edge-deleteme.ts | 305 ----- .../schema-utils/schema-usecases.spec.ts | 5 +- .../schema/schema-utils/schema-usecases.ts | 4 +- .../lib/schema/schema-utils/schema-utils.ts | 26 +- .../lib/vis/nodelink/NodeLinkViewModel.tsx | 2 +- libs/shared/lib/vis/nodelink/nodelinkvis.tsx | 2 +- libs/shared/lib/vis/paohvis/Types.tsx | 131 ++ .../paohvis/components/HyperEdgesRange.tsx | 159 +++ .../paohvis/components/MakePaohvisMenu.scss | 33 + .../paohvis/components/MakePaohvisMenu.tsx | 600 +++++++++ .../PaohvisFilterComponent.module.scss | 57 + .../PaohvisFilterComponent.module.scss.d.ts | 7 + .../components/PaohvisFilterComponent.tsx | 428 ++++++ .../vis/paohvis/components/RowLabelColumn.tsx | 82 ++ .../lib/vis/paohvis/components/Tooltip.scss | 20 + .../lib/vis/paohvis/components/Tooltip.tsx | 30 + .../vis/paohvis/models/FilterPredicates.tsx | 33 + .../vis/paohvis/models/PaohvisHolder.test.tsx | 32 + .../lib/vis/paohvis/models/PaohvisHolder.tsx | 59 + .../vis/paohvis/models/PaohvisListener.tsx | 16 + .../lib/vis/paohvis/paohvis.module.scss | 55 + .../lib/vis/paohvis/paohvis.module.scss.d.ts | 9 + .../lib/vis/paohvis/paohvis.stories.tsx | 133 +- libs/shared/lib/vis/paohvis/paohvis.tsx | 658 +++++++++- .../lib/vis/paohvis/paohvisViewModel.tsx | 0 .../paohvis/utils/AttributesFilterUseCase.tsx | 141 ++ .../utils/CalcEntitiesFromQueryResult.tsx | 23 + ...EntityAttrAndRelNamesFromSchemaUseCase.tsx | 134 ++ .../lib/vis/paohvis/utils/SortUseCase.tsx | 135 ++ .../utils/ToPaohvisDataParserUsecase.tsx | 355 +++++ libs/shared/lib/vis/paohvis/utils/utils.tsx | 158 +++ .../SemanticSubstratesViewModel.t | 1156 +++++++++++++++++ .../SemanticSubstratesViewModel.tsx | 68 + .../lib/vis/semanticsubstrates/Types.tsx | 104 ++ .../SemanticSubstrateConfigPanel.tsx | 80 ++ .../SemanticSubstratesConfigPanelViewModel.t | 280 ++++ ...SemanticSubstratesConfigPanelViewModel.tsx | 355 +++++ .../semanticsubstrates/configpanel/Types.tsx | 23 + .../semanticsubstrates.module.scss | 106 ++ .../semanticsubstrates.module.scss.d.ts | 14 + .../semanticsubstrates.stories.tsx | 56 +- .../semanticsubstrates/semanticsubstrates.tsx | 584 ++++++++- .../AddPlotButtonComponent.module.scss | 63 + .../AddPlotButtonComponent.module.scss.d.ts | 9 + .../subcomponents/AddPlotButtonComponent.tsx | 80 ++ .../subcomponents/AddPlotPopup.tsx | 216 +++ .../subcomponents/BrushComponent.tsx | 246 ++++ .../LinesBetweenPlotsComponent.tsx | 113 ++ .../OptimizedAutocomplete.module.scss | 7 + .../OptimizedAutocomplete.module.scss.d.ts | 4 + .../subcomponents/OptimizedAutocomplete.tsx | 154 +++ .../PlotAxisLabelStyles.module.css | 26 + .../PlotAxisLabelStyles.module.css.d.ts | 5 + .../subcomponents/PlotAxisLabelsComponent.tsx | 116 ++ .../subcomponents/PlotComponent.tsx | 223 ++++ .../subcomponents/PlotTitleComponent.tsx | 207 +++ .../subcomponents/PlotTitleStyles.module.css | 22 + .../PlotTitleStyles.module.css.d.ts | 4 + .../subcomponents/SVGCheckBoxComponent.tsx | 155 +++ .../CalcConnectionLinePositionsUseCase.tsx | 169 +++ .../utils/CalcDefaultPlotSpecsUseCase.tsx | 70 + .../semanticsubstrates/utils/CalcDistance.tsx | 20 + .../CalcEntityAttrNamesFromResultUseCase.tsx | 36 + .../CalcEntityAttrNamesFromSchemaUseCase.tsx | 49 + .../utils/CalcScaledPositionsUseCase.tsx | 73 ++ .../utils/CalcXYMinMaxUseCase.tsx | 71 + .../utils/FilterUseCase.tsx | 140 ++ .../semanticsubstrates/utils/RotateVec.tsx | 23 + .../utils/ToPlotDataParserUseCase.tsx | 267 ++++ libs/shared/lib/vis/shared/InputDataTypes.tsx | 8 +- .../ResultNodeLinkParserUseCase.tsx | 43 +- libs/shared/package.json | 4 +- pnpm-lock.yaml | 595 ++++++--- 142 files changed, 9468 insertions(+), 1659 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 libs/shared/lib/mock-data/schema/simpleAirportRaw.ts delete mode 100644 libs/shared/lib/model/backend/index.ts delete mode 100644 libs/shared/lib/model/general.ts delete mode 100644 libs/shared/lib/model/graphology.ts delete mode 100644 libs/shared/lib/model/index.ts delete mode 100644 libs/shared/lib/model/reactflow.ts delete mode 100644 libs/shared/lib/querybuilder/graph/graphology/JSONParser.tsx rename libs/shared/lib/querybuilder/{query-utils => model}/BackendQueryFormat.tsx (88%) create mode 100644 libs/shared/lib/querybuilder/model/graphology/index.ts rename libs/shared/lib/querybuilder/{graph => model}/graphology/model.ts (96%) rename libs/shared/lib/querybuilder/{graph => model}/graphology/utils.ts (97%) create mode 100644 libs/shared/lib/querybuilder/model/index.ts create mode 100644 libs/shared/lib/querybuilder/model/logic/index.ts rename libs/shared/lib/querybuilder/{graph => model}/logic/queryFunctions.tsx (100%) rename libs/shared/lib/querybuilder/{graph => model}/reactflow/handles.tsx (100%) create mode 100644 libs/shared/lib/querybuilder/model/reactflow/index.ts rename libs/shared/lib/querybuilder/{graph => model}/reactflow/model.tsx (100%) rename libs/shared/lib/querybuilder/{graph => model}/reactflow/pillHandles.ts (100%) rename libs/shared/lib/querybuilder/{graph => model}/reactflow/utils.ts (100%) rename libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/{relation-full.stories.tsx => relation-full_reactflow.stories.tsx} (89%) delete mode 100644 libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill copy.txt delete mode 100644 libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.module copy.scss create mode 100644 libs/shared/lib/schema/index.ts rename libs/shared/lib/{model/backend/schema.ts => schema/model/FromBackend.ts} (61%) create mode 100644 libs/shared/lib/schema/model/graphology.ts create mode 100644 libs/shared/lib/schema/model/index.ts rename libs/shared/lib/schema/{schema-utils/Types.tsx => model/reactflow.tsx} (94%) delete mode 100644 libs/shared/lib/schema/panel/SchemaComponent.t delete mode 100644 libs/shared/lib/schema/schema-utils/schema-usecases-edge-deleteme.ts create mode 100644 libs/shared/lib/vis/paohvis/Types.tsx create mode 100644 libs/shared/lib/vis/paohvis/components/HyperEdgesRange.tsx create mode 100644 libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.scss create mode 100644 libs/shared/lib/vis/paohvis/components/MakePaohvisMenu.tsx create mode 100644 libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss create mode 100644 libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.module.scss.d.ts create mode 100644 libs/shared/lib/vis/paohvis/components/PaohvisFilterComponent.tsx create mode 100644 libs/shared/lib/vis/paohvis/components/RowLabelColumn.tsx create mode 100644 libs/shared/lib/vis/paohvis/components/Tooltip.scss create mode 100644 libs/shared/lib/vis/paohvis/components/Tooltip.tsx create mode 100644 libs/shared/lib/vis/paohvis/models/FilterPredicates.tsx create mode 100644 libs/shared/lib/vis/paohvis/models/PaohvisHolder.test.tsx create mode 100644 libs/shared/lib/vis/paohvis/models/PaohvisHolder.tsx create mode 100644 libs/shared/lib/vis/paohvis/models/PaohvisListener.tsx create mode 100644 libs/shared/lib/vis/paohvis/paohvis.module.scss create mode 100644 libs/shared/lib/vis/paohvis/paohvis.module.scss.d.ts create mode 100644 libs/shared/lib/vis/paohvis/paohvisViewModel.tsx create mode 100644 libs/shared/lib/vis/paohvis/utils/AttributesFilterUseCase.tsx create mode 100644 libs/shared/lib/vis/paohvis/utils/CalcEntitiesFromQueryResult.tsx create mode 100644 libs/shared/lib/vis/paohvis/utils/CalcEntityAttrAndRelNamesFromSchemaUseCase.tsx create mode 100644 libs/shared/lib/vis/paohvis/utils/SortUseCase.tsx create mode 100644 libs/shared/lib/vis/paohvis/utils/ToPaohvisDataParserUsecase.tsx create mode 100644 libs/shared/lib/vis/paohvis/utils/utils.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.t create mode 100644 libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/Types.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstrateConfigPanel.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.t create mode 100644 libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/configpanel/Types.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss create mode 100644 libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss.d.ts create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss.d.ts create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/BrushComponent.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/LinesBetweenPlotsComponent.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss.d.ts create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css.d.ts create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelsComponent.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotComponent.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleComponent.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css.d.ts create mode 100644 libs/shared/lib/vis/semanticsubstrates/subcomponents/SVGCheckBoxComponent.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/CalcConnectionLinePositionsUseCase.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/CalcDistance.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromSchemaUseCase.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/CalcScaledPositionsUseCase.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/CalcXYMinMaxUseCase.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/FilterUseCase.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/RotateVec.tsx create mode 100644 libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx rename libs/shared/lib/vis/{nodelink => shared}/ResultNodeLinkParserUseCase.tsx (91%) diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..fe7bb8528 --- /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/api/query.ts b/libs/shared/lib/data-access/api/query.ts index 36aeeb0b9..461ab0d04 100644 --- a/libs/shared/lib/data-access/api/query.ts +++ b/libs/shared/lib/data-access/api/query.ts @@ -1,6 +1,6 @@ // All database related API calls -import { BackendQueryFormat } from "../../querybuilder/query-utils/BackendQueryFormat"; +import { BackendQueryFormat } from "../../querybuilder/model/BackendQueryFormat"; import { useAuthorizationCache, useSessionCache } from "../store"; export const useQueryAPI = (domain: string) => { diff --git a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts index 68ed96218..1835b1697 100644 --- a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts +++ b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts @@ -8,6 +8,7 @@ export interface GraphQueryResultFromBackend { }[]; edges: { + id: string; attributes: { [key: string]: unknown }; from: string; to: string; @@ -27,6 +28,7 @@ export interface Edge { attributes: { [key: string]: unknown }; from: string; to: string; + id?: string; /* type: string; */ } @@ -62,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/data-access/store/querybuilderSlice.ts b/libs/shared/lib/data-access/store/querybuilderSlice.ts index 26488a2da..61f6a4a9e 100644 --- a/libs/shared/lib/data-access/store/querybuilderSlice.ts +++ b/libs/shared/lib/data-access/store/querybuilderSlice.ts @@ -3,14 +3,14 @@ import type { RootState } from './store'; import { MultiGraph } from 'graphology'; import { Attributes, SerializedGraph } from 'graphology-types'; import { + QueryMultiGraphology, QueryMultiGraph, - QueryMultiGraphExport, -} from '@graphpolaris/shared/lib/querybuilder/graph/graphology/utils'; +} from '@graphpolaris/shared/lib/querybuilder/model/graphology/utils'; import { json } from 'd3'; // Define the initial state using that type export const initialState = { - graphologySerialized: new QueryMultiGraph().export(), + graphologySerialized: new QueryMultiGraphology().export(), // schemaLayout: 'Graphology_noverlap', }; @@ -23,15 +23,15 @@ export const querybuilderSlice = createSlice({ state, action: PayloadAction<SerializedGraph<Attributes, Attributes, Attributes>> ) => { - state.graphologySerialized = QueryMultiGraph.from( + state.graphologySerialized = QueryMultiGraphology.from( action.payload - ).export() as QueryMultiGraphExport; + ).export() as QueryMultiGraph; }, updateQBAttributeOperator: ( state, action: PayloadAction<{ id: string; operator: string }> ) => { - const graph = QueryMultiGraph.from(state.graphologySerialized); + const graph = QueryMultiGraphology.from(state.graphologySerialized); graph.setNodeAttribute( action.payload.id, 'operator', @@ -43,12 +43,12 @@ export const querybuilderSlice = createSlice({ state, action: PayloadAction<{ id: string; value: string }> ) => { - const graph = QueryMultiGraph.from(state.graphologySerialized); + const graph = QueryMultiGraphology.from(state.graphologySerialized); graph.setNodeAttribute(action.payload.id, 'value', action.payload.value); state.graphologySerialized = graph.export(); }, clearQB: (state) => { - state.graphologySerialized = new QueryMultiGraph().export(); + state.graphologySerialized = new QueryMultiGraphology().export(); }, // addQuerybuilderNode: ( @@ -75,10 +75,10 @@ export const { /** Select the querybuilder nodes in serialized fromat */ export const selectQuerybuilderGraphology = ( state: RootState -): QueryMultiGraph => { +): QueryMultiGraphology => { // This is really weird but for some reason all the attributes appeared as read-only otherwise - let ret = new QueryMultiGraph(); + let ret = new QueryMultiGraphology(); ret.import(MultiGraph.from(state.querybuilder.graphologySerialized).export()); return ret; }; @@ -86,9 +86,9 @@ export const selectQuerybuilderGraphology = ( /** Select the querybuilder nodes and convert it to a graphology object */ export const selectQuerybuilderGraph = ( state: RootState -): QueryMultiGraphExport => { +): QueryMultiGraph => { // This is really weird but for some reason all the attributes appeared as read-only otherwise - return state.querybuilder.graphologySerialized as QueryMultiGraphExport; + return state.querybuilder.graphologySerialized as QueryMultiGraph; }; /** Select the querybuilder nodes and convert it to a graphology object */ diff --git a/libs/shared/lib/data-access/store/schemaSlice.spec.ts b/libs/shared/lib/data-access/store/schemaSlice.spec.ts index 1cbc52aeb..7d37c3860 100644 --- a/libs/shared/lib/data-access/store/schemaSlice.spec.ts +++ b/libs/shared/lib/data-access/store/schemaSlice.spec.ts @@ -1,13 +1,4 @@ -import Graph from 'graphology'; -import AbstractGraph, { DirectedGraph, MultiGraph } from 'graphology'; -import { useSchemaGraphology } from '..'; -import reducer, { - schemaGraphology, - setSchema, - initialState, -} from './schemaSlice'; -// import { deleteBook, updateBook, addNewBook } from '../redux/bookSlice'; -import { store } from './store'; +import { MultiGraph } from 'graphology'; import { assert, describe, expect, it } from 'vitest'; describe('SchemaSlice Tests', () => { @@ -32,22 +23,22 @@ describe('SchemaSlice Tests', () => { expect(graphReloaded).toStrictEqual(graph); }); - it('get the initial state', () => { - expect(initialState); - }); + // it('get the initial state', () => { + // expect(initialState); + // }); - it('should return the initial state', () => { - const state = store.getState(); + // it('should return the initial state', () => { + // const state = store.getState(); - const schema = state.schema; - expect(schema); + // const schema = state.schema; + // expect(schema); - const graph = MultiGraph.from(schema.graphologySerialized); + // const graph = MultiGraph.from(schema.graphologySerialized); - // console.log(graph); - // console.log(initialState); - expect(graph); - }); + // // console.log(graph); + // // console.log(initialState); + // expect(graph); + // }); // it('should handle a todo being added to an empty list', () => { // let state = store.getState().schema; diff --git a/libs/shared/lib/data-access/store/schemaSlice.ts b/libs/shared/lib/data-access/store/schemaSlice.ts index 14a5e8446..beccab8e2 100644 --- a/libs/shared/lib/data-access/store/schemaSlice.ts +++ b/libs/shared/lib/data-access/store/schemaSlice.ts @@ -1,17 +1,17 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import Graph, { MultiGraph } from 'graphology'; -import { SerializedGraph } from 'graphology-types'; -import { SchemaFromBackend } from '@graphpolaris/shared/lib/model/backend'; import type { RootState } from './store'; import { AllLayoutAlgorithms, CytoscapeLayoutAlgorithms } from '@graphpolaris/shared/lib/graph-layout'; -import { QueryMultiGraph } from '@graphpolaris/shared/lib/querybuilder/graph/graphology/utils'; import { SchemaUtils } from '../../schema/schema-utils'; +import { SchemaFromBackend, SchemaGraph, SchemaGraphology } from '../../schema'; /**************************************************************** */ // Define the initial state using that type -export const initialState = { - graphologySerialized: new MultiGraph().export(), +export const initialState: { + graphologySerialized: SchemaGraph; + layoutName: AllLayoutAlgorithms; +} = { + graphologySerialized: new SchemaGraphology().export(), // layoutName: 'Cytoscape_fcose', layoutName: CytoscapeLayoutAlgorithms.KLAY as AllLayoutAlgorithms, }; @@ -21,7 +21,7 @@ export const schemaSlice = createSlice({ // `createSlice` will infer the state type from the `initialState` argument initialState, reducers: { - setSchema: (state, action: PayloadAction<SerializedGraph>) => { + setSchema: (state, action: PayloadAction<SchemaGraph>) => { console.log('setSchema', action); state.graphologySerialized = action.payload; }, @@ -80,16 +80,15 @@ export const { readInSchemaFromBackend, setSchema } = schemaSlice.actions; * */ export const schemaGraphology = (state: RootState) => { // This is really weird but for some reason all the attributes appeared as read-only otherwise - let ret = new MultiGraph(); - ret.import(MultiGraph.from(state.schema.graphologySerialized).export()); + let ret = new SchemaGraphology(); + ret.import(SchemaGraphology.from(state.schema.graphologySerialized).export()); return ret; }; /** * Select the schema * */ -export const schemaGraph = (state: RootState) => { - // This is really weird but for some reason all the attributes appeared as read-only otherwise +export const schemaGraph = (state: RootState): SchemaGraph => { return state.schema.graphologySerialized; }; diff --git a/libs/shared/lib/mock-data/query-result/bigMockQueryResults.ts b/libs/shared/lib/mock-data/query-result/bigMockQueryResults.ts index 66d8770d4..49bef38b7 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/mockLargeQueryResults.ts b/libs/shared/lib/mock-data/query-result/mockLargeQueryResults.ts index d21140f2d..34044c680 100644 --- a/libs/shared/lib/mock-data/query-result/mockLargeQueryResults.ts +++ b/libs/shared/lib/mock-data/query-result/mockLargeQueryResults.ts @@ -4,6 +4,8 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ +import { GraphQueryResultFromBackend } from "../../data-access/store/graphQueryResultSlice"; + export const mockLargeQueryResults: GraphQueryResultFromBackend = { "edges": [ { diff --git a/libs/shared/lib/mock-data/query-result/smallFlightsQueryResults.ts b/libs/shared/lib/mock-data/query-result/smallFlightsQueryResults.ts index 1fcaff929..0d58608d9 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/moviesSchemaRaw.ts b/libs/shared/lib/mock-data/schema/moviesSchemaRaw.ts index 4e37819de..7857f98c0 100644 --- a/libs/shared/lib/mock-data/schema/moviesSchemaRaw.ts +++ b/libs/shared/lib/mock-data/schema/moviesSchemaRaw.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'; export const movieSchemaRaw: SchemaFromBackend = { nodes: [ diff --git a/libs/shared/lib/mock-data/schema/northwindSchemaRaw.ts b/libs/shared/lib/mock-data/schema/northwindSchemaRaw.ts index 10ae8344f..529dbfa04 100644 --- a/libs/shared/lib/mock-data/schema/northwindSchemaRaw.ts +++ b/libs/shared/lib/mock-data/schema/northwindSchemaRaw.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'; export const northwindSchemaRaw: SchemaFromBackend = { nodes: [ diff --git a/libs/shared/lib/mock-data/schema/simpleAirportRaw.ts b/libs/shared/lib/mock-data/schema/simpleAirportRaw.ts new file mode 100644 index 000000000..a8524c549 --- /dev/null +++ b/libs/shared/lib/mock-data/schema/simpleAirportRaw.ts @@ -0,0 +1,43 @@ +import { SchemaFromBackend } from '../../schema'; +import { SchemaUtils } from '../../schema/schema-utils'; + +export const simpleSchemaAirportRaw: SchemaFromBackend = { + nodes: [ + { + name: 'airports', + attributes: [ + { name: 'city', type: 'string' }, + { name: 'country', type: 'string' }, + { name: 'lat', type: 'float' }, + { name: 'long', type: 'float' }, + { name: 'name', type: 'string' }, + { name: 'state', type: 'string' }, + { name: 'vip', type: 'bool' }, + ], + } + ], + edges: [ + { + name: 'flights', + from: 'airports', + to: 'airports', + collection: 'flights', + attributes: [ + { name: 'ArrTime', type: 'int' }, + { name: 'ArrTimeUTC', type: 'string' }, + { name: 'Day', type: 'int' }, + { name: 'DayOfWeek', type: 'int' }, + { name: 'DepTime', type: 'int' }, + { name: 'DepTimeUTC', type: 'string' }, + { name: 'Distance', type: 'int' }, + { name: 'FlightNum', type: 'int' }, + { name: 'Month', type: 'int' }, + { name: 'TailNum', type: 'string' }, + { name: 'UniqueCarrier', type: 'string' }, + { name: 'Year', type: 'int' }, + ], + } + ], +}; + +export const simpleSchemaAirport = SchemaUtils.schemaBackend2Graphology(simpleSchemaAirportRaw); diff --git a/libs/shared/lib/mock-data/schema/simpleRaw.ts b/libs/shared/lib/mock-data/schema/simpleRaw.ts index df6855fff..5444e1f57 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/model/backend/index.ts b/libs/shared/lib/model/backend/index.ts deleted file mode 100644 index 5d5aa13bd..000000000 --- a/libs/shared/lib/model/backend/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './schema'; \ No newline at end of file diff --git a/libs/shared/lib/model/general.ts b/libs/shared/lib/model/general.ts deleted file mode 100644 index b57417b67..000000000 --- a/libs/shared/lib/model/general.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Edge } from "reactflow"; - -/** - * Point that has an x and y coordinate - */ -export interface Point { - x: number; - y: number; -} - -/** - * Bounding box described by a top left and bottom right coordinate - */ -export interface BoundingBox { - topLeft: Point; - bottomRight: Point; -} - -/** - * List of schema elements for react flow - */ -export type SchemaElements = { - nodes: Node[]; - edges: Edge[]; - selfEdges: Edge[]; -}; \ No newline at end of file diff --git a/libs/shared/lib/model/graphology.ts b/libs/shared/lib/model/graphology.ts deleted file mode 100644 index 48b99a327..000000000 --- a/libs/shared/lib/model/graphology.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Attributes as GAttributes, NodeEntry, EdgeEntry } from "graphology-types"; - -/** Attribute type, consist of a name */ -export type Attributes = GAttributes & { - name: string; - type: 'string' | 'int' | 'bool' | 'float'; -}; - -export type Node = NodeEntry<Attributes>; -export type Edge = EdgeEntry<Attributes, Attributes>; \ No newline at end of file diff --git a/libs/shared/lib/model/index.ts b/libs/shared/lib/model/index.ts deleted file mode 100644 index 8e9856050..000000000 --- a/libs/shared/lib/model/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './general'; -export * from './graphology'; \ No newline at end of file diff --git a/libs/shared/lib/model/reactflow.ts b/libs/shared/lib/model/reactflow.ts deleted file mode 100644 index 8b1378917..000000000 --- a/libs/shared/lib/model/reactflow.ts +++ /dev/null @@ -1 +0,0 @@ - diff --git a/libs/shared/lib/querybuilder/graph/graphology/JSONParser.tsx b/libs/shared/lib/querybuilder/graph/graphology/JSONParser.tsx deleted file mode 100644 index 7d645f745..000000000 --- a/libs/shared/lib/querybuilder/graph/graphology/JSONParser.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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) - */ - -/** JSON query format used to send a query to the backend. */ -export interface TranslatedJSONQuery { - return: { - entities: number[]; - relations: number[]; - groupBys: number[]; - }; - entities: Entity[]; - relations: Relation[]; - groupBys: GroupBy[]; - machineLearning: MachineLearning[]; - limit: number; -} - -/** Interface for an entity in the JSON for the query. */ -export interface Entity { - name: string; - ID: number; - constraints: Constraint[]; -} - -/** Interface for an relation in the JSON for the query. */ -export interface Relation { - name: string; - ID: number; - fromType: string; - fromID: number; - toType: string; - toID: number; - depth: { min: number; max: number }; - constraints: Constraint[]; -} - -/** - * Constraint datatypes created from the attributes of a relation or entity. - * - * string MatchTypes: exact/contains/startswith/endswith. - * int MatchTypes: GT/LT/EQ. - * bool MatchTypes: EQ/NEQ. - */ -export interface Constraint { - attribute: string; - dataType: string; - - matchType: string; - value: string; -} - -/** Interface for a function in the JSON for the query. */ -export interface GroupBy { - ID: number; - - groupType: string; - groupID: number[]; - groupAttribute: string; - - byType: string; - byID: number[]; - byAttribute: string; - - appliedModifier: string; - relationID: number; - constraints: Constraint[]; -} - -/** Interface for Machine Learning algorithm */ -export interface MachineLearning { - ID?: number; - queuename: string; - parameters: string[]; -} -/** Interface for what the JSON needs for link predicition */ -export interface LinkPrediction { - queuename: string; - parameters: { - key: string; - value: string; - }[]; -} diff --git a/libs/shared/lib/querybuilder/index.ts b/libs/shared/lib/querybuilder/index.ts index 35906bcea..f0c630f2d 100644 --- a/libs/shared/lib/querybuilder/index.ts +++ b/libs/shared/lib/querybuilder/index.ts @@ -1,3 +1,4 @@ export * from './panel'; export * from './pills'; -export * from './query-utils'; \ No newline at end of file +export * from './query-utils'; +export * from './model'; \ No newline at end of file diff --git a/libs/shared/lib/querybuilder/query-utils/BackendQueryFormat.tsx b/libs/shared/lib/querybuilder/model/BackendQueryFormat.tsx similarity index 88% rename from libs/shared/lib/querybuilder/query-utils/BackendQueryFormat.tsx rename to libs/shared/lib/querybuilder/model/BackendQueryFormat.tsx index 4866d7412..d13b4b6f7 100644 --- a/libs/shared/lib/querybuilder/query-utils/BackendQueryFormat.tsx +++ b/libs/shared/lib/querybuilder/model/BackendQueryFormat.tsx @@ -95,6 +95,7 @@ export interface MachineLearning { queuename: string; parameters: string[]; } + /** Interface for what the JSON needs for link predicition */ export interface LinkPrediction { queuename: string; @@ -110,3 +111,18 @@ export interface ModifierStruct { selectedTypeID: number; attributeIndex: number; } + + +/** JSON query format used to send a query to the backend. */ +export interface TranslatedJSONQuery { + return: { + entities: number[]; + relations: number[]; + groupBys: number[]; + }; + entities: Entity[]; + relations: Relation[]; + groupBys: GroupBy[]; + machineLearning: MachineLearning[]; + limit: number; +} \ No newline at end of file diff --git a/libs/shared/lib/querybuilder/model/graphology/index.ts b/libs/shared/lib/querybuilder/model/graphology/index.ts new file mode 100644 index 000000000..50589a9b7 --- /dev/null +++ b/libs/shared/lib/querybuilder/model/graphology/index.ts @@ -0,0 +1,2 @@ +export * from './model' +export * from './utils' \ No newline at end of file diff --git a/libs/shared/lib/querybuilder/graph/graphology/model.ts b/libs/shared/lib/querybuilder/model/graphology/model.ts similarity index 96% rename from libs/shared/lib/querybuilder/graph/graphology/model.ts rename to libs/shared/lib/querybuilder/model/graphology/model.ts index 194ed07c7..de7c52ad5 100644 --- a/libs/shared/lib/querybuilder/graph/graphology/model.ts +++ b/libs/shared/lib/querybuilder/model/graphology/model.ts @@ -15,7 +15,7 @@ import { store, } from '@graphpolaris/shared/lib/data-access'; import { MultiGraph } from 'graphology'; -import { calcWidthHeightOfPill } from '@graphpolaris/shared/lib/querybuilder/graph/graphology/utils'; +import { calcWidthHeightOfPill } from '@graphpolaris/shared/lib/querybuilder/model/graphology/utils'; import './utils'; // export interface Attributes extends EntityNode | RelationNode | AttributeNode | FunctionNode | ModifierNode { diff --git a/libs/shared/lib/querybuilder/graph/graphology/utils.ts b/libs/shared/lib/querybuilder/model/graphology/utils.ts similarity index 97% rename from libs/shared/lib/querybuilder/graph/graphology/utils.ts rename to libs/shared/lib/querybuilder/model/graphology/utils.ts index 4128f61e9..d31faed13 100644 --- a/libs/shared/lib/querybuilder/graph/graphology/utils.ts +++ b/libs/shared/lib/querybuilder/model/graphology/utils.ts @@ -23,13 +23,13 @@ const widthPerFontsize = { 10: 6.0167, }; -export type QueryMultiGraphExport = SerializedGraph< +export type QueryMultiGraph = SerializedGraph< NodeAttributes | EntityNodeAttributes | RelationNodeAttributes, GAttributes, GAttributes >; -export class QueryMultiGraph extends MultiGraph< +export class QueryMultiGraphology extends MultiGraph< NodeAttributes | EntityNodeAttributes | RelationNodeAttributes, GAttributes, GAttributes diff --git a/libs/shared/lib/querybuilder/model/index.ts b/libs/shared/lib/querybuilder/model/index.ts new file mode 100644 index 000000000..fdf75046a --- /dev/null +++ b/libs/shared/lib/querybuilder/model/index.ts @@ -0,0 +1,4 @@ +export * from './BackendQueryFormat'; +export * from './graphology'; +export * from './logic'; +export * from './reactflow'; \ No newline at end of file diff --git a/libs/shared/lib/querybuilder/model/logic/index.ts b/libs/shared/lib/querybuilder/model/logic/index.ts new file mode 100644 index 000000000..465510a48 --- /dev/null +++ b/libs/shared/lib/querybuilder/model/logic/index.ts @@ -0,0 +1 @@ +export * from './queryFunctions' \ No newline at end of file diff --git a/libs/shared/lib/querybuilder/graph/logic/queryFunctions.tsx b/libs/shared/lib/querybuilder/model/logic/queryFunctions.tsx similarity index 100% rename from libs/shared/lib/querybuilder/graph/logic/queryFunctions.tsx rename to libs/shared/lib/querybuilder/model/logic/queryFunctions.tsx diff --git a/libs/shared/lib/querybuilder/graph/reactflow/handles.tsx b/libs/shared/lib/querybuilder/model/reactflow/handles.tsx similarity index 100% rename from libs/shared/lib/querybuilder/graph/reactflow/handles.tsx rename to libs/shared/lib/querybuilder/model/reactflow/handles.tsx diff --git a/libs/shared/lib/querybuilder/model/reactflow/index.ts b/libs/shared/lib/querybuilder/model/reactflow/index.ts new file mode 100644 index 000000000..7d808fb6e --- /dev/null +++ b/libs/shared/lib/querybuilder/model/reactflow/index.ts @@ -0,0 +1,4 @@ +export * from './handles' +export * from './model' +export * from './pillHandles' +export * from './utils' \ No newline at end of file diff --git a/libs/shared/lib/querybuilder/graph/reactflow/model.tsx b/libs/shared/lib/querybuilder/model/reactflow/model.tsx similarity index 100% rename from libs/shared/lib/querybuilder/graph/reactflow/model.tsx rename to libs/shared/lib/querybuilder/model/reactflow/model.tsx diff --git a/libs/shared/lib/querybuilder/graph/reactflow/pillHandles.ts b/libs/shared/lib/querybuilder/model/reactflow/pillHandles.ts similarity index 100% rename from libs/shared/lib/querybuilder/graph/reactflow/pillHandles.ts rename to libs/shared/lib/querybuilder/model/reactflow/pillHandles.ts diff --git a/libs/shared/lib/querybuilder/graph/reactflow/utils.ts b/libs/shared/lib/querybuilder/model/reactflow/utils.ts similarity index 100% rename from libs/shared/lib/querybuilder/graph/reactflow/utils.ts rename to libs/shared/lib/querybuilder/model/reactflow/utils.ts diff --git a/libs/shared/lib/querybuilder/panel/querybuilder.stories.tsx b/libs/shared/lib/querybuilder/panel/querybuilder.stories.tsx index 1bc0f5778..0d45f3ba5 100644 --- a/libs/shared/lib/querybuilder/panel/querybuilder.stories.tsx +++ b/libs/shared/lib/querybuilder/panel/querybuilder.stories.tsx @@ -10,8 +10,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { Meta } from '@storybook/react'; import { Provider } from 'react-redux'; import QueryBuilder from './querybuilder'; -import { Handles } from '../graph/reactflow/handles'; -import { QueryMultiGraph } from '../graph/graphology/utils'; +import { Handles, QueryMultiGraphology } from '../model'; const Component: Meta<typeof QueryBuilder> = { component: QueryBuilder, @@ -34,7 +33,7 @@ const Component: Meta<typeof QueryBuilder> = { ], }; -const graph = new QueryMultiGraph(); +const graph = new QueryMultiGraphology(); graph.addPill2Graphology( { type: 'entity', x: 100, y: 100, name: 'Entity Pill', fadeIn: false }, '0' diff --git a/libs/shared/lib/querybuilder/panel/querybuilder.tsx b/libs/shared/lib/querybuilder/panel/querybuilder.tsx index 441715437..bcfcf7408 100644 --- a/libs/shared/lib/querybuilder/panel/querybuilder.tsx +++ b/libs/shared/lib/querybuilder/panel/querybuilder.tsx @@ -31,7 +31,6 @@ import { EntityFlowElement, RelationPill, } from '../pills'; -import { createReactFlowElements } from '../graph/reactflow/utils'; import { dragPillStarted, dragPillStopped, @@ -43,18 +42,13 @@ import { ImportExport as ExportIcon, } from '@mui/icons-material'; import { clearQB } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; -import { - EntityNode, - QueryElementTypes, - RelationNode, -} from '@graphpolaris/shared/lib/querybuilder/graph/reactflow/model'; import { RelationPosToFromEntityPos, RelationPosToToEntityPos, -} from '@graphpolaris/shared/lib/querybuilder/graph/graphology/utils'; -import { Handles } from '@graphpolaris/shared/lib/querybuilder/graph/reactflow/handles'; +} from '@graphpolaris/shared/lib/querybuilder/model/graphology/utils'; import { useDispatch } from 'react-redux'; import { Card, CardContent, Typography } from '@mui/material'; +import { Handles, QueryElementTypes, createReactFlowElements } from '../model'; const nodeTypes = { entity: EntityFlowElement, diff --git a/libs/shared/lib/querybuilder/panel/shemaquerybuilder.stories.tsx b/libs/shared/lib/querybuilder/panel/shemaquerybuilder.stories.tsx index 61625236b..0bf09cee1 100644 --- a/libs/shared/lib/querybuilder/panel/shemaquerybuilder.stories.tsx +++ b/libs/shared/lib/querybuilder/panel/shemaquerybuilder.stories.tsx @@ -17,9 +17,9 @@ import { movieSchemaRaw } from '@graphpolaris/shared/lib/mock-data'; import { QueryBuilder } from '@graphpolaris/shared/lib/querybuilder'; import { configureStore } from '@reduxjs/toolkit'; import { configSlice } from '@graphpolaris/shared/lib/data-access/store/configSlice'; -import { QueryGraph } from '@graphpolaris/shared/lib/querybuilder/graph/graphology/model'; +import { QueryGraph } from '@graphpolaris/shared/lib/querybuilder/model/graphology/model'; import { MultiGraph } from 'graphology'; -import { QueryMultiGraph } from '@graphpolaris/shared/lib/querybuilder/graph/graphology/utils'; +import { QueryMultiGraphology } from '@graphpolaris/shared/lib/querybuilder/model/graphology/utils'; const SchemaAndQueryBuilder = () => { return ( @@ -69,7 +69,7 @@ export const SchemaAndQueryBuilderInteractivity = { const dispatch = store.dispatch; const schema = SchemaUtils.schemaBackend2Graphology(movieSchemaRaw); - const graph = new QueryMultiGraph(); + const graph = new QueryMultiGraphology(); dispatch(setQuerybuilderNodes(graph.export())); dispatch(setSchema(schema.export())); }, diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill-full.stories.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill-full.stories.tsx index 196afa73c..1bfee71e6 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill-full.stories.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill-full.stories.tsx @@ -10,9 +10,9 @@ import { Meta } from '@storybook/react'; import { Provider } from 'react-redux'; import { MultiGraph } from 'graphology'; import { QueryBuilder } from '../../../panel'; -import { QueryGraph } from '../../../graph/graphology/model'; +import { QueryGraph } from '../../../model/graphology/model'; import { circular } from 'graphology-layout'; -import { QueryMultiGraph } from '@graphpolaris/shared/lib/querybuilder/graph/graphology/utils'; +import { QueryMultiGraphology } from '@graphpolaris/shared/lib/querybuilder/model/graphology/utils'; const Component: Meta<typeof QueryBuilder> = { component: QueryBuilder, @@ -33,7 +33,7 @@ const mockStore = configureStore({ querybuilder: querybuilderSlice.reducer, }, }); -const graph = new QueryMultiGraph(); +const graph = new QueryMultiGraphology(); graph.addPill2Graphology( { type: 'attribute', diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill.tsx index 26d4bb0bc..89c663492 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/attributepill.tsx @@ -4,8 +4,7 @@ import styles from './attributepill.module.scss'; import { Handle, NodeProps, Position } from 'reactflow'; import { AttributeOperatorSelect } from './operator'; import Select from './select-component'; -import { AttributeNode } from '../../../graph/reactflow/model'; -import { Handles } from '../../../graph/reactflow/handles'; +import { AttributeNode, Handles } from '../../../model'; /** * Component to render an attribute flow element diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/select-component.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/select-component.tsx index 38a35b85e..3e6b6a979 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/select-component.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/attributepill/select-component.tsx @@ -11,7 +11,7 @@ import { useTheme } from '@mui/material'; import React, { useState } from 'react'; import styles from './attributepill.module.scss'; -import { AttributeData } from '../../../graph/reactflow/model'; +import { AttributeData } from '../../../model'; export default function SelectComponent({ data }: { data: AttributeData }) { const theme = useTheme(); diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill-full.stories.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill-full.stories.tsx index 18b65e8be..fe8d4464a 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill-full.stories.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill-full.stories.tsx @@ -10,9 +10,9 @@ import { Meta } from '@storybook/react'; import { Provider } from 'react-redux'; import { MultiGraph } from 'graphology'; import { QueryBuilder } from '../../../panel'; -import { QueryGraph } from '../../../graph/graphology/model'; +import { QueryGraph } from '../../../model/graphology/model'; import { circular } from 'graphology-layout'; -import { QueryMultiGraph } from '@graphpolaris/shared/lib/querybuilder/graph/graphology/utils'; +import { QueryMultiGraphology } from '@graphpolaris/shared/lib/querybuilder/model/graphology/utils'; const Component: Meta<typeof QueryBuilder> = { component: QueryBuilder, @@ -33,7 +33,7 @@ const mockStore = configureStore({ querybuilder: querybuilderSlice.reducer, }, }); -const graph = new QueryMultiGraph(); +const graph = new QueryMultiGraphology(); graph.addPill2Graphology( { type: 'entity', x: 100, y: 100, name: 'Entity Pill', fadeIn: false }, '2' diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.stories.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.stories.tsx index 87d7a9e8c..b637b5938 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.stories.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.stories.tsx @@ -11,7 +11,7 @@ import { schemaSlice, } from '@graphpolaris/shared/lib/data-access/store'; import { ReactFlowProvider } from 'reactflow'; -import { EntityData, EntityNode } from '../../../graph-reactflow/model'; +import { EntityData } from '../../../model'; const Component: Meta<typeof EntityFlowElement> = { /* 👇 The title prop is optional. diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.tsx index 51f8aa074..9a126d5d3 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/entitypill.tsx @@ -3,8 +3,7 @@ import { useTheme } from '@mui/material'; import React, { useEffect } from 'react'; import { ReactFlow, Handle, Position } from 'reactflow'; import styles from './entitypill.module.scss'; -import { EntityNode } from '../../../graph/reactflow/model'; -import { Handles } from '../../../graph/reactflow/handles'; +import { EntityNode, Handles } from '../../../model'; /** * Component to render an entity flow element diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.stories.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.stories.tsx index 2488295e0..559e2f145 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.stories.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.stories.tsx @@ -11,11 +11,7 @@ import { schemaSlice, } from '@graphpolaris/shared/lib/data-access/store'; import { ReactFlowProvider } from 'reactflow'; -import { - EntityData, - EntityNode, - FunctionData, -} from '../../../graph-reactflow/model'; +import { FunctionData } from '../../../model'; const Component: Meta<typeof FunctionFlowElement> = { /* 👇 The title prop is optional. diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.tsx index c0edcaff2..d97f2c50b 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/functionpill/functionpill.tsx @@ -12,8 +12,7 @@ import React, { useState } from 'react'; import { Handle, Position } from 'reactflow'; import styles from './functionpill.module.scss'; import { useTheme } from '@mui/material'; -import { FunctionData, FunctionNode } from '../../../graph/reactflow/model'; -import { Handles } from '../../../graph/reactflow/handles'; +import { FunctionData, Handles } from '../../../model'; const countArgs = (data: FunctionData | undefined) => { if (data !== undefined) { @@ -133,9 +132,8 @@ export default function RelationFlowElement({ data }: FunctionNode) { <div className={styles.functionWrapper}>{rows}</div> </div> <div - className={`${styles.entity} entityWrapper ${ - entity === undefined ? 'hidden' : '' - }`} + className={`${styles.entity} entityWrapper ${entity === undefined ? 'hidden' : '' + }`} > <Handle id={Handles.ToRelation} diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/modifierpill/mopdifierpill.stories.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/modifierpill/mopdifierpill.stories.tsx index 480fe054e..4b4c7f263 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/modifierpill/mopdifierpill.stories.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/modifierpill/mopdifierpill.stories.tsx @@ -11,12 +11,7 @@ import { schemaSlice, } from '@graphpolaris/shared/lib/data-access/store'; import { ReactFlowProvider } from 'reactflow'; -import { - EntityData, - EntityNode, - FunctionData, - ModifierData, -} from '../../../graph-reactflow/model'; +import { ModifierData } from '../../../model'; const Component: Meta<typeof ModifierPill> = { /* 👇 The title prop is optional. diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relation-full.stories.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relation-full_reactflow.stories.tsx similarity index 89% rename from libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relation-full.stories.tsx rename to libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relation-full_reactflow.stories.tsx index b01ffd8f3..3527cfb33 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relation-full.stories.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relation-full_reactflow.stories.tsx @@ -10,8 +10,9 @@ import { Meta } from '@storybook/react'; import { Provider } from 'react-redux'; import { MultiGraph } from 'graphology'; import { QueryBuilder } from '../../../panel'; -import { QueryGraph } from '../../../graph/graphology/model'; +import { QueryGraph } from '../../../model/graphology/model'; import { circular } from 'graphology-layout'; +import { QueryMultiGraphology } from '../../../model'; const Component: Meta<typeof QueryBuilder> = { component: QueryBuilder, @@ -32,7 +33,7 @@ const mockStore = configureStore({ querybuilder: querybuilderSlice.reducer, }, }); -const graph: QueryGraph = new MultiGraph(); +const graph = new QueryMultiGraphology(); graph.addPill2Graphology( { type: 'relation', diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill copy.txt b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill copy.txt deleted file mode 100644 index 805e3f3fc..000000000 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill copy.txt +++ /dev/null @@ -1,242 +0,0 @@ -import React, { memo, useRef, useState } from 'react'; - -import { handles } from '@graphpolaris/shared/lib/querybuilder/usecases'; -import { useTheme } from '@mui/material'; -import { Handle, Position } from 'reactflow'; -import cn from 'classnames'; - -import styles from './relationpill.module.scss'; -import { Handles } from '../../../structures/Handles'; -import { RelationNode } from '../../../structures/Nodes'; - -// export type RelationRFPillProps = { -// data: { -// name: string; -// suggestedForConnection: any; -// isFromEntityConnected?: boolean; -// isToEntityConnected?: boolean; -// }; -// }; - -/** - * Component to render a relation flow element - * @param { FlowElement<RelationData>} param0 The data of a relation flow element. - */ -export const RelationPill = memo(({ data }: RelationNode) => { - // export default function RelationRFPill({ data }: { data: any }) { - const theme = useTheme(); - // console.log('RelationRFPill', data); - - const minRef = useRef<HTMLInputElement>(null); - const maxRef = useRef<HTMLInputElement>(null); - - const [readOnlyMin, setReadOnlyMin] = useState(true); - const [readOnlyMax, setReadOnlyMax] = useState(true); - - const onDepthChanged = (depth: string) => { - // Don't allow depth above 99 - const limit = 99; - if (data?.depth != undefined) { - data.depth.min = data.depth.min >= limit ? limit : data.depth.min; - data.depth.max = data.depth.max >= limit ? limit : data.depth.max; - - // Check for for valid depth: min <= max - if (depth == 'min') { - if (data.depth.min > data.depth.max) data.depth.max = data.depth.min; - setReadOnlyMin(true); - } else if (depth == 'max') { - if (data.depth.max < data.depth.min) data.depth.min = data.depth.max; - setReadOnlyMax(true); - } - - // Set to the correct width - if (maxRef.current) - maxRef.current.style.maxWidth = calcWidth(data.depth.max); - if (minRef.current) - minRef.current.style.maxWidth = calcWidth(data.depth.min); - } - }; - - const isNumber = (x: string) => { - { - if (typeof x != 'string') return false; - return !Number.isNaN(x) && !Number.isNaN(parseFloat(x)); - } - }; - - const calcWidth = (data: number) => { - return data.toString().length + 0.5 + 'ch'; - }; - - return ( - <div - className={styles.relation} - style={{ - background: theme.palette.custom.nodesBase[0], - borderTop: `4px solid ${theme.palette.custom.nodesBase[0]}`, - borderBottom: `6px solid ${theme.palette.custom.elements.relationBase[0]}`, - }} - > - <div className={styles.relationWrapper}> - <div - className={[ - styles.relationNodeTriangleGeneral, - styles.relationNodeTriangleLeft, - ].join(' ')} - style={{ borderRightColor: theme.palette.custom.nodesBase[0] }} - > - <span className={styles.relationHandleFiller}> - <Handle - id={Handles.RelationLeft} - type="target" - position={Position.Left} - className={ - styles.relationHandleLeft + - ' ' + - (false ? styles.handleConnectedBorderLeft : '') - } - /> - </span> - </div> - <div - className={[ - styles.relationNodeTriangleGeneral, - styles.relationNodeSmallTriangleLeft, - ].join(' ')} - style={{ - borderRightColor: theme.palette.custom.elements.relationBase[0], - }} - ></div> - <div - className={[ - styles.relationNodeTriangleGeneral, - styles.relationNodeTriangleRight, - ].join(' ')} - style={{ borderLeftColor: theme.palette.custom.nodesBase[0] }} - > - <span className={styles.relationHandleFiller}> - <Handle - id={Handles.RelationRight} - type="target" - position={Position.Right} - className={ - styles.relationHandleRight + - ' ' + - (false ? styles.handleConnectedBorderRight : '') - } - /> - </span> - </div> - <div - className={[ - styles.relationNodeTriangleGeneral, - styles.relationNodeSmallTriangleRight, - ].join(' ')} - style={{ - borderLeftColor: theme.palette.custom.elements.relationBase[0], - }} - ></div> - - <span className={styles.relationHandleFiller}> - <Handle - id={Handles.ToAttributeHandle} - type="target" - position={Position.Bottom} - className={ - styles.relationHandleBottom + - ' ' + - (false ? styles.handleConnectedFill : '') - } - /> - </span> - <div className={styles.relationDataWrapper}> - <span className={styles.relationSpan}>{data?.name}</span> - <span className={styles.relationInputHolder}> - <span>[</span> - <input - className={ - styles.relationInput + - ' ' + - (readOnlyMin ? styles.relationReadonly : '') - } - ref={minRef} - type="string" - min={0} - readOnly={readOnlyMin} - placeholder={'?'} - value={data?.depth.min} - onChange={(e) => { - if (data != undefined) { - data.depth.min = isNumber(e.target.value) - ? parseInt(e.target.value) - : 0; - e.target.style.maxWidth = calcWidth(data.depth.min); - } - }} - onDoubleClick={() => { - setReadOnlyMin(false); - }} - onBlur={(e) => { - onDepthChanged('min'); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onDepthChanged('min'); - } - }} - ></input> - <span>..</span> - <input - className={ - styles.relationInput + - ' ' + - (readOnlyMax ? styles.relationReadonly : '') - } - ref={maxRef} - type="string" - min={0} - readOnly={readOnlyMax} - placeholder={'?'} - value={data?.depth.max} - onChange={(e) => { - if (data != undefined) { - data.depth.max = isNumber(e.target.value) - ? parseInt(e.target.value) - : 0; - e.target.style.maxWidth = calcWidth(data.depth.max); - } - }} - onDoubleClick={() => { - setReadOnlyMax(false); - }} - onBlur={(e) => { - onDepthChanged('max'); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onDepthChanged('max'); - } - }} - ></input> - <span>]</span> - </span> - </div> - <Handle - id={Handles.ReceiveFunction} - type="target" - position={Position.Bottom} - className={ - styles.relationHandleFunction + - ' ' + - styles.handleFunction + - ' ' + - (false ? styles.handleConnectedFill : '') - } - /> - </div> - </div> - ); -}); - -RelationPill.displayName = 'RelationPill'; -export default RelationPill; diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.module copy.scss b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.module copy.scss deleted file mode 100644 index e7dd844ff..000000000 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.module copy.scss +++ /dev/null @@ -1,304 +0,0 @@ -@import '../../querypills.module.scss'; - -.relation { - display: flex; - text-align: center; - font-family: monospace; - font-weight: bold; - font-size: 10px; - background-color: transparent; -} - -.highlighted { - box-shadow: black 0 0 2px; -} - -.contentWrapper { - display: flex; - align-items: center; - - .handleLeft { - position: relative; - z-index: 3; - - top: 25%; - border: 0px; - border-radius: 0px; - - background: transparent; - transform-origin: center; - - width: 0; - height: 0; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-right: rgba(255, 255, 255, 0.7) 6px solid; - - &::after { - content: ''; - display: block; - position: absolute; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: rgba(0, 0, 0, 0.1) 8px solid; - top: -7px; - right: -7px; - } - } - .highlighted { - z-index: -1; - box-shadow: 0 0 2px 1px gray; - } - - .content { - margin: 0 2ch; - padding: 3px 0; - max-width: 20ch; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - // pointer-events: none; - } - - .handleRight { - position: relative; - top: 25%; - border: 0px; - border-radius: 0px; - - background: transparent; - transform-origin: center; - - width: 0; - height: 0; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-left: rgba(255, 255, 255, 0.7) 6px solid; - - &::after { - content: ''; - display: block; - position: absolute; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-left: rgba(0, 0, 0, 0.1) 8px solid; - top: -7px; - left: -7px; - } - } -} - -$height: 10px; -.arrowLeft { - z-index: 2; - width: 0; - height: 0; - border-top: $height solid transparent; - border-bottom: $height solid transparent; - transform: scale(1.028) translate(0.3px 0px); - - border-right: $height solid; -} - -.arrowRight { - width: 0; - height: 0; - border-top: $height solid transparent; - border-bottom: $height solid transparent; - transform: scale(1.02) translate(-0.3px 0px); - - border-left: $height solid; -} - -// Relation element -.relation { - height: 36; - min-width: 240px; - display: flex; - text-align: center; - color: black; - line-height: 20px; - font-family: monospace; - font-weight: bold; - font-size: 11px; -} - -.relationWrapper { - display: inherit; - align-items: center; - justify-content: space-between; -} - -.relationNodeTriangleGeneral { - position: absolute; - width: 0; - height: 0; - margin: auto 0; - border-style: solid; - border-color: transparent; -} - -.relationNodeTriangleLeft { - transform: translateX(-100%); - top: 0px; - border-width: 18px 24px 18px 0; -} - -.relationNodeSmallTriangleLeft { - transform: translateX(-100%); - top: 30px; - border-width: 0 8px 6px 0; -} - -.relationNodeTriangleRight { - right: -24px; - top: 0px; - border-width: 18px 0 18px 24px; -} - -.relationNodeSmallTriangleRight { - right: -8px; - top: 30px; - border-width: 0 0 6px 8px; -} - -.relationHandleFiller { - display: block; -} - -.relationHandleLeft { - position: absolute; - top: 50%; - margin-left: 15px; - border-style: solid; - border-width: 8px 12px 8px 0; - border-radius: 0px; - left: unset; - border-color: transparent rgba(0, 0, 0, 0.3) transparent transparent; - background: transparent; - &::before { - content: ''; - border-style: solid; - border-width: 6px 8px 6px 0; - border-color: transparent rgba(255, 255, 255, 0.6) transparent transparent; - background: transparent; - z-index: -1; - display: inline-block; - position: absolute; - top: -0.5em; - left: 0.25em; - } -} - -.relationHandleRight { - position: absolute; - margin-right: 19px; - border-style: solid; - border-width: 8px 0 8px 12px; - border-radius: 0px; - left: unset; - border-color: transparent transparent transparent rgba(0, 0, 0, 0.3); - background: transparent; - &::before { - content: ''; - border-style: solid; - border-width: 6px 0 6px 8px; - border-color: transparent transparent transparent rgba(255, 255, 255, 0.6); - background: transparent; - z-index: -1; - display: inline-block; - position: absolute; - top: -0.5em; - right: 0.25em; - } -} - -.relationHandleBottom { - border: 0; - border-radius: 0; - width: 8; - height: 8; - left: unset; - margin-bottom: 18; - margin-left: 40; - background: rgba(0, 0, 0, 0.3); - transform: rotate(-45deg); - transform-origin: center; - margin: 5px; - &::before { - content: ''; - width: 6; - height: 6; - left: 1; - bottom: 1; - border: 0; - border-radius: 0; - background: rgba(255, 255, 255, 0.6); - z-index: -1; - display: inline-block; - position: fixed; - } -} - -.relationDataWrapper { - margin-left: 80; -} - -.relationSpan { - float: left; - margin-left: 5; -} - -.relationInputHolder { - display: flex; - float: right; - margin-right: 20px; - margin-top: 4px; - margin-left: 5px; - max-width: 80px; - background-color: rgba(255, 255, 255, 0.6); - border-radius: 2px; - align-items: center; - max-height: 12px; -} - -.relationInput { - z-index: 1; - cursor: text; - min-width: 0px; - max-width: 1.5ch; - border: none; - background: transparent; - text-align: center; - font-family: monospace; - font-weight: bold; - font-size: 11px; - color: #181520; - user-select: none; - font-style: italic; - &:focus { - outline: none; - user-select: none; - } - &::placeholder { - outline: none; - user-select: none; - font-style: italic; - } -} - -.relationReadonly { - cursor: grab !important; - color: #181520 !important; - user-select: none; - font-style: normal !important; -} - -.relationHandleFunction { - margin-left: 20; - margin-bottom: 18px !important; -} diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.stories.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.stories.tsx index 35171325c..0401aba54 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.stories.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.stories.tsx @@ -11,7 +11,7 @@ import { schemaSlice, } from '@graphpolaris/shared/lib/data-access/store'; import { ReactFlowProvider } from 'reactflow'; -import { RelationData } from '../../../graph/reactflow/model'; +import { RelationData } from '../../../model'; const Component: Meta<typeof RelationPill> = { /* 👇 The title prop is optional. diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.tsx index b1de1902c..073f594c9 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/relationpill/relationpill.tsx @@ -4,8 +4,7 @@ import { useTheme } from '@mui/material'; import { Handle, Position } from 'reactflow'; import styles from './relationpill.module.scss'; -import { RelationNode } from '../../../graph/reactflow/model'; -import { Handles } from '../../../graph/reactflow/handles'; +import { RelationNode, Handles } from '../../../model'; // export type RelationRFPillProps = { // data: { @@ -90,7 +89,7 @@ export const RelationPill = memo(({ data }: RelationNode) => { <div className={styles.relationWrapper}> <span className={styles.relationHandleFiller} - // style={{ transform: 'translate(-100px,0)' }} + // style={{ transform: 'translate(-100px,0)' }} > <Handle id={Handles.RelationLeft} diff --git a/libs/shared/lib/querybuilder/query-utils/index.ts b/libs/shared/lib/querybuilder/query-utils/index.ts index 2a8aba6d7..ca1c6a26a 100644 --- a/libs/shared/lib/querybuilder/query-utils/index.ts +++ b/libs/shared/lib/querybuilder/query-utils/index.ts @@ -1,2 +1,2 @@ export * from './query-utils' -export * from './BackendQueryFormat' \ No newline at end of file +export * from '../model/BackendQueryFormat' \ No newline at end of file diff --git a/libs/shared/lib/querybuilder/query-utils/query-utils.ts b/libs/shared/lib/querybuilder/query-utils/query-utils.ts index b029d65bf..df25d1293 100644 --- a/libs/shared/lib/querybuilder/query-utils/query-utils.ts +++ b/libs/shared/lib/querybuilder/query-utils/query-utils.ts @@ -1,14 +1,13 @@ -import { EntityNodeAttributes, RelationNodeAttributes } from "../graph/graphology/model"; -import { QueryMultiGraphExport } from "../graph/graphology/utils"; -import { Handles } from "../graph/reactflow/handles"; -import { EntityNode, FunctionNode, QueryElementTypes, RelationData, RelationNode, possibleTypes } from "../graph/reactflow/model"; -import { BackendQueryFormat } from "./BackendQueryFormat"; +import { EntityNodeAttributes, RelationNodeAttributes } from "../model/graphology/model"; +import { QueryMultiGraph } from "../model/graphology/utils"; +import { BackendQueryFormat } from "../model/BackendQueryFormat"; +import { Handles, QueryElementTypes } from "../model"; /** * Converts the ReactFlow query to a json data structure to send the query to the backend. * @returns {BackendQueryFormat} A JSON object in the `JSONFormat`. */ -export function Query2BackendQuery(databaseName: string, graph: QueryMultiGraphExport): BackendQueryFormat { +export function Query2BackendQuery(databaseName: string, graph: QueryMultiGraph): BackendQueryFormat { // dict of nodes per type const entities = graph.nodes.filter(n => n.attributes?.type === QueryElementTypes.Entity) .map((n) => ({ name: n.attributes!.name, ID: Number(n.key), constraints: [] })); diff --git a/libs/shared/lib/schema/index.ts b/libs/shared/lib/schema/index.ts new file mode 100644 index 000000000..44942b3cb --- /dev/null +++ b/libs/shared/lib/schema/index.ts @@ -0,0 +1,2 @@ +export * from './model' +export * from '../querybuilder/model' \ No newline at end of file diff --git a/libs/shared/lib/model/backend/schema.ts b/libs/shared/lib/schema/model/FromBackend.ts similarity index 61% rename from libs/shared/lib/model/backend/schema.ts rename to libs/shared/lib/schema/model/FromBackend.ts index aaebb3346..f2c6f701b 100644 --- a/libs/shared/lib/model/backend/schema.ts +++ b/libs/shared/lib/schema/model/FromBackend.ts @@ -1,27 +1,31 @@ /*************** schema format from the backend *************** */ /** Schema type, consist of nodes and edges */ export type SchemaFromBackend = { - edges: Edge[]; - nodes: Node[]; + edges: SchemaEdge[]; + nodes: SchemaNode[]; }; /** Attribute type, consist of a name */ -export type Attribute = { +export type SchemaAttribute = { name: string; type: 'string' | 'int' | 'bool' | 'float'; + // nodeCount: number; + // summedNullAmount: number; + // connectedRatio: number; + // handles: string[]; }; /** Node type, consist of a name and a list of attributes */ -export type Node = { +export type SchemaNode = { name: string; - attributes: Attribute[]; + attributes: SchemaAttribute[]; }; /** Edge type, consist of a name, start point, end point and a list of attributes */ -export type Edge = { +export type SchemaEdge = { name: string; to: string; from: string; collection: string; - attributes: Attribute[]; + attributes: SchemaAttribute[]; }; \ No newline at end of file diff --git a/libs/shared/lib/schema/model/graphology.ts b/libs/shared/lib/schema/model/graphology.ts new file mode 100644 index 000000000..e335f845a --- /dev/null +++ b/libs/shared/lib/schema/model/graphology.ts @@ -0,0 +1,13 @@ +import { MultiGraph } from "graphology"; +import { Attributes as GAttributes, NodeEntry, EdgeEntry, SerializedGraph } from "graphology-types"; +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, SchemaGraphologyEdge, GAttributes> { }; +export type SchemaGraph = SerializedGraph<SchemaGraphologyNode, SchemaGraphologyEdge, GAttributes>; diff --git a/libs/shared/lib/schema/model/index.ts b/libs/shared/lib/schema/model/index.ts new file mode 100644 index 000000000..2439a89d0 --- /dev/null +++ b/libs/shared/lib/schema/model/index.ts @@ -0,0 +1,3 @@ +export * from './FromBackend' +export * from './graphology' +export * from './reactflow' \ No newline at end of file diff --git a/libs/shared/lib/schema/schema-utils/Types.tsx b/libs/shared/lib/schema/model/reactflow.tsx similarity index 94% rename from libs/shared/lib/schema/schema-utils/Types.tsx rename to libs/shared/lib/schema/model/reactflow.tsx index c3fde663a..71a4399d2 100644 --- a/libs/shared/lib/schema/schema-utils/Types.tsx +++ b/libs/shared/lib/schema/model/reactflow.tsx @@ -4,9 +4,7 @@ * © Copyright Utrecht University (Department of Information and Computing Sciences) */ import { Edge, Node } from 'reactflow'; -import { Attributes } from '@graphpolaris/shared/lib/model'; -import { MultiGraph } from 'graphology'; -import { SerializedGraph } from 'graphology-types'; +import { SchemaGraphologyNode } from './graphology'; /** All possible options of node-types */ export enum NodeType { @@ -33,7 +31,7 @@ export enum AttributeCategory { export interface SchemaGraphData { name: string; - attributes: Attributes[]; + attributes: SchemaGraphologyNode[]; nodeCount: number; summedNullAmount: number; } @@ -136,7 +134,7 @@ export interface AttributeAnalyticsPopupMenuNode extends Node { /** Typing of the attributes which are stored in the popup menu's */ export type AttributeWithData = { - attribute: Attributes; + attribute: SchemaGraphologyNode; category: AttributeCategory; nullAmount: number; }; diff --git a/libs/shared/lib/schema/panel/SchemaComponent.t b/libs/shared/lib/schema/panel/SchemaComponent.t deleted file mode 100644 index b3f2a0a48..000000000 --- a/libs/shared/lib/schema/panel/SchemaComponent.t +++ /dev/null @@ -1,184 +0,0 @@ -/** - * 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 BaseView from '../BaseView'; -import SchemaViewModel from '../../view-model/graph-schema/SchemaViewModel'; -import ReactFlow, { - ControlButton, - Controls, - FlowElement, - ReactFlowProvider, -} from 'react-flow-renderer'; -import { ClassNameMap, WithStyles, withStyles } from '@material-ui/styles'; -import SettingsIcon from '@material-ui/icons/Settings'; -import exportIcon from '../icons/ExportIcon.png'; - -import { CircularProgress, ListItemText, Menu, MenuItem } from '@material-ui/core'; -import { useStyles } from './SchemaStyleSheet'; - -export let currentColours: any; - -/** SchemaComponentProps is an interface containing the SchemaViewModel. */ -export interface SchemaComponentProps extends WithStyles<typeof useStyles> { - schemaViewModel: SchemaViewModel; - currentColours: any; -} - -/** The interface for the state in the schema component */ -export interface SchemaComponentState { - zoom: number; - visible: boolean; - elements: FlowElement[]; - myRef: React.RefObject<HTMLDivElement>; - // Elements holder for the export menu - exportMenuAnchor?: Element; -} - -/** This class attaches the observers and renders the schema to the screen */ -class SchemaComponent - extends React.Component<SchemaComponentProps, SchemaComponentState> - implements BaseView -{ - private schemaViewModel: SchemaViewModel; - private currentColours: any; - - public constructor(props: SchemaComponentProps) { - super(props); - - const { schemaViewModel } = props; - this.schemaViewModel = schemaViewModel; - - this.state = { - zoom: schemaViewModel.zoom, - myRef: schemaViewModel.myRef, - visible: schemaViewModel.visible, - elements: [ - ...schemaViewModel.elements.nodes, - ...schemaViewModel.elements.edges, - ...schemaViewModel.elements.selfEdges, - schemaViewModel.nodeQualityPopup, - schemaViewModel.attributeAnalyticsPopupMenu, - ], - exportMenuAnchor: undefined, - }; - } - - /** Attach the Viewmodel Observer and Attach the Websocket Observer. */ - public componentDidMount(): void { - this.schemaViewModel.subscribeToSchemaResult(); - this.schemaViewModel.subscribeToAnalyticsData(); - this.schemaViewModel.attachView(this); - } - - /** Deattach the Viewmodel Observer and Attach the Websocket Observer. */ - public componentWillUnmount(): void { - this.schemaViewModel.unSubscribeFromSchemaResult(); - this.schemaViewModel.unSubscribeFromAnalyticsData(); - this.schemaViewModel.detachView(); - } - - /** - * We update the state of our statecomponent on each update of the viewmodel. - * Gets the parameters from schemaviewmodel. - */ - public onViewModelChanged(): void { - this.setState({ - myRef: this.schemaViewModel.myRef, - zoom: this.schemaViewModel.zoom, - visible: this.schemaViewModel.visible, - elements: [ - ...this.schemaViewModel.elements.nodes, - ...this.schemaViewModel.elements.edges, - ...this.schemaViewModel.elements.selfEdges, - this.schemaViewModel.nodeQualityPopup, - this.schemaViewModel.attributeAnalyticsPopupMenu, - ], - }); - } - - /** The function of the component that renders the schema */ - public render(): JSX.Element { - const { zoom, elements, myRef, visible } = this.state; - const styles = this.props.classes; - currentColours = this.props.currentColours; - - return ( - <div - style={{ - width: '100%', - height: '100%', - backgroundColor: '#' + currentColours.visBackground, - }} - ref={myRef} - > - <ReactFlowProvider> - <ReactFlow - className={styles.schemaPanel} - elements={elements} - defaultZoom={zoom} - nodeTypes={this.schemaViewModel.nodeTypes} - edgeTypes={this.schemaViewModel.edgeTypes} - nodesDraggable={false} - onlyRenderVisibleElements={false} - onLoad={this.schemaViewModel.onLoad} - > - <Controls - showInteractive={false} - showZoom={false} - showFitView={true} - className={styles.controls} - > - <ControlButton - className={styles.exportButton} - title={'Export graph schema'} - onClick={(event) => { - event.stopPropagation(); - this.setState({ - ...this.state, - exportMenuAnchor: event.currentTarget, - }); - }} - > - <img src={exportIcon} width={21}></img> - </ControlButton> - </Controls> - <div - style={{ - display: 'flex', - justifyContent: 'center', - visibility: visible ? 'visible' : 'hidden', - }} - > - <CircularProgress /> - </div> - </ReactFlow> - </ReactFlowProvider> - <Menu - getContentAnchorEl={null} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - anchorEl={this.state.exportMenuAnchor} - keepMounted - open={Boolean(this.state.exportMenuAnchor)} - onClose={() => this.setState({ ...this.state, exportMenuAnchor: undefined })} - > - <MenuItem className={styles.menuText} onClick={() => this.schemaViewModel.exportToPNG()}> - {'Export to PNG'} - </MenuItem> - <MenuItem className={styles.menuText} onClick={() => this.schemaViewModel.exportToPDF()}> - {'Export to PDF'} - </MenuItem> - </Menu> - </div> - ); - } -} -export default withStyles(useStyles)(SchemaComponent); diff --git a/libs/shared/lib/schema/pills/nodes/entity/entity-node.tsx b/libs/shared/lib/schema/pills/nodes/entity/entity-node.tsx index 4c54a857b..5e6261cd9 100644 --- a/libs/shared/lib/schema/pills/nodes/entity/entity-node.tsx +++ b/libs/shared/lib/schema/pills/nodes/entity/entity-node.tsx @@ -18,9 +18,8 @@ import { } from '@graphpolaris/shared/lib/schema/schema-utils'; import { useTheme } from '@mui/material'; import { - SchemaGraphNode, SchemaGraphNodeWithFunctions, -} from '../../../schema-utils/Types'; +} from '../../../model/reactflow'; /** * EntityNode is the node that represents the database entities. @@ -86,9 +85,8 @@ export const EntityNode = React.memo( ${theme.palette.custom.nodesBase} ${calculateAttributeQuality( data )}%, - ${ - theme.palette.custom.elements.entitySecond[0] - } ${calculateAttributeQuality(data)}%)`, + ${theme.palette.custom.elements.entitySecond[0] + } ${calculateAttributeQuality(data)}%)`, }} > <span @@ -112,9 +110,8 @@ export const EntityNode = React.memo( background: `linear-gradient(-90deg, ${theme.palette.custom.nodesBase} 0%, ${theme.palette.custom.nodesBase} ${calculateEntityQuality(data)}%, - ${ - theme.palette.custom.elements.entitySecond[0] - } ${calculateEntityQuality(data)}%)`, + ${theme.palette.custom.elements.entitySecond[0] + } ${calculateEntityQuality(data)}%)`, }} > <span diff --git a/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.stories.tsx b/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.stories.tsx index 291a55b28..3e12e835b 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.stories.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.stories.tsx @@ -11,7 +11,7 @@ import { } from '@graphpolaris/shared/lib/data-access/store'; import { ReactFlowProvider } from 'reactflow'; import { AttributeAnalyticsPopupMenu } from './attribute-analytics-popup-menu'; -import { AttributeCategory, NodeType } from '../../../schema-utils/Types'; +import { AttributeCategory, NodeType } from '../../../model/reactflow'; const Component: Meta<typeof AttributeAnalyticsPopupMenu> = { /* 👇 The title prop is optional. @@ -59,16 +59,16 @@ export const Default: Story = { }, ], isAttributeDataIn: false, - onClickCloseButton: () => {}, - onClickPlaceInQueryBuilderButton: (name: string, type) => {}, - searchForAttributes: (id: string, searchbarValue: string) => {}, - resetAttributeFilters: (id: string) => {}, + onClickCloseButton: () => { }, + onClickPlaceInQueryBuilderButton: (name: string, type) => { }, + searchForAttributes: (id: string, searchbarValue: string) => { }, + resetAttributeFilters: (id: string) => { }, applyAttributeFilters: ( id: string, category: AttributeCategory, predicate: string, percentage: number - ) => {}, + ) => { }, }, }, }; diff --git a/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.tsx b/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.tsx index 489c17267..bc21aacc0 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/attribute-analytics-popup-menu.tsx @@ -23,7 +23,7 @@ import { AttributeAnalyticsData, AttributeWithData, NodeType, -} from '../../../schema-utils/Types'; +} from '../../../model/reactflow'; import { NodeProps } from 'reactflow'; import './attribute-analytics-popup-menu.module.scss'; diff --git a/libs/shared/lib/schema/pills/nodes/popup/node-quality-entity-popup.tsx b/libs/shared/lib/schema/pills/nodes/popup/node-quality-entity-popup.tsx index aaefe3ce9..609226cbc 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/node-quality-entity-popup.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/node-quality-entity-popup.tsx @@ -12,7 +12,7 @@ import { ButtonBase, useTheme } from '@mui/material'; import React from 'react'; import { NodeProps } from 'reactflow'; -import { NodeQualityDataForEntities } from '../../../schema-utils/Types'; +import { NodeQualityDataForEntities } from '../../../model/reactflow'; /** * NodeQualityEntityPopupNode is the node that represents the popup that shows the node quality for an entity diff --git a/libs/shared/lib/schema/pills/nodes/popup/node-quality-relation-popup.tsx b/libs/shared/lib/schema/pills/nodes/popup/node-quality-relation-popup.tsx index bd1d1a8b4..4a2307723 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/node-quality-relation-popup.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/node-quality-relation-popup.tsx @@ -12,7 +12,7 @@ import { ButtonBase, useTheme } from '@mui/material'; import React from 'react'; import { NodeProps } from 'reactflow'; -import { NodeQualityDataForRelations } from '../../../schema-utils/Types'; +import { NodeQualityDataForRelations } from '../../../model/reactflow'; import './node-quality-popup.module.scss'; /** diff --git a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.stories.tsx b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.stories.tsx index 9de60b14c..e265579a3 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.stories.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.stories.tsx @@ -11,7 +11,7 @@ import { } from '@graphpolaris/shared/lib/data-access/store'; import { ReactFlowProvider } from 'reactflow'; import { AttributeAnalyticsPopupMenu } from './attribute-analytics-popup-menu'; -import { AttributeCategory, NodeType } from '../../../../schema-utils/Types'; +import { AttributeCategory, NodeType } from '../../../../model/reactflow'; const Component: Meta<typeof AttributeAnalyticsPopupMenu> = { /* 👇 The title prop is optional. @@ -59,16 +59,16 @@ export const Default: Story = { }, ], isAttributeDataIn: false, - onClickCloseButton: () => {}, - onClickPlaceInQueryBuilderButton: (name: string, type) => {}, - searchForAttributes: (id: string, searchbarValue: string) => {}, - resetAttributeFilters: (id: string) => {}, + onClickCloseButton: () => { }, + onClickPlaceInQueryBuilderButton: (name: string, type) => { }, + searchForAttributes: (id: string, searchbarValue: string) => { }, + resetAttributeFilters: (id: string) => { }, applyAttributeFilters: ( id: string, category: AttributeCategory, predicate: string, percentage: number - ) => {}, + ) => { }, }, }, }; diff --git a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.tsx b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.tsx index 81ff94097..8ba0bd700 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/attribute-analytics-popup-menu.tsx @@ -22,7 +22,7 @@ import { NodeType, AttributeAnalyticsData, AttributeCategory, -} from '../../../../schema-utils/Types'; +} from '../../../../model/reactflow'; import { Filter } from './filterbar'; import { Search } from './searchbar'; import { NodeProps } from 'reactflow'; diff --git a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/filterbar.tsx b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/filterbar.tsx index 868e5607e..2c044fdc1 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/filterbar.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/filterbar.tsx @@ -22,7 +22,7 @@ import { AttributeAnalyticsData, AttributeCategory, NodeType, -} from '../../../../schema-utils/Types'; +} from '../../../../model/reactflow'; /** The typing for the props of the filter bar. */ type FilterbarProps = { diff --git a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-entity-popup.tsx b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-entity-popup.tsx index c609a3b8f..1e9b666e6 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-entity-popup.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-entity-popup.tsx @@ -12,7 +12,7 @@ import { ButtonBase, useTheme } from '@mui/material'; import React from 'react'; import { NodeProps } from 'reactflow'; -import { NodeQualityDataForEntities } from '../../../../schema-utils/Types'; +import { NodeQualityDataForEntities } from '../../../../model/reactflow'; /** * NodeQualityEntityPopupNode is the node that represents the popup that shows the node quality for an entity diff --git a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-relation-popup.tsx b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-relation-popup.tsx index f675545e4..7f99a7af1 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-relation-popup.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/node-quality-relation-popup.tsx @@ -15,7 +15,7 @@ import { NodeProps } from 'reactflow'; import { NodeQualityDataForRelations, NodeType, -} from '../../../../schema-utils/Types'; +} from '../../../../model/reactflow'; /** * NodeQualityRelationPopupNode is the node that represents the popup that shows the node quality for a relation diff --git a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/searchbar.tsx b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/searchbar.tsx index ba50b8495..4b5cf9cd5 100644 --- a/libs/shared/lib/schema/pills/nodes/popup/popupmenus/searchbar.tsx +++ b/libs/shared/lib/schema/pills/nodes/popup/popupmenus/searchbar.tsx @@ -10,7 +10,7 @@ * See testing plan for more details.*/ import React, { ReactElement, useState } from 'react'; -import { AttributeAnalyticsData } from '../../../../schema-utils/Types'; +import { AttributeAnalyticsData } from '../../../../model/reactflow'; /** The typing for the props of the searchbar. */ type SearchbarProps = { diff --git a/libs/shared/lib/schema/pills/nodes/relation/relation-node.tsx b/libs/shared/lib/schema/pills/nodes/relation/relation-node.tsx index ca4f7f16d..e209a5fd9 100644 --- a/libs/shared/lib/schema/pills/nodes/relation/relation-node.tsx +++ b/libs/shared/lib/schema/pills/nodes/relation/relation-node.tsx @@ -22,7 +22,7 @@ import { useTheme } from '@mui/material'; import { SchemaGraphRelation, SchemaGraphRelationWithFunctions, -} from '../../../schema-utils/Types'; +} from '../../../model/reactflow'; /** * Relation node component that renders a relation node for the schema. @@ -76,7 +76,7 @@ export const RelationNode = React.memo( <div onDragStart={(event) => onDragStart(event)} draggable - // style={{ width: 100, height: 100 }} + // style={{ width: 100, height: 100 }} > <div className={styles.relationNode} @@ -140,9 +140,8 @@ export const RelationNode = React.memo( ${theme.palette.custom.nodesBase} ${calculateAttributeQuality( data )}%, - ${ - theme.palette.custom.elements.relationSecond[0] - } ${calculateAttributeQuality(data)}%)`, + ${theme.palette.custom.elements.relationSecond[0] + } ${calculateAttributeQuality(data)}%)`, }} > <span @@ -165,9 +164,8 @@ export const RelationNode = React.memo( ${theme.palette.custom.nodesBase} ${calculateRelationQuality( data )}%, - ${ - theme.palette.custom.elements.relationSecond[0] - } ${calculateRelationQuality(data)}%)`, + ${theme.palette.custom.elements.relationSecond[0] + } ${calculateRelationQuality(data)}%)`, }} > <span diff --git a/libs/shared/lib/schema/schema-utils/schema-usecases-edge-deleteme.ts b/libs/shared/lib/schema/schema-utils/schema-usecases-edge-deleteme.ts deleted file mode 100644 index 08bdb1e63..000000000 --- a/libs/shared/lib/schema/schema-utils/schema-usecases-edge-deleteme.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { SchemaElements } from '../../model'; -import { Attributes } from 'graphology-types'; -import { getConnectedEdges, Node, Edge } from 'reactflow'; - - -/** This class is responsible for creating and rendering the edges of the schema */ -export default class EdgeUseCase { - private counterOutgoingEdges: number; - private counterIncomingEdges: number; - private distanceCounterNormalEdges = 40; - private initialOffsetOfNodes = 155 - this.distanceCounterNormalEdges; - private distanceCounterSelfEdges = 58; - private setRelationNodePosition: - | (( - centerX: number, - centerY: number, - id: string, - from: string, - to: string, - attributes: Attributes[], - ) => void) - | undefined; - - public constructor() { - this.counterOutgoingEdges = 1; - this.counterIncomingEdges = 1; - this.setRelationNodePosition = undefined; - } - - /** - * Use the drawOrder to draw the edges of the nodes starting with the top node. - * We check all connected edges of a node and create lists of incoming and outgoing edges. - * These lists are used in renderEdges to draw the edges for the current node. - * @param drawOrder The order in which the nodes will be drawn. - * @param setRelationNodePosition The function an edge needs to create its relation node. - * @returns {Edge[]} The list of edges that will be rendered. - */ - public positionEdges = ( - drawOrder: Node[], - elements: SchemaElements, - setRelationNodePosition: ( - centerX: number, - centerY: number, - id: string, - from: string, - to: string, - attributes: Attributes[], - ) => void, - ): Edge[] => { - let incomingEdges: Edge[]; - let outgoingEdges: Edge[]; - let edges: Edge[]; - let used: Edge[]; - let result: Edge[]; - let drawOrderID: string[]; - used = []; - result = []; - - this.counterOutgoingEdges = 1; - this.counterIncomingEdges = 1; - this.setRelationNodePosition = setRelationNodePosition; - drawOrderID = this.convertDrawOrder(drawOrder); - for (let i = 0; i < drawOrder.length; i++) { - incomingEdges = []; - outgoingEdges = []; - edges = getConnectedEdges([drawOrder[i]], elements.edges); - edges.forEach((edge) => { - if (!used.includes(edge)) { - if (edge.source == drawOrder[i].id) { - outgoingEdges.push(edge); - used.push(edge); - } else { - incomingEdges.push(edge); - used.push(edge); - } - } - }); - - result = [ - ...result, - ...this.renderEdges( - this.sortEdgeArray(incomingEdges, drawOrderID, true), - this.sortEdgeArray(outgoingEdges, drawOrderID, false), - drawOrderID, - drawOrder, - i, - ), - ]; - } - if (elements.selfEdges.length > 0) - return [...result, ...this.renderSelfEdges(elements.selfEdges, drawOrder, drawOrderID)]; - - return result; - }; - - /** - * Goes over the list of edges to position them in the schema. - * First goes through all incoming edges and then through the outgoing edges - * @param incomingEdges The incoming edges of the node. - * @param outgoingEdges The outgoing edges of the node. - * @param drawOrderID The converted drawOrder that only contains the id of the node. - * @param drawOrder The order in which the nodes will be drawn. - * @param currentNode Index of the Node in the drawOrder array. - * @returns {Edge[]} Draws the edge list for the rendering of the schema. - */ - public renderEdges = ( - incomingEdges: Edge[], - outgoingEdges: Edge[], - drawOrderID: string[], - drawOrder: Node[], - currentNode: number, - ): Edge[] => { - for (let i = 0; i < incomingEdges.length; i++) { - incomingEdges = this.renderIncomingEdges( - incomingEdges, - drawOrderID, - drawOrder, - currentNode, - i, - ); - } - - for (let i = 0; i < outgoingEdges.length; i++) { - outgoingEdges = this.renderOutgoingEdges( - outgoingEdges, - drawOrderID, - drawOrder, - currentNode, - i, - ); - } - return [...incomingEdges, ...outgoingEdges]; - }; - - /** - * Draw the incoming edges between the nodes in the correct positions for the currentNode. - * We use the currentNode and draw all the incoming edges of this node. - * Nodes directly beneath are drawn with an edge from the bottom to the top handle. - * For edges that go to nodes further away the right handle is used for incoming edges. - * @param incomingEdges The incoming edges of the node. - * @param drawOrderID The converted drawOrder that only contains the id of the node. - * @param drawOrder The order in which the nodes will be drawn. - * @param currentNode Index of the Node in the drawOrder array. - * @param i Index of the edge in the edge list. - * @returns {Edge[]} Draws the edge list for the incoming edges. - */ - public renderIncomingEdges = ( - incomingEdges: Edge[], - drawOrderID: string[], - drawOrder: Node[], - currentNode: number, - index: number, - ): Edge[] => { - if (incomingEdges[index].source == drawOrderID[currentNode + 1] && index == 0) { - // Draw nodes that are directly next to eachother with an edge from the top to the bottom - incomingEdges[0].sourceHandle = 'entitySourceTop'; - incomingEdges[0].targetHandle = 'entityTargetBottom'; - - // Add the handle id to the data of the specified node in order to display the correct handles - drawOrder[drawOrderID.indexOf(incomingEdges[index].source)].data.handles.push( - 'entitySourceTop', - ); - drawOrder[drawOrderID.indexOf(incomingEdges[index].target)].data.handles.push( - 'entityTargetBottom', - ); - - incomingEdges[index].data.setRelationNodePosition = this.setRelationNodePosition; - - incomingEdges[index].data.d = 0; - } else { - // Draw nodes that are not directly next to eachother with an edge from the right handle - incomingEdges[index].sourceHandle = 'entitySourceRight'; - incomingEdges[index].targetHandle = 'entityTargetRight'; - - // Add the handle id to the data of the specified node in order to display the correct handles - drawOrder[drawOrderID.indexOf(incomingEdges[index].source)].data.handles.push( - 'entitySourceRight', - ); - drawOrder[drawOrderID.indexOf(incomingEdges[index].target)].data.handles.push( - 'entityTargetRight', - ); - - // Create distance between the links with a counter - incomingEdges[index].data.d = - this.counterIncomingEdges * this.distanceCounterNormalEdges + this.initialOffsetOfNodes; - - incomingEdges[index].data.setRelationNodePosition = this.setRelationNodePosition; - - this.counterIncomingEdges++; - } - return incomingEdges; - }; - - /** - * Draw the outgoing edges between the nodes in the correct positions for the currentNode. - * We use the currentNode and draw all the outgoing edges of this node. - * Nodes directly beneath are drawn with an edge from the bottom to the top handle. - * For edges that go to nodes further away the right handle is used for outgoing edges. - * @param outgoingEdges The outgoing edges of the node. - * @param drawOrderID The converted drawOrder that only contains the id of the node. - * @param drawOrder The order in which the nodes will be drawn. - * @param currentNode Index of the Node in the drawOrder array. - * @param index Index of the edge in the edge list. - * @returns {Edge[]} Draws the edge list for the outgoing edges. - */ - public renderOutgoingEdges = ( - outgoingEdges: Edge[], - drawOrderID: string[], - drawOrder: Node[], - currentNode: number, - index: number, - ): Edge[] => { - if (outgoingEdges[index].target == drawOrderID[currentNode + 1] && index == 0) { - // Draw nodes that are directly next to eachother with a link from the top to the bottom - outgoingEdges[0].sourceHandle = 'entitySourceBottom'; - outgoingEdges[0].targetHandle = 'entityTargetTop'; - - // Add the handle id to the data of the specified node in order to display the correct handles - drawOrder[drawOrderID.indexOf(outgoingEdges[index].source)].data.handles.push( - 'entitySourceBottom', - ); - drawOrder[drawOrderID.indexOf(outgoingEdges[index].target)].data.handles.push( - 'entityTargetTop', - ); - - outgoingEdges[index].data.setRelationNodePosition = this.setRelationNodePosition; - - outgoingEdges[index].data.d = 0; - } else { - outgoingEdges[index].sourceHandle = 'entitySourceLeft'; - outgoingEdges[index].targetHandle = 'entityTargetLeft'; - - // Add the handle id to the data of the specified node in order to display the correct handle - drawOrder[drawOrderID.indexOf(outgoingEdges[index].source)].data.handles.push( - 'entitySourceLeft', - ); - drawOrder[drawOrderID.indexOf(outgoingEdges[index].target)].data.handles.push( - 'entityTargetLeft', - ); - - // Create distance between the links with a counter - outgoingEdges[index].data.d = - -this.counterOutgoingEdges * this.distanceCounterNormalEdges - this.initialOffsetOfNodes; - - outgoingEdges[index].data.setRelationNodePosition = this.setRelationNodePosition; - - this.counterOutgoingEdges++; - } - return outgoingEdges; - }; - - /** - * Sort edges depending on if they are incoming or outgoing edges. - * @param input List of edges to sort. - * @param drawOrderID Draw order by id to sort the list with. - * @param source Boolean that contains if the edge is a source or target. - * @returns {Edge[]} List of sorted edges. - * - */ - public sortEdgeArray(input: Edge[], drawOrderID: string[], source: boolean): Edge[] { - let output: Edge[]; - output = []; - input.forEach((edge) => { - if (source) output[drawOrderID.indexOf(edge.source)] = edge; - else output[drawOrderID.indexOf(edge.target)] = edge; - }); - - output = output.filter(function (element) { - return element != null; - }); - return output; - } - - /** - * Edges only know the id of the nodes, so we need to convert the nodes in the drawOrder to a list of their id's. - * This way the edges can reference nodes from this list. - * @param drawOrder The order in which the nodes will be drawn. - * @returns {string[]} List of orderd node id's. - */ - private convertDrawOrder(drawOrder: Node[]): string[] { - let orderedNodeIDs: string[] = []; - drawOrder.forEach((node) => { - orderedNodeIDs.push(node.id); - }); - return orderedNodeIDs; - } - - /** This creates the self-edges */ - private renderSelfEdges(selfEdges: Edge[], drawOrder: Node[], drawOrderID: string[]): Edge[] { - selfEdges.forEach((edge) => { - edge.sourceHandle = 'entitySourceLeft'; - edge.targetHandle = 'entityTargetRight'; - - // Add the handle id to the data of the specified node in order to display the correct handles - drawOrder[drawOrderID.indexOf(edge.source)].data.handles.push('entitySourceLeft'); - drawOrder[drawOrderID.indexOf(edge.target)].data.handles.push('entityTargetRight'); - - // Create distance between the links with a counter - edge.data.d = this.distanceCounterSelfEdges; - - edge.data.setRelationNodePosition = this.setRelationNodePosition; - }); - return selfEdges; - } -} diff --git a/libs/shared/lib/schema/schema-utils/schema-usecases.spec.ts b/libs/shared/lib/schema/schema-utils/schema-usecases.spec.ts index bdc7c4122..72c9a59cf 100644 --- a/libs/shared/lib/schema/schema-utils/schema-usecases.spec.ts +++ b/libs/shared/lib/schema/schema-utils/schema-usecases.spec.ts @@ -9,6 +9,7 @@ import { simpleSchemaRaw, twitterSchemaRaw, } from '@graphpolaris/shared/lib/mock-data'; +import { SchemaGraphology } from "../model"; describe('SchemaUsecases', () => { test.each([ @@ -29,9 +30,9 @@ describe('SchemaUsecases', () => { const parsed = SchemaUtils.schemaBackend2Graphology( simpleSchemaRaw as SchemaFromBackend ); - const reload = MultiGraph.from(parsed.export()); + const reload = SchemaGraphology.from(parsed.export()); - expect(parsed).toStrictEqual(reload); + expect(parsed).toEqual(reload); }); test.each([ diff --git a/libs/shared/lib/schema/schema-utils/schema-usecases.ts b/libs/shared/lib/schema/schema-utils/schema-usecases.ts index ccd647e2c..09b7ed3a7 100644 --- a/libs/shared/lib/schema/schema-utils/schema-usecases.ts +++ b/libs/shared/lib/schema/schema-utils/schema-usecases.ts @@ -1,8 +1,8 @@ -import { SchemaGraphNodeWithFunctions, SchemaGraphRelation, SchemaGraphRelationWithFunctions } from './Types'; +import { SchemaGraphNodeWithFunctions, SchemaGraphRelation, SchemaGraphRelationWithFunctions } from '../model/reactflow'; import Graph from 'graphology'; import { Attributes } from 'graphology-types'; import { MarkerType, Edge, Node } from 'reactflow'; -import { SchemaGraphNode } from '../schema-utils/Types'; +import { SchemaGraphNode } from '../model/reactflow'; //TODO does not belong here; maybe should go into the GraphPolarisThemeProvider const ANIMATEDEDGES = false; diff --git a/libs/shared/lib/schema/schema-utils/schema-utils.ts b/libs/shared/lib/schema/schema-utils/schema-utils.ts index d58124b57..e0cd75daf 100644 --- a/libs/shared/lib/schema/schema-utils/schema-utils.ts +++ b/libs/shared/lib/schema/schema-utils/schema-utils.ts @@ -1,42 +1,42 @@ -import { SchemaFromBackend, Node, Edge } from '@graphpolaris/shared/lib/model/backend'; -import Graph, { MultiGraph } from 'graphology'; -import { Attributes } from 'graphology-types'; +import { SchemaAttribute, SchemaFromBackend, SchemaGraphology, SchemaGraphologyNode } from '../model'; export class SchemaUtils { public static schemaBackend2Graphology( schemaFromBackend: SchemaFromBackend - ): Graph { + ): SchemaGraphology { const { nodes, edges } = schemaFromBackend; // Instantiate a directed graph that allows self loops and parallel edges - const schemaGraph = new MultiGraph({ allowSelfLoops: true }); + const schemaGraphology = new SchemaGraphology({ allowSelfLoops: true }); // console.log('parsing schema'); // The graph schema needs a node for each node AND edge. These need then be connected - nodes.forEach((node: Node) => { - schemaGraph.addNode(node.name, { + + nodes.forEach((node) => { + const attributes: SchemaGraphologyNode = { ...node, name: node.name, nodeCount: 0, summedNullAmount: 0, connectedRatio: 1, handles: ['entitySourceLeft', 'entityTargetLeft'], - attributes: { + attributes: [ ...node.attributes, - }, + ], x: 0, y: 0, - }); + } + schemaGraphology.addNode(node.name, attributes); // console.log(node, schemaGraph.nodes()); }); // The name of the edge will be name + from + to, since edge names are not unique - edges.forEach((edge: Edge) => { + edges.forEach((edge) => { const edgeID = [edge.name, '_', edge.from, edge.to].join(''); //ensure that all interpreted as string // This node is the actual edge - schemaGraph.addDirectedEdgeWithKey(edgeID, edge.from, edge.to, { + schemaGraphology.addDirectedEdgeWithKey(edgeID, edge.from, edge.to, { nodeCount: 0, summedNullAmount: 0, fromRatio: 0, @@ -48,7 +48,7 @@ export class SchemaUtils { attributes: edge.attributes, }); }); - return schemaGraph; + return schemaGraphology; } } diff --git a/libs/shared/lib/vis/nodelink/NodeLinkViewModel.tsx b/libs/shared/lib/vis/nodelink/NodeLinkViewModel.tsx index 6d5ab96ab..8f72e455e 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 358b43873..09e3295b2 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 000000000..2192b5612 --- /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 000000000..6d46ce235 --- /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 000000000..dd4f3bbc2 --- /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 000000000..d19e1292c --- /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 000000000..d6ef0a52a --- /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 000000000..0139761e2 --- /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 000000000..6876e9363 --- /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 000000000..82ca7b58b --- /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 000000000..d62ff563b --- /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 000000000..faf885025 --- /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 000000000..71c4bf8cf --- /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 000000000..e2811c96e --- /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 000000000..fe857ef4d --- /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 000000000..a93293b03 --- /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 000000000..009c546e9 --- /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 000000000..cfe7ee67e --- /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 59c0ff975..8799d2ec2 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 d4797f6f2..3826c893d 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 000000000..e69de29bb 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 000000000..725d7a88b --- /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 000000000..3474abc6c --- /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 000000000..834ec22a6 --- /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 000000000..818384e13 --- /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 000000000..454ffe6c0 --- /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 000000000..4476bb68f --- /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.t b/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.t new file mode 100644 index 000000000..2b1b6b267 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.t @@ -0,0 +1,1156 @@ +import Broker from '../../../../domain/entity/broker/broker'; +import SemanticSubstratesViewModelImpl from './SemanticSubstratesViewModelImpl'; +import mockNodeLinkResult from '../../../../data/mock-data/query-result/big2ndChamberQueryResult'; +import mockSchema from '../../../../data/mock-data/schema-result/2ndChamberSchemaMock'; +import { MinMaxType } from '../../../../domain/entity/semantic-substrates/structures/Types'; + +jest.mock('../../../view/result-visualisations/semantic-substrates/SemanticSubstratesStylesheet'); + +describe('SemanticSubstratesViewModelImpl: Broker subscriptions', () => { + it('should consume schema results when subscribed', () => { + const viewModel = new SemanticSubstratesViewModelImpl(); + + const mockConsumeMessages = jest.fn(); + viewModel.consumeMessageFromBackend = mockConsumeMessages; + + viewModel.subscribeToSchemaResult(); + Broker.instance().publish('test schema result', 'schema_result'); + expect(mockConsumeMessages.mock.calls[0][0]).toEqual('test schema result'); + + viewModel.unSubscribeFromSchemaResult(); + Broker.instance().publish('test schema result', 'schema_result'); + expect(mockConsumeMessages).toBeCalledTimes(1); + }); +}); + +describe('SemanticSubstratesViewModelImpl:OnPlotTitleChanged', () => { + it('should change the plot title', () => { + // Initialize a ViewModel with some plots. + const viewModelImpl = new SemanticSubstratesViewModelImpl(); + viewModelImpl.consumeMessageFromBackend(mockSchema); + viewModelImpl.consumeMessageFromBackend(mockNodeLinkResult); + + const onViewModelChangedMock = jest.fn(); + const baseViewMock = { + onViewModelChanged: onViewModelChangedMock, + }; + viewModelImpl.attachView(baseViewMock); + + const i = 1; + const oldPlotSpec = viewModelImpl.plotSpecifications[i]; + viewModelImpl.onPlotTitleChanged(i, 'commissies', 'naam', 'Defensie'); + + expect(viewModelImpl.plotSpecifications[i]).toEqual({ + ...oldPlotSpec, + entity: 'commissies', + labelAttributeType: 'naam', + labelAttributeValue: 'Defensie', + }); + + // And check if the NotifyViewAboutChanges is called. + expect(onViewModelChangedMock).toBeCalledTimes(1); + + viewModelImpl.onPlotTitleChanged(i, 'commissies', 'id', 'Defensie'); + expect(viewModelImpl.plotSpecifications[i]).toEqual({ + ...oldPlotSpec, + entity: 'commissies', + labelAttributeType: 'id', + labelAttributeValue: 'Defensie', + }); + }); +}); + +describe('SemanticSubstratesViewModelImpl', () => { + it('should apply default specs on consuming a new node link result', () => { + const viewModelImpl = new SemanticSubstratesViewModelImpl(); + + const onViewModelChangedMock = jest.fn(); + const baseViewMock = { + onViewModelChanged: onViewModelChangedMock, + }; + viewModelImpl.attachView(baseViewMock); + + viewModelImpl.consumeMessageFromBackend(mockSchema); + + // Consume a nodelink query result. + viewModelImpl.consumeMessageFromBackend(mockNodeLinkResult); + + let mockCalc = viewModelImpl.scaleCalculation; + let mockCalcColour = viewModelImpl.colourCalculation; + + const expectedPlots = [ + { + title: 'partij:VVD', + nodes: [ + { + id: 'kamerleden/148', + data: { text: 'kamerleden/148' }, + originalPosition: { x: 1, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/9ab41898-332f-4c08-a834-d58b4669c990.jpg?itok=pbUPRriP', + leeftijd: 43, + naam: 'Dilan Yeilgz-Zegerius', + partij: 'VVD', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 114.28571428571432, y: 100 }, + }, + { + id: 'kamerleden/132', + data: { text: 'kamerleden/132' }, + originalPosition: { x: 2, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/b625d2bf-da74-40a2-b013-2069a09dcb6c.jpg?itok=zMgno8bi', + leeftijd: 36, + naam: 'Peter Valstar', + partij: 'VVD', + woonplaats: "'s-Gravenzande", + }, + scaledPosition: { x: 133.99014778325127, y: 100 }, + }, + { + id: 'kamerleden/131', + data: { text: 'kamerleden/131' }, + originalPosition: { x: 3, y: 0 }, + attributes: { + anc: 1304, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/97ffe642-648f-4dab-a0e8-0720e96e3e35.jpg?itok=eDllv9dS', + leeftijd: 49, + naam: 'Judith Tielen', + partij: 'VVD', + woonplaats: 'Utrecht', + }, + scaledPosition: { x: 153.6945812807882, y: 100 }, + }, + { + id: 'kamerleden/128', + data: { text: 'kamerleden/128' }, + originalPosition: { x: 4, y: 0 }, + attributes: { + anc: 3171, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/2538ca6a-8254-4c7e-af71-a20db82afd30.jpg?itok=GvwO8NYO', + leeftijd: 46, + naam: 'Ockje Tellegen', + partij: 'VVD', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 173.39901477832515, y: 100 }, + }, + { + id: 'kamerleden/117', + data: { text: 'kamerleden/117' }, + originalPosition: { x: 5, y: 0 }, + attributes: { + anc: 2004, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/8b3664bd-77e4-468b-af96-f3f4ec27fcce.jpg?itok=ofrc9cnP', + leeftijd: 54, + naam: 'Mark Rutte', + partij: 'VVD', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 193.1034482758621, y: 100 }, + }, + { + id: 'kamerleden/106', + data: { text: 'kamerleden/106' }, + originalPosition: { x: 6, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/0e34f732-337a-4339-961f-8c18d68e714d.jpg?itok=FTBMCT9a', + leeftijd: 54, + naam: 'Marille Paul', + partij: 'VVD', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 212.80788177339906, y: 100 }, + }, + { + id: 'kamerleden/100', + data: { text: 'kamerleden/100' }, + originalPosition: { x: 7, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/2b223ce1-e251-462b-a5fc-971c92bbf37c.jpg?itok=MD8qI-l1', + leeftijd: 43, + naam: 'Daan de Neef', + partij: 'VVD', + woonplaats: 'Breda', + }, + scaledPosition: { x: 232.512315270936, y: 100 }, + }, + { + id: 'kamerleden/97', + data: { text: 'kamerleden/97' }, + originalPosition: { x: 8, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/cd02edbc-106e-46d1-8d70-6594741356ea.jpg?itok=Q9q13ntf', + leeftijd: 33, + naam: 'Fahid Minhas', + partij: 'VVD', + woonplaats: 'Schiedam', + }, + scaledPosition: { x: 252.21674876847294, y: 100 }, + }, + { + id: 'kamerleden/81', + data: { text: 'kamerleden/81' }, + originalPosition: { x: 9, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/8767e9de-869e-4e8a-940f-1a63d15eab3c.jpg?itok=tdoE8yQX', + leeftijd: 28, + naam: 'Daan de Kort', + partij: 'VVD', + woonplaats: 'Veldhoven', + }, + scaledPosition: { x: 271.9211822660099, y: 100 }, + }, + { + id: 'kamerleden/79', + data: { text: 'kamerleden/79' }, + originalPosition: { x: 10, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/88e0734b-8c03-4daa-8ec5-055e36c07030.jpg?itok=_ZteUeWk', + leeftijd: 39, + naam: 'Daniel Koerhuis', + partij: 'VVD', + woonplaats: 'Raalte', + }, + scaledPosition: { x: 291.6256157635468, y: 100 }, + }, + { + id: 'kamerleden/145', + data: { text: 'kamerleden/145' }, + originalPosition: { x: 11, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/8ee20f38-f3f0-4ff9-b5d9-84557b5ddcb1.jpg?itok=ExYCR1Ti', + leeftijd: 51, + naam: 'Hatte van der Woude', + partij: 'VVD', + woonplaats: 'Delft', + }, + scaledPosition: { x: 311.3300492610838, y: 100 }, + }, + { + id: 'kamerleden/70', + data: { text: 'kamerleden/70' }, + originalPosition: { x: 12, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/f23be4bd-7b2b-45dd-be84-4f76f11b624a.jpg?itok=DsfJ-8o0', + leeftijd: 43, + naam: 'Roelien Kamminga', + partij: 'VVD', + woonplaats: 'Groningen', + }, + scaledPosition: { x: 331.03448275862075, y: 100 }, + }, + { + id: 'kamerleden/127', + data: { text: 'kamerleden/127' }, + originalPosition: { x: 13, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/ce400601-651e-4ac9-a43e-ed8d4817b7fd.jpg?itok=wsISYKCx', + leeftijd: 44, + naam: 'Pim van Strien', + partij: 'VVD', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 350.7389162561577, y: 100 }, + }, + { + id: 'kamerleden/115', + data: { text: 'kamerleden/115' }, + originalPosition: { x: 14, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/4bf69aa9-29a7-44db-b7ee-8bfa40e343dd.jpg?itok=-BKeAg8F', + leeftijd: 32, + naam: 'Queeny Rajkowski', + partij: 'VVD', + woonplaats: 'Utrecht', + }, + scaledPosition: { x: 370.44334975369463, y: 100 }, + }, + { + id: 'kamerleden/64', + data: { text: 'kamerleden/64' }, + originalPosition: { x: 15, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/7954eb2c-1bf5-42af-80dc-85f29b367db0.jpg?itok=9ucjAERV', + leeftijd: 49, + naam: 'Folkert Idsinga', + partij: 'VVD', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 390.1477832512316, y: 100 }, + }, + { + id: 'kamerleden/60', + data: { text: 'kamerleden/60' }, + originalPosition: { x: 16, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/8509e44d-0f9b-413a-a2cd-f64d656c3955.jpg?itok=Iy2_G4Jx', + leeftijd: 53, + naam: 'Jacqueline van den Hil', + partij: 'VVD', + woonplaats: 'Goes', + }, + scaledPosition: { x: 409.8522167487685, y: 100 }, + }, + { + id: 'kamerleden/143', + data: { text: 'kamerleden/143' }, + originalPosition: { x: 17, y: 0 }, + attributes: { + anc: 1823, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/7d18b6e3-de0c-4aea-bef8-242bbc38e936.jpg?itok=zebZoGjq', + leeftijd: 43, + naam: 'Jeroen van Wijngaarden', + partij: 'VVD', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 429.55665024630554, y: 100 }, + }, + { + id: 'kamerleden/56', + data: { text: 'kamerleden/56' }, + originalPosition: { x: 18, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/a3b17348-54cf-4bca-82d4-e62bd46c2fbc.jpg?itok=MF1OOpeK', + leeftijd: 40, + naam: 'Eelco Heinen', + partij: 'VVD', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 449.26108374384245, y: 100 }, + }, + { + id: 'kamerleden/142', + data: { text: 'kamerleden/142' }, + originalPosition: { x: 19, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/c3b1dca8-2cb4-4025-9527-36163a098c1f.jpg?itok=3BnXwQgy', + leeftijd: 35, + naam: 'Dennis Wiersma', + partij: 'VVD', + woonplaats: 'De Bilt', + }, + scaledPosition: { x: 468.96551724137936, y: 100 }, + }, + { + id: 'kamerleden/46', + data: { text: 'kamerleden/46' }, + originalPosition: { x: 20, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/a0b48551-42b7-4c55-9556-6b997d978ff2.jpg?itok=-A3mAzHe', + leeftijd: 41, + naam: 'Peter de Groot', + partij: 'VVD', + woonplaats: 'Harderwijk', + }, + scaledPosition: { x: 488.6699507389164, y: 100 }, + }, + { + id: 'kamerleden/38', + data: { text: 'kamerleden/38' }, + originalPosition: { x: 21, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/9c8bc5bc-8d13-4c11-be37-57a43a1d734b.jpg?itok=TqEaAJCz', + leeftijd: 30, + naam: 'Silvio Erkens', + partij: 'VVD', + woonplaats: 'Kerkrade', + }, + scaledPosition: { x: 508.3743842364533, y: 100 }, + }, + { + id: 'kamerleden/35', + data: { text: 'kamerleden/35' }, + originalPosition: { x: 22, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/ea21d788-7cfe-41ff-b22f-0664397139af.jpg?itok=4G8NOaak', + leeftijd: 32, + naam: 'Ulysse Ellian', + partij: 'VVD', + woonplaats: 'Almere', + }, + scaledPosition: { x: 528.0788177339903, y: 100 }, + }, + { + id: 'kamerleden/33', + data: { text: 'kamerleden/33' }, + originalPosition: { x: 23, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/c4f7c468-721e-4953-97f2-bff05fe570c4.jpg?itok=MmXfOgJx', + leeftijd: 41, + naam: 'Zohair El Yassini', + partij: 'VVD', + woonplaats: 'Utrecht', + }, + scaledPosition: { x: 547.7832512315272, y: 100 }, + }, + { + id: 'kamerleden/25', + data: { text: 'kamerleden/25' }, + originalPosition: { x: 24, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/bf494e35-c009-4c74-a5c6-3cb2cd26bb51.jpg?itok=2CTfrPhP', + leeftijd: 31, + naam: 'Thom van Campen', + partij: 'VVD', + woonplaats: 'Zwolle', + }, + scaledPosition: { x: 567.4876847290642, y: 100 }, + }, + { + id: 'kamerleden/54', + data: { text: 'kamerleden/54' }, + originalPosition: { x: 25, y: 0 }, + attributes: { + anc: 2601, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/522d59a0-10ba-488f-a27a-a1dda7581900.jpg?itok=5b_3G5nE', + leeftijd: 43, + naam: 'Rudmer Heerema', + partij: 'VVD', + woonplaats: 'Alkmaar', + }, + scaledPosition: { x: 587.1921182266011, y: 100 }, + }, + { + id: 'kamerleden/96', + data: { text: 'kamerleden/96' }, + originalPosition: { x: 26, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/973da897-66c7-4010-a98d-505e0e97a60c.jpg?itok=HLNJtdsA', + leeftijd: 44, + naam: 'Ingrid Michon-Derkzen', + partij: 'VVD', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 606.896551724138, y: 100 }, + }, + { + id: 'kamerleden/9', + data: { text: 'kamerleden/9' }, + originalPosition: { x: 27, y: 0 }, + attributes: { + anc: 1414, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/a962ffcb-a300-45e1-8850-70c7173c233e.jpg?itok=0f-c2lGz', + leeftijd: 35, + naam: 'Bente Becker', + partij: 'VVD', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 626.600985221675, y: 100 }, + }, + { + id: 'kamerleden/23', + data: { text: 'kamerleden/23' }, + originalPosition: { x: 28, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/e5c4753f-d2d8-4a4a-ae9d-62118892a731.jpg?itok=iGnPsRMX', + leeftijd: 34, + naam: 'Ruben Brekelmans', + partij: 'VVD', + woonplaats: 'Oisterwijk', + }, + scaledPosition: { x: 646.3054187192118, y: 100 }, + }, + { + id: 'kamerleden/135', + data: { text: 'kamerleden/135' }, + originalPosition: { x: 29, y: 0 }, + attributes: { + anc: 3122, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/b8d002bc-ea6c-4f57-b36e-38415b205e09.jpg?itok=tHQU-Qrn', + leeftijd: 56, + naam: 'Aukje de Vries', + partij: 'VVD', + woonplaats: 'Leeuwarden', + }, + scaledPosition: { x: 666.0098522167489, y: 100 }, + }, + { + id: 'kamerleden/0', + data: { text: 'kamerleden/0' }, + originalPosition: { x: 30, y: 0 }, + attributes: { + anc: 987, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/397c857a-fda0-414d-8fdc-8288cd3284aa.jpg?itok=55l5zRvr', + leeftijd: 31, + naam: 'Thierry Aartsen', + partij: 'VVD', + woonplaats: 'Breda', + }, + scaledPosition: { x: 685.7142857142858, y: 100 }, + }, + ], + selectedAttributeCatecorigal: 'state', + selectedAttributeNumerical: 'long', + scaleCalculation: mockCalc, + colourCalculation: mockCalcColour, + minmaxXAxis: { min: -4.800000000000001, max: 35.8 }, + minmaxYAxis: { min: -1, max: 1 }, + width: 800, + height: 200, + yOffset: 0, + possibleTitleAttributeValues: [ + 'VVD', + 'D66', + 'GL', + 'PVV', + 'PvdD', + 'PvdA', + 'SGP', + 'BIJ1', + 'CU', + 'BBB', + 'CDA', + 'SP', + 'FVD', + 'DENK', + 'Fractie Den Haan', + 'Volt', + 'JA21', + ], + }, + { + title: 'partij:D66', + nodes: [ + { + id: 'kamerleden/141', + data: { text: 'kamerleden/141' }, + originalPosition: { x: 1, y: 0 }, + attributes: { + anc: 3171, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/d1bb566e-a111-43b7-8856-273ebac1f753.jpg?itok=-k-RCynx', + leeftijd: 48, + naam: 'Steven van Weyenberg', + partij: 'D66', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 114.2857142857143, y: 100 }, + }, + { + id: 'kamerleden/138', + data: { text: 'kamerleden/138' }, + originalPosition: { x: 2, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/6a54c095-9872-4322-81be-114b74233d40.jpg?itok=uf9jc1Gk', + leeftijd: 36, + naam: 'Hanneke van der Werf', + partij: 'D66', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 140.25974025974028, y: 100 }, + }, + { + id: 'kamerleden/123', + data: { text: 'kamerleden/123' }, + originalPosition: { x: 3, y: 0 }, + attributes: { + anc: 1304, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/da9f33b0-80ca-49eb-a1ec-def3b46538d9.jpg?itok=nL6HIBWH', + leeftijd: 38, + naam: 'Joost Sneller', + partij: 'D66', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 166.23376623376626, y: 100 }, + }, + { + id: 'kamerleden/114', + data: { text: 'kamerleden/114' }, + originalPosition: { x: 4, y: 0 }, + attributes: { + anc: 1414, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/6fc5878e-d76a-4e9d-a4e3-e5ef581bb2ff.jpg?itok=qf_oudwu', + leeftijd: 30, + naam: 'Rens Raemakers', + partij: 'D66', + woonplaats: 'Neer', + }, + scaledPosition: { x: 192.20779220779224, y: 100 }, + }, + { + id: 'kamerleden/147', + data: { text: 'kamerleden/147' }, + originalPosition: { x: 5, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/45ef5aae-a6d0-4bad-861e-dd5b883a9665.jpg?itok=h5aEZJVM', + leeftijd: 56, + naam: 'Jorien Wuite', + partij: 'D66', + woonplaats: 'Voorburg', + }, + scaledPosition: { x: 218.18181818181822, y: 100 }, + }, + { + id: 'kamerleden/107', + data: { text: 'kamerleden/107' }, + originalPosition: { x: 6, y: 0 }, + attributes: { + anc: 42, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/d89044ef-b7df-4a15-be10-d342fe381664.jpg?itok=gmiV1adi', + leeftijd: 42, + naam: 'Wieke Paulusma', + partij: 'D66', + woonplaats: 'Groningen', + }, + scaledPosition: { x: 244.1558441558442, y: 100 }, + }, + { + id: 'kamerleden/105', + data: { text: 'kamerleden/105' }, + originalPosition: { x: 7, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/2690bccc-5463-459a-b1df-cb93e222b348.jpg?itok=UZOQRvuv', + leeftijd: 37, + naam: 'Jan Paternotte', + partij: 'D66', + woonplaats: 'Leiden', + }, + scaledPosition: { x: 270.1298701298702, y: 100 }, + }, + { + id: 'kamerleden/94', + data: { text: 'kamerleden/94' }, + originalPosition: { x: 8, y: 0 }, + attributes: { + anc: 3171, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/17f26a48-9f54-42a1-9890-c6da761a9a2f.jpg?itok=BA8JoO5G', + leeftijd: 65, + naam: 'Paul van Meenen', + partij: 'D66', + woonplaats: 'Leiden', + }, + scaledPosition: { x: 296.10389610389615, y: 100 }, + }, + { + id: 'kamerleden/86', + data: { text: 'kamerleden/86' }, + originalPosition: { x: 9, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/fb291eb2-962a-482f-971a-0b62673f9a86.jpg?itok=2J7OszgV', + leeftijd: 41, + naam: 'Jeanet van der Laan', + partij: 'D66', + woonplaats: 'Lisse', + }, + scaledPosition: { x: 322.0779220779221, y: 100 }, + }, + { + id: 'kamerleden/71', + data: { text: 'kamerleden/71' }, + originalPosition: { x: 10, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/8349fff0-99b3-449a-bd4d-932ae95b29da.jpg?itok=xYICmuaX', + leeftijd: 37, + naam: 'Hlya Kat', + partij: 'D66', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 348.0519480519481, y: 100 }, + }, + { + id: 'kamerleden/69', + data: { text: 'kamerleden/69' }, + originalPosition: { x: 11, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/64457f28-37b6-4948-860a-f28bc62b99ae.jpg?itok=Q2hiR0vj', + leeftijd: 59, + naam: 'Sigrid Kaag', + partij: 'D66', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 374.02597402597405, y: 100 }, + }, + { + id: 'kamerleden/68', + data: { text: 'kamerleden/68' }, + originalPosition: { x: 12, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/666f31ca-041f-466d-9e78-804317921d69.jpg?itok=G3ozh6C3', + leeftijd: 37, + naam: 'Romke de Jong', + partij: 'D66', + woonplaats: 'Gorredijk', + }, + scaledPosition: { x: 400.0000000000001, y: 100 }, + }, + { + id: 'kamerleden/66', + data: { text: 'kamerleden/66' }, + originalPosition: { x: 13, y: 0 }, + attributes: { + anc: 1527, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/49be3576-cea3-46c0-87eb-89beb108248d.jpg?itok=VfqikY8P', + leeftijd: 34, + naam: 'Rob Jetten', + partij: 'D66', + woonplaats: 'Ubbergen', + }, + scaledPosition: { x: 425.97402597402595, y: 100 }, + }, + { + id: 'kamerleden/134', + data: { text: 'kamerleden/134' }, + originalPosition: { x: 14, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/19c8d0fb-66ca-4b45-be82-057cb36e1e61.jpg?itok=Bkq8gaTU', + leeftijd: 57, + naam: 'Hans Vijlbrief', + partij: 'D66', + woonplaats: 'Woubrugge', + }, + scaledPosition: { x: 451.94805194805195, y: 100 }, + }, + { + id: 'kamerleden/52', + data: { text: 'kamerleden/52' }, + originalPosition: { x: 15, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/03d66e4a-8698-44fb-9c29-625630da268c.jpg?itok=hZ9q6Llc', + leeftijd: 39, + naam: 'Alexander Hammelburg', + partij: 'D66', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 477.9220779220779, y: 100 }, + }, + { + id: 'kamerleden/51', + data: { text: 'kamerleden/51' }, + originalPosition: { x: 16, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/425ddb12-0b71-44b6-9875-70b075dc99f7.jpg?itok=txVxBI0l', + leeftijd: 34, + naam: 'Kiki Hagen', + partij: 'D66', + woonplaats: 'Mijdrecht', + }, + scaledPosition: { x: 503.8961038961039, y: 100 }, + }, + { + id: 'kamerleden/47', + data: { text: 'kamerleden/47' }, + originalPosition: { x: 17, y: 0 }, + attributes: { + anc: 1525, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/762e0f40-f100-4896-85a2-b1d8818fae41.jpg?itok=YM5w7UBV', + leeftijd: 53, + naam: 'Tjeerd de Groot', + partij: 'D66', + woonplaats: 'Haarlem', + }, + scaledPosition: { x: 529.8701298701299, y: 100 }, + }, + { + id: 'kamerleden/121', + data: { text: 'kamerleden/121' }, + originalPosition: { x: 18, y: 0 }, + attributes: { + anc: 3171, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/bf030048-0434-42f1-bbed-8b0c04014db4.jpg?itok=G35Rmw4M', + leeftijd: 39, + naam: 'Sjoerd Sjoerdsma', + partij: 'D66', + woonplaats: "'s-Gravenhage", + }, + scaledPosition: { x: 555.8441558441559, y: 100 }, + }, + { + id: 'kamerleden/42', + data: { text: 'kamerleden/42' }, + originalPosition: { x: 19, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/49e7fc50-90f5-4476-9275-c31f88864082.jpg?itok=n7ZLWx8e', + leeftijd: 48, + naam: 'Lisa van Ginneken', + partij: 'D66', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 581.8181818181819, y: 100 }, + }, + { + id: 'kamerleden/22', + data: { text: 'kamerleden/22' }, + originalPosition: { x: 20, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/e85125f3-b7cf-426c-9355-412838893f55.jpg?itok=Qs1_f_yV', + leeftijd: 42, + naam: 'Faissal Boulakjar', + partij: 'D66', + woonplaats: 'Teteringen', + }, + scaledPosition: { x: 607.7922077922078, y: 100 }, + }, + { + id: 'kamerleden/21', + data: { text: 'kamerleden/21' }, + originalPosition: { x: 21, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/13e26587-88d8-440b-bb93-527a8e845342.jpg?itok=18OPNvjY', + leeftijd: 45, + naam: 'Raoul Boucke', + partij: 'D66', + woonplaats: 'Rotterdam', + }, + scaledPosition: { x: 633.7662337662338, y: 100 }, + }, + { + id: 'kamerleden/14', + data: { text: 'kamerleden/14' }, + originalPosition: { x: 22, y: 0 }, + attributes: { + anc: 3171, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/aa0272f6-6e3c-41f7-b040-b774b30d2196.jpg?itok=IQu5H4Bx', + leeftijd: 49, + naam: 'Vera Bergkamp', + partij: 'D66', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 659.7402597402597, y: 100 }, + }, + { + id: 'kamerleden/12', + data: { text: 'kamerleden/12' }, + originalPosition: { x: 23, y: 0 }, + attributes: { + anc: 1948, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/29124ef6-e1d5-4138-bf01-8433afde7269.jpg?itok=qw0TE4jl', + leeftijd: 42, + naam: 'Salima Belhaj', + partij: 'D66', + woonplaats: 'Rotterdam', + }, + scaledPosition: { x: 685.7142857142858, y: 100 }, + }, + ], + selectedAttributeCatecorigal: 'state', + selectedAttributeNumerical: 'long', + scaleCalculation: mockCalc, + colourCalculation: mockCalcColour, + minmaxXAxis: { min: -3.4000000000000004, max: 27.4 }, + minmaxYAxis: { min: -1, max: 1 }, + width: 800, + height: 200, + yOffset: 250, + possibleTitleAttributeValues: [ + 'VVD', + 'D66', + 'GL', + 'PVV', + 'PvdD', + 'PvdA', + 'SGP', + 'BIJ1', + 'CU', + 'BBB', + 'CDA', + 'SP', + 'FVD', + 'DENK', + 'Fractie Den Haan', + 'Volt', + 'JA21', + ], + }, + { + title: 'partij:GL', + nodes: [ + { + id: 'kamerleden/140', + data: { text: 'kamerleden/140' }, + originalPosition: { x: 1, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/c7822b58-103f-4612-87ef-648be97192c6.jpg?itok=jDwKCC26', + leeftijd: 39, + naam: 'Lisa Westerveld', + partij: 'GL', + woonplaats: 'Nijmegen', + }, + scaledPosition: { x: 114.28571428571432, y: 100 }, + }, + { + id: 'kamerleden/89', + data: { text: 'kamerleden/89' }, + originalPosition: { x: 2, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/66b2be3d-10f8-46a4-90c6-b86005df0a50.jpg?itok=obcIgEe-', + leeftijd: 31, + naam: 'Senna Maatoug', + partij: 'GL', + woonplaats: 'Leiden', + }, + scaledPosition: { x: 209.52380952380958, y: 100 }, + }, + { + id: 'kamerleden/124', + data: { text: 'kamerleden/124' }, + originalPosition: { x: 3, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/b50b3f0b-afc8-46e5-bfda-cb60e2935a5a.jpg?itok=7R0cTE10', + leeftijd: 55, + naam: 'Bart Snels', + partij: 'GL', + woonplaats: 'Utrecht', + }, + scaledPosition: { x: 304.7619047619048, y: 100 }, + }, + { + id: 'kamerleden/87', + data: { text: 'kamerleden/87' }, + originalPosition: { x: 4, y: 0 }, + attributes: { + anc: 1526, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/75a44c3a-5914-4c8e-9ca4-d987853f5844.jpg?itok=kA8ZaoNf', + leeftijd: 56, + naam: 'Tom van der Lee', + partij: 'GL', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 400.0000000000001, y: 100 }, + }, + { + id: 'kamerleden/24', + data: { text: 'kamerleden/24' }, + originalPosition: { x: 5, y: 0 }, + attributes: { + anc: 1085, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/ccea26dd-dcae-4174-8945-ec5cb568e951.jpg?itok=z7gsBQYd', + leeftijd: 51, + naam: 'Laura Bromet', + partij: 'GL', + woonplaats: 'Monnickendam', + }, + scaledPosition: { x: 495.23809523809535, y: 100 }, + }, + { + id: 'kamerleden/20', + data: { text: 'kamerleden/20' }, + originalPosition: { x: 6, y: 0 }, + attributes: { + anc: 57, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/dab4cf29-4843-4261-b09b-973fe98dbf65.jpg?itok=szVeNy9I', + leeftijd: 27, + naam: 'Kauthar Bouchallikh', + partij: 'GL', + woonplaats: 'Amsterdam', + }, + scaledPosition: { x: 590.4761904761906, y: 100 }, + }, + { + id: 'kamerleden/34', + data: { text: 'kamerleden/34' }, + originalPosition: { x: 7, y: 0 }, + attributes: { + anc: 1637, + img: 'https://www.tweedekamer.nl/sites/default/files/styles/member_parlement_profile_square/public/551f9a19-738c-4298-afe1-5ca7959ab74b.jpg?itok=24RXpYrd', + leeftijd: 45, + naam: 'Corinne Ellemeet', + partij: 'GL', + woonplaats: 'Abcoude', + }, + scaledPosition: { x: 685.7142857142859, y: 100 }, + }, + ], + selectedAttributeCatecorigal: 'state', + selectedAttributeNumerical: 'long', + scaleCalculation: mockCalc, + colourCalculation: mockCalcColour, + minmaxXAxis: { min: -0.20000000000000018, max: 8.2 }, + minmaxYAxis: { min: -1, max: 1 }, + width: 800, + height: 200, + yOffset: 500, + possibleTitleAttributeValues: [ + 'VVD', + 'D66', + 'GL', + 'PVV', + 'PvdD', + 'PvdA', + 'SGP', + 'BIJ1', + 'CU', + 'BBB', + 'CDA', + 'SP', + 'FVD', + 'DENK', + 'Fractie Den Haan', + 'Volt', + 'JA21', + ], + }, + ]; + + // Check the plots and the relations. + expect(viewModelImpl.plots).toEqual(expectedPlots); + expect(viewModelImpl.allRelations).toEqual(expectedRelations); + + // Also check if notifyViewAboutChanges is called. + expect(onViewModelChangedMock).toHaveBeenCalledTimes(2); + }); + + it('add a plot and delete itonDelete', () => { + const viewModelImpl = new SemanticSubstratesViewModelImpl(); + + const onViewModelChangedMock = jest.fn(); + const baseViewMock = { + onViewModelChanged: onViewModelChangedMock, + }; + viewModelImpl.attachView(baseViewMock); + + viewModelImpl.onDelete(1); + }); + + it('onBrush', () => { + const viewModelImpl = new SemanticSubstratesViewModelImpl(); + viewModelImpl.consumeMessageFromBackend(mockSchema); + viewModelImpl.consumeMessageFromBackend(mockNodeLinkResult); + + const onViewModelChangedMock = jest.fn(); + const baseViewMock = { + onViewModelChanged: onViewModelChangedMock, + }; + viewModelImpl.attachView(baseViewMock); + + let expectedFilters: { x: MinMaxType; y: MinMaxType } = { + x: { min: 30, max: 40 }, + y: { min: 30, max: 40 }, + }; + + viewModelImpl.onBrush(1, { min: 30, max: 40 }, { min: 30, max: 40 }); + + expect(viewModelImpl.filtersPerPlot[1]).toEqual(expectedFilters); + }); + + it('onAxisChanges', () => { + const viewModelImpl = new SemanticSubstratesViewModelImpl(); + viewModelImpl.consumeMessageFromBackend(mockSchema); + viewModelImpl.consumeMessageFromBackend(mockNodeLinkResult); + + const onViewModelChangedMock = jest.fn(); + const baseViewMock = { + onViewModelChanged: onViewModelChangedMock, + }; + viewModelImpl.attachView(baseViewMock); + + const expectedX = 'nieuweXaxis'; + const expectedY = 'nieuweYaxis'; + const expectedX2 = 'evenly spaced'; + const expectedY2 = '# outbound connections'; + + // change labels + viewModelImpl.onAxisLabelChanged(1, 'x', 'nieuweXaxis'); + viewModelImpl.onAxisLabelChanged(1, 'y', 'nieuweYaxis'); + + expect(viewModelImpl.plotSpecifications[1].xAxisAttributeType).toEqual(expectedX); + expect(viewModelImpl.plotSpecifications[1].yAxisAttributeType).toEqual(expectedY); + + // change labels again + viewModelImpl.onAxisLabelChanged(1, 'x', 'evenly spaced'); + viewModelImpl.onAxisLabelChanged(1, 'y', '# outbound connections'); + + expect(viewModelImpl.plotSpecifications[1].xAxis).toEqual(expectedX2); + expect(viewModelImpl.plotSpecifications[1].yAxis).toEqual(expectedY2); + }); + + it('onCheckboxChanges', () => { + const viewModelImpl = new SemanticSubstratesViewModelImpl(); + viewModelImpl.consumeMessageFromBackend(mockSchema); + viewModelImpl.consumeMessageFromBackend(mockNodeLinkResult); + + const onViewModelChangedMock = jest.fn(); + const baseViewMock = { + onViewModelChanged: onViewModelChangedMock, + }; + viewModelImpl.attachView(baseViewMock); + + viewModelImpl.onCheckboxChanged(1, 2, true); + + expect(viewModelImpl.visibleRelations[1][2]).toBeTruthy(); + }); + + it('addPlot', () => { + const viewModelImpl = new SemanticSubstratesViewModelImpl(); + viewModelImpl.consumeMessageFromBackend(mockSchema); + viewModelImpl.consumeMessageFromBackend(mockNodeLinkResult); + + const onViewModelChangedMock = jest.fn(); + const baseViewMock = { + onViewModelChanged: onViewModelChangedMock, + }; + viewModelImpl.attachView(baseViewMock); + + expect(viewModelImpl.plots).toHaveLength(3); + + viewModelImpl.addPlot('partij', 'naam', 'VVD'); + + expect(viewModelImpl.plots).toHaveLength(4); + }); +}); + +const deletePlot = [ + { + title: 'partij:VVD', + nodes: [ + { + id: 'kamerleden/148', + data: { + text: 'kamerleden/148', + }, + originalPosition: { + x: 1, + y: 0, + }, + scaledPosition: { + x: 114.28571428571432, + y: 100, + }, + }, + ], + }, +]; + +const expectedRelations = [ + [[], [], []], + [[], [], []], + [[], [], []], +]; diff --git a/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx b/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx new file mode 100644 index 000000000..04ea7bf0c --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/SemanticSubstratesViewModel.tsx @@ -0,0 +1,68 @@ +/** + * 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 { + MinMaxType, + PlotType, + RelationType, + AxisLabel, + PlotSpecifications, + EntitiesFromSchema, +} from './Types'; +import CalcScaledPosUseCase from './utils/CalcScaledPositionsUseCase'; +import FilterUseCase from './utils/FilterUseCase'; + +import CalcDefaultPlotSpecsUseCase from './utils/CalcDefaultPlotSpecsUseCase'; +import ToPlotDataParserUseCase from './utils/ToPlotDataParserUseCase'; +import CalcXYMinMaxUseCase from './utils/CalcXYMinMaxUseCase'; + +import { ClassNameMap } from '@mui/material'; +import { NodeLinkResultType, isNodeLinkResult } from '../shared/ResultNodeLinkParserUseCase'; +import CalcEntityAttrNamesFromSchemaUseCase from './utils/CalcEntityAttrNamesFromSchemaUseCase'; +import CalcEntityAttrNamesFromResultUseCase from './utils/CalcEntityAttrNamesFromResultUseCase'; +import { isSchemaResult } from '../shared/SchemaResultType'; + +/** An implementation of the `SemanticSubstratesViewModel` to be used by the semantic-substrates view. */ +export default class SemanticSubstratesViewModel { + /** + * These functions are mock function for now, but can be properly implemented later down the line. + */ + public scaleCalculation: (x: number) => number = (x: number) => { + return 3; + }; + public colourCalculation: (x: string) => string = (x: string) => { + return '#d56a50'; + }; + public relationColourCalculation: (x: string) => string = (x: string) => { + return '#d49350'; + }; + private relationScaleCalculation: (x: number) => number = (x: number) => { + return 1; + }; + + /** Initializes the plots and relations arrays as empty arrays. */ + public constructor(state: SemanticSubstratesState, setState) { + this.state = state; + this.setState = setState; + } + + + + + + // /** + // * Subscribe to the schema result routing key so that this view model is receiving results that are to be visualised. + // */ + // public subscribeToSchemaResult(): void { + // Broker.instance().subscribe(this, 'schema_result'); + // } + + // /** + // * Unsubscribe from the schema result routing key so that this view model no longer handles query results from the backend. + // */ + // public unSubscribeFromSchemaResult(): void { + // Broker.instance().unSubscribe(this, 'schema_result'); + // } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/Types.tsx b/libs/shared/lib/vis/semanticsubstrates/Types.tsx new file mode 100644 index 000000000..28691f3ce --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/Types.tsx @@ -0,0 +1,104 @@ +/** + * 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 { XYPosition } from "reactflow"; + +/** Stores the minimum and maximum of the data. */ +export type MinMaxType = { + min: number; + max: number; +}; + +/** The data of a node. */ +export type NodeData = { + text: string; +}; + +/** The from and to index of a relation. */ +export type RelationType = { + fromIndex: number; + toIndex: number; + value?: number; + colour?: string; +}; + +/** Used as input for calculating the min max values and scale the position. */ +export type PlotInputData = { + title: string; + nodes: InputNodeType[]; + width: number; + height: number; +}; + +/** The nodes for inputdata for plot generation. */ +export interface InputNodeType { + id: string; + data: NodeData; + originalPosition: XYPosition; + attributes: Record<string, any>; +} + +/** The node type for nodes in plots. */ +export interface NodeType extends InputNodeType { + scaledPosition: XYPosition; +} + +/** Used for semantic substrate components as input. */ +export type PlotType = { + title: string; + nodes: NodeType[]; + minmaxXAxis: { min: number; max: number }; + minmaxYAxis: { min: number; max: number }; + selectedAttributeNumerical: string; + selectedAttributeCatecorigal: string; + scaleCalculation: (x: number) => number; + colourCalculation: (x: string) => string; + width: number; + height: number; + yOffset: number; + + //Used for autocompleting the attribute value in a plot title/filter + possibleTitleAttributeValues: string[]; +}; + +/** The entities with names and attribute parameters from the schema. */ +export type EntitiesFromSchema = { + entityNames: string[]; + attributesPerEntity: Record<string, AttributeNames>; +}; + +/** The names of attributes per datatype. */ +export type AttributeNames = { + textAttributeNames: string[]; + numberAttributeNames: string[]; + boolAttributeNames: string[]; +}; + +/** The specifications for filtering nodes and scaling a plot. */ +export type PlotSpecifications = { + entity: string; + + labelAttributeType: string; + labelAttributeValue: string; + + xAxis: AxisLabel; + yAxis: AxisLabel; + + // If the axisPositioning is attributeType, this will be used to index the attributes of a node + xAxisAttributeType: string; + yAxisAttributeType: string; + + width: number; + height: number; +}; + +/** The default possible options for an axis label. */ +export enum AxisLabel { + evenlySpaced = 'evenly spaced', + outboundConnections = '# outbound connections', + inboundConnections = '# inbound connections', + byAttribute = 'byAttribute', +} diff --git a/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstrateConfigPanel.tsx b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstrateConfigPanel.tsx new file mode 100644 index 000000000..8270164c6 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstrateConfigPanel.tsx @@ -0,0 +1,80 @@ +/** + * 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, { useRef, useState } from 'react'; +import styles from '../SemanticSubstratesComponent.module.scss'; +import { + EntityWithAttributes, + FSSConfigPanelProps, +} from './Types'; +import SemanticSubstratesConfigPanelViewModelImpl from './SemanticSubstratesConfigPanelViewModel'; +import SemanticSubstratesConfigPanelViewModel from './SemanticSubstratesConfigPanelViewModel'; + +/** Component for rendering config input fields for Faceted Semantic Subrate attributes. */ +export default function FSSConfigPanel(props: FSSConfigPanelProps) { + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModel = + new SemanticSubstratesConfigPanelViewModelImpl(props); + + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.makeRelationTypes(); + + return ( + <div className={styles.container}> + <p className={styles.title}>Semantic Substrates Settings:</p> + + {/* Experimental options implementation */} + <p className={styles.subtitle}>- Visual Assignment</p> + <div className={styles.subContainer}> + <p>Node: </p> + <select onChange={(e) => FSSConfigPanelViewModel.onNodeChange(e.target.value)}> + {FSSConfigPanelViewModel.nodes.map((n) => ( + <option key={n.name} value={n.name}> + {n.name} + </option> + ))} + </select> + </div> + + {/* NODE ATTRIBUTES */} + <div className={styles.subContainer}> + <p>Attribute: </p> + <select + id="attribute-select" + onChange={(e) => { + FSSConfigPanelViewModel.onAttributeChange(e.target.value); + }} + > + {FSSConfigPanelViewModel.getUniqueNodeAttributes().map((name) => ( + <option key={name} value={name}> + {name} + </option> + ))} + </select> + </div> + + {/* RELATION ATTRIBUTES */} + <div className={styles.subContainer}> + <p>Relation Attribute: </p> + <select + id="relation-attribute-select" + onChange={(e) => { + FSSConfigPanelViewModel.onRelationAttributeChange(e.target.value); + }} + > + {FSSConfigPanelViewModel.getUniqueRelationAttributes().map((name) => ( + <option key={name} value={name}> + {name} + </option> + ))} + </select> + </div> + </div> + ); +} diff --git a/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.t b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.t new file mode 100644 index 000000000..3075b2e5b --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.t @@ -0,0 +1,280 @@ +import SemanticSubstratesConfigPanelViewModelImpl from './SemanticSubstratesConfigPanelViewModel'; +import SemanticSubstratesViewModelImpl from '../SemanticSubstratesViewModel'; +import big2ndChamberQueryResult from '../../../../data/mock-data/query-result/big2ndChamberQueryResult'; +import smallFlightsQueryResults from '../../../../data/mock-data/query-result/smallFlightsQueryResults'; + +jest.mock('../../../view/result-visualisations/semantic-substrates/SemanticSubstratesStylesheet'); + +let parties = ['BIJ1', 'VVD', 'PvdA', 'PVV', 'BBB', 'D66', 'GL']; + +describe('SemanticSubstratesConfigPanelViewModelImpl: unique attribute creation', () => { + it('Should consume result corectly to generate the correct unique node attributes.', () => { + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: big2ndChamberQueryResult, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.onNodeChange('kamerleden'); + expect(FSSConfigPanelViewModel.getUniqueNodeAttributes()).toEqual([ + 'anc', + 'img', + 'leeftijd', + 'naam', + 'partij', + 'woonplaats', + ]); + }); + + it('Should consume result corectly to generate the correct unique relation attributes.', () => { + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: big2ndChamberQueryResult, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + expect(FSSConfigPanelViewModel.getUniqueRelationAttributes()).toEqual([]); + }); +}); + +describe('SemanticSubstratesConfigPanelViewModelImpl: unique attribute creation', () => { + it('Should consume result corectly to generate the correct unique node attributes.', () => { + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: smallFlightsQueryResults, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.onNodeChange('airports'); + expect(FSSConfigPanelViewModel.getUniqueNodeAttributes()).toEqual([ + 'city', + 'country', + 'lat', + 'long', + 'name', + 'state', + 'vip', + ]); + }); + + it('Should consume result corectly to generate the correct unique relation attributes.', () => { + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: smallFlightsQueryResults, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + expect(FSSConfigPanelViewModel.getUniqueRelationAttributes()).toEqual([ + 'Year', + 'Month', + 'Day', + 'DayOfWeek', + 'DepTime', + 'ArrTime', + 'DepTimeUTC', + 'ArrTimeUTC', + 'UniqueCarrier', + 'FlightNum', + 'TailNum', + 'Distance', + ]); + }); +}); + +describe('SemanticSubstratesConfigPanelViewModelImpl: Correct values should be set', () => { + it("Should consume result corectly and get the calculations for the numerical attribute 'leeftijd' ", () => { + let attributeToSelect = 'leeftijd'; + let nodeToSelect = 'kamerleden'; + let maxAgeInParlement = 69; + let minAgeInParlement = 23; + let minSize = 1; + let maxSize = 10; + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: big2ndChamberQueryResult, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.onNodeChange(nodeToSelect); + + expect(FSSConfigPanelViewModel.currentNode).toEqual(nodeToSelect); + expect(FSSConfigPanelViewModel.isNodeAttributeNumber(attributeToSelect)).toEqual(true); + + let calculationF = FSSConfigPanelViewModel.getTheScaleCalculationForNodes( + attributeToSelect, + minSize, + maxSize, + ); + expect(calculationF(maxAgeInParlement)).toEqual(maxSize); + expect(calculationF((maxAgeInParlement + minAgeInParlement) / 2)).toEqual( + (maxSize + minSize) / 2, + ); + expect(calculationF(minAgeInParlement)).toEqual(minSize); + }); + + it("Should consume result corectly and get the calculations for the catecorigal attribute 'partij' ", () => { + let attributeToSelect = 'partij'; + let nodeToSelect = 'kamerleden'; + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: big2ndChamberQueryResult, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.onNodeChange(nodeToSelect); + + expect(FSSConfigPanelViewModel.currentNode).toEqual(nodeToSelect); + expect(FSSConfigPanelViewModel.isNodeAttributeNumber(attributeToSelect)).toEqual(false); + + let calculationF = FSSConfigPanelViewModel.getTheColourCalculationForNodes(attributeToSelect); + parties.forEach((party) => expect(calculationF(party)).toBeDefined()); + expect(calculationF('Not a party')).toBeUndefined(); + }); +}); + +describe('SemanticSubstratesConfigPanelViewModelImpl: Correct values should be set for smallFlightsQueryResults', () => { + it("Should consume result corectly and get the calculations for the numerical attribute 'distance' ", () => { + let attributeToSelect = 'Distance'; + let maxDistance = 487; + let minDistance = 200; + let minSize = 1; + let maxSize = 10; + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: smallFlightsQueryResults, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + FSSConfigPanelViewModel.makeNodeTypes(); + + expect(FSSConfigPanelViewModel.isRelationAttributeNumber(attributeToSelect)).toEqual(true); + + let calculationF = FSSConfigPanelViewModel.getTheScaleCalculationForRelations( + attributeToSelect, + minSize, + maxSize, + ); + expect(calculationF(maxDistance)).toEqual(maxSize); + expect(calculationF((maxDistance + minDistance) / 2)).toEqual((maxSize + minSize) / 2); + expect(calculationF(minDistance)).toEqual(minSize); + }); + + it("Should consume result corectly and get the calculations for the catecorigal attribute 'UniqueCarrier' ", () => { + let attributeToSelect = 'UniqueCarrier'; + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: smallFlightsQueryResults, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + FSSConfigPanelViewModel.makeNodeTypes(); + + expect(FSSConfigPanelViewModel.isRelationAttributeNumber(attributeToSelect)).toEqual(false); + + let calculationF = + FSSConfigPanelViewModel.getTheColourCalculationForRelations(attributeToSelect); + expect(calculationF('NW')).toBeDefined(); + expect(calculationF('WN')).toBeDefined(); + expect(calculationF('Not a UniqueCarrier')).toBeUndefined(); + }); + + it("Should consume result corectly and get the calculations for the catecorigal attribute 'state' ", () => { + let attributeToSelect = 'state'; + let nodeToSelect = 'airports'; + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: smallFlightsQueryResults, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.onNodeChange(nodeToSelect); + + expect(FSSConfigPanelViewModel.currentNode).toEqual(nodeToSelect); + expect(FSSConfigPanelViewModel.isNodeAttributeNumber(attributeToSelect)).toEqual(false); + + let calculationF = FSSConfigPanelViewModel.getTheColourCalculationForNodes(attributeToSelect); + expect(calculationF('CA')).toBeDefined(); + expect(calculationF('NY')).toBeDefined(); + expect(calculationF('Not a state')).toBeUndefined(); + }); + + it('Should update the visualisation when the attribute changes', () => { + let attributeToSelect = 'state'; + let attributeToSelect2 = 'lat'; + let nodeToSelect = 'airports'; + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: smallFlightsQueryResults, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.onNodeChange(nodeToSelect); + + // change the attribute for the first time + FSSConfigPanelViewModel.onAttributeChange(attributeToSelect); + expect(FSSConfigPanelViewModel.currentAttribute).toEqual(attributeToSelect); + + // change the attribute for the second time, this time with an attribute that has numbers as values + FSSConfigPanelViewModel.onAttributeChange(attributeToSelect2); + expect(FSSConfigPanelViewModel.currentAttribute).toEqual(attributeToSelect2); + }); + + it('Should update the visualisation when the relation attribute changes', () => { + let attributeToSelect = 'TailNum'; + let attributeToSelect2 = 'Distance'; + let nodeToSelect = 'airports'; + let mockProperties = { + fssViewModel: new SemanticSubstratesViewModelImpl(), + graph: smallFlightsQueryResults, + }; + + let FSSConfigPanelViewModel: SemanticSubstratesConfigPanelViewModelImpl = + new SemanticSubstratesConfigPanelViewModelImpl(mockProperties); + + FSSConfigPanelViewModel.makeRelationTypes(); + FSSConfigPanelViewModel.makeNodeTypes(); + FSSConfigPanelViewModel.onNodeChange(nodeToSelect); + + // change the attribute for the first time + FSSConfigPanelViewModel.onRelationAttributeChange(attributeToSelect); + expect(FSSConfigPanelViewModel.currentRelationAttribute).toEqual(attributeToSelect); + + // change the attribute for the second time, this time with an attribute that has numbers as values + FSSConfigPanelViewModel.onRelationAttributeChange(attributeToSelect2); + expect(FSSConfigPanelViewModel.currentRelationAttribute).toEqual(attributeToSelect2); + }); +}); diff --git a/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx new file mode 100644 index 000000000..4f257a6af --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/configpanel/SemanticSubstratesConfigPanelViewModel.tsx @@ -0,0 +1,355 @@ +import { range } from 'd3'; + +import { + EntityWithAttributes, + FSSConfigPanelProps, +} from './Types'; +import { Link, Node } from '../../shared/ResultNodeLinkParserUseCase'; +import SemanticSubstratesViewModel from '../SemanticSubstratesViewModel'; + +/** Viewmodel for rendering config input fields for Faceted Semantic Substrate attributes. */ +export default class SemanticSubstratesConfigPanelViewModel { + public nodes: EntityWithAttributes[]; + public relations: EntityWithAttributes[]; + + minimalNodeSize = 2; + maximalNodeSize = 10; + + minimalRelationWidth = 0.05; + maximalRelationWidth = 4; + + nodesloaded: Node[]; + relationsloaded: Link[]; + public currentNode = ''; + currentRelation = ''; + currentAttribute = ''; + currentRelationAttribute = ''; + // Faceted Semantic Substrates View Model. + fssViewModel: SemanticSubstratesViewModel; + /** + * The constructor for the FSSConfigPanelViewModelImpl (FacetedSemanticSubstratesConfigPanelViewModelImpl). + * This handles the view for changing how to display the attributes of the nodes and relations + * in the FSS view. + * @param props The properties for the FacetedSemanticSubstratesConfigPanel. These define how the + * fss ViewModel is generated. + */ + public constructor(props: FSSConfigPanelProps) { + let graph = props.graph; + let fssViewModel = props.fssViewModel; + + this.nodes = []; + this.relations = []; + + this.nodesloaded = graph.nodes; + this.relationsloaded = graph.edges; + this.fssViewModel = fssViewModel; + } + + /** + * Creates a list of unique node types based on the current list of nodes. + */ + public makeNodeTypes() { + this.nodes = MakeTypesFromGraphUseCase.makeNodeTypes(this.nodesloaded); + if (!this.isNodeSet()) { + if (this.nodes[0]) { + this.currentNode = this.nodes[0].name; + } + } + } + + /** + * Creates a list of unique relation types based on the current list of relations. + */ + public makeRelationTypes() { + this.relations = MakeTypesFromGraphUseCase.makeRelationTypes(this.relationsloaded); + if (!this.isRelationSet()) { + if (this.relations[0]) { + this.currentRelation = this.relations[0].name; + } + } + } + + /** + * Checks if the current node type exists in the types list. + * @returns True if the node has been set. + */ + private isNodeSet() { + for (let i in range(this.nodes.length)) { + let node = this.nodes[i]; + if (this.currentNode == node.name) { + return true; + } + } + return false; + } + + /** + * Checks if the current relation type exists in the types list. + * @returns True if the function has been set. + */ + private isRelationSet() { + for (let i in range(this.relations.length)) { + let relation = this.relations[i]; + if (this.currentRelation == relation.name) { + return true; + } + } + return false; + } + + /** + * Updates the state variables and dropdowns. Should be triggered on switching nodes in the dropdown. + * @param newCurrentNode The new node type that has been selected. + */ + public onNodeChange(newCurrentNode: string) { + this.currentNode = newCurrentNode; + this.makeNodeTypes(); + } + + /** + * Tells the FSSViewModel what node attributes have been changed, when something has changed. + */ + public onAttributeChange(attributeSelected: string) { + //Retrieve the current visualisation and set the vis dropdown to this. + this.currentAttribute = attributeSelected; + if (this.isNodeAttributeNumber(attributeSelected)) { + this.fssViewModel.selectedAttributeNumerical = attributeSelected; + this.fssViewModel.changeSelectedAttributeNumerical( + this.getTheScaleCalculationForNodes( + attributeSelected, + this.minimalNodeSize, + this.maximalNodeSize, + ), + ); + } else { + this.fssViewModel.selectedAttributeCatecorigal = attributeSelected; + this.fssViewModel.changeSelectedAttributeCatecorigal( + this.getTheColourCalculationForNodes(attributeSelected), + ); + } + } + + /** + * Tells the FSSViewModel what relation attributes have been changed, when something has changed. + */ + public onRelationAttributeChange(attributeSelected: string) { + //Retrieve the current visualisation and set the vis dropdown to this. + this.currentRelationAttribute = attributeSelected; + if (this.isRelationAttributeNumber(attributeSelected)) { + this.fssViewModel.relationSelectedAttributeNumerical = attributeSelected; + this.fssViewModel.changeRelationSelectedAttributeNumerical( + this.getTheScaleCalculationForRelations( + this.fssViewModel.relationSelectedAttributeNumerical, + this.minimalRelationWidth, + this.maximalRelationWidth, + ), + ); + } else { + this.fssViewModel.relationSelectedAttributeCatecorigal = attributeSelected; + this.fssViewModel.changeRelationSelectedAttributeCatecorigal( + this.getTheColourCalculationForRelations( + this.fssViewModel.relationSelectedAttributeCatecorigal, + ), + ); + } + } + + /** + * Gets a list of all different attributes that are present in the loaded nodes list. + * @returns List of strings that each represent an attribute. + */ + public getUniqueNodeAttributes() { + let uniqueValues: string[] = []; + this.nodesloaded.forEach((node) => { + if (node.attributes) { + for (const attribute in node.attributes) { + if (uniqueValues.find((x) => x == attribute) == undefined) { + uniqueValues.push(attribute); + } + } + } + }); + return uniqueValues; + } + + /** + * Gets a list with unique relation attributes that are presented in the loaded relations list. + * @returns List of string that each represent an attribute. + */ + public getUniqueRelationAttributes() { + let uniqueValues: string[] = []; + this.relationsloaded.forEach((relation) => { + if (relation.attributes) { + for (const attribute in relation.attributes) { + if (uniqueValues.find((x) => x == attribute) == undefined) { + uniqueValues.push(attribute); + } + } + } + }); + return uniqueValues; + } + + /** + * Checks if the attribute is a numerical value. + * @param attribute The attribute of the nodes to check for. + * @returns True if all values of that attribute are numbers. Returns false + * if one or more values for this attribute are not a number + */ + public isNodeAttributeNumber(attribute: string): boolean { + let values: number[] = []; + for (const node of this.nodesloaded) { + if (node.attributes) { + if (node.attributes[attribute] != undefined) { + if (isNaN(node.attributes[attribute])) return false; + } + } + } + return true; + } + + /** + * Checks if all values of an attribute of the list of relations are a number. + * @param attribute The attribute of the relations to check for + * @returns True if all values of that attribute are numbers. Returns false + * if one or more values for this attribute are not a number + */ + public isRelationAttributeNumber(attribute: string): boolean { + for (const relation of this.relationsloaded) { + if (relation.attributes) { + if (relation.attributes[attribute] != undefined) { + if (isNaN(relation.attributes[attribute])) return false; + } + } + } + return true; + } + + /** + * Gets the scaling value for pixel sizes. + * @param attribute The selected attribute. + * @param minP Minimum node size. + * @param maxP Maximum node size. + * @returns Scaling value as a number. + */ + public getTheScaleCalculationForNodes( + attribute: string, + minP: number, + maxP: number, + ): (x: number) => number { + let values: number[] = []; + this.nodesloaded.forEach((node) => { + if (node.attributes) { + if (node.attributes[attribute] != undefined) { + values.push(node.attributes[attribute]); + } + } + }); + + //value min/max + let minX = Math.min(...values); + let maxX = Math.max(...values); + + let a = (maxP - minP) / (maxX - minX); + let b = maxP - a * maxX; + + //linear scaling between minP and maxP + return (x: number) => a * x + b; // - minX; + } + + /** + * Creates a function to get the colours for each value of a given attribute of a relation. + * @param attribute The attribute to generate a colour calculation for. + * @returns Returns colourisation fucntion for each different attribute value + */ + public getTheColourCalculationForNodes(attribute: string): (x: string) => string { + let uniqueValues: string[] = []; + this.nodesloaded.forEach((node) => { + if (node.attributes) { + if (node.attributes[attribute] != undefined) { + uniqueValues.push(node.attributes[attribute]); + } + } + }); + + let colours = ColourPalettes['default'].nodes; + + // Create the key value pairs. + let valueToColour: Record<string, string> = {}; + let i = 0; + uniqueValues.forEach((uniqueValue) => { + valueToColour[uniqueValue] = '#' + colours[i]; + i++; + if (i > colours.length) { + i = 0; + } + }); + + //Get a colour for each attribute + return (x: string) => valueToColour[x]; + } + + /** + * returns the scaling function for relations. (Can be used in pixel size.) + * @param attribute Attribute to generate a size calculation for. + * @param minP Minimal width + * @param maxP Maximum width + * @returns + */ + public getTheScaleCalculationForRelations( + attribute: string, + minP: number, + maxP: number, + ): (x: number) => number { + let values: number[] = []; + this.relationsloaded.forEach((relation) => { + if (relation.attributes) { + if (relation.attributes[attribute] != undefined) { + values.push(relation.attributes[attribute]); + } + } + }); + + //value min/max + let minX = Math.min(...values); + let maxX = Math.max(...values); + + let a = (maxP - minP) / (maxX - minX); + let b = maxP - a * maxX; + + //linear scaling between minP and maxP + return (x: number) => a * x + b; // - minX; + } + + /** + * Generates a function that returns a value for each given possible value of an attribute. + * @param attribute The attribute to generate the function for. + * @returns A function to get the correct colour for a relation attribute. + */ + public getTheColourCalculationForRelations(attribute: string): (x: string) => string { + let uniqueValues: string[] = []; + this.relationsloaded.forEach((relation) => { + if (relation.attributes) { + if (relation.attributes[attribute] != undefined) { + uniqueValues.push(relation.attributes[attribute]); + } + } + }); + + let colours = ColourPalettes['default'].elements.relation; + + // Create the key value pairs. + let valueToColour: Record<string, string> = {}; + let i = 0; + uniqueValues.forEach((uniqueValue) => { + valueToColour[uniqueValue] = '#' + colours[i]; + i++; + if (i > colours.length) { + i = 0; + } + }); + + //Get a colour for each attribute + return (x: string) => valueToColour[x]; + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/configpanel/Types.tsx b/libs/shared/lib/vis/semanticsubstrates/configpanel/Types.tsx new file mode 100644 index 000000000..3a7462d70 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/configpanel/Types.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 { NodeLinkResultType } from "../../shared/ResultNodeLinkParserUseCase"; +import SemanticSubstratesViewModel from "../SemanticSubstratesViewModel"; + +/* An entity that has an attribute (Either a node with attributes or an edges with attributes) + For the config-panel of semantic-substrates.*/ +export type EntityWithAttributes = { + name: string; + attributes: string[]; + group: number; +}; + +/** Props for this component */ +export type FSSConfigPanelProps = { + graph: NodeLinkResultType; + // currentColours: any; + fssViewModel: SemanticSubstratesViewModel; +}; diff --git a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss new file mode 100644 index 000000000..3784b6597 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss @@ -0,0 +1,106 @@ +/** + * 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.*/ + +.container { + font-family: 'Open Sans', sans-serif; + display: flex; + flex-direction: column; + p { + margin: 0.5rem 0; + font-size: 13px; + font-weight: 600; + color: #2d2d2d; + } + .title { + color: #212020; + font-weight: 800; + line-height: 1.6em; + font-size: 16px; + text-align: center; + margin-bottom: 1rem; + margin-top: 0; + } + .subtitle { + color: #212020; + font-weight: 700; + font-size: 14px; + margin-top: 1.5rem; + } + .subsubtitle { + font-weight: 700; + margin-top: 0.9rem; + } + .subContainer { + .rulesContainer { + margin-top: 0.5rem; + .subsubtitle { + text-align: center; + } + } + } +} + +.selectContainer { + display: flex; + align-items: center; + justify-content: space-around; + select { + width: 6rem; + overflow: hidden; + text-overflow: ellipsis; + option { + width: 35px; + text-overflow: ellipsis; + } + } +} + +.container { + // @global TODO FIX + .selection { + fill-opacity: 0.1; + stroke: lightgrey; + } + + .delete-plot-icon { + & path { + opacity: 0.4; + transition: 0.1s; + + transform-box: fill-box; + } + &:hover { + & path { + opacity: 0.8; + fill: #d00; + transform: scale(1.1); + } + & .lid { + transform: rotate(-15deg) scale(1.1); + } + } + } + + display: flex; + flex-direction: row; + + width: 100%; + + margin-top: 10px; + margin-left: 30px; + overflow-y: auto; +} + +.checkboxGroup { + display: flex; + flex-direction: column; +} diff --git a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss.d.ts b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss.d.ts new file mode 100644 index 000000000..1e99e3570 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.module.scss.d.ts @@ -0,0 +1,14 @@ +declare const classNames: { + readonly container: 'container'; + readonly title: 'title'; + readonly subtitle: 'subtitle'; + readonly subsubtitle: 'subsubtitle'; + readonly subContainer: 'subContainer'; + readonly rulesContainer: 'rulesContainer'; + readonly selectContainer: 'selectContainer'; + readonly selection: 'selection'; + readonly 'delete-plot-icon': 'delete-plot-icon'; + readonly lid: 'lid'; + readonly checkboxGroup: 'checkboxGroup'; +}; +export = classNames; diff --git a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.stories.tsx b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.stories.tsx index bf93c79f4..49af3a0c2 100644 --- a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.stories.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.stories.tsx @@ -1,6 +1,9 @@ import { + assignNewGraphQueryResult, colorPaletteConfigSlice, graphQueryResultSlice, + schemaSlice, + setSchema, } from "../../data-access/store"; import { GraphPolarisThemeProvider } from "../../data-access/theme"; import { configureStore } from "@reduxjs/toolkit"; @@ -8,6 +11,7 @@ import { Meta, ComponentStory } from "@storybook/react"; import { Provider } from "react-redux"; import SemanticSubstrates from "./semanticsubstrates"; +import { SchemaUtils } from "../../schema/schema-utils"; const Component: Meta<typeof SemanticSubstrates> = { /* 👇 The title prop is optional. @@ -27,24 +31,50 @@ const Component: Meta<typeof SemanticSubstrates> = { const Mockstore = configureStore({ reducer: { + schema: schemaSlice.reducer, colorPaletteConfig: colorPaletteConfigSlice.reducer, graphQueryResult: graphQueryResultSlice.reducer, }, }); -const Template: ComponentStory<typeof SemanticSubstrates> = (args) => ( - <SemanticSubstrates /> -); -export const Primary = Template.bind({}); - -Primary.args = { - content: "Testing header #1", -}; - -export const Secondary = Template.bind({}); +export const TestWithData = { + args: { + loading: false, + }, + 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' }], + }, + ], + }); -Secondary.args = { - content: "Testing header number twoooo", -}; + 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: '12/z1', from: '1/b1', to: '1/a', attributes: { a: 's1' } }, + // { from: 'b2', to: 'a', attributes: {} }, + // { from: 'b3', to: 'a', attributes: {} }, + { id: '12/z1', from: '1/a', to: '1/b1', attributes: { a: 's1' } }, + ], + }) + ) + } +} export default Component; diff --git a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx index 7465247d4..f5149f537 100644 --- a/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx +++ b/libs/shared/lib/vis/semanticsubstrates/semanticsubstrates.tsx @@ -1,85 +1,529 @@ -import { Theme, useTheme } from '@mui/material/styles'; -import Box from '@mui/system/Box'; +import { useTheme } from '@mui/material'; import { - changeDataPointColors, - changePrimary, - toggleDarkMode, - useAppDispatch, + useAppDispatch, useGraphQueryResult, useSchemaGraph, } from '@graphpolaris/shared/lib/data-access/store'; -import styled from 'styled-components'; -import { Button } from '@mui/material'; +import { useEffect, useRef, useState } from 'react'; +import { AxisLabel, EntitiesFromSchema, MinMaxType, PlotSpecifications, PlotType, RelationType } from './Types'; +import { NodeLinkResultType, isNodeLinkResult } from '../shared/ResultNodeLinkParserUseCase'; +import styles from './semanticsubstrates.module.scss'; +import AddPlotButtonComponent from './subcomponents/AddPlotButtonComponent'; +import SVGCheckboxesWithSemanticSubstrLabel from './subcomponents/SVGCheckBoxComponent'; +import AddPlotPopup from './subcomponents/AddPlotPopup'; +import VisConfigPanelComponent from '../shared/VisConfigPanel/VisConfigPanel'; +import FSSConfigPanel from './configpanel/SemanticSubstrateConfigPanel'; +import Plot from './subcomponents/PlotComponent'; +import LinesBetweenPlots from './subcomponents/LinesBetweenPlotsComponent'; +import Color from 'color'; +import CalcEntityAttrNamesFromResultUseCase from './utils/CalcEntityAttrNamesFromResultUseCase'; +import CalcEntityAttrNamesFromSchemaUseCase from './utils/CalcEntityAttrNamesFromSchemaUseCase'; +import CalcDefaultPlotSpecsUseCase from './utils/CalcDefaultPlotSpecsUseCase'; +import ToPlotDataParserUseCase from './utils/ToPlotDataParserUseCase'; +import CalcXYMinMaxUseCase from './utils/CalcXYMinMaxUseCase'; +import CalcScaledPosUseCase from './utils/CalcScaledPositionsUseCase'; +import { useImmer } from "use-immer"; +import FilterUseCase from './utils/FilterUseCase'; -const Div = styled.div` - // add :{ theme: Theme } if you want to have typing (autocomplete) - background-color: ${({ theme }: { theme: Theme }) => - theme.palette.primary.main}; -`; +export type SemanticSubstrateState = { + plotSpecifications: PlotSpecifications[]; + + nodeRadius: number; + nodeColors: string[]; + + gapBetweenPlots: number; + + entitiesFromSchemaPruned: EntitiesFromSchema; + + selectedAttributeNumerical: string; + selectedAttributeCatecorigal: string; + relationSelectedAttributeNumerical: string; + relationSelectedAttributeCatecorigal: string; + scaleCalculation: (x: number) => number, + colourCalculation: (x: string) => string, + + relationScaleCalculation: (x: number) => number, + relationColourCalculation: (x: string) => string, + + addPlotButtonAnchor: (EventTarget & SVGRectElement) | null; +} + +export type SemanticSubstratePlotState = { + plots: PlotType[]; + + // Relations is a 3d array, with the first array denoting the outbound plot, the second array denotes the inbound plot + // For example, [plot1][plot2] will give all edges which are outbound from plot1 and ingoing to plot2 + allRelations: RelationType[][][]; + filteredRelations: RelationType[][][]; + + // Determines if connections [fromPlotIndex][toPlotIndex] will be shown + visibleRelations: boolean[][]; + + // Used for filtering the relations when brushing (brush is the square selection tool) + filtersPerPlot: { x: MinMaxType; y: MinMaxType }[]; +} + +/** + * These functions are mock function for now, but can be properly implemented later down the line. + */ +const scaleCalculation: (x: number) => number = (x: number) => { + return 3; +}; +const colourCalculation: (x: string) => string = (x: string) => { + return '#d56a50'; +}; +const relationColourCalculation: (x: string) => string = (x: string) => { + return '#d49350'; +}; +const relationScaleCalculation: (x: number) => number = (x: number) => { + return 1; +}; const SemanticSubstrates = () => { const dispatch = useAppDispatch(); const theme = useTheme(); + const graphQueryResult = useGraphQueryResult(); + const schema = useSchemaGraph(); + const [entitiesFromSchema, setEntitiesFromSchema] = useState<EntitiesFromSchema>({ entityNames: [], attributesPerEntity: {} }); - return ( - <> - <Div> - <h1>semantic substrates, primary.main</h1> - </Div> - <div - style={{ - backgroundColor: theme.palette.primary.dark, - }} - > - <h1>semantic substrates, primary.dark</h1> - </div> - <input - onChange={(v) => - dispatch( - changePrimary({ - main: v.currentTarget.value, - darkMode: theme.palette.mode, - }) - ) + const [state, setState] = useImmer<SemanticSubstrateState>({ + plotSpecifications: [], + entitiesFromSchemaPruned: { entityNames: [], attributesPerEntity: {} }, + selectedAttributeNumerical: 'long', selectedAttributeCatecorigal: 'state', + relationSelectedAttributeNumerical: 'Distance', relationSelectedAttributeCatecorigal: 'Day', + nodeRadius: 3, + nodeColors: ['#D56A50', '#1E9797', '#d49350', '#1e974a', '#D49350'], + gapBetweenPlots: 50, + scaleCalculation: scaleCalculation, + colourCalculation: colourCalculation, + relationScaleCalculation: relationScaleCalculation, + relationColourCalculation: relationColourCalculation, + + addPlotButtonAnchor: null, + }); + + const [plotState, setPlotState] = useImmer<SemanticSubstratePlotState>({ + allRelations: [], filteredRelations: [], plots: [], filtersPerPlot: [], visibleRelations: [], + }); + + useEffect(() => { + const ret = CalcEntityAttrNamesFromSchemaUseCase.calculate(schema); + setEntitiesFromSchema(ret); + }, [schema]); + + useEffect(() => { + if (isNodeLinkResult(graphQueryResult)) { + // Only apply new result if we have a valid schema + if (entitiesFromSchema.entityNames.length === 0) { + console.log('Semantic substrates: No valid schema available.'); + return; + } + + setState((draft) => { + draft.entitiesFromSchemaPruned = CalcEntityAttrNamesFromResultUseCase.CalcEntityAttrNamesFromResult( + graphQueryResult, + entitiesFromSchema, + ); + + // Generate default plots, if there aren't any currently + if (state.plotSpecifications.length == 0) { + draft.plotSpecifications = CalcDefaultPlotSpecsUseCase.calculate(graphQueryResult); } - type="color" - name="head" - value={theme.palette.primary.main} - /> - <p>Change Primary Color</p> - <input - onChange={(v) => - dispatch( - changeDataPointColors([ - v.currentTarget.value, - ...theme.palette.custom.dataPointColors.slice(1), - ]) - ) + + return draft; + }) + + } else { + console.error('Invalid query result!') + } + + applyNewPlotSpecifications(); + }, [graphQueryResult, entitiesFromSchema]); + + useEffect(() => { applyNewPlotSpecifications(); }, [state]); + + + + /** + * Update the scaling of the nodes after a new attribute has been selected. This function is called when the new attribute has numbers as values. + * @param scale The scaling function provided by the config panel ViewModel. + */ + function changeSelectedAttributeNumerical(scale: (x: number) => number): void { + setState({ ...state, scaleCalculation: scale }); + } + + /** + * Update the colours of the nodes after a new attribute has been selected. This function is called when the new attribute has NaNs as values. + * @param getColour The colouring function provided by the config panel ViewModel. + */ + function changeSelectedAttributeCatecorigal(getColour: (x: string) => string): void { + setState({ ...state, colourCalculation: getColour }); + } + + /** + * Update the scaling of the nodes after a new relation attribute has been selected. This function is called when the new relation attribute has numbers as values. + * @param scale The scaling function provided by the config panel ViewModel. + */ + function changeRelationSelectedAttributeNumerical(scale: (x: number) => number): void { + setState({ ...state, relationScaleCalculation: scale }); + } + + /** + * Update the colours of the nodes after a new relation attribute has been selected. This function is called when the new relation attribute has NaNs as values. + * @param getColour The colouring function provided by the config panel ViewModel. + */ + function changeRelationSelectedAttributeCatecorigal(getColour: (x: string) => string): void { + setState({ ...state, relationColourCalculation: getColour }); + } + + /** + * Apply plot specifications to a node link query result. Calculates the plots and relations using the provided plot specs. + * @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 { + + // Parse the incoming data to plotdata with the auto generated plot specifications + const { plots, relations } = ToPlotDataParserUseCase.parseQueryResult( + graphQueryResult, + state.plotSpecifications, + state.relationSelectedAttributeNumerical, + state.relationSelectedAttributeCatecorigal, + state.relationScaleCalculation, + state.relationColourCalculation, + ); + + setPlotState((draft) => { + draft.allRelations = relations; + // Clone relations to filteredRelations, because the filters are initialized with no constraints + draft.filteredRelations = relations.map((i) => i.map((j) => j.map((v) => v))); + + // Calculate the scaled positions to the width and height of a plot + let yOffset = 0; + draft.plots = plots.map((plot, i) => { + const minmaxAxis = CalcXYMinMaxUseCase.calculate(plot); + const scaledPositions = CalcScaledPosUseCase.calculate(plot, minmaxAxis.x, minmaxAxis.y); + + // Collect all possible values for a certain attribute, used for the autocomplete title field + const possibleTitleAttributeValues = graphQueryResult.nodes.reduce( + (values: Set<string>, node) => { + // Filter on nodes which are of the entity type in the plotspec + // Use a Set so we only collect unique values + if ( + node.id.split('/')[0] == state.plotSpecifications[i].entity && + state.plotSpecifications[i].labelAttributeType in node.attributes + ) + values.add(node.attributes[state.plotSpecifications[i].labelAttributeType] as string); + return values; + }, + new Set<string>(), + ); + + // Accumulate the yOffset for each plot, with the width and gapbetweenplots + const thisYOffset = yOffset; + yOffset += plot.height + state.gapBetweenPlots; + return { + title: plot.title, + nodes: plot.nodes.map((node, i) => { + return { ...node, scaledPosition: scaledPositions[i] }; + }), + selectedAttributeNumerical: state.selectedAttributeNumerical, + scaleCalculation: state.scaleCalculation, + selectedAttributeCatecorigal: state.selectedAttributeCatecorigal, + colourCalculation: state.colourCalculation, + minmaxXAxis: minmaxAxis.x, + minmaxYAxis: minmaxAxis.y, + width: plot.width, + height: plot.height, + yOffset: thisYOffset, + possibleTitleAttributeValues: Array.from(possibleTitleAttributeValues), + }; + }); + + // Initialize filters with no constraints + draft.filtersPerPlot = plotState.plots.map((plot) => { + return { + x: { min: plot.minmaxXAxis.min, max: plot.minmaxXAxis.max }, + y: { min: plot.minmaxYAxis.min, max: plot.minmaxYAxis.max }, + }; + }); + + // Initialize the visible relations with all false values + if (draft.visibleRelations.length !== draft.plots.length) { + draft.visibleRelations = new Array(draft.plots.length) + .fill([]) + .map(() => new Array(draft.plots.length).fill(false)) + } + + return draft; + }); + } + + /** + * Updates the visualisation when a checkbox is pressed. + * @param fromPlotIndex Which plot the connections should come from. + * @param toPlotIndex Which plot the connections should go to. + * @param value Whether the box has been checked or not. + */ + function onCheckboxChanged(fromPlotIndex: number, toPlotIndex: number, value: boolean): void { + setPlotState((draft) => { + draft.visibleRelations[fromPlotIndex][toPlotIndex] = value; + return draft; + }); + } + + /** Callback for the square selection tool on the plots. + * Changes the filter of the plot index and applies this new filter. + * @param {number} plotIndex The index of the plot where there is brushed. + * @param {MinMaxType} xRange The min and max x values of the selection. + * @param {MinMaxType} yRange The min and max y values of the selection. + */ + function onBrush(plotIndex: number, xRange: MinMaxType, yRange: MinMaxType) { + setPlotState((draft) => { + // Change the filter of the corresponding plot + draft.filtersPerPlot[plotIndex] = { + x: xRange, + y: yRange, + }; + + // Apply the new filter to the relations + FilterUseCase.filterRelations( + draft.allRelations, + draft.filteredRelations, + draft.plots, + draft.filtersPerPlot, + plotIndex, + ); + return draft; + }); + } + + /** + * Prepends a new plot specifications and re-applies this to the current nodelink query result. + * @param {string} entity The entity filter for the new plot. + * @param {string} attributeName The attribute to filter on for the new plot. + * @param {string} attributeValue The value for the attributeto filter on for the new plot. + */ + function addPlot(entity: string, attributeName: string, attributeValue: string): void { + setState((draft) => { + draft.plotSpecifications = [ + { + entity: entity, + labelAttributeType: attributeName, + labelAttributeValue: attributeValue, + xAxis: AxisLabel.evenlySpaced, // Use default evenly spaced and # outbound connections on the x and y axis + yAxis: AxisLabel.outboundConnections, + xAxisAttributeType: '', + yAxisAttributeType: '', + width: 800, // The default width and height are 800x200 + height: 200, + }, + ...state.plotSpecifications, + ]; + + return draft; + }); + } + + /** + * Callback when the delete button of a plot is pressed. + * Will remove values at plotIndex from the `plotSpecifications`, `plots` , `filtersPerPlot` , `relations` , `filteredRelations` and `visibleRelations`. + * @param {number} plotIndex The index for the plot that needs to be deleted. + */ + function onDelete(plotIndex: number): void { + setState((draft) => { + draft.plotSpecifications.splice(plotIndex, 1); + return draft; + }); + + setPlotState((draft) => { + // Recalculate the plot y offsets with this plot removed. + for (let i = plotIndex + 1; i < draft.plots.length; i++) + draft.plots[i].yOffset -= draft.plots[plotIndex].height + state.gapBetweenPlots; + + draft.plots.splice(plotIndex, 1); + draft.filtersPerPlot.splice(plotIndex, 1); + + draft.allRelations.splice(plotIndex, 1); + draft.allRelations = draft.allRelations.map((r) => r.filter((_, i) => i != plotIndex)); + draft.filteredRelations.splice(plotIndex, 1); + draft.filteredRelations = draft.filteredRelations.map((r) => r.filter((_, i) => i != plotIndex)); + draft.visibleRelations.splice(plotIndex, 1); + draft.visibleRelations = draft.visibleRelations.map((r) => r.filter((_, i) => i != plotIndex)); + + return draft + }); + } + + /** + * Changes the axislabel of a plot. + * @param {number} plotIndex The plot number for which to change an axis. + * @param {'x' | 'y'} axis The axis to change. Can only be 'x' or 'y'. + * @param {string} newLabel The axis label. + */ + function onAxisLabelChanged(plotIndex: number, axis: 'x' | 'y', newLabel: string): void { + setState((draft) => { + const axisLabels: string[] = Object.values(AxisLabel); + if (axisLabels.includes(newLabel) && newLabel != AxisLabel.byAttribute) { + // If the new axis label is one of "# outbound conn", "# inbound conn" or "evenly spaced" + if (axis === 'x') draft.plotSpecifications[plotIndex].xAxis = newLabel as AxisLabel; + else if (axis === 'y') draft.plotSpecifications[plotIndex].yAxis = newLabel as AxisLabel; + } else { + // Else it is an attribute of the entity + if (axis === 'x') { + draft.plotSpecifications[plotIndex].xAxis = AxisLabel.byAttribute; + draft.plotSpecifications[plotIndex].xAxisAttributeType = newLabel; + } else if (axis === 'y') { + draft.plotSpecifications[plotIndex].yAxis = AxisLabel.byAttribute; + draft.plotSpecifications[plotIndex].yAxisAttributeType = newLabel; } - type="color" - id="head" - name="head" - value={theme.palette.custom.dataPointColors[0]} - /> - <p>Change dataPointColor 0</p> - <input - onChange={(v) => - dispatch( - changeDataPointColors([ - theme.palette.custom.dataPointColors[0], - v.currentTarget.value, - ...theme.palette.custom.dataPointColors.slice(2), - ]) - ) + } + + return draft; + + }); + } + + /** + * Applies the new plot filter. Called when the user changes the plot title/filter. + * @param {number} plotIndex The plotindex of which the title is changed. + * @param {string} entity The new entity value for the plot filter, might be unchanged. + * @param {string} attrName The new attribute name for the plot filter, might be unchanged. + * @param {string} attrValue The new attribute value for the plot filter. + */ + function onPlotTitleChanged( + plotIndex: number, + entity: string, + attrName: string, + attrValue: string, + ): void { + // If the entity or the attrName changed, auto select a default attrValue + if ( + (entity != state.plotSpecifications[plotIndex].entity || + attrName != state.plotSpecifications[plotIndex].labelAttributeType) && + attrValue == state.plotSpecifications[plotIndex].labelAttributeValue + ) { + const firstValidNode = graphQueryResult.nodes.find( + (node) => node.id.split('/')[0] == entity && attrName in node.attributes, + ); + if (firstValidNode != undefined) attrValue = firstValidNode.attributes[attrName] as string; + } + + setState((draft) => { + + draft.plotSpecifications[plotIndex] = { + ...draft.plotSpecifications[plotIndex], + entity, + labelAttributeType: attrName, + labelAttributeValue: attrValue, + }; + + return draft; + }); + }; + + + const plotElements: JSX.Element[] = []; + for (let i = 0; i < plotState.plots.length; i++) { + if (!state.plotSpecifications?.[i]) continue; + + plotElements.push( + <Plot + key={plotState.plots[i].title + i} + plotData={plotState.plots[i]} + plotSpecification={state.plotSpecifications[i]} + entitiesFromSchema={entitiesFromSchema} + nodeColor={state.nodeColors[i]} + nodeRadius={state.nodeRadius} + width={plotState.plots[i].width} + height={plotState.plots[i].height} + selectedAttributeNumerical={plotState.plots[i].selectedAttributeNumerical} + selectedAttributeCatecorigal={plotState.plots[i].selectedAttributeCatecorigal} + scaleCalculation={plotState.plots[i].scaleCalculation} + colourCalculation={plotState.plots[i].colourCalculation} + onBrush={(xRange: MinMaxType, yRange: MinMaxType) => + // Call the onBrush method with the index of this plot + onBrush(i, xRange, yRange) + } + onDelete={() => onDelete(i)} + onAxisLabelChanged={(axis: 'x' | 'y', value: string) => + onAxisLabelChanged(i, axis, value) + } + onTitleChanged={(entity: string, attrName: string, attrValue: string) => + onPlotTitleChanged(i, entity, attrName, attrValue) + } + />, + ); + } + + // Create the arrow-lines between the plots. + const linesBetween: JSX.Element[] = []; + for (let i = 0; i < plotState.filteredRelations.length; i++) { + for (let j = 0; j < plotState.filteredRelations[i].length; j++) { + let color = Color(state.nodeColors[i]); + color = i == j ? color.lighten(0.3) : color.darken(0.3); + if (plotState.visibleRelations?.[i]?.[j]) { + // Only create the lines if the checkbox is ticked + linesBetween.push( + <LinesBetweenPlots + key={plotState.plots[i].title + i + '-' + plotState.plots[j].title + j} + fromPlot={plotState.plots[i]} + toPlot={plotState.plots[j]} + relations={plotState.filteredRelations[i][j]} + color={color.hex()} + nodeRadius={state.nodeRadius} + />, + ); + } + } + } + + useEffect(() => { }, [state]); + + const heightOfAllPlots = plotState.plots.length > 0 + ? plotState.plots[plotState.plots.length - 1].yOffset + plotState.plots[plotState.plots.length - 1].height + 50 + : 0; + return ( + <div className={styles.container}> + <div style={{ width: '100%', height: '100%' }}> + <svg style={{ width: '100%', height: heightOfAllPlots + 60 }}> + <AddPlotButtonComponent + x={750} + onClick={(event: React.MouseEvent<SVGRectElement>) => + setState({ ...state, addPlotButtonAnchor: event.currentTarget }) + } + /> + <g transform={'translate(60,60)'}> + <SVGCheckboxesWithSemanticSubstrLabel + plots={plotState.plots} + relations={plotState.allRelations} + visibleRelations={plotState.visibleRelations} + nodeColors={state.nodeColors} + onCheckboxChanged={(fromPlot: number, toPlot: number, value: boolean) => + onCheckboxChanged(fromPlot, toPlot, value) + } + /> + {plotElements} + {linesBetween}, + </g> + </svg> + </div> + <AddPlotPopup + open={Boolean(state.addPlotButtonAnchor)} + anchorEl={state.addPlotButtonAnchor} + entitiesFromSchema={entitiesFromSchema} + nodeLinkResultNodes={graphQueryResult.nodes} + handleClose={() => setState({ ...state, addPlotButtonAnchor: null })} + addPlot={(entity: string, attributeName: string, attributeValue: string) => + addPlot(entity, attributeName, attributeValue) } - type="color" - id="head" - name="head" - value={theme.palette.custom.dataPointColors[1]} /> - <p>Change dataPointColor 1</p> - <Button variant="contained" onClick={() => dispatch(toggleDarkMode())}> - toggle dark mode - </Button> - </> + <VisConfigPanelComponent> + {/* <FSSConfigPanel + fssViewModel={semanticSubstratesViewModel.current} + graph={state.nodeLinkQueryResult} + // currentColours={currentColours} + /> */} + </VisConfigPanelComponent> + </div> ); }; diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss new file mode 100644 index 000000000..72574e223 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss @@ -0,0 +1,63 @@ +root { + &:hover { + & .display { + & .background { + fill: '#009100'; + transition-delay: 0s; + } + & .plus { + transform: translate(-4px, 0); + transition-delay: 0s; + + & line { + transition: 0.2s; + transition-delay: 0s; + stroke-width: 3; + } + & .xAxis { + transform: translate(0, 0) scale(1, 1); + } + & .yAxis { + transform: translate(0, 0); + } + & .nodes { + opacity: 0; + transition-delay: 0s; + } + } + } + } +} + +.background { + transition: 0.1s; + transition-delay: 0.1s; +} + +.plus { + transform-box: fill-box; + transform-origin: center center; + transition: 0.2s; + transition-delay: 0.1s; + transform: translate(0, 0); + + & line { + transition: 0.2s; + transition-delay: 0.1s; + stroke-width: 1; + } + + & .nodes { + transition-delay: 0.1s; + } +} + +.xAxis { + transform-box: fill-box; + transform-origin: center right; + transform: translate(0, 5px) scale(1.4, 1); +} + +.yAxis { + transform: translate(-11px, 0); +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss.d.ts b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss.d.ts new file mode 100644 index 000000000..0d249f122 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.module.scss.d.ts @@ -0,0 +1,9 @@ +declare const classNames: { + readonly display: 'display'; + readonly background: 'background'; + readonly plus: 'plus'; + readonly xAxis: 'xAxis'; + readonly yAxis: 'yAxis'; + readonly nodes: 'nodes'; +}; +export = classNames; diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.tsx new file mode 100644 index 000000000..d26288f8b --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotButtonComponent.tsx @@ -0,0 +1,80 @@ +/** + * 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 { createStyles, Theme } from '@mui/material'; +import styles from './AddPlotButtonComponent.module.scss'; + +/** + * Contains a component that renders the "add plot" button with SVG elements for semantic substrates. + * @param props The x and y, and an on click event function. + */ +export default function AddPlotButtonComponent(props: { + x?: number; + y?: number; + onClick(event: React.MouseEvent<SVGRectElement>): void; +}) { + + return ( + <g transform={`translate(${props.x || 0},${props.y || 0})`} className={styles.root}> + <g className={'display'}> + <rect + className={styles.background} + x="5" + y="1" + width="108" + height="29" + rx="3" + fill="green" + /> + <path + d="M20.986 18.264H17.318L16.73 20H14.224L17.78 10.172H20.552L24.108 20H21.574L20.986 18.264ZM20.37 16.416L19.152 12.818L17.948 16.416H20.37ZM24.7143 16.08C24.7143 15.2773 24.8636 14.5727 25.1623 13.966C25.4703 13.3593 25.8856 12.8927 26.4083 12.566C26.9309 12.2393 27.5143 12.076 28.1583 12.076C28.6716 12.076 29.1383 12.1833 29.5583 12.398C29.9876 12.6127 30.3236 12.902 30.5663 13.266V9.64H32.9603V20H30.5663V18.88C30.3423 19.2533 30.0203 19.552 29.6003 19.776C29.1896 20 28.7089 20.112 28.1583 20.112C27.5143 20.112 26.9309 19.9487 26.4083 19.622C25.8856 19.286 25.4703 18.8147 25.1623 18.208C24.8636 17.592 24.7143 16.8827 24.7143 16.08ZM30.5663 16.094C30.5663 15.4967 30.3983 15.0253 30.0623 14.68C29.7356 14.3347 29.3343 14.162 28.8583 14.162C28.3823 14.162 27.9763 14.3347 27.6403 14.68C27.3136 15.016 27.1503 15.4827 27.1503 16.08C27.1503 16.6773 27.3136 17.1533 27.6403 17.508C27.9763 17.8533 28.3823 18.026 28.8583 18.026C29.3343 18.026 29.7356 17.8533 30.0623 17.508C30.3983 17.1627 30.5663 16.6913 30.5663 16.094ZM34.2162 16.08C34.2162 15.2773 34.3656 14.5727 34.6642 13.966C34.9722 13.3593 35.3876 12.8927 35.9102 12.566C36.4329 12.2393 37.0162 12.076 37.6602 12.076C38.1736 12.076 38.6402 12.1833 39.0602 12.398C39.4896 12.6127 39.8256 12.902 40.0682 13.266V9.64H42.4622V20H40.0682V18.88C39.8442 19.2533 39.5222 19.552 39.1022 19.776C38.6916 20 38.2109 20.112 37.6602 20.112C37.0162 20.112 36.4329 19.9487 35.9102 19.622C35.3876 19.286 34.9722 18.8147 34.6642 18.208C34.3656 17.592 34.2162 16.8827 34.2162 16.08ZM40.0682 16.094C40.0682 15.4967 39.9002 15.0253 39.5642 14.68C39.2376 14.3347 38.8362 14.162 38.3602 14.162C37.8842 14.162 37.4782 14.3347 37.1422 14.68C36.8156 15.016 36.6522 15.4827 36.6522 16.08C36.6522 16.6773 36.8156 17.1533 37.1422 17.508C37.4782 17.8533 37.8842 18.026 38.3602 18.026C38.8362 18.026 39.2376 17.8533 39.5642 17.508C39.9002 17.1627 40.0682 16.6913 40.0682 16.094ZM49.555 13.294C49.7883 12.93 50.1103 12.636 50.521 12.412C50.9316 12.188 51.4123 12.076 51.963 12.076C52.607 12.076 53.1903 12.2393 53.713 12.566C54.2356 12.8927 54.6463 13.3593 54.945 13.966C55.253 14.5727 55.407 15.2773 55.407 16.08C55.407 16.8827 55.253 17.592 54.945 18.208C54.6463 18.8147 54.2356 19.286 53.713 19.622C53.1903 19.9487 52.607 20.112 51.963 20.112C51.4216 20.112 50.941 20 50.521 19.776C50.1103 19.552 49.7883 19.2627 49.555 18.908V23.724H47.161V12.188H49.555V13.294ZM52.971 16.08C52.971 15.4827 52.803 15.016 52.467 14.68C52.1403 14.3347 51.7343 14.162 51.249 14.162C50.773 14.162 50.367 14.3347 50.031 14.68C49.7043 15.0253 49.541 15.4967 49.541 16.094C49.541 16.6913 49.7043 17.1627 50.031 17.508C50.367 17.8533 50.773 18.026 51.249 18.026C51.725 18.026 52.131 17.8533 52.467 17.508C52.803 17.1533 52.971 16.6773 52.971 16.08ZM59.0569 9.64V20H56.6629V9.64H59.0569ZM64.3478 20.112C63.5825 20.112 62.8918 19.9487 62.2758 19.622C61.6692 19.2953 61.1885 18.8287 60.8338 18.222C60.4885 17.6153 60.3158 16.906 60.3158 16.094C60.3158 15.2913 60.4932 14.5867 60.8478 13.98C61.2025 13.364 61.6878 12.8927 62.3038 12.566C62.9198 12.2393 63.6105 12.076 64.3758 12.076C65.1412 12.076 65.8318 12.2393 66.4478 12.566C67.0638 12.8927 67.5492 13.364 67.9038 13.98C68.2585 14.5867 68.4358 15.2913 68.4358 16.094C68.4358 16.8967 68.2538 17.606 67.8898 18.222C67.5352 18.8287 67.0452 19.2953 66.4198 19.622C65.8038 19.9487 65.1132 20.112 64.3478 20.112ZM64.3478 18.04C64.8052 18.04 65.1925 17.872 65.5098 17.536C65.8365 17.2 65.9998 16.7193 65.9998 16.094C65.9998 15.4687 65.8412 14.988 65.5238 14.652C65.2158 14.316 64.8332 14.148 64.3758 14.148C63.9092 14.148 63.5218 14.316 63.2138 14.652C62.9058 14.9787 62.7518 15.4593 62.7518 16.094C62.7518 16.7193 62.9012 17.2 63.1998 17.536C63.5078 17.872 63.8905 18.04 64.3478 18.04ZM74.0599 17.97V20H72.8419C71.9739 20 71.2972 19.79 70.8119 19.37C70.3266 18.9407 70.0839 18.2453 70.0839 17.284V14.176H69.1319V12.188H70.0839V10.284H72.4779V12.188H74.0459V14.176H72.4779V17.312C72.4779 17.5453 72.5339 17.7133 72.6459 17.816C72.7579 17.9187 72.9446 17.97 73.2059 17.97H74.0599Z" + fill="white" + /> + <g className={styles.plus}> + <line + className={styles.xAxis} + x1="103.987" + y1="15.6278" + x2="88.9872" + y2="15.5" + stroke="white" + strokeWidth="3" + /> + <line + className={styles.yAxis} + x1="96.5" + y1="8" + x2="96.5" + y2="23" + stroke="white" + strokeWidth="3" + /> + <g className={'nodes'}> + <circle cx="89" cy="16" r="1" fill="white" /> + <circle cx="94" cy="14" r="1" fill="white" /> + <circle cx="99" cy="14" r="1" fill="white" /> + </g> + </g> + </g> + <g> + <rect + x="5" + y="1" + width="108" + height="29" + rx="3" + fill="transparent" + onClick={(e) => props.onClick(e)} + /> + </g> + </g> + ); +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx new file mode 100644 index 000000000..f53468bc4 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/AddPlotPopup.tsx @@ -0,0 +1,216 @@ +/** + * 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 { Link, Node } from '../../shared/ResultNodeLinkParserUseCase'; +import { Button, MenuItem, Popover, TextField } from '@mui/material'; +import React, { ReactElement } from 'react'; +import { EntitiesFromSchema } from '../Types'; +import OptimizedAutocomplete from './OptimizedAutocomplete'; + +/** The typing for the props of the plot popups */ +type AddPlotPopupProps = { + anchorEl: Element | null; + open: boolean; + entitiesFromSchema: EntitiesFromSchema; + nodeLinkResultNodes: Node[]; + + // Called when the the popup should close. + handleClose(): void; + // Called when the has filled in the required field and pressed the "add" button. + addPlot(entity: string, attributeName: string, attributeValue: string): void; +}; + +/** The variables in the state of the plot popups */ +type AddPlotPopupState = { + entity: string; + attributeName: string; + isButtonEnabled: boolean; +}; + +/** React component that renders a popup with input fields for adding a plot. */ +export default class AddPlotPopup extends React.Component<AddPlotPopupProps, AddPlotPopupState> { + classes: any; + possibleAttrValues: string[] = []; + attributeNameOptions: string[] = []; + attributeValue = '?'; + + constructor(props: AddPlotPopupProps) { + super(props); + + this.state = { + entity: '', + attributeName: '', + isButtonEnabled: false, + }; + } + + /** + * Called when the entity field is changed. + * Sets the `attributeNameOptions`, resets the `attributeValue` and sets the state. + * @param {React.ChangeEvent<HTMLInputElement>} event The event that is given by the input field when a change event is fired. + */ + private entityChanged = (event: React.ChangeEvent<HTMLInputElement>) => { + const newEntity = event.target.value; + + this.attributeNameOptions = []; + if (this.props.entitiesFromSchema.attributesPerEntity[newEntity]) + this.attributeNameOptions = + this.props.entitiesFromSchema.attributesPerEntity[newEntity].textAttributeNames; + + this.attributeValue = ''; + this.setState({ ...this.state, entity: newEntity, attributeName: '', isButtonEnabled: false }); + }; + + /** + * Called when the attribute name field is changed. + * Sets the possible attribute values, resets the `attributeValue`, enables the "Add" button if all fields valid and sets the state. + * @param {React.ChangeEvent<HTMLInputElement>} event The event that is given by the input field when a change event is fired. + */ + private attrNameChanged = (event: React.ChangeEvent<HTMLInputElement>) => { + const newAttrName = event.target.value; + + this.possibleAttrValues = Array.from( + this.props.nodeLinkResultNodes.reduce((values: Set<string>, node) => { + if (this.state.entity == node.id.split('/')[0] && newAttrName in node.attributes) + values.add(node.attributes[newAttrName]); + return values; + }, new Set<string>()), + ); + + // Reset attribute value. + this.attributeValue = ''; + // Filter the possible attribute values from the entity attributes from the schema. + const isButtonDisabled = + this.props.entitiesFromSchema.entityNames.includes(this.state.entity) && + this.props.entitiesFromSchema.attributesPerEntity[ + this.state.entity + ].textAttributeNames.includes(newAttrName); + this.setState({ + ...this.state, + attributeName: newAttrName, + isButtonEnabled: isButtonDisabled, + }); + }; + + /** + * Called when the user clicks the "Add" button. + * Checks if all fields are valid before calling `handleClose()` and `addPlot()`. + */ + private addPlotButtonClicked = () => { + if ( + this.props.entitiesFromSchema.entityNames.includes(this.state.entity) && + this.props.entitiesFromSchema.attributesPerEntity[ + this.state.entity + ].textAttributeNames.includes(this.state.attributeName) + ) { + this.props.handleClose(); + this.props.addPlot( + this.state.entity, + this.state.attributeName, + this.attributeValue == '' ? '?' : this.attributeValue, + ); + } else + this.setState({ + ...this.state, + isButtonEnabled: false, + }); + }; + + render(): ReactElement { + const { open, anchorEl, entitiesFromSchema, handleClose } = this.props; + + // Retrieve the possible entity options. If none available, set helper message. + let entityMenuItems: ReactElement[]; + if (this.props.entitiesFromSchema.entityNames.length > 0) + entityMenuItems = entitiesFromSchema.entityNames.map((entity) => ( + <MenuItem key={entity} value={entity}> + {entity} + </MenuItem> + )); + else + entityMenuItems = [ + <MenuItem key="placeholder" value="" disabled> + No schema data available + </MenuItem>, + ]; + + // Retrieve the possible attributeName options. If none available, set helper message. + let attributeNameMenuItems: ReactElement[]; + if (this.attributeNameOptions.length > 0) + attributeNameMenuItems = this.attributeNameOptions.map((attribute) => ( + <MenuItem key={attribute} value={attribute}> + {attribute} + </MenuItem> + )); + else + attributeNameMenuItems = [ + <MenuItem key="placeholder" value="" disabled> + First select an entity + </MenuItem>, + ]; + + return ( + <div> + <Popover + id={'simple-addplot-popover'} + open={open} + anchorEl={anchorEl} + onClose={handleClose} + anchorOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + <div style={{ padding: 20, paddingTop: 10, display: 'flex' }}> + <TextField + select + id="standard-select-entity" + style={{ minWidth: 120 }} + label="Entity" + value={this.state.entity} + onChange={this.entityChanged} + > + {entityMenuItems} + </TextField> + <TextField + select + id="standard-select-attribute" + style={{ minWidth: 120, marginLeft: 20, marginRight: 20 }} + label="Attribute" + value={this.state.attributeName} + onChange={this.attrNameChanged} + > + {attributeNameMenuItems} + </TextField> + <OptimizedAutocomplete + currentValue={this.attributeValue} + options={this.possibleAttrValues} + onChange={(v) => (this.attributeValue = v)} + useMaterialStyle={{ label: 'Value', helperText: '' }} + /> + <div style={{ height: 40, paddingTop: 10, marginLeft: 30 }}> + <Button + variant="contained" + disabled={!this.state.isButtonEnabled} + onClick={this.addPlotButtonClicked} + > + <span style={{ fontWeight: 'bold' }}>Add</span> + </Button> + </div> + </div> + </Popover> + </div> + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/BrushComponent.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/BrushComponent.tsx new file mode 100644 index 000000000..17b179640 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/BrushComponent.tsx @@ -0,0 +1,246 @@ +/** + * 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, { ReactElement } from 'react'; +import { + AxisLabel, + MinMaxType, +} from '../Types'; +import { ScaleLinear, brush, scaleLinear, select } from 'd3'; + +/** The variables in the props of the brush component*/ +type BrushProps = { + width: number; + height: number; + + xAxisDomain: MinMaxType; + yAxisDomain: MinMaxType; + + xAxisLabel: AxisLabel; + yAxisLabel: AxisLabel; + + onBrush(xRange: MinMaxType, yRange: MinMaxType): void; +}; + +/** The variables in the state of the brush component*/ +type BrushState = { + xBrushRange: MinMaxType; + yBrushRange: MinMaxType; + brushActive: boolean; +}; + +/** + * Takes care of the brush (square selection) functionality for a plot. + * Also renders the x y selections ticks with their values. + */ +export default class BrushComponent extends React.Component<BrushProps, BrushState> { + ref = React.createRef<SVGGElement>(); + + xAxisScale: ScaleLinear<number, number, never> = scaleLinear(); + yAxisScale: ScaleLinear<number, number, never> = scaleLinear(); + + constructor(props: BrushProps) { + super(props); + this.state = { + xBrushRange: { min: 0, max: this.props.width }, + yBrushRange: { min: 0, max: this.props.height }, + brushActive: false, + }; + + this.updateXYAxisScaleFunctions(); + } + + public componentDidMount(): void { + const d3Selected = select(this.ref.current); + + // Add square selection tool to this plot with the width and height. Also called brush. + d3Selected.append('g').call( + brush() + .extent([ + [0, 0], + [this.props.width, this.props.height], + ]) + .on('brush', (e) => { + this.onBrush(e.selection); + }) + .on('end', (e) => { + this.onBrushEnd(e.selection); + }), + ); + } + + public componentDidUpdate(prevProps: BrushProps): void { + if ( + this.props.xAxisDomain != prevProps.xAxisDomain || + this.props.yAxisDomain != prevProps.yAxisDomain + ) { + this.updateXYAxisScaleFunctions(); + } + } + + /** Updates the X Y axis scale for the plots */ + private updateXYAxisScaleFunctions(): void { + // Create the x axis scale funtion with d3 + const xAxisDomain = [this.props.xAxisDomain.min, this.props.xAxisDomain.max]; + this.xAxisScale = scaleLinear().domain(xAxisDomain).range([0, this.props.width]); + + // Create the y axis scale funtion with d3 + const yAxisDomain = [this.props.yAxisDomain.max, this.props.yAxisDomain.min]; + this.yAxisScale = scaleLinear().domain(yAxisDomain).range([0, this.props.height]); + } + + /** + * Called when brushing. Brush will be displayed and the props callback gets called. + * @param {number[][]} selection A 2D array where the first row is the topleft coordinates and the second bottomright. + */ + private onBrush(selection: number[][] | undefined): void { + if (selection != undefined) { + // Set the state with the inverted selection, this will give us the original positions, (the values) + const newState: BrushState = { + xBrushRange: { + min: this.xAxisScale.invert(selection[0][0]), + max: this.xAxisScale.invert(selection[1][0]), + }, + yBrushRange: { + min: this.yAxisScale.invert(selection[1][1]), + max: this.yAxisScale.invert(selection[0][1]), + }, + brushActive: true, + }; + this.setState(newState); + + // Add event when brushing, call the onBrush with start and end positions + this.props.onBrush(newState.xBrushRange, newState.yBrushRange); + } + } + + /** + * Called when brush ends. If the selection was empty reset selection to cover whole plot and notify viewmodel. + * @param {number[][]} selection A 2D array where the first row is the topleft coordinates and the second bottomright. + */ + private onBrushEnd(selection: number[][] | undefined): void { + if (selection == undefined) { + // Set the state with the inverted selection, this will give us the original positions, (the values) + const newState: BrushState = { + xBrushRange: { + min: this.xAxisScale.invert(0), + max: this.xAxisScale.invert(this.props.width), + }, + yBrushRange: { + min: this.yAxisScale.invert(this.props.height), + max: this.yAxisScale.invert(0), + }, + brushActive: false, + }; + this.setState(newState); + + // Add event when brushing, call the onBrush with start and end positions + this.props.onBrush(newState.xBrushRange, newState.yBrushRange); + } + } + + /** + * Renders the X axis brush ticks depending on the brushActive state. + * @param xRangePositions The x positions of the brush ticks. + */ + private renderXAxisBrushTicks(xRangePositions: MinMaxType): ReactElement { + if (this.state.brushActive && this.props.xAxisLabel != AxisLabel.evenlySpaced) { + return ( + <g> + <line + x1={xRangePositions.min} + y1={this.props.height - 5} + x2={xRangePositions.min} + y2={this.props.height} + stroke={'red'} + strokeWidth={1} + fill={'transparent'} + /> + <text x={xRangePositions.min} y={this.props.height - 8} textAnchor="middle" fontSize={10}> + {+this.state.xBrushRange.min.toFixed(2)} + </text> + <line + x1={xRangePositions.max} + y1={this.props.height - 5} + x2={xRangePositions.max} + y2={this.props.height} + stroke={'red'} + strokeWidth={1} + fill={'transparent'} + /> + <text x={xRangePositions.max} y={this.props.height - 8} textAnchor="middle" fontSize={10}> + {+this.state.xBrushRange.max.toFixed(2)} + </text> + </g> + ); + } + + return <></>; + } + + /** + * Renders the Y axis brush ticks depending on the brushActive state. + * @param yRangePositions The y positions of the brush ticks. + */ + private renderYAxisBrushTicks(yRangePositions: MinMaxType): ReactElement { + if (this.state.brushActive && this.props.yAxisLabel != AxisLabel.evenlySpaced) { + return ( + <g> + <line + x1={0} + y1={yRangePositions.min} + x2={5} + y2={yRangePositions.min} + stroke={'red'} + strokeWidth={1} + fill={'transparent'} + /> + <text x={8} y={yRangePositions.min + 3} fontSize={10}> + {+this.state.yBrushRange.min.toFixed(2)} + </text> + <line + x1={0} + y1={yRangePositions.max} + x2={5} + y2={yRangePositions.max} + stroke={'red'} + strokeWidth={1} + fill={'transparent'} + /> + <text x={8} y={yRangePositions.max + 3} fontSize={10}> + {+this.state.yBrushRange.max.toFixed(2)} + </text> + </g> + ); + } + + return <></>; + } + + public render(): ReactElement { + // Scale the brush to the scaled range + // Used for rendering the positions of the x and y axis brush ticks + const xRangePositions: MinMaxType = { + min: this.xAxisScale(this.state.xBrushRange.min), + max: this.xAxisScale(this.state.xBrushRange.max), + }; + const yRangePositions: MinMaxType = { + min: this.yAxisScale(this.state.yBrushRange.min), + max: this.yAxisScale(this.state.yBrushRange.max), + }; + + return ( + <g ref={this.ref}> + {this.renderXAxisBrushTicks(xRangePositions)} + {this.renderYAxisBrushTicks(yRangePositions)} + </g> + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/LinesBetweenPlotsComponent.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/LinesBetweenPlotsComponent.tsx new file mode 100644 index 000000000..91adb4afc --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/LinesBetweenPlotsComponent.tsx @@ -0,0 +1,113 @@ +/** + * 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, { ReactElement } from 'react'; +import { + NodeType, + PlotType, + RelationType, +} from '../Types'; +import { XYPosition } from 'reactflow'; +import CalcConnectionLinePositionsUseCase from '../utils/CalcConnectionLinePositionsUseCase'; + +/** Props of the lines between the plots component */ +type LinesBetweenPlotsProps = { + fromPlot: PlotType; + toPlot: PlotType; + relations: RelationType[]; + nodeRadius: number; + color: string; +}; +/** Renders all the connection lines between nodes from two plots. */ +export default class LinesBetweenPlots extends React.Component<LinesBetweenPlotsProps> { + render(): JSX.Element { + const { fromPlot, toPlot, relations, nodeRadius, color } = this.props; + + // The JSX elements to render a connection line with arrow + const lines = relations.map((relation) => ( + <LineBetweenNodesComponent + key={ + fromPlot.nodes[relation.fromIndex].data.text + toPlot.nodes[relation.toIndex].data.text + } + fromNode={fromPlot.nodes[relation.fromIndex]} + fromPlotYOffset={fromPlot.yOffset} + toNode={toPlot.nodes[relation.toIndex]} + toPlotYOffset={toPlot.yOffset} + width={relation.value ?? 1} + nodeRadius={nodeRadius} + color={relation.colour ?? color} + /> + )); + + return <g>{lines}</g>; + } +} + +/** Props of the nodes between the plots component. */ +type LineBetweenNodesProps = { + fromNode: NodeType; + fromPlotYOffset: number; + + toNode: NodeType; + toPlotYOffset: number; + + width: number; + color: string; + nodeRadius: number; +}; +/** React component for drawing a single connectionline between nodes for semantic substrates. */ +class LineBetweenNodesComponent extends React.Component<LineBetweenNodesProps> { + render(): ReactElement { + const { fromNode, toNode, fromPlotYOffset, toPlotYOffset, width, color, nodeRadius } = + this.props; + + // The start and end position with their plot offset + const startNodePos: XYPosition = { + x: fromNode.scaledPosition.x, + y: fromNode.scaledPosition.y + fromPlotYOffset, + }; + + const endNodePos: XYPosition = { + x: toNode.scaledPosition.x, + y: toNode.scaledPosition.y + toPlotYOffset, + }; + + // Get the positions to draw the arrow + const { start, end, controlPoint, arrowRStart, arrowLStart } = + CalcConnectionLinePositionsUseCase.calculatePositions(startNodePos, endNodePos, nodeRadius); + + // Create the curved line path + const path = `M ${start.x} ${start.y} Q ${controlPoint.x} ${controlPoint.y} ${end.x} ${end.y}`; + + return ( + <g pointerEvents={'none'}> + <path d={path} stroke={color} strokeWidth={width} fill="transparent" /> + <line + x1={arrowRStart.x} + y1={arrowRStart.y} + x2={end.x} + y2={end.y} + stroke={color} + strokeWidth={width} + fill="transparent" + /> + <line + x1={arrowLStart.x} + y1={arrowLStart.y} + x2={end.x} + y2={end.y} + stroke={color} + strokeWidth={width} + fill="transparent" + /> + </g> + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss b/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss new file mode 100644 index 000000000..8b0df8b37 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss @@ -0,0 +1,7 @@ +.listbox { + box-sizing: border-box; + & ul { + padding: 0; + margin: 0; + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss.d.ts b/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss.d.ts new file mode 100644 index 000000000..83092e29c --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.module.scss.d.ts @@ -0,0 +1,4 @@ +declare const classNames: { + readonly listbox: 'listbox'; +}; +export = classNames; diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.tsx new file mode 100644 index 000000000..dba94dfc5 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/OptimizedAutocomplete.tsx @@ -0,0 +1,154 @@ +/** + * 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 { Autocomplete, AutocompleteRenderGroupParams, AutocompleteRenderInputParams, ListSubheader, TextField, Typography, makeStyles, useMediaQuery, useTheme } from '@mui/material'; +import React from 'react'; +import { VariableSizeList, ListChildComponentProps } from 'react-window'; +import styles from './OptimizedAutocomplete.module.scss'; + +const LISTBOX_PADDING = 8; // px + +function renderRow(props: ListChildComponentProps) { + const { data, index, style } = props; + return React.cloneElement(data[index], { + style: { + ...style, + top: (style.top as number) + LISTBOX_PADDING, + }, + }); +} + +const OuterElementContext = React.createContext({}); + +// eslint-disable-next-line react/display-name +const OuterElementType = React.forwardRef<HTMLDivElement>((props, ref) => { + const outerProps = React.useContext(OuterElementContext); + return <div ref={ref} {...props} {...outerProps} />; +}); + +function useResetCache(data: any) { + const ref = React.useRef<VariableSizeList>(null); + React.useEffect(() => { + if (ref.current != null) { + ref.current.resetAfterIndex(0, true); + } + }, [data]); + return ref; +} + +// Adapter for react-window +const ListboxComponent = React.forwardRef<HTMLDivElement>(function ListboxComponent(props, ref) { + const { children, ...other } = props; + const itemData = React.Children.toArray(children); + const theme = useTheme(); + const smUp = useMediaQuery(theme.breakpoints.up('sm'), { noSsr: true }); + const itemCount = itemData.length; + const itemSize = smUp ? 36 : 48; + + const getChildSize = (child: React.ReactNode) => { + if (React.isValidElement(child) && child.type === ListSubheader) { + return 48; + } + + return itemSize; + }; + + const getHeight = () => { + if (itemCount > 8) { + return 8 * itemSize; + } + return itemData.map(getChildSize).reduce((a, b) => a + b, 0); + }; + + const gridRef = useResetCache(itemCount); + + return ( + <div ref={ref}> + <OuterElementContext.Provider value={other}> + <VariableSizeList + itemData={itemData} + height={getHeight() + 2 * LISTBOX_PADDING} + width="100%" + ref={gridRef} + outerElementType={OuterElementType} + innerElementType="ul" + itemSize={(index: number) => getChildSize(itemData[index])} + overscanCount={5} + itemCount={itemCount} + > + {renderRow} + </VariableSizeList> + </OuterElementContext.Provider> + </div> + ); +}); + +const renderGroup = (params: AutocompleteRenderGroupParams) => [ + <ListSubheader key={params.key} component="div"> + {params.group} + </ListSubheader>, + params.children, +]; + +type OptimizedAutocomplete = { + currentValue: string; + options: string[]; + useMaterialStyle?: { label: string; helperText: string }; + /** Called when the value of the input field changes. */ + onChange?(value: string): void; + /** Called when the user leaves focus of the input field. */ + onLeave?(value: string): void; +}; +/** Renders the autocomplete input field with the given props. */ +export default function OptimizedAutocomplete(props: OptimizedAutocomplete) { + let newValue = props.currentValue; + + return ( + <Autocomplete + id="optimized-autocomplete" + style={{ width: 200 }} + disableListWrap + autoHighlight + className={styles.listbox} + ListboxComponent={ListboxComponent as React.ComponentType<React.HTMLAttributes<HTMLElement>>} + renderGroup={renderGroup} + options={props.options} + // groupBy={(option: string) => option[0].toUpperCase()} + renderInput={(params: AutocompleteRenderInputParams) => + props.useMaterialStyle != undefined ? ( + <TextField + {...params} + variant="standard" + // size="small" + label={props.useMaterialStyle.label} + helperText={props.useMaterialStyle.helperText} + /> + ) : ( + <div ref={params.InputProps.ref}> + <input style={{ width: 200 }} type="text" {...params.inputProps} autoFocus /> + </div> + ) + } + // defaultValue={props.currentValue} + placeholder={props.currentValue} + renderOption={(props, option, state) => <Typography noWrap>{option}</Typography>} + onChange={(_: React.ChangeEvent<any>, value: string | null) => { + newValue = value || '?'; + if (props.onChange) props.onChange(newValue); + }} + onBlur={() => { + if (props.onLeave) props.onLeave(newValue); + }} + onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>): void => { + if (e.key == 'Enter' && props.onLeave) props.onLeave(newValue); + }} + /> + ); +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css new file mode 100644 index 000000000..5ee3cdfe9 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css @@ -0,0 +1,26 @@ +/** + * 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.*/ + +/* Contains styling for the plot axis labels. */ +.xLabelText, +.yLabelText { + text-anchor: end; + cursor: pointer; +} + +.xLabelText:hover { + /* filter: drop-shadow(1px 1px 0.4px #80808078); */ + text-decoration: underline; +} +.yLabelText:hover { + /* filter: drop-shadow(1px 1px 0.4px #80808000); */ + text-decoration: underline; +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css.d.ts b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css.d.ts new file mode 100644 index 000000000..8dcb2518e --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelStyles.module.css.d.ts @@ -0,0 +1,5 @@ +declare const classNames: { + readonly xLabelText: 'xLabelText'; + readonly yLabelText: 'yLabelText'; +}; +export = classNames; diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelsComponent.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelsComponent.tsx new file mode 100644 index 000000000..9776b45d1 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotAxisLabelsComponent.tsx @@ -0,0 +1,116 @@ +/** + * 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, { ReactElement } from 'react'; +import { Menu, MenuItem } from '@mui/material'; +import styles from './PlotAxisLabelStyles.module.css'; + +/** Props of the axis labels component. */ +type PlotAxisLabelsProps = { + xAxisLabel: string; + yAxisLabel: string; + axisLabelOptions: string[]; + + width: number; + height: number; + + // Callback when an axis label changed + onAxisLabelChanged(axis: 'x' | 'y', value: string): void; +}; +type PlotAxisLabelsState = { + menuAnchor: Element | null; + menuOpenForAxis: 'x' | 'y'; +}; +/** Component for rendering the axis labels of an semantic substrates plot. + * With functionality to edit the labels. + */ +export default class PlotAxisLabelsComponent extends React.Component< + PlotAxisLabelsProps, + PlotAxisLabelsState +> { + constructor(props: PlotAxisLabelsProps) { + super(props); + this.state = { menuAnchor: null, menuOpenForAxis: 'x' }; + } + + render(): ReactElement { + const { width, height, xAxisLabel, yAxisLabel, axisLabelOptions } = this.props; + + return ( + <g> + <text + className={styles.xLabelText} + x={width - 10} + y={height + 35} + onClick={(event: React.MouseEvent<SVGTextElement>) => { + this.setState({ + menuAnchor: event.currentTarget, + menuOpenForAxis: 'x', + }); + }} + > + {xAxisLabel} + </text> + <text + className={styles.yLabelText} + x={-10} + y={-50} + transform={'rotate(-90)'} + onClick={(event: React.MouseEvent<SVGTextElement>) => { + this.setState({ + menuAnchor: event.currentTarget, + menuOpenForAxis: 'y', + }); + }} + > + {yAxisLabel} + </text> + <Menu + id="simple-menu" + anchorEl={this.state.menuAnchor} + keepMounted + // getContentAnchorEl={null} + anchorOrigin={ + this.state.menuOpenForAxis == 'x' + ? { vertical: 'bottom', horizontal: 'center' } + : { vertical: 'center', horizontal: 'right' } + } + transformOrigin={ + this.state.menuOpenForAxis == 'x' + ? { vertical: 'top', horizontal: 'center' } + : { vertical: 'center', horizontal: 'left' } + } + open={Boolean(this.state.menuAnchor)} + onClose={() => this.setState({ menuAnchor: null })} + transitionDuration={150} + PaperProps={{ + style: { + maxHeight: 48 * 4.5, // 48 ITEM_HEIGHT + }, + }} + > + {axisLabelOptions.map((option) => ( + <MenuItem + key={option} + selected={option == (this.state.menuOpenForAxis == 'x' ? xAxisLabel : yAxisLabel)} + onClick={() => { + this.setState({ menuAnchor: null }); + + this.props.onAxisLabelChanged(this.state.menuOpenForAxis, option); + }} + > + {option} + </MenuItem> + ))} + </Menu> + </g> + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotComponent.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotComponent.tsx new file mode 100644 index 000000000..183fcbcc3 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotComponent.tsx @@ -0,0 +1,223 @@ +/** + * 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, { ReactElement } from 'react'; +import { + EntitiesFromSchema, + MinMaxType, + PlotType, + AxisLabel, + PlotSpecifications, + NodeType, +} from '../Types'; +import BrushComponent from './BrushComponent'; +import PlotTitleComponent from './PlotTitleComponent'; +import PlotAxisLabelsComponent from './PlotAxisLabelsComponent'; +import { axisBottom, axisLeft, ScaleLinear, scaleLinear, select, Selection } from 'd3'; + +/** Props of the plots component */ +type PlotProps = { + plotData: PlotType; + plotSpecification: PlotSpecifications; + entitiesFromSchema: EntitiesFromSchema; + nodeColor: string; + nodeRadius: number; + + selectedAttributeNumerical: string; + selectedAttributeCatecorigal: string; + scaleCalculation: (x: number) => number; + colourCalculation: (x: string) => string; + + width: number; + height: number; + + onBrush(xRange: MinMaxType, yRange: MinMaxType): void; + onDelete(): void; + onAxisLabelChanged(axis: 'x' | 'y', value: string): void; + + onTitleChanged(entity: string, attrName: string, attrValue: string): void; +}; + +/** State for the plots component */ +type PlotState = { + menuAnchor: Element | null; +}; + +/** React component to render a plot with axis and square selection tool. */ +export default class Plot extends React.Component<PlotProps, PlotState> { + ref = React.createRef<SVGGElement>(); + + xAxisScale: ScaleLinear<number, number, never> = scaleLinear(); + yAxisScale: ScaleLinear<number, number, never> = scaleLinear(); + + d3XAxis: Selection<SVGGElement, unknown, null, undefined> | undefined; + d3YAxis: Selection<SVGGElement, unknown, null, undefined> | undefined; + + constructor(props: PlotProps) { + super(props); + this.state = { + menuAnchor: null, + }; + + this.updateXYAxisScaleFunctions(); + } + + public componentDidMount(): void { + const d3Selected = select(this.ref.current); + + // Add x axis ticks + this.d3XAxis = d3Selected + .append('g') + .attr('transform', 'translate(0,' + this.props.height + ')') + .call(axisBottom(this.xAxisScale)); + + // Add y axis ticks + this.d3YAxis = d3Selected.append('g').call(axisLeft(this.yAxisScale)); + } + + public componentDidUpdate(prevProps: PlotProps): void { + if ( + this.props.plotData.minmaxXAxis != prevProps.plotData.minmaxXAxis || + this.props.plotData.minmaxYAxis != prevProps.plotData.minmaxYAxis + ) { + this.updateXYAxisScaleFunctions(); + + // Update x axis ticks + if (this.d3XAxis) { + this.d3XAxis.transition().duration(1000).call(axisBottom(this.xAxisScale)); + } + + // Update y axis ticks + if (this.d3YAxis) { + this.d3YAxis.transition().duration(1000).call(axisLeft(this.yAxisScale)); + } + } + } + + /** Update the x and y axis scale functions with the new axis domains. */ + private updateXYAxisScaleFunctions(): void { + // Create the x axis scale funtion with d3 + if (this.props.plotSpecification.xAxis == AxisLabel.evenlySpaced) + this.xAxisScale = scaleLinear().domain([]).range([this.props.width, 0]); + else + this.xAxisScale = scaleLinear() + .domain([this.props.plotData.minmaxXAxis.max, this.props.plotData.minmaxXAxis.min]) + .range([this.props.width, 0]); + + // Create the y axis scale funtion with d3 + if (this.props.plotSpecification.yAxis == AxisLabel.evenlySpaced) + this.yAxisScale = scaleLinear().domain([]).range([0, this.props.height]); + else + this.yAxisScale = scaleLinear() + .domain([this.props.plotData.minmaxYAxis.max, this.props.plotData.minmaxYAxis.min]) + .range([0, this.props.height]); + } + + public render(): ReactElement { + const { + width, + height, + // yOffset, + plotData, + nodeColor, + nodeRadius, + entitiesFromSchema, + plotSpecification, + selectedAttributeNumerical, + selectedAttributeCatecorigal, + scaleCalculation, + colourCalculation, + onDelete, + onBrush, + onAxisLabelChanged, + onTitleChanged, + } = this.props; + + const circles = plotData.nodes.map((node) => ( + <circle + key={node.data.text} + cx={node.scaledPosition.x} + cy={node.scaledPosition.y} + r={Math.abs(IfAttributeIsNumber(nodeRadius, node))} + fill={colourCalculation(node.attributes[selectedAttributeCatecorigal])} + /> + )); + + function IfAttributeIsNumber(nodeRadius: number, node: NodeType): number { + let possibleRadius = Number(node.attributes[selectedAttributeNumerical]); + if (!isNaN(possibleRadius)) { + return scaleCalculation(possibleRadius); + } + return nodeRadius; + } + + // Determine the x y axis label, if it's byAttribute, give it the attribute type. + let xAxisLabel: string; + if (plotSpecification.xAxis == AxisLabel.byAttribute) + xAxisLabel = plotSpecification.xAxisAttributeType; + else xAxisLabel = plotSpecification.xAxis; + let yAxisLabel: string; + if (plotSpecification.yAxis == AxisLabel.byAttribute) + yAxisLabel = plotSpecification.yAxisAttributeType; + else yAxisLabel = plotSpecification.yAxis; + + const axisLabelOptions = [ + ...entitiesFromSchema.attributesPerEntity[plotSpecification.entity].numberAttributeNames, + AxisLabel.inboundConnections, + AxisLabel.outboundConnections, + AxisLabel.evenlySpaced, + ]; + + return ( + <g ref={this.ref} transform={'translate(0,' + plotData.yOffset + ')'}> + <PlotTitleComponent + pos={{ x: 0, y: -5 }} + nodeColor={nodeColor} + entity={plotSpecification.entity} + attributeName={plotSpecification.labelAttributeType} + attributeValue={plotSpecification.labelAttributeValue} + entitiesFromSchema={entitiesFromSchema} + onTitleChanged={onTitleChanged} + possibleAttrValues={plotData.possibleTitleAttributeValues} + /> + {circles} + <PlotAxisLabelsComponent + width={width} + height={height} + xAxisLabel={xAxisLabel} + yAxisLabel={yAxisLabel} + axisLabelOptions={axisLabelOptions} + onAxisLabelChanged={onAxisLabelChanged} + /> + <g + onClick={() => onDelete()} + transform={`translate(${width + 3},${height - 30})`} + className="delete-plot-icon" + > + <rect x1="-1" y1="-1" width="26" height="26" fill="transparent" /> + <path d="M3,6V24H21V6ZM8,20a1,1,0,0,1-2,0V10a1,1,0,0,1,2,0Zm5,0a1,1,0,0,1-2,0V10a1,1,0,0,1,2,0Zm5,0a1,1,0,0,1-2,0V10a1,1,0,0,1,2,0Z" /> + <path + className="lid" + d="M22,2V4H2V2H7.711c.9,0,1.631-1.1,1.631-2h5.315c0,.9.73,2,1.631,2Z" + /> + </g> + <BrushComponent + width={width} + height={height} + xAxisDomain={plotData.minmaxXAxis} + yAxisDomain={plotData.minmaxYAxis} + xAxisLabel={plotSpecification.xAxis} + yAxisLabel={plotSpecification.yAxis} + onBrush={onBrush} + /> + </g> + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleComponent.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleComponent.tsx new file mode 100644 index 000000000..d21528294 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleComponent.tsx @@ -0,0 +1,207 @@ +/** + * 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 { Menu, MenuItem } from '@mui/material'; +import Color from 'color'; +import React, { ReactElement } from 'react'; +import { EntitiesFromSchema } from '../Types'; +import styles from './PlotTitleStyles.module.css'; +import { XYPosition } from 'reactflow'; +import { getWidthOfText } from '@graphpolaris/shared/lib/schema/schema-utils'; +import OptimizedAutocomplete from './OptimizedAutocomplete'; + +/** Props of the plots title component. */ +type PlotTitleProps = { + entity: string; + attributeName: string; + attributeValue: string; + entitiesFromSchema: EntitiesFromSchema; + nodeColor: string; + pos: XYPosition; + possibleAttrValues: string[]; + onTitleChanged(entity: string, attrName: string, attrValue: string): void; +}; + +/** State of the plots title component. */ +type PlotTitleState = { + isEditingAttrValue: boolean; + + // Dropdown when editing entity or attributeName + menuAnchor: Element | null; + menuItems: string[]; + menuItemsOnClick(value: string): void; +}; +/** + * A semantic substrates React component for rendering a plot title. + * With functionality to change the entity, attributeType and attributeName. + */ +export default class PlotTitleComponent extends React.Component<PlotTitleProps, PlotTitleState> { + constructor(props: PlotTitleProps) { + super(props); + + this.state = { + isEditingAttrValue: false, + menuAnchor: null, + menuItems: [], + menuItemsOnClick: () => true, + }; + } + + /** + * Will be called when the value of the entity changes. + * @param {string} value The new entity value. + */ + private onEntityChanged(value: string): void { + // Get the first attribute name for this entity. + const attrName = + this.props.entitiesFromSchema.attributesPerEntity[value].textAttributeNames.length > 0 + ? this.props.entitiesFromSchema.attributesPerEntity[value].textAttributeNames[0] + : '?'; + + this.props.onTitleChanged(value, attrName, '?'); + } + + /** + * Will be called when the attribute name changes. + * @param {string} value The new attribute name value. + */ + private onAttrNameChanged(value: string): void { + this.props.onTitleChanged(this.props.entity, value, '?'); + } + + /** + * Will be called when the attribute value changes. + * @param {string} value The new value of the attribute value. + */ + private onAttrValueChanged(value: string): void { + if (value == '') value = '?'; + + // only update the state if the attribute value didn't change + // If the attribute value did change, this component will be rerendered anyway + if (value != this.props.attributeValue) + this.props.onTitleChanged(this.props.entity, this.props.attributeName, value); + else + this.setState({ + isEditingAttrValue: false, + }); + } + + /** + * Renders the attribute value, either plain svg <text> element or, <input> if the user is editing this field. + * @param {number} xOffset The x position offset. + * @returns {ReactElement} The svg elements to render for the attribute value. + */ + private renderAttributeValue(xOffset: number): ReactElement { + if (this.state.isEditingAttrValue) + return ( + <foreignObject x={xOffset} y="-16" width="200" height="150"> + <div> + <OptimizedAutocomplete + options={this.props.possibleAttrValues} + currentValue={this.props.attributeValue} + onLeave={(v: string) => this.onAttrValueChanged(v)} + /> + </div> + </foreignObject> + ); + else + return ( + <text + x={xOffset} + className={styles.clickable} + onClick={() => this.setState({ isEditingAttrValue: true })} + > + {this.props.attributeValue} + </text> + ); + } + + render(): ReactElement { + const { entity, attributeName, entitiesFromSchema, pos, nodeColor } = this.props; + + const withOffset = getWidthOfText(entity + ' ', 'arial', '15px', 'bold'); + const attrNameOffset = getWidthOfText('with ', 'arial', '15px') + withOffset; + const colonOffset = + getWidthOfText(attributeName + ' ', 'arial', '15px', 'bold') + attrNameOffset; + const attrValueOffset = getWidthOfText(': ', 'arial', '15px', 'bold') + colonOffset; + + const nodeColorDarkened = Color(nodeColor).darken(0.3).hex(); + + return ( + <g transform={`translate(${pos.x},${pos.y})`}> + <text + className={styles.clickable} + fill={nodeColorDarkened} + onClick={(event: React.MouseEvent<SVGTextElement>) => { + this.setState({ + menuAnchor: event.currentTarget, + menuItems: entitiesFromSchema.entityNames, + menuItemsOnClick: (v: string) => this.onEntityChanged(v), + }); + }} + > + {entity} + </text> + <text x={withOffset} fontSize={15}> + with + </text> + <text + className={styles.clickable} + x={attrNameOffset} + onClick={(event: React.MouseEvent<SVGTextElement>) => { + this.setState({ + menuAnchor: event.currentTarget, + menuItems: entitiesFromSchema.attributesPerEntity[entity].textAttributeNames, + menuItemsOnClick: (v: string) => this.onAttrNameChanged(v), + }); + }} + > + {attributeName} + </text> + <text x={colonOffset} fontWeight="bold" fontSize={15}> + : + </text> + + {this.renderAttributeValue(attrValueOffset)} + + <Menu + id="simple-menu" + anchorEl={this.state.menuAnchor} + keepMounted + // getContentAnchorEl={null} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + transformOrigin={{ vertical: 'top', horizontal: 'center' }} + open={Boolean(this.state.menuAnchor)} + onClose={() => this.setState({ menuAnchor: null })} + transitionDuration={150} + PaperProps={{ + style: { + maxHeight: 48 * 4.5, // 48 ITEM_HEIGHT + }, + }} + > + {this.state.menuItems.map((option) => ( + <MenuItem + key={option} + selected={option == this.state.menuAnchor?.innerHTML} + onClick={() => { + this.setState({ menuAnchor: null, menuItems: [] }); + + this.state.menuItemsOnClick(option); + }} + > + {option} + </MenuItem> + ))} + </Menu> + </g> + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css new file mode 100644 index 000000000..35a50d6d9 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css @@ -0,0 +1,22 @@ +/** + * 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.*/ + +/* Contains styling for the plot title text. */ +.clickable { + font-weight: bold; + cursor: pointer; + font-size: 15px; + transition: 2s; +} +.clickable:hover { + /* filter: drop-shadow(1px 1px 0.4px #80808078); */ + text-decoration: underline; +} diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css.d.ts b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css.d.ts new file mode 100644 index 000000000..59be27951 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/PlotTitleStyles.module.css.d.ts @@ -0,0 +1,4 @@ +declare const classNames: { + readonly clickable: 'clickable'; +}; +export = classNames; diff --git a/libs/shared/lib/vis/semanticsubstrates/subcomponents/SVGCheckBoxComponent.tsx b/libs/shared/lib/vis/semanticsubstrates/subcomponents/SVGCheckBoxComponent.tsx new file mode 100644 index 000000000..d1f8a425a --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/subcomponents/SVGCheckBoxComponent.tsx @@ -0,0 +1,155 @@ +/** + * 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 Color from 'color'; +import React, { ReactElement } from 'react'; +import { + PlotType, + RelationType, +} from '../Types'; + +/** All the props needed to visualize all the semantic substrates checkboxes. */ +type SVGCheckboxesWithSemanticSubstrLabelProps = { + plots: PlotType[]; + relations: RelationType[][][]; + visibleRelations: boolean[][]; + nodeColors: string[]; + onCheckboxChanged(fromPlot: number, toPlot: number, value: boolean): void; +}; + +/** Renders all the checkboxes for a semantic substrates visualisation. */ +export default class SVGCheckboxesWithSemanticSubstrLabel extends React.Component<SVGCheckboxesWithSemanticSubstrLabelProps> { + render(): ReactElement { + const { plots, relations, visibleRelations, nodeColors, onCheckboxChanged } = this.props; + + const checkboxGroups: ReactElement[] = []; + + // Go through each relation. + for (let fromPlot = 0; fromPlot < relations.length; fromPlot++) { + const checkboxes: JSX.Element[] = []; + // The from- and toPlot title color will be a bit darker than the node colors for their plots. + const fromColor = Color(nodeColors[fromPlot]).darken(0.3).hex(); + for (let toPlot = 0; toPlot < relations[fromPlot].length; toPlot++) { + if (!!relations?.[fromPlot]?.[toPlot] && visibleRelations?.[fromPlot]?.[toPlot] !== undefined && relations[fromPlot][toPlot].length > 0) { + // Add a checkbox for the connections between fromPlot and toPlot. + checkboxes.push( + <g + key={plots[fromPlot].title + fromPlot + '-' + plots[toPlot].title + toPlot} + transform={'translate(0,' + toPlot * 30 + ')'} + > + <text x={20} y={12} fill={fromColor} style={{ fontWeight: 'bold' }}> + {plots[fromPlot].title} + </text> + <path + fill="transparent" + stroke={'black'} + d={`M${110} 7 l40 0 l-0 0 l-15 -5 m15 5 l-15 5`} + /> + <text + x={170} + y={12} + fill={Color(nodeColors[toPlot]).darken(0.3).hex()} + style={{ fontWeight: 'bold' }} + > + {plots[toPlot].title} + </text> + <SVGCheckboxComponent + x={0} + y={0} + key={plots[fromPlot].title + plots[toPlot].title + fromPlot + toPlot} + width={15} + value={visibleRelations[fromPlot][toPlot]} + onChange={(value: boolean) => { + onCheckboxChanged(fromPlot, toPlot, value); + }} + /> + </g>, + ); + } + } + + // Offset the height of the checkboxes for each plot. + checkboxGroups.push( + <g + key={plots[fromPlot].title + fromPlot} + transform={ + 'translate(' + (plots[fromPlot].width + 30) + ',' + plots[fromPlot].yOffset + ')' + } + > + {checkboxes} + </g>, + ); + } + + return <g>{checkboxGroups}</g>; + } +} + +/** The props for the SVG checkbox component */ +type SVGCheckBoxProps = { + x: number; + y: number; + width: number; + value: boolean; + // Called when the the checkbox state changes. + onChange(value: boolean): void; +}; + +/** The state for the SVG checkbox component */ +type SVGCheckBoxState = { + value: boolean; +}; +/** Renders a simple checkbox in SVG elements. */ +export class SVGCheckboxComponent extends React.Component<SVGCheckBoxProps, SVGCheckBoxState> { + static defaultProps = { + width: 15, + value: false, + onChange: () => true, + }; + constructor(props: SVGCheckBoxProps) { + super(props); + this.state = { value: this.props.value }; + } + + render(): ReactElement { + return ( + <g> + <rect + x={this.props.x} + y={this.props.y} + rx="0" + ry="0" + width={this.props.width} + height={this.props.width} + style={{ fill: 'transparent', stroke: 'grey', strokeWidth: 2 }} + onClick={() => { + const newVal = this.state.value ? false : true; + this.props.onChange(newVal); + this.setState({ value: newVal }); + }} + /> + <rect + x={this.props.x + 3} + y={this.props.y + 3} + rx="0" + ry="0" + width={this.props.width - 6} + height={this.props.width - 6} + style={{ fill: this.state.value ? '#00a300' : 'white' }} + onClick={() => { + const newVal = this.state.value ? false : true; + this.props.onChange(newVal); + this.setState({ value: newVal }); + }} + /> + </g> + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcConnectionLinePositionsUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcConnectionLinePositionsUseCase.tsx new file mode 100644 index 000000000..857aad24b --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcConnectionLinePositionsUseCase.tsx @@ -0,0 +1,169 @@ +/** + * 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 { CalcDistance } from './CalcDistance'; +import { RotateVectorByDeg } from '../utils/RotateVec'; +import { XYPosition } from 'reactflow'; + +/** The return type for this usecase. controlPoint is used for the curvature of the line. */ +type ConnecionLinePositions = { + start: XYPosition; + end: XYPosition; + controlPoint: XYPosition; + + arrowRStart: XYPosition; + arrowLStart: XYPosition; +}; + +/** The use case that calculates the line positions */ +export default class CalcConnectionLinePositionsUseCase { + /** + * Calculates the positions for the points needed to draw the curved line with the arrow. + * Also offsets the start and end point so they touch the edge of the node, instead of going to the center. + * @param {XYPosition} startNodePos The position of the start node. + * @param {XYPosition} endNodePos The position of the end node. + * @param {number} nodeRadius The node radius, used to calculate the start and end offset. + * @returns {ConnecionLinePositions} The positions for drawing the curved line. + */ + public static calculatePositions( + startNodePos: XYPosition, + endNodePos: XYPosition, + nodeRadius: number, + ): ConnecionLinePositions { + // Calculate the control point for the quadratic curve path, see https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths + const distance = CalcDistance(startNodePos, endNodePos); + if (distance == 0) return this.returnStartPosition(startNodePos); + const controlPoint = this.calculateControlPoint(endNodePos, startNodePos, distance); + + // move the start and end point so they are on the edge of their node + const movedStartPos = this.calculateMovedStartPos(startNodePos, controlPoint, nodeRadius); + const endToControlPointDist = CalcDistance(endNodePos, controlPoint); + const endToControlPointVec = { + x: (endNodePos.x - controlPoint.x) / endToControlPointDist, + y: (endNodePos.y - controlPoint.y) / endToControlPointDist, + }; + + const movedEndPos = this.calculateMovedEndPos(endNodePos, endToControlPointVec, nodeRadius); + + // Create arrowhead start points + const arrowRStart = this.calculateArrowStart( + movedEndPos, + RotateVectorByDeg(endToControlPointVec, 30), + ); + const arrowLStart = this.calculateArrowStart( + movedEndPos, + RotateVectorByDeg(endToControlPointVec, -30), + ); + + return { + start: movedStartPos, + end: movedEndPos, + controlPoint, + arrowRStart, + arrowLStart, + }; + } + + /** + * Creates the connection line positions for a distance of zero. + * In this special case all values are set to the start node position. + * @param {XYPosition} startNodePos The position of the start node. + * @returns {ConnectionLinePosition} The positions for drawing the curved line. + */ + private static returnStartPosition(startNodePos: XYPosition): ConnecionLinePositions { + return { + start: startNodePos, + end: startNodePos, + controlPoint: startNodePos, + arrowRStart: startNodePos, + arrowLStart: startNodePos, + }; + } + + /** + * Calculate the control point for the quadratic curve path. + * @param {XYPosition} startNodePos The position of the start node. + * @param {XYPosition} endNodePos The position of the end node. + * @param {number} distance The distance between the two nodes. + * @returns {XYPosition} The control point. + */ + private static calculateControlPoint( + endNodePos: XYPosition, + startNodePos: XYPosition, + distance: number, + ): XYPosition { + // Normalized vector from start to end + const vec: XYPosition = { + x: (endNodePos.x - startNodePos.x) / distance, + y: (endNodePos.y - startNodePos.y) / distance, + }; + + // The point between the start and end, moved 15% of the distance closer to the start + const pointBetween = { + x: (startNodePos.x + endNodePos.x) / 2 - vec.x * distance * 0.15, + y: (startNodePos.y + endNodePos.y) / 2 - vec.y * distance * 0.15, + }; + + // The control point for th quadratic curve + // Move this point 25% of the distance away from the line between the start and end, at a 90 deg. angle + return { + x: pointBetween.x + -vec.y * distance * 0.25, + y: pointBetween.y + vec.x * distance * 0.25, + }; + } + + /** + * Calculates the moved start position. + * @param {XYPosition} startNodePos The position of the start node. + * @param {XYPosition} controlPoint The control point for the quadratic curve path. + * @param {number} nodeRadius The node radius, used to calculate the start and end offset. + * @returns {XYPosition} The moved start position. + */ + private static calculateMovedStartPos( + startNodePos: XYPosition, + controlPoint: XYPosition, + nodeRadius: number, + ): XYPosition { + const startToControlPointDist = CalcDistance(startNodePos, controlPoint); + return { + x: + startNodePos.x + ((controlPoint.x - startNodePos.x) / startToControlPointDist) * nodeRadius, + y: + startNodePos.y + ((controlPoint.y - startNodePos.y) / startToControlPointDist) * nodeRadius, + }; + } + + /** + * Calculates the moved end position + * @param {XYPosition} endNodePos The position of the end node. + * @param {XYPosition} endToControlPointVec The control point vector. + * @param {number} nodeRadius The node radius, used to calculate the start and end offset. + * @returns {XYPosition} The moved end position. + */ + private static calculateMovedEndPos( + endNodePos: XYPosition, + endToControlPointVec: XYPosition, + nodeRadius: number, + ): XYPosition { + return { + x: endNodePos.x - endToControlPointVec.x * nodeRadius, + y: endNodePos.y - endToControlPointVec.y * nodeRadius, + }; + } + + /** + * Calculates the start position of the arrow. + * @param {XYPosition} movedEndPos The position of the moved end node. + * @param {XYPosition} rotatedVec The rotated arrow vector. + * @returns {XYPosition} The arrow's start position. + */ + private static calculateArrowStart(movedEndPos: XYPosition, rotatedVec: XYPosition): XYPosition { + const arrowLength = 7; + return { + x: movedEndPos.x - rotatedVec.x * arrowLength, + y: movedEndPos.y - rotatedVec.y * arrowLength, + }; + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx new file mode 100644 index 000000000..dac2a940f --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcDefaultPlotSpecsUseCase.tsx @@ -0,0 +1,70 @@ +/** + * 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 { NodeLinkResultType } from "../../shared/ResultNodeLinkParserUseCase"; +import { AxisLabel, PlotSpecifications } from "../Types"; + +/** UseCase for calculating default plots from node link query result data. */ +export default class CalcDefaultPlotSpecsUseCase { + /** + * Calculates default plot specifications for incoming query result data. + * This determines what the default plots will be after executing a new query. + * @param {NodeLinkResultType} nodeLinkResult Query result data in the node link format. + * @returns {PlotSpecifications[]} PlotSpecifications to generate the plots with the result data. + */ + public static calculate(nodeLinkResult: NodeLinkResultType): PlotSpecifications[] { + // Search through the first nodes' attributes for the shortest attribute value + const plotSpecifications: PlotSpecifications[] = []; + if (nodeLinkResult.nodes.length > 0) { + const firstNodeAttributes = nodeLinkResult.nodes[0].attributes; + const firstNodeEntity = nodeLinkResult.nodes[0].id.split('/')[0]; + + let shortestStringKey = ''; + let shortestStringValueLength: number = Number.MAX_VALUE; + for (let key in firstNodeAttributes) { + if (typeof firstNodeAttributes[key] == 'string') { + const v = firstNodeAttributes[key]; + if (v.length < shortestStringValueLength) { + shortestStringKey = key; + shortestStringValueLength = v.length; + } + } + } + + // The key with the shortest attribute value, will be used to filter nodes for max 3 plots + const values: string[] = []; + for (let i = 0; i < nodeLinkResult.nodes.length; i++) { + // Search for the first three nodes with different attribute values with the given attributekey + + if (nodeLinkResult.nodes[i].id.split('/')[0] != firstNodeEntity) continue; + + const v = nodeLinkResult.nodes[i].attributes[shortestStringKey]; + if (values.includes(v)) continue; + + values.push(v); + let entity = nodeLinkResult.nodes[i].id; + if (nodeLinkResult.nodes[i].id.includes('/')) entity = entity.split('/')[0]; + else if (!!nodeLinkResult.nodes[i]?.attributes?.labels) entity = nodeLinkResult.nodes[i]?.attributes?.labels[0]; + + plotSpecifications.push({ + entity: nodeLinkResult.nodes[i].id.split('/')[0], + labelAttributeType: shortestStringKey, + labelAttributeValue: nodeLinkResult.nodes[i].attributes[shortestStringKey], + xAxis: AxisLabel.evenlySpaced, // Use default evenly spaced and # outbound connections on the x and y axis. + yAxis: AxisLabel.outboundConnections, + xAxisAttributeType: '', + yAxisAttributeType: '', + width: 800, + height: 200, + }); + + if (values.length >= 3) break; + } + } + + return plotSpecifications; + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcDistance.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcDistance.tsx new file mode 100644 index 000000000..d07141822 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcDistance.tsx @@ -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) + */ + +import { XYPosition } from "reactflow"; + +/** + * Calculates the difference between two positions. + * @param {XYPosition} posA The first position. + * @param {XYPosition} posB The second position. + * @return {number} The distance between the first and second position. + */ +export function CalcDistance(posA: XYPosition, posB: XYPosition): number { + const diffX = posA.x - posB.x; + const diffY = posA.y - posB.y; + + return Math.sqrt(diffX * diffX + diffY * diffY); +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx new file mode 100644 index 000000000..7120f0e03 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromResultUseCase.tsx @@ -0,0 +1,36 @@ +/** + * 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 { NodeLinkResultType } from "../../shared/ResultNodeLinkParserUseCase"; +import { EntitiesFromSchema } from "../Types"; + + +/** Use case for retrieving entity names and attribute names from a schema result. */ +export default class CalcEntityAttrNamesFromResultUseCase { + /** + * Takes a schema result and calculates all the entity names and attribute names per datatype. + * Used by semantic substrates for the plot titles and add plot selections. + * @param {NodeLinkResultType} schemaResult A new schema result from the backend. + * @param {EntitiesFromSchema} All entity names and attribute names per datatype. So we know what is in the Schema. + * @returns {EntitiesFromSchema} All entity names and attribute names per datatype. + */ + public static CalcEntityAttrNamesFromResult(nodeLinkResult: NodeLinkResultType, entitiesFromSchema: EntitiesFromSchema): EntitiesFromSchema { + const listOfNodeTypes: string[] = [] + + nodeLinkResult.nodes.forEach((node) => { + let entityName = node.id.split('/')[0] + if (!listOfNodeTypes.includes(entityName)) { + listOfNodeTypes.push(entityName); + } + }) + + let entitiesFromSchemaPruned: EntitiesFromSchema = { entityNames: [], attributesPerEntity: {} };; + Object.assign(entitiesFromSchemaPruned, entitiesFromSchema); + + entitiesFromSchemaPruned.entityNames = listOfNodeTypes; + return entitiesFromSchemaPruned; + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromSchemaUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromSchemaUseCase.tsx new file mode 100644 index 000000000..6e84d932c --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcEntityAttrNamesFromSchemaUseCase.tsx @@ -0,0 +1,49 @@ +/** + * 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 } from '@graphpolaris/shared/lib/schema'; +import { Schema } from '../../shared/InputDataTypes'; +import { AttributeNames, EntitiesFromSchema } from '../Types'; + +/** Use case for retrieving entity names and attribute names from a schema result. */ +export default class CalcEntityAttrNamesFromSchemaUseCase { + /** + * Takes a schema result and calculates all the entity names and attribute names per datatype. + * Used by semantic substrates for the plot titles and add plot selections. + * @param {SchemaResultType} schemaResult A new schema result from the backend. + * @returns {EntitiesFromSchema} All entity names and attribute names per datatype. + */ + public static calculate(schemaResult: SchemaGraph): EntitiesFromSchema { + const attributesPerEntity: Record<string, AttributeNames> = {}; + // Go through each entity. + schemaResult.nodes.forEach((node) => { + if (!node.attributes) 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') numberAttributeNames.push(attr.name); + else if (attr.type == 'bool') boolAttributeNames.push(attr.name); + }); + + // Create a new object with the arrays with attribute names per datatype. + attributesPerEntity[node.attributes.name] = { + textAttributeNames, + boolAttributeNames, + numberAttributeNames, + }; + }); + + // Create the object with entity names and attribute names. + return { + entityNames: schemaResult.nodes.map((node) => (node?.attributes?.name || 'ERROR')), + attributesPerEntity, + }; + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcScaledPositionsUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcScaledPositionsUseCase.tsx new file mode 100644 index 000000000..d850b3152 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcScaledPositionsUseCase.tsx @@ -0,0 +1,73 @@ +/** + * 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 { XYPosition } from "reactflow"; +import { MinMaxType, PlotInputData } from "../Types"; +import { scaleLinear } from "d3"; + +/** + * Calculates the scaled positions using d3's scaleLinear functions. + * Also calculates the min and max x y values. + */ +export default class CalcScaledPosUseCase { + /** + * Linearly scales positions to the width and height of the plot + * @param {PlotInputData} plot The plot for which to scale the nodes positions. + * @param {MinMaxType} minmaxXAxis The min and max for the x axis. + * @param {MinMaxType} minmaxYAxis The min and max for the y axis. + * @returns {Position[]} The scaled positions. + */ + public static calculate( + plot: PlotInputData, + minmaxXAxis: MinMaxType, + minmaxYAxis: MinMaxType, + ): XYPosition[] { + // Create the scale functions with the minmax and width and height of the plot + const scaleFunctions = CalcScaledPosUseCase.createScaleFunctions( + minmaxXAxis, + minmaxYAxis, + plot.width, + plot.height, + ); + + // Use the scale functions to scale the nodes positions. + const scaledPositions: XYPosition[] = []; + plot.nodes.forEach((node) => { + scaledPositions.push({ + x: scaleFunctions.xAxis(node.originalPosition.x), + y: scaleFunctions.yAxis(node.originalPosition.y), + }); + }); + + return scaledPositions; + } + + /** Uses D3 to create linear scale functions. */ + private static createScaleFunctions = ( + minmaxXAxis: MinMaxType, + minmaxYAxis: MinMaxType, + plotWidth: number, + plotHeight: number, + ): { + xAxis: d3.ScaleLinear<number, number, never>; + yAxis: d3.ScaleLinear<number, number, never>; + } => { + // Create the x axis scale funtion with d3 + const xAxisScale = scaleLinear() + .domain([minmaxXAxis.min, minmaxXAxis.max]) + .range([0, plotWidth]); + + // Create the y axis scale funtion with d3 + const yAxisScale = scaleLinear() + .domain([minmaxYAxis.max, minmaxYAxis.min]) + .range([0, plotHeight]); + + return { + xAxis: xAxisScale, + yAxis: yAxisScale, + }; + }; +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/CalcXYMinMaxUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/CalcXYMinMaxUseCase.tsx new file mode 100644 index 000000000..11f9dcdd9 --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/CalcXYMinMaxUseCase.tsx @@ -0,0 +1,71 @@ +/** + * 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 { MinMaxType, PlotInputData } from "../Types"; + +/** UseCase for calculating the min and max for the x and y values of the node positions. */ +export default class CalcXYMinMaxUseCase { + /** + * Calculates the min and max for the x and y values of the node positions. + * @param {PlotInputData} plot The input data plot with all the nodes. + * @returns {{x: MinMaxType; y: MinMaxType}} An object with the min and max for x and y. + */ + public static calculate(plot: PlotInputData): { x: MinMaxType; y: MinMaxType } { + // If there are no nodes in the plot, set the min and max to [-10, 10] + if (plot.nodes.length == 0) { + return { + x: { + min: -10, + max: 10, + }, + y: { + min: -10, + max: 10, + }, + }; + } + // Calculate the min and max values for the x and y positions + // Start the x and y values of the first node as min and max + const minmaxX: MinMaxType = { + min: plot.nodes[0].originalPosition.x, + max: plot.nodes[0].originalPosition.x, + }; + const minmaxY: MinMaxType = { + min: plot.nodes[0].originalPosition.y, + max: plot.nodes[0].originalPosition.y, + }; + for (let i = 1; i < plot.nodes.length; i++) { + const position = plot.nodes[i].originalPosition; + + if (position.x > minmaxX.max) minmaxX.max = position.x; + else if (position.x < minmaxX.min) minmaxX.min = position.x; + + if (position.y > minmaxY.max) minmaxY.max = position.y; + else if (position.y < minmaxY.min) minmaxY.min = position.y; + } + + // Add 20%, so there are no nodes whose position is exactly on the axis + let xDiff = (minmaxX.max - minmaxX.min) * 0.2; + + minmaxX.min -= xDiff; + minmaxX.max += xDiff; + let yDiff = (minmaxY.max - minmaxY.min) * 0.2; + + minmaxY.min -= yDiff; + minmaxY.max += yDiff; + + // If the min and max are the same, add and subtract 10 + if (minmaxX.min == minmaxX.max) { + minmaxX.min -= 1; + minmaxX.max += 1; + } + if (minmaxY.min == minmaxY.max) { + minmaxY.min -= 1; + minmaxY.max += 1; + } + return { x: minmaxX, y: minmaxY }; + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/FilterUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/FilterUseCase.tsx new file mode 100644 index 000000000..532a3c8fe --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/FilterUseCase.tsx @@ -0,0 +1,140 @@ +/** + * 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 { + MinMaxType, + NodeType, + PlotType, + RelationType, +} from '../Types'; + +type Filter = { x: MinMaxType; y: MinMaxType }; + +/** A use case for applying filters on nodes and filter relations. */ +export default class FilterUseCase { + /** + * Checks if a node matches the given filters. + * @param {NodeType} node The node to be matched. + * @param {Filter} filter The filters to match the node to. + * @returns True when the node matches the constraints of the filter. + */ + public static checkIfNodeMatchesFilter(node: NodeType, filter: Filter): boolean { + return ( + node.originalPosition.x >= filter.x.min && + node.originalPosition.x <= filter.x.max && + node.originalPosition.y >= filter.y.min && + node.originalPosition.y <= filter.y.max + ); + } + + /** + * Applies the filter that changed to the filteredRelations list. + * @param {RelationType[][][]} relations The original unmodified relations. All relations. + * @param {RelationType[][][]} filteredRelations The current filtered relations. This will be updated with the new filter. + * Note that initially relations should be a clone of filteredRelations. + * @param {PlotType[]} plots The plots. + * @param {Filter[]} filtersPerPlot The filters for each plot, with a filter at the filterIndexThatChanged that is different from the previous call to this function. + * @param {number} filterIndexThatChanged index for the filter that changed. + */ + public static filterRelations( + relations: RelationType[][][], + filteredRelations: RelationType[][][], + plots: PlotType[], + filtersPerPlot: Filter[], + filterIndexThatChanged: number, + ): void { + this.checkOutboundConnections( + relations, + filteredRelations, + plots, + filtersPerPlot, + filterIndexThatChanged, + ); + for (let fromPlot = 0; fromPlot < relations.length; fromPlot++) { + this.checkInboundConnection( + relations, + filteredRelations, + plots, + filtersPerPlot, + filterIndexThatChanged, + fromPlot, + ); + } + } + + /** + * Check for the inbound connections if the filter has changed + * @param {RelationType[][][]} relations The original unmodified relations. All relations. + * @param {RelationType[][][]} filteredRelations The current filtered relations. This will be updated with the new filter. + * Note that initially relations should be a clone of filteredRelations. + * @param {PlotType[]} plots The plots. + * @param {Filter[]} filtersPerPlot The filters for each plot, with a filter at the filterIndexThatChanged that is different from the previous call to this function. + * @param {number} filterIndexThatChanged index for the filter that changed. + */ + private static checkOutboundConnections( + relations: RelationType[][][], + filteredRelations: RelationType[][][], + plots: PlotType[], + filtersPerPlot: Filter[], + filterIndexThatChanged: number, + ) { + // Check all the outbound connections for nodes in the plot for which the filter changed. + relations[filterIndexThatChanged].forEach((relations, toPlot) => { + filteredRelations[filterIndexThatChanged][toPlot] = relations.filter((relation) => { + const fromNode = plots[filterIndexThatChanged].nodes[relation.fromIndex]; + const fromNodeFilter = filtersPerPlot[filterIndexThatChanged]; + const fromNodeMatches = this.checkIfNodeMatchesFilter(fromNode, fromNodeFilter); + + const toNode = plots[toPlot].nodes[relation.toIndex]; + const toNodeFilter = filtersPerPlot[toPlot]; + const toNodeMatches = this.checkIfNodeMatchesFilter(toNode, toNodeFilter); + + // Check if the from- and to-node match their plot filters. + return fromNodeMatches && toNodeMatches; + }); + }); + } + + /** + * Check for the inbound connections if the filter has changed + * @param {RelationType[][][]} relations The original unmodified relations. All relations. + * @param {RelationType[][][]} filteredRelations The current filtered relations. This will be updated with the new filter. + * Note that initially relations should be a clone of filteredRelations. + * @param {PlotType[]} plots The plots. + * @param {Filter[]} filtersPerPlot The filters for each plot, with a filter at the filterIndexThatChanged that is different from the previous call to this function. + * @param {number} filterIndexThatChanged index for the filter that changed. + * @param {number} fromPlot The index of the current from plot. + */ + private static checkInboundConnection( + relations: RelationType[][][], + filteredRelations: RelationType[][][], + plots: PlotType[], + filtersPerPlot: Filter[], + filterIndexThatChanged: number, + fromPlot: number, + ) { + // Check all the inbound connections for nodes in the plot for which the filter changed. + const relationsBetweenPlots = relations[fromPlot][filterIndexThatChanged]; + + filteredRelations[fromPlot][filterIndexThatChanged] = relationsBetweenPlots.filter( + (relation) => { + const toNode = plots[filterIndexThatChanged].nodes[relation.toIndex]; + const toNodeFilter = filtersPerPlot[filterIndexThatChanged]; + const toNodeMatches = this.checkIfNodeMatchesFilter(toNode, toNodeFilter); + + const fromNode = plots[fromPlot].nodes[relation.fromIndex]; + const fromNodeFilter = filtersPerPlot[fromPlot]; + const fromNodeMatches = this.checkIfNodeMatchesFilter(fromNode, fromNodeFilter); + + // Here we also check for connections within the same plot. + // For these connections we only need to check if the from- or the to-node matches the filter. + return ( + (fromNodeMatches && toNodeMatches) || + (fromPlot == filterIndexThatChanged && (fromNodeMatches || toNodeMatches)) + ); + }, + ); + } +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/RotateVec.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/RotateVec.tsx new file mode 100644 index 000000000..5677ebb1f --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/RotateVec.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) + */ + +/** + * Rotates a vector by given degrees, clockwise. + * @param {number, number} vec The vector to be rotated. + * @param {number} degrees The amount of degrees the vector needs to be rotated. + * @return {number, number} The rotated vector. + */ +export function RotateVectorByDeg( + vec: { x: number; y: number }, + degrees: number, +): { x: number; y: number } { + const radians = -(degrees % 360) * 0.01745329251; // degrees * (PI/180) + + return { + x: vec.x * Math.cos(radians) - vec.y * Math.sin(radians), + y: vec.x * Math.sin(radians) + vec.y * Math.cos(radians), + }; +} diff --git a/libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx b/libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx new file mode 100644 index 000000000..7c09b475f --- /dev/null +++ b/libs/shared/lib/vis/semanticsubstrates/utils/ToPlotDataParserUseCase.tsx @@ -0,0 +1,267 @@ +/** + * 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 { NodeLinkResultType, Node, ParseToUniqueEdges } from '../../shared/ResultNodeLinkParserUseCase'; +import { + AxisLabel, + PlotInputData, + PlotSpecifications, + RelationType, +} from '../Types'; + +/** A use case for parsing incoming node-link data to plot data */ +export default class ToPlotDataParserUseCase { + /** + * Parses incoming node link data from the backend to plotdata. + * @param {NodeLinkResultType} queryResult The query result coming from the backend. + * @param {NodeAttributesForPlot[]} plotSpecifications The attribute types to use for label and x y values. + * @returns Plotdata with relations. + */ + public static parseQueryResult( + queryResult: GraphQueryResult, + plotSpecifications: PlotSpecifications[], + relationSelectedAttributeNumerical: string, + relationSelectedAttributeCatecorigal: string, + relationScaleCalculation: (x: number) => number, + relationColourCalculation: (x: string) => string, + ): { plots: PlotInputData[]; relations: RelationType[][][] } { + const { plots, nodePlotIndexDict } = this.filterNodesWithPlotSpecs( + queryResult.nodes, + plotSpecifications, + ); + + // Initialize the relations with empty arrays, an |plots| x |plots| matrix with empty arrays + const relations: RelationType[][][] = new Array(plots.length) + .fill([]) + .map(() => new Array(plots.length).fill([]).map(() => [])); + + // 2D arrays used to collect the number of out- and inbound connections + // indexed like so: [plotIndex][nodeIndex in that plot] + const nOutboundConnsPerNode: number[][] = new Array(plots.length) + .fill([]) + .map((_, i) => new Array(plots[i].nodes.length).fill(0)); + const nInboundConnsPerNode: number[][] = new Array(plots.length) + .fill([]) + .map((_, i) => new Array(plots[i].nodes.length).fill(0)); + + // Only use unique edges + const uniqueEdges = ParseToUniqueEdges.parse(queryResult.edges, false); + + /* Search for the maximum value of the value attribute for each edge. + Using epsilon as first max value so we do never divide by zero. + */ + let maxValue = Number.EPSILON; + uniqueEdges.forEach((e) => { + if (e.count > maxValue) maxValue = e.count; + }); + + // Calculate the # in- and outbound connections and fill the relations. + uniqueEdges.forEach((edge) => { + // Check if the from and to are present in any plots + if (nodePlotIndexDict[edge.from] && nodePlotIndexDict[edge.to]) { + // Retrieve the from and to indices of the plotindex and nodeindex from the dictionary + const fromPlotsIndices = nodePlotIndexDict[edge.from]; + const toPlotsIndices = nodePlotIndexDict[edge.to]; + + // The same node can be in multiple plots + // We need to add all its connections + fromPlotsIndices.forEach((fromPlot) => { + nOutboundConnsPerNode[fromPlot.plotIndex][fromPlot.nodeIndex]++; + + toPlotsIndices.forEach((toPlot) => { + // Add the connection to the relations list, with the from and to indices + relations[fromPlot.plotIndex][toPlot.plotIndex].push({ + fromIndex: fromPlot.nodeIndex, + toIndex: toPlot.nodeIndex, + value: relationScaleCalculation(edge.attributes[relationSelectedAttributeNumerical]), + colour: relationColourCalculation( + edge.attributes[relationSelectedAttributeCatecorigal], + ), + }); + + nInboundConnsPerNode[toPlot.plotIndex][toPlot.nodeIndex]++; + }); + }); + } + }); + + // Determine the node position if plotSpecification had an out- or inboundconnection as x or y axis specified + this.determineNodePosForInOutboundConn( + plots, + plotSpecifications, + nInboundConnsPerNode, + nOutboundConnsPerNode, + ); + + return { plots, relations }; + } + + /** + * If the node position is of # in- or outbound connections, set this as the original node position. + * @param plots {PlotInputData[]} The plots. + * @param plotSpecs {PlotSpecifications[]} The plot specifications. + * @param nInboundConnsPerNode {number[][]} The number of inbound connections for plots. + * @param nOutboundConnsPerNode {number[][]} The number of outbound connections for plots. + */ + private static determineNodePosForInOutboundConn( + plots: PlotInputData[], + plotSpecs: PlotSpecifications[], + nInboundConnsPerNode: number[][], + nOutboundConnsPerNode: number[][], + ): void { + // Determine the node position if plotSpecification had an out- or inboundconnection as x or y axis specified + plots.forEach((plot, plotIndex) => { + plot.nodes.forEach((node, nodeIndex) => { + // Determine the x pos if the plotspecXAxis is of enum out- or inboundConnections + const x = this.getAxisPosOutOrInboundConns( + plotSpecs[plotIndex].xAxis, + plotIndex, + nodeIndex, + nOutboundConnsPerNode, + nInboundConnsPerNode, + ); + if (x != undefined) node.originalPosition.x = x; + // Same for the y pos + const y = this.getAxisPosOutOrInboundConns( + plotSpecs[plotIndex].yAxis, + plotIndex, + nodeIndex, + nOutboundConnsPerNode, + nInboundConnsPerNode, + ); + if (y != undefined) node.originalPosition.y = y; + }); + }); + } + + /** + * Filters the nodes with the plotSpecifications. + * @param nodes {Node[]} The query result nodes to filter on. + * @param plotSpecs {PlotSpecifications[]} The plot specifications used for filtering. + * @returns plots with the filtered nodes in them, and a nodePlotIndexDict used for getting the plotIndex and nodeIndex for a node. + */ + private static filterNodesWithPlotSpecs( + nodes: Node[], + plotSpecs: PlotSpecifications[], + ): { + plots: PlotInputData[]; + nodePlotIndexDict: Record<string, { plotIndex: number; nodeIndex: number }[]>; + } { + // Initialze the plots with a title and the default witdh and height + const plots: PlotInputData[] = []; + plotSpecs.forEach((plotSpec) => { + plots.push({ + title: plotSpec.labelAttributeType + ':' + plotSpec.labelAttributeValue, + nodes: [], + width: plotSpec.width, + height: plotSpec.height, + }); + }); + + // Dictionary used for getting the plotIndex and nodeIndex for a node + // plotIndex: in which plot the node is + // nodeIndex: the index of the node in the list of nodes for that plot + // A node could be in multiple plots so that is why the value is an array. + const nodePlotIndexDict: Record<string, { plotIndex: number; nodeIndex: number }[]> = {}; + + // Add all nodes to their plot if they satisfy its corresponding plotspec + nodes.forEach((node) => { + for (let i = 0; i < plotSpecs.length; i++) { + // Check if the node has an label attributeType value that matches the filter + if ( + plotSpecs[i].entity == node.id.split('/')[0] && + plotSpecs[i].labelAttributeValue == node.attributes[plotSpecs[i].labelAttributeType] + ) { + // Check if the axisPositioning spec is of equalDistance or attributeType + const x = this.getAxisPosEqualDistanceOrAttrType( + plotSpecs[i].xAxis, + plots[i].nodes.length + 1, + plotSpecs[i].xAxisAttributeType, + node.attributes, + ); + const y = this.getAxisPosEqualDistanceOrAttrType( + plotSpecs[i].yAxis, + plots[i].nodes.length + 1, + plotSpecs[i].yAxisAttributeType, + node.attributes, + ); + + // Add the node to the correct plot + plots[i].nodes.push({ + id: node.id, + data: { text: node.id }, + originalPosition: { x, y }, + attributes: node.attributes, + }); + + if (!nodePlotIndexDict[node.id]) nodePlotIndexDict[node.id] = []; + + nodePlotIndexDict[node.id].push({ + plotIndex: i, + nodeIndex: plots[i].nodes.length - 1, + }); + } + } + }); + + return { plots, nodePlotIndexDict }; + } + + /** + * Determine the position based on the provided axispositioning specification + * Check for enums equalDistance and attributeType + * @param plotSpecAxis + * @param nodeIndex + * @param xAxisAttrType + * @param attributes + * @returns + */ + private static getAxisPosEqualDistanceOrAttrType( + plotSpecAxis: AxisLabel, + nodeIndex: number, + xAxisAttrType: string, + attributes: Record<string, any>, + ): number { + switch (plotSpecAxis) { + case AxisLabel.byAttribute: + if (attributes[xAxisAttrType] && !isNaN(attributes[xAxisAttrType])) + return +attributes[xAxisAttrType]; + else return nodeIndex; + + default: + // plotSpecAxis == equalDistance + return nodeIndex; + } + } + + /** + * + * @param plotSpecAxis + * @param plotIndex + * @param nodeIndex + * @param nOutboundConnsPerNode + * @param nInboundConnsPerNode + * @returns + */ + private static getAxisPosOutOrInboundConns( + plotSpecAxis: AxisLabel, + plotIndex: number, + nodeIndex: number, + nOutboundConnsPerNode: number[][], + nInboundConnsPerNode: number[][], + ): number | undefined { + switch (plotSpecAxis) { + case AxisLabel.outboundConnections: + return nOutboundConnsPerNode[plotIndex][nodeIndex]; + + case AxisLabel.inboundConnections: + return nInboundConnsPerNode[plotIndex][nodeIndex]; + + default: + return undefined; + } + } +} diff --git a/libs/shared/lib/vis/shared/InputDataTypes.tsx b/libs/shared/lib/vis/shared/InputDataTypes.tsx index b675a1fe3..fdc0a35a5 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 dc8cbdca6..eadcc972c 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> = {}; diff --git a/libs/shared/package.json b/libs/shared/package.json index 717ce13fa..a96e25e0a 100644 --- a/libs/shared/package.json +++ b/libs/shared/package.json @@ -37,18 +37,21 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-types": "^0.24.7", + "immer": "^10.0.2", "jspdf": "^2.5.1", "pixi.js": "^7.1.4", "react-cookie": "^4.1.1", "react-grid-layout": "^1.3.4", "react-json-view": "^1.21.3", "react-router-dom": "^6.8.1", + "react-window": "^1.8.9", "reactflow": "^11.7.0", "regenerator-runtime": "0.13.11", "sass": "^1.59.3", "scss": "^0.2.4", "styled-components": "^5.3.6", "tslib": "^2.5.0", + "use-immer": "^0.9.0", "web-worker": "^1.2.0" }, "devDependencies": { @@ -60,7 +63,6 @@ "@types/color": "^3.0.3", "@types/d3": "^7.4.0", "@types/node": "18.13.0", - "@types/pixi.js": "^5.0.0", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "@typescript-eslint/eslint-plugin": "~5.52.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72c5b4c3b..6514d4a82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: version: 2.8.8 turbo: specifier: latest - version: 1.9.3 + version: 1.9.9 apps/docs: dependencies: @@ -182,7 +182,7 @@ importers: version: 5.11.13(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.0.28)(react@18.2.0) '@reactflow/node-resizer': specifier: ^2.0.1 - version: 2.1.0(react-dom@18.2.0)(react@18.2.0) + version: 2.1.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) '@reduxjs/toolkit': specifier: ^1.9.2 version: 1.9.3(react-redux@8.0.5)(react@18.2.0) @@ -225,6 +225,9 @@ importers: graphology-types: specifier: ^0.24.7 version: 0.24.7 + immer: + specifier: ^10.0.2 + version: 10.0.2 jspdf: specifier: ^2.5.1 version: 2.5.1 @@ -243,9 +246,12 @@ importers: react-router-dom: specifier: ^6.8.1 version: 6.9.0(react-dom@18.2.0)(react@18.2.0) + react-window: + specifier: ^1.8.9 + version: 1.8.9(react-dom@18.2.0)(react@18.2.0) reactflow: specifier: ^11.7.0 - version: 11.7.0(react-dom@18.2.0)(react@18.2.0) + version: 11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) regenerator-runtime: specifier: 0.13.11 version: 0.13.11 @@ -261,6 +267,9 @@ importers: tslib: specifier: ^2.5.0 version: 2.5.0 + use-immer: + specifier: ^0.9.0 + version: 0.9.0(immer@10.0.2)(react@18.2.0) web-worker: specifier: ^1.2.0 version: 1.2.0 @@ -289,9 +298,6 @@ importers: '@types/node': specifier: 18.13.0 version: 18.13.0 - '@types/pixi.js': - specifier: ^5.0.0 - version: 5.0.0(@pixi/utils@7.2.1) '@types/react': specifier: ^18.0.27 version: 18.0.28 @@ -339,7 +345,7 @@ importers: version: 8.7.0(eslint@7.32.0) eslint-config-turbo: specifier: latest - version: 1.9.3(eslint@7.32.0) + version: 1.10.0(eslint@7.32.0) eslint-plugin-import: specifier: 2.27.5 version: 2.27.5(@typescript-eslint/parser@5.52.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0) @@ -442,10 +448,10 @@ importers: devDependencies: '@storybook/addon-essentials': specifier: next - version: 7.0.7(react-dom@18.2.0)(react@18.2.0) + version: 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) '@storybook/addon-interactions': specifier: next - version: 7.0.7(react-dom@18.2.0)(react@18.2.0) + version: 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) '@storybook/addon-links': specifier: ^7.0.7 version: 7.0.7(react-dom@18.2.0)(react@18.2.0) @@ -529,7 +535,7 @@ importers: version: 8.7.0(eslint@7.32.0) eslint-config-turbo: specifier: latest - version: 1.9.3(eslint@7.32.0) + version: 1.10.0(eslint@7.32.0) eslint-plugin-react: specifier: 7.31.8 version: 7.31.8(eslint@7.32.0) @@ -3196,6 +3202,7 @@ packages: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/events': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/utils@7.2.1) + dev: false /@pixi/app@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1): resolution: {integrity: sha512-I6YLs+JTUQkmZBeO8YzK/9T6P9TpwULyfoMsUh77MiAI0tnJobortXf1eMEgMbq5rVk44M5gtl1MJL9QclxcBA==} @@ -3205,6 +3212,7 @@ packages: dependencies: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) + dev: false /@pixi/assets@7.2.1(@pixi/core@7.2.1)(@pixi/utils@7.2.1): resolution: {integrity: sha512-6JFm5ZQpE5n2scj60UFN5z8dr/1SDKFmPEgE0HNsqSuIhHlA0yS2ARvgHGRjVJdBPsOSUHsCnVYG9HOlM1pSJA==} @@ -3215,11 +3223,13 @@ packages: '@pixi/core': 7.2.1 '@pixi/utils': 7.2.1 '@types/css-font-loading-module': 0.0.7 + dev: false /@pixi/color@7.2.1: resolution: {integrity: sha512-rzJy1Bu6x+LuADSxUImvH49hIVLEl/1bGH+WjfcPOuxPQYZzHTGQJeISQYSJrTE+kdU2+GvRLH8cUlLijyFFqA==} dependencies: colord: 2.9.3 + dev: false /@pixi/compressed-textures@7.2.1(@pixi/assets@7.2.1)(@pixi/core@7.2.1): resolution: {integrity: sha512-U5SDgy/JsieMrB45GQDoC9Y055LKh52/EYV8TFlf5OcRvwLBOqjJLXXVwwkDF/79O/5WT2vcR6KSRKjB9gXxgw==} @@ -3229,9 +3239,11 @@ packages: dependencies: '@pixi/assets': 7.2.1(@pixi/core@7.2.1)(@pixi/utils@7.2.1) '@pixi/core': 7.2.1 + dev: false /@pixi/constants@7.2.1: resolution: {integrity: sha512-Tyu7Ue59ZYPyhBtP0gneMgixlAvM9+XXBscoICLyompA09NVsarul1czFfP+GnwiW5jVzXvd4kNDPAayNmKRFg==} + dev: false /@pixi/core@7.2.1: resolution: {integrity: sha512-ASoedGRUnirpHGP7Axgnarb2v7FHlfHByuFhkWDn+6IXVenQJNgwtC9wRPyzJQV5S0OhxYGDtvw/BsRjUZX3tA==} @@ -3245,6 +3257,7 @@ packages: '@pixi/ticker': 7.2.1 '@pixi/utils': 7.2.1 '@types/offscreencanvas': 2019.7.0 + dev: false /@pixi/display@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-S8rw+OaP3z4js73z1XmHV8eJnlFfdxwTIwdBoJqu6UPoTowFL5aBaObaNmraQXbCpSIimziG/txaOxrltc2y6Q==} @@ -3252,6 +3265,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/events@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/utils@7.2.1): resolution: {integrity: sha512-kFT3l3g1DwJnDn2xLVct0n2qaerqNdiDOS3njVX/YBp7+IX5xD6gpXxLgy7M7fYOislUwIMorfc6oMUSlpXcKg==} @@ -3263,9 +3277,11 @@ packages: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/utils': 7.2.1 + dev: false /@pixi/extensions@7.2.1: resolution: {integrity: sha512-bVPhu+adqW1gsBABX3ZtQ6jpwUaM+Bz9lSv3+kXoKUY+mPlDpXDHDdmJsnGh1yktHvE+WU1tdFv1gSaXA0IfGQ==} + dev: false /@pixi/extract@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-bXnRHBvYBRvmLeTiQvv1ZYVkxqK4RXZTwGsFPsKo39MIzdrtPrGeyzstFGAb3kUTIlsFn5plO+12iKnkxZBlvg==} @@ -3273,6 +3289,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/filter-alpha@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-qBpp/hvJ5BoqPEXUz5Q93ZSLL5lom7xsdvmr4CLwV5dJLe49a8m3xrbEDRETl7lDOq/SULALTghjdmqdtu/DLQ==} @@ -3280,6 +3297,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/filter-blur@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-y9bw5g64P7hHq+UFxgEbkFHLK4wIwXHPAoN5RBqee66IMDKpwy+FcLUJMkrh9nJu+1XAS4gzL/4Ewyvq16Z8MA==} @@ -3287,6 +3305,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/filter-color-matrix@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-VUzl9NDejEHbt7VGyqkNaQLNYk9VLCiksXPQrcWEoRuLUHMhB7BoarPR7esy+QUhzmUwla/H4rR+jwHyi4dRjA==} @@ -3294,6 +3313,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/filter-displacement@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-/vSegvfnV5XCtu5WImzDAbpXztMYEctVAb3pzc/hUD3ZEfuEIvaYbYljgYEphD++lTOozxTV9DXEfl7MQtWCiw==} @@ -3301,6 +3321,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/filter-fxaa@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-sKi7yKUSue2coV/O63RaUUr1vLiJjG2xt3iw7v6oP6xZK87q564Y/z5p6NiyxK7L6/fT9SsgpHr79cOE24c3yw==} @@ -3308,6 +3329,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/filter-noise@7.2.1(@pixi/core@7.2.1): resolution: {integrity: sha512-wgDwBGxWhrNW55oJr/OSO/hfcEo6h/k2OTe+82ytUCywONNwFUsY5Z+wO1U4qwHVufMwm2CCMGQdcYYn/BPNfQ==} @@ -3315,6 +3337,7 @@ packages: '@pixi/core': 7.2.1 dependencies: '@pixi/core': 7.2.1 + dev: false /@pixi/graphics@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/sprite@7.2.1): resolution: {integrity: sha512-4smA746hs4aUcgCR9QDa5k8/fXgT/lo+0QG0BGr86OdIspt6UpvtAQ5bO09tFUWeTtXbOGmBnRKIgIjMgqJe8A==} @@ -3326,9 +3349,11 @@ packages: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/sprite': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) + dev: false /@pixi/math@7.2.1: resolution: {integrity: sha512-hKML8rG6iWYutAczy5Fo5zssWU7JNThHo9QhBYQbcR7ijPO00Xz/phTHpbu5l2KGwGN0Umpn1zYiogiJ82WPTA==} + dev: false /@pixi/mesh-extras@7.2.1(@pixi/core@7.2.1)(@pixi/mesh@7.2.1): resolution: {integrity: sha512-kc744QIpI92iCUV+UCQFNiUwX1BPmJvuYnolSfHHI9B3edGVgMgxQyBuNA2YUUC1i1JAE3aFLXvPVf1hVMbMNA==} @@ -3338,6 +3363,7 @@ packages: dependencies: '@pixi/core': 7.2.1 '@pixi/mesh': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) + dev: false /@pixi/mesh@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1): resolution: {integrity: sha512-IpyCZoVIc78X+aflPST3CNHPovKzmsDIIigqeQrUnIrSldA/W9TdbW3z5v6bOB2fQmy+cfYXfN2pO+EvDiqviA==} @@ -3347,6 +3373,7 @@ packages: dependencies: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) + dev: false /@pixi/mixin-cache-as-bitmap@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/sprite@7.2.1): resolution: {integrity: sha512-C6d1y1GkvHtrE8hRtBqvyMek6g8V6p9h63EgQ3oMBxY1zhKCRdka1bsjvn6nK07ScVL23bYW1r8IkQhrPRmUoA==} @@ -3358,6 +3385,7 @@ packages: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/sprite': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) + dev: false /@pixi/mixin-get-child-by-name@7.2.1(@pixi/display@7.2.1): resolution: {integrity: sha512-xFr2/iDN4Q0lWEZI8MvqTRD1o5V7Rvh0/aF7Id5HiJeeh2QeYxPIDFleZ0Wy5U+3u65ddHgG2lCYveDkLyy6TA==} @@ -3365,6 +3393,7 @@ packages: '@pixi/display': 7.2.1 dependencies: '@pixi/display': 7.2.1(@pixi/core@7.2.1) + dev: false /@pixi/mixin-get-global-position@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1): resolution: {integrity: sha512-+yG5iUgoPT0y2LjxTPwaa33Sb/CdmhC+15z7qVMjzDMRSRIss1JeF8k8aAepXBHNQUDv0eLOy3je1M3fHUhJaw==} @@ -3374,6 +3403,7 @@ packages: dependencies: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) + dev: false /@pixi/particle-container@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/sprite@7.2.1): resolution: {integrity: sha512-aR1ieuB1kmOT4nidIO6oy08g/T/fvKYoDdSzONVz7qCPksNBHVIJBa/D3ig7VukiAwsJm+k12yOzKM7oku2pmg==} @@ -3385,6 +3415,7 @@ packages: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/sprite': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) + dev: false /@pixi/prepare@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/graphics@7.2.1)(@pixi/text@7.2.1): resolution: {integrity: sha512-Tc06tQFJdRntI7pOV8UFUpbBLOEiuiIpfFHVTdadCECuO5IR1JT3OIEcAF+2OYws1+QVPYxafB1TMw5ZIuanHg==} @@ -3398,9 +3429,11 @@ packages: '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/graphics': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/sprite@7.2.1) '@pixi/text': 7.2.1(@pixi/core@7.2.1)(@pixi/sprite@7.2.1) + dev: false /@pixi/runner@7.2.1: resolution: {integrity: sha512-sGfUdqKY7KIEhKdTT5gOLT7n1y/Satr9GJpDjVyQCABqDPvGTRynmAhVDEjiVaw9pd4Q2P5ZEJZm0+VVJOVirw==} + dev: false /@pixi/settings@7.2.1: resolution: {integrity: sha512-PmDMnJWFowbC5qgRWS+AtpuXpSCmoo22NlfGL5Rfu1Hz7c6U3+XFhMrQTdBGw7HITI/8wkRhD23+A4eMU0ALZg==} @@ -3408,6 +3441,7 @@ packages: '@pixi/constants': 7.2.1 '@types/css-font-loading-module': 0.0.7 ismobilejs: 1.1.1 + dev: false /@pixi/sprite-animated@7.2.1(@pixi/core@7.2.1)(@pixi/sprite@7.2.1): resolution: {integrity: sha512-dm5JeiVplpx5ibClw4VtBu/XbOSxGV9GGSa2wAX8awXSU+a8MKudqP+w0D2a96LNbkOr8wxfueelybSNRFbJTw==} @@ -3417,6 +3451,7 @@ packages: dependencies: '@pixi/core': 7.2.1 '@pixi/sprite': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) + dev: false /@pixi/sprite-tiling@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/sprite@7.2.1): resolution: {integrity: sha512-1CTLkN9HNIcT1TiMarEcJa6B2JXX3Cr3me8DfLR96A1H4AZXSL3aNdYpZ1Asc3Ktx7lDNDMuAtTN78ksaI0oLw==} @@ -3428,6 +3463,7 @@ packages: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/sprite': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) + dev: false /@pixi/sprite@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1): resolution: {integrity: sha512-roo4R15Z9ilR+oKJkoepJGMUY31lWIW2iFiR+HlfMbUgvxeTzpbRi9gRMCSIAisow8Jz8UEY7nkQmdB83uDv1g==} @@ -3437,6 +3473,7 @@ packages: dependencies: '@pixi/core': 7.2.1 '@pixi/display': 7.2.1(@pixi/core@7.2.1) + dev: false /@pixi/spritesheet@7.2.1(@pixi/assets@7.2.1)(@pixi/core@7.2.1): resolution: {integrity: sha512-cUa+k0kk9K+9H5e/HH8+7E5b2MbgrqYgAylW4H/7BTVoBRreofVamltjtWtFr5m8M7oHDwuNFtPEci1ppLV3vQ==} @@ -3446,6 +3483,7 @@ packages: dependencies: '@pixi/assets': 7.2.1(@pixi/core@7.2.1)(@pixi/utils@7.2.1) '@pixi/core': 7.2.1 + dev: false /@pixi/text-bitmap@7.2.1(@pixi/assets@7.2.1)(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/mesh@7.2.1)(@pixi/text@7.2.1): resolution: {integrity: sha512-C9i1+kgyKmcoMqK401BsQh8CuN4u78gYDLO9EdUQgReOi31qXKbTohA5ffjdPwgkqU8VYIvXbbUqDA+/mwX56g==} @@ -3461,6 +3499,7 @@ packages: '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/mesh': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) '@pixi/text': 7.2.1(@pixi/core@7.2.1)(@pixi/sprite@7.2.1) + dev: false /@pixi/text-html@7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/sprite@7.2.1)(@pixi/text@7.2.1): resolution: {integrity: sha512-iXY73/0LGr27q5hJuOaBCWM2yvY/0xKfbSE/m7dohhF+p8gFzxbi/Z+lVk/dQyc2KtSl3KboML/hTuxsnVCQPw==} @@ -3474,6 +3513,7 @@ packages: '@pixi/display': 7.2.1(@pixi/core@7.2.1) '@pixi/sprite': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) '@pixi/text': 7.2.1(@pixi/core@7.2.1)(@pixi/sprite@7.2.1) + dev: false /@pixi/text@7.2.1(@pixi/core@7.2.1)(@pixi/sprite@7.2.1): resolution: {integrity: sha512-keshF4UswmyQzgjWN7cFxxqY+yoabtpu9UCWkW6XtfMLBAlKRyjUpx7tmBUGTFrHoDEek77iR9+XCYoFE7U/VQ==} @@ -3483,6 +3523,7 @@ packages: dependencies: '@pixi/core': 7.2.1 '@pixi/sprite': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1) + dev: false /@pixi/ticker@7.2.1: resolution: {integrity: sha512-fDnaSASY55FtMggGiLxu8AR26i4MyWo/5jpuhsK5zMTKGMFbk6L3C+BwYxWPADMppbdeNdeZj+L6dABSXdKLZQ==} @@ -3490,6 +3531,7 @@ packages: '@pixi/extensions': 7.2.1 '@pixi/settings': 7.2.1 '@pixi/utils': 7.2.1 + dev: false /@pixi/utils@7.2.1: resolution: {integrity: sha512-oslw8PWwCrbrd8GeIa9DP9cEpDebWekIzIPyEVlmua+PGYu7x/P5lGGhSeLpNXarnbW9wva4SQmLF3kwWTmaRw==} @@ -3501,6 +3543,7 @@ packages: earcut: 2.2.4 eventemitter3: 4.0.7 url: 0.11.0 + dev: false /@popperjs/core@2.11.6: resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==} @@ -3517,22 +3560,22 @@ packages: classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false - /@reactflow/background@11.2.0(react-dom@18.2.0)(react@18.2.0): + /@reactflow/background@11.2.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-Fd8Few2JsLuE/2GaIM6fkxEBaAJvfzi2Lc106HKi/ddX+dZs8NUsSwMsJy1Ajs8b4GbiX8v8axfKpbK6qFMV8w==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.7.0(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false @@ -3552,13 +3595,13 @@ packages: - immer dev: false - /@reactflow/controls@11.1.11(react-dom@18.2.0)(react@18.2.0): + /@reactflow/controls@11.1.11(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-g6WrsszhNkQjzkJ9HbVUBkGGoUy2z8dQVgH6CYQEjuoonD15cWAPGvjyg8vx8oGG7CuktUhWu5JPivL6qjECow==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.7.0(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -3582,12 +3625,12 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false - /@reactflow/core@11.6.1(react-dom@18.2.0)(react@18.2.0): + /@reactflow/core@11.6.1(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-qKmXi9z1n6aycAGv+GYCNHgsGznCAyY73DsvOvPH99V/+2M3FtpOnQf6Am5HHfDsCk+h5vuRiT6hlv7Oi2Xn0w==} peerDependencies: react: '>=17' @@ -3603,12 +3646,12 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false - /@reactflow/core@11.7.0(react-dom@18.2.0)(react@18.2.0): + /@reactflow/core@11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-UJcpbNRSupSSoMWh5UmRp6UUr0ug7xVKmMvadnkKKiNi9584q57nz4HMfkqwN3/ESbre7LD043yh2n678d/5FQ==} peerDependencies: react: '>=17' @@ -3624,7 +3667,7 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false @@ -3644,18 +3687,18 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false - /@reactflow/minimap@11.5.0(react-dom@18.2.0)(react@18.2.0): + /@reactflow/minimap@11.5.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-n/3tlaknLpi3zaqCC+tDDPvUTOjd6jglto9V3RB1F2wlaUEbCwmuoR2GYTkiRyZMvuskKyAoQW8+0DX0+cWwsA==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.7.0(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) '@types/d3-selection': 3.0.5 '@types/d3-zoom': 3.0.2 classcat: 5.0.4 @@ -3663,24 +3706,24 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false - /@reactflow/node-resizer@2.1.0(react-dom@18.2.0)(react@18.2.0): + /@reactflow/node-resizer@2.1.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-DVL8nnWsltP8/iANadAcTaDB4wsEkx2mOLlBEPNE3yc5loSm3u9l5m4enXRcBym61MiMuTtDPzZMyYYQUjuYIg==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.6.1(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.6.1(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 d3-drag: 3.0.0 d3-selection: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false @@ -3696,22 +3739,22 @@ packages: classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false - /@reactflow/node-toolbar@1.1.11(react-dom@18.2.0)(react@18.2.0): + /@reactflow/node-toolbar@1.1.11(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-+hKtx+cvXwfCa9paGxE+G34rWRIIVEh68ZOqAtivClVmfqGzH/sEoGWtIOUyg9OEDNE1nEmZ1NrnpBGSmHHXFg==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.7.0(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.3.6(react@18.2.0) + zustand: 4.3.6(immer@10.0.2)(react@18.2.0) transitivePeerDependencies: - immer dev: false @@ -3821,8 +3864,8 @@ packages: resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} dev: true - /@storybook/addon-actions@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-WxsnSjAvdf6NhUfTqcwV+FJmsJV56gh2cY4QnGfqfwO5zoBWTUYnhz57TgxSMhJY0kspyX9Q1Kc//r1d5lt1qA==} + /@storybook/addon-actions@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qEvhnGeFb9c2TXdSgcCm+LQsZC+8yj1xXv+xfXu/maEcf3DoFU7iF4pBQJRsmawLP+m/yNaXujUbg/aty4fSng==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3832,14 +3875,14 @@ packages: react-dom: optional: true dependencies: - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-events': 7.0.7 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/preview-api': 7.0.7 - '@storybook/theming': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/types': 7.0.7 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 dequal: 2.0.3 lodash: 4.17.21 polished: 4.2.2 @@ -3849,11 +3892,11 @@ packages: react-inspector: 6.0.1(react@18.2.0) telejson: 7.0.4 ts-dedent: 2.2.0 - uuid: 9.0.0 + uuid-browser: 3.1.0 dev: true - /@storybook/addon-backgrounds@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-DhT32K1+ti7MXY9oqt36b9jlg7iY68IP0ZQbR3gjShcsIXZpFqh18TQo0vwDY1ldqnBvkTk6Jd5vcxA8tfyshw==} + /@storybook/addon-backgrounds@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-vjuPvgZjM1IFVCMSvbrAPO0piY+xgzh5433JqZuYGnIPOtqLuRpq1/xE7aSMNKC7bXIczukydo184p+rfqUUgw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3863,22 +3906,22 @@ packages: react-dom: optional: true dependencies: - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-events': 7.0.7 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/preview-api': 7.0.7 - '@storybook/theming': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/types': 7.0.7 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 memoizerific: 1.11.3 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) ts-dedent: 2.2.0 dev: true - /@storybook/addon-controls@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-/QEzleKoWRQ3i7KB32QvqDGcGMw4kG2BxEf0d+ymxd2SjoeL6kX2eHE0b4OxFPXiWUyTfXBFwmcI2Re3fRUJnQ==} + /@storybook/addon-controls@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-dV7ljOjZsxtPJ4jlzurnEOQ15opPelKmcEAN6Tl0Id4gW0ouAkb7f++/TfSeI9+BDd0+JPvsw6w3SCCD0t+46A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3888,15 +3931,15 @@ packages: react-dom: optional: true dependencies: - '@storybook/blocks': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-common': 7.0.7 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/node-logger': 7.0.7 - '@storybook/preview-api': 7.0.7 - '@storybook/theming': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/types': 7.0.7 + '@storybook/blocks': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-common': 7.0.0-rc.5 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/node-logger': 7.0.0-rc.5 + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 lodash: 4.17.21 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -3905,29 +3948,33 @@ packages: - supports-color dev: true - /@storybook/addon-docs@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-5PT7aiTD6QPH+4CZLcv4PiUgWucD9JNGHVMRbQMEyFW6qbs87dHmu1m1uXIvx3BF5h3mTo4FHNAf8IQIq5HH9w==} + /@storybook/addon-docs@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-MhxJmZ/Pxi57+SGhpKhrNMxeP4Bj5UM1dmHYk49cwOZBIG0NuWr8lOvMvL8tRjvq7u0jHKqNSa0reSPCBZvvxg==} peerDependencies: + '@storybook/mdx1-csf': '>=1.0.0-0' react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@storybook/mdx1-csf': + optional: true dependencies: '@babel/core': 7.21.3 '@babel/plugin-transform-react-jsx': 7.21.0(@babel/core@7.21.3) '@jest/transform': 29.5.0 '@mdx-js/react': 2.3.0(react@18.2.0) - '@storybook/blocks': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/csf-plugin': 7.0.7 - '@storybook/csf-tools': 7.0.7 + '@storybook/blocks': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/csf-plugin': 7.0.0-rc.5 + '@storybook/csf-tools': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/mdx2-csf': 1.0.0 - '@storybook/node-logger': 7.0.7 - '@storybook/postinstall': 7.0.7 - '@storybook/preview-api': 7.0.7 - '@storybook/react-dom-shim': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/theming': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/types': 7.0.7 + '@storybook/mdx2-csf': 1.1.0-next.1 + '@storybook/node-logger': 7.0.0-rc.5 + '@storybook/postinstall': 7.0.0-rc.5 + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/react-dom-shim': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 fs-extra: 11.1.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -3938,42 +3985,43 @@ packages: - supports-color dev: true - /@storybook/addon-essentials@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-uNx0BvN1XP7cNnk/L4oiFQlEB/KABqOeIyI8/mhfIyTvvwo9uAYIQAyiwWuz9MFmofCNm7CgLNOUaEwNDkM4CA==} + /@storybook/addon-essentials@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-dfBOZ5odCzki6F7tMcbX9x6fpuGsz4Owxw5iIL7FUCjxUrlClwTMpvwqqZniHPFAEWnISLcKgkPX7D7oRrWCig==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - '@storybook/addon-actions': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/addon-backgrounds': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/addon-controls': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/addon-docs': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/addon-highlight': 7.0.7 - '@storybook/addon-measure': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/addon-outline': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/addon-toolbars': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/addon-viewport': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-common': 7.0.7 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/node-logger': 7.0.7 - '@storybook/preview-api': 7.0.7 + '@storybook/addon-actions': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-backgrounds': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-controls': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-docs': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-highlight': 7.0.0-rc.5 + '@storybook/addon-measure': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-outline': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-toolbars': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-viewport': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-common': 7.0.0-rc.5 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/node-logger': 7.0.0-rc.5 + '@storybook/preview-api': 7.0.0-rc.5 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) ts-dedent: 2.2.0 transitivePeerDependencies: + - '@storybook/mdx1-csf' - supports-color dev: true - /@storybook/addon-highlight@7.0.7: - resolution: {integrity: sha512-expme2GzzCXX7/lL7UjCDi1Tfj+4LeNsAdWiurVLH7glK7yKPPeXXkIldbLP/XjJv4NKlqCwnNRHQx0vDLlE6g==} + /@storybook/addon-highlight@7.0.0-rc.5: + resolution: {integrity: sha512-Dx4xObuDMQHJ/Et83HuzXI1g4LDJmw36Zgke09wdNta7CbvJG3eyDyiA+JrHRs+4eXYi1IWDhztpM5uQ/Chtaw==} dependencies: - '@storybook/core-events': 7.0.7 + '@storybook/core-events': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/preview-api': 7.0.7 + '@storybook/preview-api': 7.0.0-rc.5 dev: true - /@storybook/addon-interactions@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-jBl6O5sSbix0X1G9dFuWvvu4qefgLP9dAB/utVdDadZxlbPfa5B2C2q2YIqjcKZoX8DS8Fh8SUhlX1mdW5tu5w==} + /@storybook/addon-interactions@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OPAp+0LS+vtFcBvfrY+5/xFyXfihLCWJauFmMI02g0tsHObB4Ua6juAnOYSwNSKdea0uW5GGTkVRxS7zEgqr3Q==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3983,16 +4031,16 @@ packages: react-dom: optional: true dependencies: - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-common': 7.0.7 - '@storybook/core-events': 7.0.7 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-common': 7.0.0-rc.5 + '@storybook/core-events': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/instrumenter': 7.0.7 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/preview-api': 7.0.7 - '@storybook/theming': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/types': 7.0.7 + '@storybook/instrumenter': 7.0.0-rc.5 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 jest-mock: 27.5.1 polished: 4.2.2 react: 18.2.0 @@ -4027,8 +4075,8 @@ packages: ts-dedent: 2.2.0 dev: true - /@storybook/addon-measure@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-lb4wEIvIVF+ePx1sC+n9rDI0+49sRa6MWbcvZ+BhbAoCeGcX7uACQFdW6HyXolmBuZASsTnzVQ4KqzzvY1dSWw==} + /@storybook/addon-measure@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-m9CCcMpSrV7psZ9z6FaekdY0m7XNh+XRpiLLWn/TwQONHrUb0UBQGKloITNKE4QxCSDKpqCOUl/yJTxkCRCsrg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4038,19 +4086,19 @@ packages: react-dom: optional: true dependencies: - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-events': 7.0.7 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/preview-api': 7.0.7 - '@storybook/types': 7.0.7 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/types': 7.0.0-rc.5 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: true - /@storybook/addon-outline@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-AxbNZ4N1fXBTeMYM9tFudfW+Gzq7UikCjPxn5ax3Pde+zZjaEMppUxv5EMz4g5GIJupLYRmKH5pN0YcYoRLY6w==} + /@storybook/addon-outline@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CkwW6b9gzIqQFw68cdAYbaY15DzLhBSpCRsccl/Mnm83xxm2MeC3Z5yxvi+3fGyuV6iyJxDsyxn4y4MD/Zho9w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4060,13 +4108,13 @@ packages: react-dom: optional: true dependencies: - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-events': 7.0.7 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/preview-api': 7.0.7 - '@storybook/types': 7.0.7 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/types': 7.0.0-rc.5 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) ts-dedent: 2.2.0 @@ -4143,8 +4191,8 @@ packages: - webpack dev: true - /@storybook/addon-toolbars@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-/NkYHhU1VAz5lXjWuV8+ADWB84HzktvZv4jfiKX7Zzu6JVzrBu7FotQSWh3pDqqVwCB50RClUGtcHmSSac9CAQ==} + /@storybook/addon-toolbars@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GErLxEBVh3HaQEvUNmKlNDcNuEYpGNVT1Nr1Tsc4J8EKG1ivEfQfVu6/5fduPZE8Vt1IUAzrVEp9NYzSELH49Q==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4154,17 +4202,17 @@ packages: react-dom: optional: true dependencies: - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/preview-api': 7.0.7 - '@storybook/theming': 7.0.7(react-dom@18.2.0)(react@18.2.0) + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: true - /@storybook/addon-viewport@7.0.7(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-znqhd8JFEFoXcAdwYhz1CwrCpVAzhuSyUVBUNDsDs+mgBEfGth4D4abIdWWGcfP6+CmI5ebFHtk443cExZebag==} + /@storybook/addon-viewport@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-EzjGyi0s6VvwZvCuN6E8zgc6RcIOUz85G1Zt5U59as4GwhvezwiJdM9IjtX0/I17hdKS7vL36Gli67PJZKb/Bw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4174,13 +4222,13 @@ packages: react-dom: optional: true dependencies: - '@storybook/client-logger': 7.0.7 - '@storybook/components': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/core-events': 7.0.7 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.0.0-rc.5 '@storybook/global': 5.0.0 - '@storybook/manager-api': 7.0.7(react-dom@18.2.0)(react@18.2.0) - '@storybook/preview-api': 7.0.7 - '@storybook/theming': 7.0.7(react-dom@18.2.0)(react@18.2.0) + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) memoizerific: 1.11.3 prop-types: 15.8.1 react: 18.2.0 @@ -4252,6 +4300,40 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true + /@storybook/blocks@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-9ZGDExwA6DgR/BsFSk2aCe7p/AIIQAiCemV1W1Djp7lt6OOALWfLZ7r1sFUqY9ZgNkfD1N41JpmqJtPDLXejGQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/channels': 7.0.0-rc.5 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/components': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.0.0-rc.5 + '@storybook/csf': 0.0.2-next.11 + '@storybook/docs-tools': 7.0.0-rc.5 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.0.0-rc.5 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 + '@types/lodash': 4.14.191 + color-convert: 2.0.1 + dequal: 2.0.3 + lodash: 4.17.21 + markdown-to-jsx: 7.2.0(react@18.2.0) + memoizerific: 1.11.3 + polished: 4.2.2 + react: 18.2.0 + react-colorful: 5.6.1(react-dom@18.2.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + telejson: 7.0.4 + ts-dedent: 2.2.0 + util-deprecate: 1.0.2 + transitivePeerDependencies: + - supports-color + dev: true + /@storybook/blocks@7.0.7(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-ehR0hAFWNHHqmrmbwYPKhLpgbIBKtyMbeoGClTRSnrVBGONciYJdmxegkCTReUklCY+HBJjtlwNowT+7+5sSaw==} peerDependencies: @@ -4513,6 +4595,24 @@ packages: util-deprecate: 1.0.2 dev: true + /@storybook/components@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-zuKQ0+uOtRbmnF0trJ4LpWZ5w9Dzcs5dZjF3Uu4ka4F4vJ/fUWKL2spxAIsRalu2jyk2XVp6/mz/NiWQnrophw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/csf': 0.0.2-next.11 + '@storybook/global': 5.0.0 + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + use-resize-observer: 9.1.0(react-dom@18.2.0)(react@18.2.0) + util-deprecate: 1.0.2 + dev: true + /@storybook/components@7.0.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-SHftxNH3FG3RZwJ5nbyBZwn5pkI3Ei2xjD7zDwxztI8bCp5hPnOTDwAnQZZCkeW7atSQUe7xFkYqlCgNmXR4PQ==} peerDependencies: @@ -4682,6 +4782,15 @@ packages: - utf-8-validate dev: true + /@storybook/csf-plugin@7.0.0-rc.5: + resolution: {integrity: sha512-sgIEqV1MfhybvODcjtG0Ce/XlzWv2Sg5Prg5Qqsr5sMU7aET+yLHmr1umbM5L8ieRjsXS4CsxZCqZMrY9hDdNw==} + dependencies: + '@storybook/csf-tools': 7.0.0-rc.5 + unplugin: 0.10.2 + transitivePeerDependencies: + - supports-color + dev: true + /@storybook/csf-plugin@7.0.7: resolution: {integrity: sha512-uhf2g077gXA6ZEMXIPQ0RnX+IoOTBJbj+6+VQfT7K5tvJeop1z0Fvk0FoknNXcUe7aUA0nzA/cUQ1v4vXqbY3Q==} dependencies: @@ -4691,6 +4800,22 @@ packages: - supports-color dev: true + /@storybook/csf-tools@7.0.0-rc.5: + resolution: {integrity: sha512-DvcAygIZMZIL30j7WxMXeJ6a+A2/Y/FuatZItmW+3sNv0FK1J9wH2SKw7QjzEw75LsgjvO07lU2cgcsPDFhXoA==} + dependencies: + '@babel/generator': 7.21.3 + '@babel/parser': 7.21.3 + '@babel/traverse': 7.21.3(supports-color@5.5.0) + '@babel/types': 7.21.4 + '@storybook/csf': 0.0.2-next.11 + '@storybook/types': 7.0.0-rc.5 + fs-extra: 11.1.1 + recast: 0.23.1 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - supports-color + dev: true + /@storybook/csf-tools@7.0.7: resolution: {integrity: sha512-KbO5K2RS0oFm94eR49bAPvoyXY3Q6+ozvBek/F05RP7iAV790icQc59Xci9YDM1ONgb3afS+gSJGFBsE0h4pmg==} dependencies: @@ -4761,6 +4886,16 @@ packages: resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} dev: true + /@storybook/instrumenter@7.0.0-rc.5: + resolution: {integrity: sha512-e9AtV1hNTs4ppmqKfst/cInmRnhkK9VcGf3xB/d9Qqm0Sqo+sNXu6ywK5KpAURdCzsUEOPXbJ9H52yTrU4f74A==} + dependencies: + '@storybook/channels': 7.0.0-rc.5 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/core-events': 7.0.0-rc.5 + '@storybook/global': 5.0.0 + '@storybook/preview-api': 7.0.0-rc.5 + dev: true + /@storybook/instrumenter@7.0.7: resolution: {integrity: sha512-0zE5lM3laKvCT4GW/XKKw8kakvI4catqK8PObZolRhfxbtGufW4VJZ2E8vXLtgA/+K3zikypjuWE6d45NLbh9w==} dependencies: @@ -4771,6 +4906,31 @@ packages: '@storybook/preview-api': 7.0.7 dev: true + /@storybook/manager-api@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-MsNj/cPIOlL7HJ8ReYahUvJVfvZDtNfacUYSFuQjQwdnp0u3pbC5mGZPd32tAGj7lLaLzcqqo1yR+NAgwpZUBw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/channels': 7.0.0-rc.5 + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/core-events': 7.0.0-rc.5 + '@storybook/csf': 0.0.2-next.11 + '@storybook/global': 5.0.0 + '@storybook/router': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/theming': 7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.0.0-rc.5 + dequal: 2.0.3 + lodash: 4.17.21 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + semver: 7.3.8 + store2: 2.14.2 + telejson: 7.0.4 + ts-dedent: 2.2.0 + dev: true + /@storybook/manager-api@7.0.7(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-QTd/P72peAhofKqK+8yzIO9iWAEfPn8WUGGveV2KGaTlSlgbr87RLHEKilcXMZcYhBWC9izFRmjKum9ROdskrQ==} peerDependencies: @@ -4804,6 +4964,10 @@ packages: resolution: {integrity: sha512-dBAnEL4HfxxJmv7LdEYUoZlQbWj9APZNIbOaq0tgF8XkxiIbzqvgB0jhL/9UOrysSDbQWBiCRTu2wOVxedGfmw==} dev: true + /@storybook/mdx2-csf@1.1.0-next.1: + resolution: {integrity: sha512-ONvFBZySHsBIkUYGrUM8FCG2tDKf663TIErztPSOghOpmBGyFLjSsXJHkNWiRi4c740PoemLqJd2XZZVlXRVLQ==} + dev: true + /@storybook/node-logger@7.0.0-rc.5: resolution: {integrity: sha512-3DpM988ndfbwc/03doFVP/HUJgoCp4eKVFMmSqnKVUd6qWx/dhsrTv+jqLt43wNZCgL/N/8QE+Q+FhVwefh6Tg==} dependencies: @@ -4822,8 +4986,8 @@ packages: pretty-hrtime: 1.0.3 dev: true - /@storybook/postinstall@7.0.7: - resolution: {integrity: sha512-APcZ2KaR7z1aJje3pID4Ywmt1/aVcP3Sc4ltzNdH9mCkEsuq0fZHHQrYSa9Ya1IPRmSeLZ5/23q1iyqmGU3zoQ==} + /@storybook/postinstall@7.0.0-rc.5: + resolution: {integrity: sha512-F23wxKEJ2XoVnHT7oAMjCXtANWvNq7M+FmIowgI98b3FT1dxt9fFPKKY+3Lcqp0Xa6Pzezd03KR9vAxXvvK/iQ==} dev: true /@storybook/preset-scss@1.0.3(css-loader@6.7.3)(sass-loader@13.2.2)(style-loader@3.3.2): @@ -5022,6 +5186,19 @@ packages: regenerator-runtime: 0.13.11 dev: true + /@storybook/router@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-s23O2OOQ4+CvySk3QC/PXhDJChc4jjyQu/h3gLMKF7bfWx0bd5KR4LnP3rCKLIMkxoJYFPUayPMgwEEeN/ENSw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/client-logger': 7.0.0-rc.5 + memoizerific: 1.11.3 + qs: 6.11.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + /@storybook/router@7.0.7(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-/lM8/NHQKeshfnC3ayFuO8Y9TCSHnCAPRhIsVxvanBzcj+ILbCIyZ+TspvB3hT4MbX/Ez+JR8VrMbjXIGwmH8w==} peerDependencies: @@ -5085,6 +5262,20 @@ packages: regenerator-runtime: 0.13.11 dev: true + /@storybook/theming@7.0.0-rc.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OzwybDA2+4FWg85tcTNQkVI0JnHkwCRG9HM1qx9hOZJHNRfxmJFjJePOnBoXM6CjVlz0S1PJUwCmMHNH8OTvEw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@emotion/use-insertion-effect-with-fallbacks': 1.0.0(react@18.2.0) + '@storybook/client-logger': 7.0.0-rc.5 + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + /@storybook/theming@7.0.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-XgQXKktcVBOkJT5gXjqtjH7C2pjdreDy0BTVTaEmFzggyyw+cgFrkJ7tuB27oKwYe+svx26c/olVMSHYf+KqhA==} peerDependencies: @@ -5441,6 +5632,7 @@ packages: /@types/css-font-loading-module@0.0.7: resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==} + dev: false /@types/cytoscape@3.19.9: resolution: {integrity: sha512-oqCx0ZGiBO0UESbjgq052vjDAy2X53lZpMrWqiweMpvVwKw/2IiYDdzPFK6+f4tMfdv9YKEM9raO5bAZc3UYBg==} @@ -5604,6 +5796,7 @@ packages: /@types/earcut@2.1.1: resolution: {integrity: sha512-w8oigUCDjElRHRRrMvn/spybSMyX8MTkKA5Dv+tS1IE/TgmNZPqUYtvYBXGY8cieSE66gm+szeK+bnbxC2xHTQ==} + dev: false /@types/ejs@3.1.2: resolution: {integrity: sha512-ZmiaE3wglXVWBM9fyVC17aGPkLo/UgaOjEiI2FXQfyczrCefORPxIe+2dVmnmk3zkVIbizjrlQzmPGhSYGXG5g==} @@ -5765,20 +5958,12 @@ packages: /@types/offscreencanvas@2019.7.0: resolution: {integrity: sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==} + dev: false /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: false - /@types/pixi.js@5.0.0(@pixi/utils@7.2.1): - resolution: {integrity: sha512-yZqQBR043lRBlBZci2cx6hgmX0fvBfYIqFm6VThlnueXEjitxd3coy+BGsqsZ7+ary7O//+ks4aJRhC5MJoHqA==} - deprecated: This is a stub types definition. pixi.js provides its own type definitions, so you do not need this installed. - dependencies: - pixi.js: 7.2.1(@pixi/utils@7.2.1) - transitivePeerDependencies: - - '@pixi/utils' - dev: true - /@types/pretty-hrtime@1.0.1: resolution: {integrity: sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==} dev: true @@ -7075,6 +7260,7 @@ packages: /colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + dev: false /colorette@2.0.19: resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} @@ -7146,7 +7332,7 @@ packages: dev: true /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} /concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -8016,6 +8202,7 @@ packages: /earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + dev: false /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -8329,13 +8516,13 @@ packages: dependencies: eslint: 7.32.0 - /eslint-config-turbo@1.9.3(eslint@7.32.0): - resolution: {integrity: sha512-QG6jxFQkrGSpQqlFKefPdtgUfr20EbU0s4tGGIuGFOcPuJEdsY6VYZpZUxNJvmMcTGqPgMyOPjAFBKhy/DPHLA==} + /eslint-config-turbo@1.10.0(eslint@7.32.0): + resolution: {integrity: sha512-iJsjoOb0vjnpV65rL+SAOIgbOluEibFqo/ke4UzXA9bi13cNJOT9k6FCQA7RXBHwChz+6BzYq5ZyoE3WcUg+0g==} peerDependencies: eslint: '>6.6.0' dependencies: eslint: 7.32.0 - eslint-plugin-turbo: 1.9.3(eslint@7.32.0) + eslint-plugin-turbo: 1.10.0(eslint@7.32.0) /eslint-import-resolver-node@0.3.7: resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} @@ -8478,8 +8665,8 @@ packages: semver: 6.3.0 string.prototype.matchall: 4.0.8 - /eslint-plugin-turbo@1.9.3(eslint@7.32.0): - resolution: {integrity: sha512-ZsRtksdzk3v+z5/I/K4E50E4lfZ7oYmLX395gkrUMBz4/spJlYbr+GC8hP9oVNLj9s5Pvnm9rLv/zoj5PVYaVw==} + /eslint-plugin-turbo@1.10.0(eslint@7.32.0): + resolution: {integrity: sha512-7eDeOjZDx/GcrZj4XYX1puaxsDtm6FE53qlbb4JyJhhkKC8OVYTJHTpu8kSmFTNsVoIgDFo/SEUIMwKHLYRM3w==} peerDependencies: eslint: '>6.6.0' dependencies: @@ -8627,6 +8814,7 @@ packages: /eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: false /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} @@ -9551,6 +9739,10 @@ packages: dev: true optional: true + /immer@10.0.2: + resolution: {integrity: sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==} + dev: false + /immer@9.0.19: resolution: {integrity: sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ==} dev: false @@ -9869,6 +10061,7 @@ packages: /ismobilejs@1.1.1: resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + dev: false /isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} @@ -10491,6 +10684,10 @@ packages: engines: {node: '>= 0.6'} dev: true + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + /memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} dependencies: @@ -11211,6 +11408,7 @@ packages: '@pixi/text-html': 7.2.1(@pixi/core@7.2.1)(@pixi/display@7.2.1)(@pixi/sprite@7.2.1)(@pixi/text@7.2.1) transitivePeerDependencies: - '@pixi/utils' + dev: false /pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} @@ -11548,6 +11746,7 @@ packages: /punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} + dev: false /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} @@ -11600,6 +11799,7 @@ packages: resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} engines: {node: '>=0.4.x'} deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + dev: false /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -11931,6 +12131,19 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-window@1.8.9(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.21.0 + memoize-one: 5.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -11954,18 +12167,18 @@ packages: - immer dev: false - /reactflow@11.7.0(react-dom@18.2.0)(react@18.2.0): + /reactflow@11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-bjfJV1iQZ+VwIlvsnd4TbXNs6kuJ5ONscud6fNkF8RY/oU2VUZpdjA3q1zwmgnjmJcIhxuBiBI5VLGajYx/Ozg==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/background': 11.2.0(react-dom@18.2.0)(react@18.2.0) - '@reactflow/controls': 11.1.11(react-dom@18.2.0)(react@18.2.0) - '@reactflow/core': 11.7.0(react-dom@18.2.0)(react@18.2.0) - '@reactflow/minimap': 11.5.0(react-dom@18.2.0)(react@18.2.0) - '@reactflow/node-resizer': 2.1.0(react-dom@18.2.0)(react@18.2.0) - '@reactflow/node-toolbar': 1.1.11(react-dom@18.2.0)(react@18.2.0) + '@reactflow/background': 11.2.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/controls': 11.1.11(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.7.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/minimap': 11.5.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-resizer': 2.1.0(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-toolbar': 1.1.11(immer@10.0.2)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) transitivePeerDependencies: @@ -13255,65 +13468,65 @@ packages: tslib: 1.14.1 typescript: 4.9.5 - /turbo-darwin-64@1.9.3: - resolution: {integrity: sha512-0dFc2cWXl82kRE4Z+QqPHhbEFEpUZho1msHXHWbz5+PqLxn8FY0lEVOHkq5tgKNNEd5KnGyj33gC/bHhpZOk5g==} + /turbo-darwin-64@1.9.9: + resolution: {integrity: sha512-UDGM9E21eCDzF5t1F4rzrjwWutcup33e7ZjNJcW/mJDPorazZzqXGKEPIy9kXwKhamUUXfC7668r6ZuA1WXF2Q==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.9.3: - resolution: {integrity: sha512-1cYbjqLBA2zYE1nbf/qVnEkrHa4PkJJbLo7hnuMuGM0bPzh4+AnTNe98gELhqI1mkTWBu/XAEeF5u6dgz0jLNA==} + /turbo-darwin-arm64@1.9.9: + resolution: {integrity: sha512-VyfkXzTJpYLTAQ9krq2myyEq7RPObilpS04lgJ4OO1piq76RNmSpX9F/t9JCaY9Pj/4TL7i0d8PM7NGhwEA5Ag==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.9.3: - resolution: {integrity: sha512-UuBPFefawEwpuxh5pM9Jqq3q4C8M0vYxVYlB3qea/nHQ80pxYq7ZcaLGEpb10SGnr3oMUUs1zZvkXWDNKCJb8Q==} + /turbo-linux-64@1.9.9: + resolution: {integrity: sha512-Fu1MY29Odg8dHOqXcpIIGC3T63XLOGgnGfbobXMKdrC7JQDvtJv8TUCYciRsyknZYjyyKK1z6zKuYIiDjf3KeQ==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.9.3: - resolution: {integrity: sha512-vUrNGa3hyDtRh9W0MkO+l1dzP8Co2gKnOVmlJQW0hdpOlWlIh22nHNGGlICg+xFa2f9j4PbQlWTsc22c019s8Q==} + /turbo-linux-arm64@1.9.9: + resolution: {integrity: sha512-50LI8NafPuJxdnMCBeDdzgyt1cgjQG7FwkyY336v4e95WJPUVjrHdrKH6jYXhOUyrv9+jCJxwX1Yrg02t5yJ1g==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.9.3: - resolution: {integrity: sha512-0BZ7YaHs6r+K4ksqWus1GKK3W45DuDqlmfjm/yuUbTEVc8szmMCs12vugU2Zi5GdrdJSYfoKfEJ/PeegSLIQGQ==} + /turbo-windows-64@1.9.9: + resolution: {integrity: sha512-9IsTReoLmQl1IRsy3WExe2j2RKWXQyXujfJ4fXF+jp08KxjVF4/tYP2CIRJx/A7UP/7keBta27bZqzAjsmbSTA==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.9.3: - resolution: {integrity: sha512-QJUYLSsxdXOsR1TquiOmLdAgtYcQ/RuSRpScGvnZb1hY0oLc7JWU0llkYB81wVtWs469y8H9O0cxbKwCZGR4RQ==} + /turbo-windows-arm64@1.9.9: + resolution: {integrity: sha512-CUu4hpeQo68JjDr0V0ygTQRLbS+/sNfdqEVV+Xz9136vpKn2WMQLAuUBVZV0Sp0S/7i+zGnplskT0fED+W46wQ==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.9.3: - resolution: {integrity: sha512-ID7mxmaLUPKG/hVkp+h0VuucB1U99RPCJD9cEuSEOdIPoSIuomcIClEJtKamUsdPLhLCud+BvapBNnhgh58Nzw==} + /turbo@1.9.9: + resolution: {integrity: sha512-+ZS66LOT7ahKHxh6XrIdcmf2Yk9mNpAbPEj4iF2cs0cAeaDU3xLVPZFF0HbSho89Uxwhx7b5HBgPbdcjQTwQkg==} hasBin: true requiresBuild: true optionalDependencies: - turbo-darwin-64: 1.9.3 - turbo-darwin-arm64: 1.9.3 - turbo-linux-64: 1.9.3 - turbo-linux-arm64: 1.9.3 - turbo-windows-64: 1.9.3 - turbo-windows-arm64: 1.9.3 + turbo-darwin-64: 1.9.9 + turbo-darwin-arm64: 1.9.9 + turbo-linux-64: 1.9.9 + turbo-linux-arm64: 1.9.9 + turbo-windows-64: 1.9.9 + turbo-windows-arm64: 1.9.9 dev: true /type-check@0.3.2: @@ -13554,6 +13767,7 @@ packages: dependencies: punycode: 1.3.2 querystring: 0.2.0 + dev: false /use-composed-ref@1.3.0(react@18.2.0): resolution: {integrity: sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==} @@ -13563,6 +13777,16 @@ packages: react: 18.2.0 dev: false + /use-immer@0.9.0(immer@10.0.2)(react@18.2.0): + resolution: {integrity: sha512-/L+enLi0nvuZ6j4WlyK0US9/ECUtV5v9RUbtxnn5+WbtaXYUaOBoKHDNL9I5AETdurQ4rIFIj/s+Z5X80ATyKw==} + peerDependencies: + immer: '>=2.0.0' + react: ^16.8.0 || ^17.0.1 || ^18.0.0 + dependencies: + immer: 10.0.2 + react: 18.2.0 + dev: false + /use-isomorphic-layout-effect@1.1.2(@types/react@18.0.28)(react@18.2.0): resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} peerDependencies: @@ -13634,9 +13858,9 @@ packages: dev: false optional: true - /uuid@9.0.0: - resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} - hasBin: true + /uuid-browser@3.1.0: + resolution: {integrity: sha512-dsNgbLaTrd6l3MMxTtouOCFw4CBFc/3a+GgYA2YyrJvyQ1u6q4pcu3ktLoUZ/VN/Aw9WsauazbgsgdfVWgAKQg==} + deprecated: Package no longer supported and required. Use the uuid package or crypto.randomUUID instead dev: true /v8-compile-cache-lib@3.0.1: @@ -14259,7 +14483,7 @@ packages: commander: 9.5.0 dev: true - /zustand@4.3.6(react@18.2.0): + /zustand@4.3.6(immer@10.0.2)(react@18.2.0): resolution: {integrity: sha512-6J5zDxjxLE+yukC2XZWf/IyWVKnXT9b9HUv09VJ/bwGCpKNcaTqp7Ws28Xr8jnbvnZcdRaidztAPsXFBIqufiw==} engines: {node: '>=12.7.0'} peerDependencies: @@ -14271,6 +14495,7 @@ packages: react: optional: true dependencies: + immer: 10.0.2 react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) dev: false -- GitLab