diff --git a/.gitignore b/.gitignore index cf4d02514a1d9a3ffb355213bb01be0e31f8d7ad..7749c1bd20bd40fb0e1d5746687a0dd15eabc63e 100644 --- a/.gitignore +++ b/.gitignore @@ -80,5 +80,10 @@ node_modules tsconfig.tsbuildinfo vite.config.ts.* -# autogenerated scss type files + +# Generated ts definitions files for CSS modules +*.d.ts +*.module.scss.d.ts +libs/shared/lib/components/buttons/buttons.module.scss.d.ts +libs/shared/lib/vis/visualizations/table_vis/components/table.module.scss.d.ts *.scss.d.ts diff --git a/libs/config/src/colors.js b/libs/config/src/colors.js index 5dab6bc1df1451c058d2acd0d283f2e8befe6b82..ae2aabc807f5e8a1ee12db9cb3b99121ca65bd75 100644 --- a/libs/config/src/colors.js +++ b/libs/config/src/colors.js @@ -229,18 +229,18 @@ export const dataColors = { 100: 'hsl(334 62% 10%)', }, neutral: { - 5: 'hsl(0 0 98%)', - 10: 'hsl(0 0 95%)', - 20: 'hsl(0 0 89%)', - 30: 'hsl(0 0 79%)', - 40: 'hsl(0 0 66%)', - 50: 'hsl(0 0 55%)', - 60: 'hsl(0 0 44%)', - 70: 'hsl(0 0 33%)', - 80: 'hsl(0 0 25%)', - 90: 'hsl(0 0 17%)', - 95: 'hsl(0 0 12%)', - 100: 'hsl(0 0 8%)', + 5: 'hsl(0 0% 98%)', + 10: 'hsl(0 0% 95%)', + 20: 'hsl(0 0% 89%)', + 30: 'hsl(0 0% 79%)', + 40: 'hsl(0 0% 66%)', + 50: 'hsl(0 0% 55%)', + 60: 'hsl(0 0% 44%)', + 70: 'hsl(0 0% 33%)', + 80: 'hsl(0 0% 25%)', + 90: 'hsl(0 0% 17%)', + 95: 'hsl(0 0% 12%)', + 100: 'hsl(0 0% 8%)', }, orange: { 5: 'hsl(29 100% 98%)', diff --git a/libs/shared/lib/data-access/store/visualizationSlice.ts b/libs/shared/lib/data-access/store/visualizationSlice.ts index 9213b9e68465aec83a37b6c431e930eeadeafa5a..bbb2e9671a6bcc8b32f671b8cf107161d637b445 100644 --- a/libs/shared/lib/data-access/store/visualizationSlice.ts +++ b/libs/shared/lib/data-access/store/visualizationSlice.ts @@ -8,6 +8,7 @@ export enum Visualizations { Paohvis = 'PaohVis', RawJSON = 'RawJSONVis', Table = 'TableVis', + Matrix = 'MatrixVis', } type VisState = { diff --git a/libs/shared/lib/vis/index.tsx b/libs/shared/lib/vis/index.tsx index 3abc7b697884c0527d0b103751d3d60aa79480e1..ca5a61cded998af5703f0a70e94f69e2a3ccf69c 100644 --- a/libs/shared/lib/vis/index.tsx +++ b/libs/shared/lib/vis/index.tsx @@ -9,7 +9,7 @@ import { useVisualizationState, } from '@graphpolaris/shared/lib/data-access/store/hooks'; import { localConfigPropTypes, globalConfigPropTypes, VISComponentType } from './Types'; -import { NodeLinkComponent, PaohVisComponent, RawJSONComponent, TableComponent } from './visualizations'; +import {MatrixVisComponent, NodeLinkComponent, PaohVisComponent, RawJSONComponent, TableComponent } from './visualizations'; // export * from './rawjsonvis'; // export * from './nodelink/nodelinkvis'; @@ -24,6 +24,7 @@ export const Visualizations = { RawJSONVis: RawJSONComponent, NodeLinkVis: NodeLinkComponent, MapVis: '', + MatrixVis: MatrixVisComponent, }; export const createVisualizationComponent = () => { diff --git a/libs/shared/lib/vis/visualizations/index.tsx b/libs/shared/lib/vis/visualizations/index.tsx index f47266be85680a5427198e8ab4485dabd18a56ca..0009a7afed90229263924851aed2809e67eb4127 100644 --- a/libs/shared/lib/vis/visualizations/index.tsx +++ b/libs/shared/lib/vis/visualizations/index.tsx @@ -3,3 +3,4 @@ export * from './nodelink/nodelinkvis'; export * from './paohvis/paohvis'; export * from './semanticsubstrates/semanticsubstrates'; export * from './table_vis/tableVis'; +export * from './matrix/matrixvis'; \ No newline at end of file diff --git a/libs/shared/lib/vis/visualizations/matrix/Types.tsx b/libs/shared/lib/vis/visualizations/matrix/Types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4103fa516745ce12b044603549e0b17d6ff89220 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/Types.tsx @@ -0,0 +1,102 @@ +/** + * 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 * as PIXI from 'pixi.js'; + +/** Types for the nodes and links in the node-link diagram. */ +export type GraphType = { + nodes: NodeType[]; + links: LinkType[]; + // linkPrediction?: boolean; + // shortestPath?: boolean; + // communityDetection?: boolean; + // numberOfMlClusters?: number; +}; + +/** The interface for a node in the node-link diagram */ +export interface NodeType extends d3.SimulationNodeDatum { + id: string; + + // Number to determine the color of the node + label?: string; + type: number; + attributes?: Record<string, any>; + cluster?: number; + clusterAccoringToMLData?: number; + shortestPathData?: Record<string, string[]>; + + // Node that is drawn. + radius: number; + // Text to be displayed on top of the node. + gfxtext?: PIXI.Text; + gfxAttributes?: PIXI.Graphics; + selected?: boolean; + isShortestPathSource?: boolean; + isShortestPathTarget?: boolean; + index?: number; + + // The text that will be shown on top of the node if selected. + displayInfo?: string; + + // Node’s current x-position. + x?: number; + + // Node’s current y-position. + y?: number; + + // Node’s current x-velocity + vx?: number; + + // Node’s current y-velocity + vy?: number; + + // Node’s fixed x-position (if position was fixed) + fx?: number | null; + + // Node’s fixed y-position (if position was fixed) + fy?: number | null; +} + +/** The interface for a link in the node-link diagram */ +export interface LinkType extends d3.SimulationLinkDatum<NodeType> { + // The thickness of a line + id: string; + value: number; + // To check if an edge is calculated based on a ML algorithm + mlEdge: boolean; + color: number; + alpha?: number; +} + +/**collectionNode holds 1 entry per node kind (so for example a MockNode with name "parties" and all associated attributes,) */ +export type TypeNode = { + name: string; //Collection name + attributes: string[]; //attributes. This includes all attributes found in the collection + type: number | undefined; //number that represents collection of node, for colorscheme + visualisations: Visualisation[]; //The way to visualize attributes of this Node kind +}; + +export type CommunityDetectionNode = { + cluster: number; //group as used by colouring scheme +}; + +/**Visualisation holds the visualisation method for an attribute */ +export type Visualisation = { + attribute: string; //attribute type (e.g. 'age') + vis: string; //visualisation type (e.g. 'radius') +}; + +/** possible colors to pick from*/ +export type Colors = { + name: string; +}; + +/**AssignedColors is a simple holder for color selection */ +export type AssignedColors = { + collection: number | undefined; //number of the collection (type or group) + color: string; //color in hex + default: string; //default color, for easy switching back +}; diff --git a/libs/shared/lib/vis/visualizations/matrix/components/ColumnGraphicsComponent.tsx b/libs/shared/lib/vis/visualizations/matrix/components/ColumnGraphicsComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7226749f9bf6d3e1f53aee905d84226ac19adef5 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/components/ColumnGraphicsComponent.tsx @@ -0,0 +1,83 @@ +import { Edge } from '@graphpolaris/shared/lib/data-access'; +import { dataColors } from 'config'; +import { Graphics } from 'pixi.js'; + +export const createColumn = ( + orderRow: string[], + edgesForThisColumn: Edge[], + visMapping: any[], // TODO type + cellWidth: number, + cellHeight: number +) => { + const currentVisMapping = visMapping[0]; + let gfx = new Graphics(); + gfx.eventMode = 'static'; + if (!currentVisMapping) { + return gfx; + } + + let color = null; + for (let i = 0; i < orderRow.length; i++) { + const rowOrderID = orderRow[i]; + + const inOutEdge = edgesForThisColumn.filter((edge) => { + return edge.from === rowOrderID || edge.to === rowOrderID; + }); + + const inEdges = edgesForThisColumn.filter((edge) => { + return edge.to === rowOrderID; + }); + + const outEdges = edgesForThisColumn.filter((edge) => { + return edge.from === rowOrderID; + }); + + /** + * maybe this should inverted: make it a visitor + * pattern and let the visMapping decide how ot implment all this + **/ + if (currentVisMapping.attribute === 'adjacency') { + color = currentVisMapping.colorScale(Math.min(inOutEdge.length, 1)); + gfx = currentVisMapping.renderFunction(i, color, gfx); + } else if (currentVisMapping.attribute === 'label') { + if (outEdges.length > 0) { + const thisEdge = outEdges[0]; + color = currentVisMapping.colorScale(thisEdge.label); + } + gfx.beginFill(color, 1); + // gfx.beginFill(colorScale(node.type))); + gfx.lineStyle(2, 0xffffff); + gfx.drawRect(0, i * cellWidth, cellWidth, cellHeight); + gfx.endFill(); + } else { + if (outEdges.length > 0) { + const thisEdge = outEdges[0]; + const value = byString(thisEdge, currentVisMapping.attribute); + + color = currentVisMapping.colorScale(value); + } + gfx.beginFill(color, 1); + // gfx.beginFill(colorScale(node.type))); + gfx.lineStyle(2, 0xffffff); + gfx.drawRect(0, i * cellWidth, cellWidth, cellHeight); + gfx.endFill(); + } + } + + return gfx; +}; + +const byString = (o: any, accessor: string) => { + accessor = accessor.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties + accessor = accessor.replace(/^\./, ''); // strip a leading dot + var a = accessor.split('.'); + for (var i = 0, n = a.length; i < n; ++i) { + var k = a[i]; + if (k in o) { + o = o[k]; + } else { + return; + } + } + return o; +}; diff --git a/libs/shared/lib/vis/visualizations/matrix/components/ColumnSpriteComponent.tsx b/libs/shared/lib/vis/visualizations/matrix/components/ColumnSpriteComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6176f1d5b9e6859980f94dfbcaa4d49735338cfa --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/components/ColumnSpriteComponent.tsx @@ -0,0 +1,48 @@ +import { Edge, Node } from '@graphpolaris/shared/lib/data-access'; +import { dataColors, tailwindColors } from 'config'; +import { Graphics, Sprite, Texture } from 'pixi.js'; + +export const createColumn = ( + id: number, + nodesCol: Node[], + nodesRow: Node[], + edges: Edge[], + colorScale: any, + cellWidth: number, + cellHeight: number +) => { + const sprite = new Sprite(Texture.WHITE); + sprite.name = 'col_' + id; + sprite.eventMode = 'static'; + // button.eventMode = 'static'; + sprite.cursor = 'pointer'; + const edgesForThisColumn = edges.filter((edge) => { + return edge.from === nodesCol[id].id || edge.to === nodesCol[id].id; + }); + const bgCellColor = dataColors.neutral['5']; + const fgCellColor = tailwindColors.entity.DEFAULT; + let color = bgCellColor; + for (let i = 0; i < nodesRow.length; i++) { + const node = nodesRow[i]; + if ( + edgesForThisColumn.filter((edge) => { + return edge.from === node.id || edge.to === node.id; + }).length > 0 + ) { + color = fgCellColor; + } else { + color = bgCellColor; + } + sprite.tint = color; + sprite.width = cellWidth; + sprite.height = cellHeight; + sprite.position.set(id * cellHeight, i * cellWidth); + // sprite.acceleration = new Point(0); + // TODO get + // gfx.beginFill(colorScale(node.type))); + // sprite.lineStyle(2, 0xffffff); + // sprite.drawRect(0, i * cellWidth, cellWidth, cellHeight); + // sprite.endFill(); + } + return sprite; +}; diff --git a/libs/shared/lib/vis/visualizations/matrix/components/MatrixExport.tsx b/libs/shared/lib/vis/visualizations/matrix/components/MatrixExport.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e6907ecc307f5f4a20188c543f80d76413adc520 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/components/MatrixExport.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import jsPDF from 'jspdf'; +import { Container, IRenderer } from 'pixi.js'; + +/** Exports the nodelink diagram as a pdf for downloading. */ +async function exportToPDF(renderer: IRenderer, stage: Container): Promise<void> { + const b = await renderer.extract.canvas(stage).convertToBlob?.({ type: 'image/png' }); + if (!b) return; + const pdf = new jsPDF(); + if (b) { + pdf.addImage(URL.createObjectURL(b), 'JPEG', 0, 0, 100, 100, '', 'NONE', 0); + pdf.save('diagram.pdf'); + } else { + console.log('null blob in exportToPDF'); + } +} + +/** Exports the nodelink diagram as a png for downloading. */ +async function exportToPNG(renderer: IRenderer, stage: Container): Promise<void> { + const b = await renderer.extract.canvas(stage).convertToBlob?.({ type: 'image/png' }); + if (b) { + const a = document.createElement('a'); + document.body.append(a); + a.download = 'diagram'; + a.href = URL.createObjectURL(b); + a.click(); + a.remove(); + } else { + console.log('null blob in exportToPNG'); + } +} + +const useExport = () => { + return { exportToPDF, exportToPNG }; +}; diff --git a/libs/shared/lib/vis/visualizations/matrix/components/MatrixPixi.tsx b/libs/shared/lib/vis/visualizations/matrix/components/MatrixPixi.tsx new file mode 100644 index 0000000000000000000000000000000000000000..550a1cef542eae22dda7e79b345a47106cfbe80a --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/components/MatrixPixi.tsx @@ -0,0 +1,593 @@ +import { Edge, GraphQueryResult, Node, useML, useSearchResultData } from '@graphpolaris/shared/lib/data-access'; +import { dataColors, tailwindColors } from 'config'; +import * as d3 from 'd3'; +import { Viewport } from 'pixi-viewport'; +import { + Application, + ColorSource, + Container, + DisplayObject, + FederatedPointerEvent, + Graphics, + IPointData, + Point, + Sprite, + Text, + Texture, +} from 'pixi.js'; +import { useEffect, useRef, useState } from 'react'; +import { LinkType, NodeType } from '../Types'; +import { NLPopup } from './MatrixPopup'; + +import { Actions, Interpolations } from 'pixi-actions'; + +import Color from 'color'; +import { localConfigSchemaType } from '../../../Types'; +import { createColumn } from './ColumnGraphicsComponent'; +import { ReorderingManager } from './ReorderingManager'; + +type Props = { + // onClick: (node: NodeType, pos: IPointData) => void; + // onHover: (data: { node: NodeType; pos: IPointData }) => void; + // onUnHover: (data: { node: NodeType; pos: IPointData }) => void; + highlightNodes: NodeType[]; + currentShortestPathEdges?: LinkType[]; + highlightedLinks?: LinkType[]; + graph?: GraphQueryResult; + localConfig: localConfigSchemaType; +}; + +const app = new Application({ background: 0xffffff, antialias: true, autoDensity: true, eventMode: 'auto' }); +const columnsContainer = new Container(); +const columnAxisContainer = new Container(); +const rowsContainer = new Container(); + +////////////////// +// MAIN COMPONENT +////////////////// + +export const MatrixPixi = (props: Props) => { + let config = { + textOffsetX: 100, + textOffsetY: 100, + visMapping: [] as any[], // TODO type + + cellHeight: 100, + cellWidth: 100, + }; + + let columnOrder: string[] = []; + let rowOrder: string[] = []; + + const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: IPointData } | undefined>(); + const [popups, setPopups] = useState<{ node: NodeType; pos: IPointData }[]>([]); + // const [columnOrder, setColumnOrder] = useState<string[]>([]); + + const nodeMap = useRef(new Map<string, Graphics>()); + const linkMap = useRef(new Map<string, Graphics>()); + const viewport = useRef<Viewport>(); + const ref = useRef<HTMLDivElement>(null); + const isSetup = useRef(false); + const ml = useML(); + const searchResults = useSearchResultData(); + + // const imperative = useRef<any>(null); + + function resize() { + const width = ref?.current?.clientWidth || 1000; + const height = ref?.current?.clientHeight || 1000; + app.renderer.resize(width, height); + if (viewport.current) { + viewport.current.screenWidth = width; + viewport.current.worldWidth = width; + viewport.current.worldHeight = height; + viewport.current.screenHeight = height; + } + + if (props.graph) { + setup(); + } + + app.render(); + } + + useEffect(() => { + if (!ref.current) return; + const resizeObserver = new ResizeObserver(() => { + resize(); + }); + resizeObserver.observe(ref.current); + return () => resizeObserver.disconnect(); // clean up + }, []); + + useEffect(() => { + if (ref.current && ref.current.children.length === 0) { + ref.current.appendChild(app.view as HTMLCanvasElement); + resize(); + } + }, [ref]); + + useEffect(() => { + console.log('graph change'); + // console.log('graph changed', props.graph, ref.current, ref.current.children.length > 0, imperative.current); + if (props.graph && ref.current && ref.current.children.length > 0) { + if (!isSetup.current) setup(); + else update(); + } + }, [props.graph]); + + useEffect(() => { + console.log('config change'); + // console.log('graph changed', props.graph, ref.current, ref.current.children.length > 0, imperative.current); + if (props.graph && ref.current && ref.current.children.length > 0) { + setup(); + } + }, [props.localConfig]); + + // TODO implement search results + // useEffect(() => { + // if (props.graph) { + // props.graph.nodes.forEach((node: NodeType) => { + // const gfx = nodeMap.current.get(node.id); + // if (!gfx) return; + // const isNodeInSearchResults = searchResults.nodes.some((resultNode) => resultNode.id === node.id); + // gfx.alpha = isNodeInSearchResults || searchResults.nodes.length === 0 ? 1 : 0.05; + // }); + + // props.graph.links.forEach((link: LinkType) => { + // const gfx = linkMap.current.get(link.id); + // if (!gfx) return; + // const isLinkInSearchResults = searchResults.edges.some((resultEdge) => resultEdge.id === link.id); + // gfx.alpha = isLinkInSearchResults || searchResults.edges.length === 0 ? 1 : 0.05; + // }); + // } + // }, [searchResults]); + + function onButtonDown(event: FederatedPointerEvent) { + console.log( + event.currentTarget + // graph.nodes.find((node) => node.id === event.currentTarget.name) + ); + } + + const update = (forceClear = false) => { + setPopups([]); + if (!props.graph || !ref.current) return; + + if (props.graph) { + if (forceClear) { + nodeMap.current.clear(); + linkMap.current.clear(); + rowsContainer.removeChildren(); + columnsContainer.removeChildren(); + } + } + }; + + const onHoverColumn = (event: any, currentNode: Node | null) => { + app.renderer.plugins.interaction.cursorStyles.pointer = 'crosshair'; + if (!currentNode) { + rowsContainer.children.forEach((row) => { + row.alpha = 1; + }); + columnsContainer.children.forEach((col) => { + col.alpha = 1; + }); + columnAxisContainer.children.forEach((colText) => { + colText.alpha = 1; + }); + + return; + } + + columnsContainer.children.forEach((col) => { + col.alpha = 0.25; + }); + event.currentTarget.alpha = 1; + + columnAxisContainer.children.forEach((colText) => { + colText.alpha = 0.25; + if (colText.name?.endsWith(currentNode.id)) colText.alpha = 1; + }); + + if (!props.graph) return; + rowsContainer.children.forEach((col) => { + col.alpha = 0.25; + }); + + if (!props.graph) return; + rowsContainer.children.forEach((col) => { + col.alpha = 0.25; + }); + + // console.log(event.currentTarget); + const edgesForThisColumn = props.graph.edges.filter((edge) => { + return edge.from === currentNode.id || edge.to === currentNode.id; + }); + + edgesForThisColumn.forEach((edge) => { + let row = rowsContainer.getChildByName(edge.to); + if (row) row.alpha = 1; + + row = rowsContainer.getChildByName(edge.from); + if (row) row.alpha = 1; + }); + }; + + const reorderColumns = (columnOrder: string[]) => { + const columns = columnsContainer.children; + const columnsText = columnAxisContainer.children; + let columnPositions: Point[] = []; + for (let i = 0; i < columns.length; i++) { + const col = columns[i]; + columnPositions.push(new Point(col.position.x, col.position.y)); + } + + let columnTextPositions: Point[] = []; + for (let i = 0; i < columnsText.length; i++) { + const col = columnsText[i]; + columnTextPositions.push(new Point(col.position.x, col.position.y)); + } + + if (columnTextPositions.length !== columnPositions.length) + throw new Error( + 'columnTextPositions and columnPositions have different length ' + columnTextPositions.length + ' / ' + columnPositions.length + ); + + for (let i = 0; i < columns.length; i++) { + const column = columns[i]; + if (!column.name) throw new Error('column has no name'); + const index = columnOrder.indexOf(column.name); + if (index === -1) throw new Error('column not found in columnOrder'); + + const newPosition = columnPositions[index]; + // move column to new position + Actions.moveTo(columns[i], newPosition.x, newPosition.y, 1, Interpolations.pow2out).play(); + + const newPositionText = columnTextPositions[index]; + // move column to new position + Actions.moveTo(columnsText[i], newPositionText.x, newPositionText.y, 1, Interpolations.pow2out).play(); + } + }; + + const reorderRows = ( + nodesCol: Node[], + nodesRow: Node[], + edges: Edge[], + rowOrder: string[], + colorScale: any, + cellWidth: number, + cellHeight: number + ) => { + const rows = rowsContainer.children; + let rowPositions: Point[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + rowPositions.push(new Point(row.position.x, row.position.y)); + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i] as DisplayObject; + if (!row.name) throw new Error('row has no name'); + const index = rowOrder.indexOf(row.name); + if (index === -1) throw new Error('row not found in rowOrder'); + + const newPosition = rowPositions[index]; + // move column to new position + Actions.moveTo(row, newPosition.x, newPosition.y, 1, Interpolations.pow2out).play(); + } + + const colOrder = columnsContainer.children.map((col) => col.name) as string[]; + console.log('colOrder', colOrder); + if (!colOrder) throw new Error('colOrder is undefined'); + columnsContainer.removeChildren(); + setupColumns(edges, colOrder, rowOrder); + }; + + const setup = () => { + if (!props.graph) throw Error('Graph is undefined; setup not possible'); + columnsContainer.removeChildren(); + rowsContainer.removeChildren(); + columnAxisContainer.removeChildren(); + app.stage.removeChildren(); + + const size = ref.current?.getBoundingClientRect(); + viewport.current = new Viewport({ + screenWidth: size?.width || 1000, + screenHeight: size?.height || 1000, + worldWidth: size?.width || 1000, + worldHeight: size?.height || 1000, + stopPropagation: true, + events: app.renderer.events, // the interaction module is important for wheel to work properly when renderer.view is placed or scaled + }); + + app.stage.addChild(viewport.current); + + viewport.current.addChild(columnsContainer); + viewport.current.addChild(rowsContainer); + + const groupByType = props.graph.nodes.reduce((group: any, node: Node) => { + if (!group[node.label]) group[node.label] = []; + group[node.label].push(node); + return group; + }, {} as { [key: string]: Node[] }); + + // order groupByType by size + const ordered = Object.entries(groupByType).sort((a: any[], b: any[]) => b[1].length - a[1].length); + + // console.log('ordered', ordered); + // console.log('edges', props.graph.edges); + + let cols = [] as Node[]; + let rows = [] as Node[]; + if (ordered.length == 2) { + cols = ordered[0][1] as Node[]; + rows = ordered[1][1] as Node[]; + } else if (ordered.length == 1) { + cols = ordered[0][1] as Node[]; + rows = ordered[0][1] as Node[]; + } else { + // show text that there are no nodes on the viewport + const finalTextSize = 25; + const bigRandomScaleFactor = 10; + const basicText = new Text('Result is empty', { fontSize: finalTextSize * bigRandomScaleFactor, fill: 0x000000, align: 'center' }); + basicText.position.set(viewport.current?.screenWidth / 2 || 1000, viewport.current?.screenHeight / 2 || 1000); + basicText.scale.set(finalTextSize / (bigRandomScaleFactor * bigRandomScaleFactor)); + basicText.eventMode = 'none'; + viewport.current.addChild(basicText); + return; + } + + columnOrder = d3.range(0, cols.length).map((d) => cols[d].id); + rowOrder = d3.range(0, rows.length).map((d) => rows[d].id); + const reorderingManager = new ReorderingManager(props.graph); + const newOrdering = reorderingManager.reorderMatrix('count', columnOrder, rowOrder); + columnOrder = newOrdering.columnOrder; + rowOrder = newOrdering.rowOrder; + + config.cellWidth = Math.max((size?.width || 1000) / props.graph.nodes.length, (size?.height || 1000) / props.graph.nodes.length); + config.cellHeight = config.cellWidth; + + // console.log('currentColumnOrder', columnOrder); + + setupVisualizationEncodingMapping(props.localConfig); + + setupColumns(props.graph.edges, columnOrder, rowOrder); + setupColumnLegend(columnOrder); + setupColumnInteractivity(cols); + + setupRowLegend(rows, rowOrder); + setupRowInteractivity(cols, rows, rowOrder, d3.scaleOrdinal(d3.schemeCategory10)); + + console.log('setup matrixvis with graph:', props.graph); + + // activate plugins + viewport.current.drag().pinch().wheel().animate({}).decelerate({ friction: 0.75 }); + + app.ticker.add(tick); + update(); + isSetup.current = true; + }; + + const setupVisualizationEncodingMapping = (localConfig: localConfigSchemaType) => { + if (!props.graph) throw new Error('Graph is undefined; cannot setup matrix'); + const visMapping = []; // TODO type + + let colorNeutralString = dataColors.neutral['5']; + const colorNeutral = Color(colorNeutralString); + + // make adjacency + // const adjacenyScale = d3.scaleLinear([dataColors.neutral['5'], tailwindColors.entity.DEFAULT]); + const adjacenyScale = d3.scaleLinear([colorNeutral, tailwindColors.entity.DEFAULT]); + visMapping.push({ + attribute: 'adjacency', + encoding: localConfig.marks, + colorScale: adjacenyScale, + renderFunction: function (i: number, color: ColorSource, gfxContext: Graphics) { + gfxContext.beginFill(color, 1); + + // TODO get + // gfx.beginFill(colorScale(node.type))); + + gfxContext.lineStyle(2, 0xffffff); + if (this.encoding === 'rect') { + gfxContext.drawRect(0, i * config.cellHeight, config.cellWidth, config.cellHeight); + } + if (this.encoding === 'circle') { + gfxContext.drawCircle(config.cellWidth / 2, i * config.cellHeight + config.cellHeight / 2, config.cellWidth / 2); + } + gfxContext.endFill(); + + return gfxContext; + }, + }); + + // make label + // const labels = props.graph.edges.map((edge: Edge) => { + // return edge.label; + // }); + // const tailwindColorsArray = Object.values(tailwindColors.entity).slice(0, labels.length); + // const scale = d3.scaleOrdinal(tailwindColorsArray.reverse()); + // scale.domain(Array.from(labels)); + // console.log('labels', labels, props.graph.edges); + // visMapping.push({ attribute: 'label', encoding: 'rect', colorScale: scale }); + + // make value + // const values = props.graph.edges.map((edge: Edge) => { + // return edge?.attributes?.Distance as number; + // }); + + // const extent = d3.extent(values); + // if (extent[0] === undefined || extent[1] === undefined) throw new Error('Extent is undefined'); + // console.log('labels', values, extent, props.graph.edges); + + // const scale = d3.scaleLinear(['red', 'blue']); + // scale.domain(extent as [number, number]); + // visMapping.push({ attribute: 'attributes.Distance', encoding: 'rect', colorScale: scale }); + + config.visMapping = visMapping; + }; + + function setupColumns(edges: Edge[], columnOrder: string[], rowOrder: string[]) { + const visMapping = config.visMapping[0]; + + if (!visMapping) throw new Error('Cannot setup matrix without visMapping'); + + for (let j = 0; j < columnOrder.length; j++) { + const oneColumn = new Container(); + oneColumn.name = columnOrder[j]; + + const edgesForThisColumn = edges.filter((edge) => { + return edge.from === oneColumn.name || edge.to === oneColumn.name; + }); + // console.log('edgesForThisColumn', oneColumn.name, edgesForThisColumn); + + const col = createColumn(rowOrder, edgesForThisColumn, config.visMapping, config.cellWidth, config.cellHeight); + oneColumn.addChild(col); + oneColumn.position.set(j * config.cellWidth + config.textOffsetX, config.textOffsetY); + + columnsContainer.addChild(oneColumn); + oneColumn.alpha = 0; + Actions.sequence(Actions.delay(j * 0.005 * Math.random()), Actions.fadeIn(oneColumn, 0.8, Interpolations.pow2out)).play(); + } + } + + const setupColumnInteractivity = (cols: Node[]) => { + for (let j = 0; j < columnsContainer.children.length; j++) { + const oneColumn = columnsContainer.children[j]; + oneColumn.interactive = true; + oneColumn.on('pointerdown', onButtonDown); + oneColumn.on('pointerover', (event) => { + const col = cols.find((col) => col.id === oneColumn.name); + if (col) onHoverColumn(event, col); + }); + oneColumn.on('pointerout', (event) => { + onHoverColumn(event, null as any); + }); + } + }; + + const setupColumnLegend = (columnOrder: string[]) => { + // console.log('setupColumnLegend'); + // columnAxisContainer.removeChildren(); + columnAxisContainer.position.set(config.textOffsetX, 0); + for (let j = 0; j < columnOrder.length; j++) { + // from the PixiJS documention: Setting a text object's scale to > 1.0 will result in blurry/pixely display, + // because the text is not re-rendered at the higher resolution needed to look sharp - + // it's still the same resolution it was when generated. To deal with this, you can render at a higher + // initial size and down-scale, instead. + const finalTextSize = 10; + const bigRandomScaleFactor = 10; + const basicText = new Text(columnOrder[j], { fontSize: finalTextSize * bigRandomScaleFactor, fill: 0x000000, align: 'center' }); + basicText.position.set(j * config.cellWidth + config.cellWidth / 4, config.textOffsetY - 5); + basicText.scale.set(finalTextSize / (bigRandomScaleFactor * bigRandomScaleFactor)); + basicText.rotation = (Math.PI * 3) / 2; + basicText.eventMode = 'none'; + basicText.name = 'Text_' + columnOrder[j]; + columnAxisContainer.addChild(basicText); + } + viewport.current?.addChild(columnAxisContainer); + + const columnAxis = new Sprite(Texture.WHITE); + columnAxis.width = columnOrder.length * config.cellWidth; + columnAxis.height = config.textOffsetY; + columnAxis.tint = 0xaaaaaa; + columnAxis.position.set(config.textOffsetX, 0); + columnAxis.alpha = 0.0; + columnAxis.interactive = true; + columnAxis.on('pointerdown', (event) => { + // const reordering = new ReorderColumnsAction(columnOrder); + + if (!props.graph) throw new Error('Graph is undefined; cannot reorder matrix'); + + const reorderingManager = new ReorderingManager(props.graph); + const newOrdering = reorderingManager.reorderMatrix('count', columnOrder, rowOrder); + columnOrder = newOrdering.columnOrder.reverse(); + + reorderColumns(columnOrder); + }); + // columnAxis.on('pointerdown', reorderColumns); + columnAxis.on('pointerover', (event) => { + Actions.fadeTo(columnAxis, 0.5, 0.5, Interpolations.pow2out).play(); + }); + columnAxis.on('pointerout', (event) => { + Actions.fadeTo(columnAxis, 0.0, 0.5, Interpolations.pow2out).play(); + }); + viewport.current?.addChild(columnAxis); + }; + + function shuffle(array: any[]) { + var m = array.length, + t, + i; + + // While there remain elements to shuffle… + while (m) { + // Pick a remaining element… + i = Math.floor(Math.random() * m--); + + // And swap it with the current element. + t = array[m]; + array[m] = array[i]; + array[i] = t; + } + + return array; + } + + function setupRowLegend(rows: Node[], rowOrder: string[]) { + for (let i = 0; i < rowOrder.length; i++) { + const rowID = rowOrder[i]; + const finalTextSize = 10; + const bigRandomScaleFactor = 10; + const basicText = new Text(rowID, { fontSize: finalTextSize * bigRandomScaleFactor, fill: 0x000000, align: 'right' }); + basicText.anchor.x = 1; + basicText.anchor.y = 0; + basicText.scale.set(finalTextSize / (bigRandomScaleFactor * bigRandomScaleFactor)); + basicText.position.set(config.textOffsetX - 5, i * config.cellHeight + config.textOffsetY + config.cellHeight / 4); + basicText.name = rowID; + rowsContainer.addChild(basicText); + + Actions.sequence(Actions.delay(i * 0.005 * Math.random()), Actions.fadeIn(basicText, 0.5, Interpolations.pow2out)).play(); + } + } + + const setupRowInteractivity = (cols: Node[], rows: Node[], rowOrder: string[], colorScale: any) => { + const rowAxis = new Sprite(Texture.WHITE); + rowAxis.width = config.textOffsetX; + rowAxis.height = rows.length * config.cellHeight; + rowAxis.tint = 0xaaaaaa; + rowAxis.position.set(0, config.textOffsetY); + rowAxis.alpha = 0.0; + rowAxis.interactive = true; + rowAxis.on('pointerdown', (event) => { + if (props.graph) { + rowOrder = shuffle(rowOrder); + reorderRows(cols, rows, props?.graph.edges, rowOrder, colorScale, config.cellWidth, config.cellHeight); + } + }); + rowAxis.on('pointerover', (event) => { + Actions.fadeTo(rowAxis, 0.5, 0.5, Interpolations.pow2out).play(); + }); + rowAxis.on('pointerout', (event) => { + Actions.fadeTo(rowAxis, 0.0, 0.5, Interpolations.pow2out).play(); + }); + viewport.current?.addChild(rowAxis); + }; + + const tick = (delta: number) => { + if (props.graph) { + Actions.tick(delta / 60); + } + }; + + return ( + <> + {popups.map((popup) => ( + <NLPopup onClose={() => {}} data={popup} key={popup.node.id} /> + ))} + {quickPopup && <NLPopup onClose={() => {}} data={quickPopup} />} + <div className="h-full w-full overflow-hidden" ref={ref}></div> + </> + ); +}; diff --git a/libs/shared/lib/vis/visualizations/matrix/components/MatrixPopup.tsx b/libs/shared/lib/vis/visualizations/matrix/components/MatrixPopup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cab6d5b29d129197bd484c404aeb85679a742b56 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/components/MatrixPopup.tsx @@ -0,0 +1,46 @@ +import { IPointData } from 'pixi.js'; +import { NodeType } from '../Types'; + +export type NodelinkPopupProps = { + data: { node: NodeType; pos: IPointData }; + onClose: () => void; +}; + +export const NLPopup = (props: NodelinkPopupProps) => { + const node = props.data.node; + + return ( + <div + className="absolute card card-bordered z-50 bg-white rounded-none text-[0.9rem] min-w-[10rem]" + // style={{ top: props.data.pos.y + 10, left: props.data.pos.x + 10 }} + style={{ transform: 'translate(' + (props.data.pos.x + 20) + 'px, ' + (props.data.pos.y + 10) + 'px)' }} + > + <div className="card-body p-0"> + <span className="px-2.5 pt-2"> + <span>Node</span> + <span className="float-right">{node.id}</span> + </span> + <div className="h-[1px] w-full bg-offwhite-300"></div> + <div className="px-2.5 text-[0.8rem]"> + {node.attributes && + Object.entries(node.attributes).map(([k, v], i) => { + return ( + <div key={k} className="flex flex-row gap-3"> + <span className="">{k}: </span> + <span className="ml-auto max-w-[10rem] text-right truncate"> + <span title={JSON.stringify(v)}>{JSON.stringify(v)}</span> + </span> + </div> + ); + })} + {node.cluster && ( + <p> + Cluster: <span className="float-right">{node.cluster}</span> + </p> + )} + </div> + <div className="h-[1px] w-full"></div> + </div> + </div> + ); +}; diff --git a/libs/shared/lib/vis/visualizations/matrix/components/ReorderingManager.tsx b/libs/shared/lib/vis/visualizations/matrix/components/ReorderingManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ae69d44e285be9e15fd93bb3cdaf49e9dd4f5398 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/components/ReorderingManager.tsx @@ -0,0 +1,227 @@ +import { GraphQueryResult } from '@graphpolaris/shared/lib/data-access'; +import { toPlainObject } from 'lodash-es'; +import * as reorder from 'reorder.js'; + +export class ReorderingManager { + private leafOrder = reorder.optimal_leaf_order().distance(reorder.distance.manhattan); + graph: GraphQueryResult; + + constructor(graph: GraphQueryResult) { + this.graph = graph; + } + + private computeNameOrder = (columnOrder: string[], rowOrder: string[]) => { + const columnOrderSorted = columnOrder.sort((a, b) => a.localeCompare(b)); + const rowOrderSorted = rowOrder.sort((a, b) => a.localeCompare(b)); + + return { columnOrder: columnOrderSorted, rowOrder: rowOrderSorted }; + }; + + private computeCountOrder = (columnOrder: string[], rowOrder: string[]) => { + let columnOrderSorted: { id: string; count: number }[] = []; + let rowOrderSorted: { id: string; count: number }[] = []; + + for (let i = 0; i < columnOrder.length; i++) { + const oneColumn = columnOrder[i]; + const edgesForThisColumn = this.graph.edges.filter((edge) => { + return edge.from === oneColumn || edge.to === oneColumn; + }); + + columnOrderSorted.push({ id: oneColumn, count: edgesForThisColumn.length }); + } + + for (let j = 0; j < rowOrder.length; j++) { + const oneRow = rowOrder[j]; + const edgesForThisRow = this.graph.edges.filter((edge) => { + return edge.from === oneRow || edge.to === oneRow; + }); + + rowOrderSorted.push({ + id: oneRow, + count: edgesForThisRow.length, + }); + } + + columnOrderSorted = columnOrderSorted.sort((a, b) => b.count - a.count); + rowOrderSorted = rowOrderSorted.sort((a, b) => b.count - a.count); + + return { columnOrder: columnOrderSorted.map((a) => a.id), rowOrder: rowOrderSorted.map((a) => a.id) }; + }; + + private computeLeaforder = (columnOrder: string[], rowOrder: string[]) => { + const adjacency: number[][] = this.constructAdjacencyMatrix(this.graph, columnOrder, rowOrder); + + const order = this.leafOrder.reorder(adjacency).map((i) => columnOrder[i]); + + console.debug('computed leaforder', order); + + return { columnOrder: order, rowOrder: rowOrder }; + }; + + private constructAdjacencyMatrix(graph: GraphQueryResult, columnOrder: string[], rowOrder: string[]): number[][] { + const adjacency: number[][] = []; + for (let i = 0; i < columnOrder.length; i++) { + const oneColumn = columnOrder[i]; + const edgesForThisColumn = graph.edges.filter((edge) => { + return edge.from === oneColumn || edge.to === oneColumn; + }); + + const rowOrderIDs = []; + for (let j = 0; j < rowOrder.length; j++) { + const oneRow = rowOrder[j]; + if ( + edgesForThisColumn.filter((edge) => { + return edge.from === oneRow || edge.to === oneRow; + }).length > 0 + ) { + rowOrderIDs.push(j); + } + } + adjacency.push(rowOrderIDs); + } + + return adjacency; + } + + private computeBarycenter = (columnOrder: string[], rowOrder: string[]) => { + const reorderAny = reorder as any; + + const reorderGraph = this.getReorderJSGraph(this.graph, columnOrder, rowOrder); + const barycenter = reorderAny.barycenter_order(reorderGraph); + + let improved = reorderAny.adjacent_exchange(reorderGraph, barycenter[0], barycenter[1]); + + const columnOrderSorted = improved[0].map((i: number) => columnOrder[i]).filter((a: string) => columnOrder.includes(a)); + const rowOrderSorted = improved[0].map((i: number) => rowOrder[i]).filter((a: string) => rowOrder.includes(a)); + + console.debug('improved barycenter', improved, columnOrderSorted, rowOrderSorted); + + return { columnOrder: columnOrderSorted, rowOrder: rowOrderSorted }; + }; + + private getReorderJSGraph = (graph: GraphQueryResult, columnOrder: string[], rowOrder: string[]) => { + const reorderAny = reorder as any; + + // console.log('getReorderJSGraph', graph, columnOrder, rowOrder); + + const nodesTemp = graph.nodes.map( + (node) => + ({ + ...node, + index: 0, + weight: 0, + } as any) + ); + + const nodes = columnOrder.map((id) => nodesTemp.find((node) => node.id === id)); + const graphReorderJs = reorderAny.graph().nodes(nodes); + + const edges = graph.edges.map((edge) => { + const source = nodes.find((node) => node.id === edge.from); + const target = nodes.find((node) => node.id === edge.to); + + if (!source || !target) { + return new Error('Source or target not found from edge list'); + } + return { + ...edge, + source, + target, + } as any; + }); + + graphReorderJs.links(edges); + graphReorderJs.init(); + + return graphReorderJs; + }; + + private computeRCM = (columnOrder: string[], rowOrder: string[]) => { + const reorderAny = reorder as any; + const reorderGraph = this.getReorderJSGraph(this.graph, columnOrder, rowOrder); + + const order = reorderAny.reverse_cuthill_mckee_order(reorderGraph); + const columnOrderSorted = order.map((i: number) => columnOrder[i]).filter((a: string) => columnOrder.includes(a)); + const rowOrderSorted = order.map((i: number) => rowOrder[i]).filter((a: string) => rowOrder.includes(a)); + + console.debug('improved rcm', order, columnOrderSorted, rowOrderSorted); + + return { columnOrder: columnOrderSorted, rowOrder: rowOrderSorted }; + }; + + private computeSpectal = (columnOrder: string[], rowOrder: string[]) => { + const reorderAny = reorder as any; + const reorderGraph = this.getReorderJSGraph(this.graph, columnOrder, rowOrder); + + var order = reorderAny.spectral_order(reorderGraph); + const columnOrderSorted = order.map((i: number) => columnOrder[i]).filter((a: string) => columnOrder.includes(a)); + const rowOrderSorted = order.map((i: number) => rowOrder[i]).filter((a: string) => rowOrder.includes(a)); + + console.debug('improved spectral', order, columnOrderSorted, rowOrderSorted); + + return { columnOrder: columnOrderSorted, rowOrder: rowOrderSorted }; + }; + + private computeBFS = (columnOrder: string[], rowOrder: string[]) => { + const reorderAny = reorder as any; + const reorderGraph = this.getReorderJSGraph(this.graph, columnOrder, rowOrder); + + var order = reorderAny.bfs_order(reorderGraph); + + const columnOrderSorted = order.map((i: number) => columnOrder[i]).filter((a: string) => columnOrder.includes(a)); + const rowOrderSorted = order.map((i: number) => rowOrder[i]).filter((a: string) => rowOrder.includes(a)); + + console.debug('improved bfs', order, columnOrderSorted, rowOrderSorted); + + return { columnOrder: columnOrderSorted, rowOrder: rowOrderSorted }; + }; + + private computePCA = (columnOrder: string[], rowOrder: string[]) => { + const reorderAny = reorder as any; + + const columnOrderUnSorted = columnOrder.map((id: string) => this.graph.nodes.find((node) => node.id === id)); + + const order = reorderAny.pca_order(columnOrderUnSorted); + const columnOrderSorted = order.map((i: number) => columnOrder[i]); + + console.debug('improved pca', order, columnOrderSorted, rowOrder); + + return { columnOrder: columnOrderSorted, rowOrder: rowOrder }; + }; + + public reorderMatrix = (orderingname = 'leafordering', columnOrder: string[], rowOrder: string[]) => { + console.log('reorderMatrix', orderingname); + switch (orderingname.toLowerCase()) { + case 'leafordering': { + return this.computeLeaforder(columnOrder, rowOrder); + } + case 'name': { + return this.computeNameOrder(columnOrder, rowOrder); + } + case 'count': { + return this.computeCountOrder(columnOrder, rowOrder); + } + case 'barycenter': { + return this.computeBarycenter(columnOrder, rowOrder); + } + case 'rcm': { + return this.computeRCM(columnOrder, rowOrder); + } + case 'spectral': { + return this.computeSpectal(columnOrder, rowOrder); + } + // case 'pca': { + // return this.computePCA(columnOrder, rowOrder); + // } + // case 'bfs': { + // return this.computeBFS(columnOrder, rowOrder); + // } + case 'none' || 'identity': { + return { columnOrder, rowOrder }; + } + default: { + return this.computeLeaforder(columnOrder, rowOrder); + } + } + }; +} diff --git a/libs/shared/lib/vis/visualizations/matrix/matrix.stories.tsx b/libs/shared/lib/vis/visualizations/matrix/matrix.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea5977146249d3f88051f58c14083c0d51fcd192 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/matrix.stories.tsx @@ -0,0 +1,130 @@ +import { Meta } from '@storybook/react'; + +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { + big2ndChamberQueryResult, + bigMockQueryResults, + big2ndChamberSchemaRaw, + smallFlightsQueryResults, + mockLargeQueryResults, +} from '../../../mock-data'; + +import { VisualizationPanel } from '../../panel/visualization'; +import { + assignNewGraphQueryResult, + graphQueryResultSlice, + querybuilderSlice, + schemaSlice, + setSchema, + visualizationSlice, +} from '../../../data-access/store'; + +import { SchemaUtils } from '../../../schema/schema-utils'; +import { simpleSchemaAirportRaw } from '../../../mock-data/schema/simpleAirportRaw'; +import { Visualizations, setActiveVisualization } from '@graphpolaris/shared/lib/data-access/store/visualizationSlice'; + +const Component: Meta<typeof VisualizationPanel> = { + title: 'Visualizations/MatrixVis', + component: VisualizationPanel, + decorators: [ + (story) => ( + <Provider store={Mockstore}> + <div + style={{ + width: '100%', + height: '100vh', + }} + > + {story()} + </div> + </Provider> + ), + ], +}; + +const Mockstore: any = configureStore({ + reducer: { + schema: schemaSlice.reducer, + graphQueryResult: graphQueryResultSlice.reducer, + visualize: visualizationSlice.reducer, + querybuilder: querybuilderSlice.reducer, + }, +}); + +export const TestWithData = { + layout: 'fullscreen', + args: { + loading: false, + }, + play: async () => { + const dispatch = Mockstore.dispatch; + const schema = SchemaUtils.schemaBackend2Graphology(simpleSchemaAirportRaw); + dispatch(setSchema(schema.export())); + dispatch( + assignNewGraphQueryResult({ + queryID: '1', + result: { + type: 'nodelink', + payload: { + nodes: [ + { id: 'agent/007', attributes: { name: 'Daniel Craig' } }, + { id: 'villain', attributes: { name: 'Le Chiffre' } }, + ], + edges: [{ id: 'escape/escape', from: 'agent/007', to: 'villain', attributes: { name: 'Escape' } }], + }, + }, + }) + ); + dispatch(setActiveVisualization(Visualizations.Matrix)); + }, +}; + +export const TestWithNoData = { + args: { loading: false }, + play: async () => { + const dispatch = Mockstore.dispatch; + dispatch( + assignNewGraphQueryResult({ + queryID: '1', + result: { + type: 'nodelink', + payload: { + nodes: [], + edges: [], + }, + }, + }) + ); + dispatch(setActiveVisualization(Visualizations.Matrix)); + }, +}; + +export const TestWithBig2ndChamber = { + args: { loading: false }, + play: async () => { + const dispatch = Mockstore.dispatch; + dispatch(assignNewGraphQueryResult({ queryID: '1', result: { type: 'nodelink', payload: big2ndChamberQueryResult } })); + dispatch(setActiveVisualization(Visualizations.Matrix)); + }, +}; + +export const TestWithSmallFlights = { + args: { loading: false }, + play: async () => { + const dispatch = Mockstore.dispatch; + dispatch(assignNewGraphQueryResult({ queryID: '1', result: { type: 'nodelink', payload: smallFlightsQueryResults } })); + dispatch(setActiveVisualization(Visualizations.Matrix)); + }, +}; + +export const TestWithLargeQueryResult = { + args: { loading: false }, + play: async () => { + const dispatch = Mockstore.dispatch; + dispatch(assignNewGraphQueryResult({ queryID: '1', result: { type: 'nodelink', payload: mockLargeQueryResults } })); + dispatch(setActiveVisualization(Visualizations.Matrix)); + }, +}; + +export default Component; diff --git a/libs/shared/lib/vis/visualizations/matrix/matrixvis.module.scss b/libs/shared/lib/vis/visualizations/matrix/matrixvis.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..915342c14ebecc0e044334036536df75fc681f73 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/matrixvis.module.scss @@ -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) + */ + +/* 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 NodeLinkConfigPanel +.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: 10px; + } + .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; + } + } +} + +#Slider { + width: 80%; + margin-left: 20px; +} diff --git a/libs/shared/lib/vis/visualizations/matrix/matrixvis.tsx b/libs/shared/lib/vis/visualizations/matrix/matrixvis.tsx new file mode 100644 index 0000000000000000000000000000000000000000..689a996ffc9ac2457ac5661fa88c09d27126f5f9 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/matrix/matrixvis.tsx @@ -0,0 +1,51 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useImmer } from 'use-immer'; +import { GraphQueryResult, useGraphQueryResult } from '../../../data-access/store'; +import { ML } from '../../../data-access/store/mlSlice'; +import { LinkType, NodeType } from './Types'; +import { MatrixPixi } from './components/MatrixPixi'; +import { VisualizationPropTypes, VISComponentType, localConfigSchemaType } from '../../Types'; + +interface Props { + loading?: boolean; + // currentColours: any; +} + +// const displayName = 'Matrix Visualization'; + +export const MatrixVis = React.memo(({ data, ml, dispatch, localConfig }: VisualizationPropTypes<typeof displayName>) => { + const ref = useRef<HTMLDivElement>(null); + const [graph, setGraph] = useImmer<GraphQueryResult | undefined>(undefined); + const [highlightNodes, setHighlightNodes] = useState<NodeType[]>([]); + const [highlightedLinks, setHighlightedLinks] = useState<LinkType[]>([]); + + useEffect(() => { + if (data) { + setGraph(data); + } + }, [data, ml]); + + return ( + <> + <div className="h-full w-full overflow-hidden" ref={ref}> + <MatrixPixi graph={graph} highlightNodes={highlightNodes} highlightedLinks={highlightedLinks} localConfig={localConfig} /> + </div> + </> + ); +}); + +const displayName = 'MatrixVis'; +const localConfigSchema: localConfigSchemaType = { + marks: { + type: 'dropdown', + options: ['rect', 'circle'], + value: 'rect', + label: 'Configure Marks', + }, +}; + +export const MatrixVisComponent: VISComponentType = { + displayName: displayName, + VIS: MatrixVis, + localConfigSchema: localConfigSchema, +}; diff --git a/libs/shared/package.json b/libs/shared/package.json index f654e515f26e5652a13126c8e6c4f238acf217dd..13eebf5a69bc7be78bf9f8ea983e662fa716515c 100644 --- a/libs/shared/package.json +++ b/libs/shared/package.json @@ -51,6 +51,7 @@ "keycloak-js": "^21.1.1", "lodash-es": "^4.17.21", "pixi-viewport": "^5.0.2", + "pixi-actions": "^1.1.10", "pixi.js": "^7.1.4", "react-color": "^2.19.3", "react-cookie": "^4.1.1", @@ -69,7 +70,9 @@ "tslib": "^2.5.0", "typed": "link:@deck.gl/core/typed", "use-immer": "^0.9.0", - "web-worker": "^1.2.0" + "web-worker": "^1.2.0", + "reorder.js": "^2.2.6", + "color-string": "^1.9.1" }, "devDependencies": { "@iconify/json": "^2.2.95", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c584b0f6fd86b4cd64003b6f039bb050330e62ee..b5d2313547e6785b6f3ec6823529b2da25daf75c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,9 @@ importers: color: specifier: ^4.2.3 version: 4.2.3 + color-string: + specifier: ^1.9.1 + version: 1.9.1 config: specifier: workspace:* version: link:../config @@ -310,6 +313,9 @@ importers: lodash-es: specifier: ^4.17.21 version: 4.17.21 + pixi-actions: + specifier: ^1.1.10 + version: 1.1.10(pixi.js@7.2.1) pixi-viewport: specifier: ^5.0.2 version: 5.0.2 @@ -346,6 +352,9 @@ importers: regenerator-runtime: specifier: 0.13.11 version: 0.13.11 + reorder.js: + specifier: ^2.2.6 + version: 2.2.6 sass: specifier: ^1.59.3 version: 1.59.3 @@ -5962,6 +5971,10 @@ packages: string-argv: 0.3.1 dev: true + /@sgratzl/science@2.0.0: + resolution: {integrity: sha512-LO3gArm8rVczcksg35xnZbOLEOE091VjyvvzR8mnPjP5K5+xqZEWhBi+lDcEk50dAlfPBoBHfwmu+MmiiSpXHw==} + dev: false + /@sinclair/typebox@0.25.24: resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} dev: true @@ -16172,6 +16185,14 @@ packages: engines: {node: '>= 6'} dev: true + /pixi-actions@1.1.10(pixi.js@7.2.1): + resolution: {integrity: sha512-GLAcgMY8qWh9V/cVXbHEbnpxSb9hJRFYGOfjQyiEdJRUBVm4ZQ6j5mGnKTOV0DxFozpkdqw+WjNXcAKKdWeu8w==} + peerDependencies: + pixi.js: ^7.0.0 + dependencies: + pixi.js: 7.2.1(@pixi/utils@7.2.1) + dev: false + /pixi-viewport@5.0.2: resolution: {integrity: sha512-U77KnCTl81xEgxEQRFEuI7MYVySWwCVkA41EnM8KiOYwgVOwdBUa7318O+u61IOnTwnoYLzaihy/kpoONKU13Q==} dev: false @@ -16987,6 +17008,7 @@ packages: react: 18.2.0 scheduler: 0.19.1 dev: false + bundledDependencies: false /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} @@ -17875,6 +17897,12 @@ packages: strip-ansi: 3.0.1 dev: false + /reorder.js@2.2.6: + resolution: {integrity: sha512-mE0Vffgm6Bf2REDUCF5eNbYl0FAzuTzhAneokLEcnhWcamdwz9TwGLySrgYQDubqgr4bM85hRAcGG0q3kqCwCw==} + dependencies: + '@sgratzl/science': 2.0.0 + dev: false + /request@2.88.2: resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} engines: {node: '>= 6'}