From 2097231d2de648f8b57e087d9ac7f43c749cb6ab Mon Sep 17 00:00:00 2001 From: Marcos Pieras <pieras.marcos@gmail.com> Date: Wed, 1 Nov 2023 15:31:47 +0000 Subject: [PATCH] #DAV-feat(0D_vis_table_barplot): add table barplot solves #DEV-209 #DEV-225 --- .husky/pre-commit | 2 +- .husky/pre-commit-lint | 4 - Makefile | 4 +- apps/web/src/app/panels/Visualization.tsx | 10 +- apps/web/src/components/navbar/navbar.tsx | 3 + .../store/graphQueryResultSlice.ts | 10 +- .../data-access/store/visualizationSlice.ts | 1 + ...erybuilder-simple-disconnected.stories.tsx | 105 --------- .../stories/querybuilder-simple.stories.tsx | 22 +- libs/shared/lib/schema/model/graphology.ts | 2 +- .../schema/schema-utils/schema-usecases.ts | 7 +- libs/shared/lib/vis/index.ts | 1 + .../components/Pagination.tsx | 53 ----- .../components/Table.tsx | 121 ---------- .../simple_table_pagination/simpleTable.tsx | 25 --- .../lib/vis/table_vis/components/BarPlot.tsx | 150 +++++++++++++ .../vis/table_vis/components/Pagination.tsx | 54 +++++ .../lib/vis/table_vis/components/Table.tsx | 208 ++++++++++++++++++ libs/shared/lib/vis/table_vis/tableVis.tsx | 40 ++++ .../tablevis.stories.tsx} | 6 +- package.json | 2 +- 21 files changed, 491 insertions(+), 339 deletions(-) delete mode 100755 .husky/pre-commit-lint delete mode 100644 libs/shared/lib/querybuilder/panel/stories/querybuilder-simple-disconnected.stories.tsx delete mode 100644 libs/shared/lib/vis/simple_table_pagination/components/Pagination.tsx delete mode 100644 libs/shared/lib/vis/simple_table_pagination/components/Table.tsx delete mode 100644 libs/shared/lib/vis/simple_table_pagination/simpleTable.tsx create mode 100644 libs/shared/lib/vis/table_vis/components/BarPlot.tsx create mode 100644 libs/shared/lib/vis/table_vis/components/Pagination.tsx create mode 100644 libs/shared/lib/vis/table_vis/components/Table.tsx create mode 100644 libs/shared/lib/vis/table_vis/tableVis.tsx rename libs/shared/lib/vis/{simple_table_pagination/simpleTablevis.stories.tsx => table_vis/tablevis.stories.tsx} (91%) diff --git a/.husky/pre-commit b/.husky/pre-commit index af0cff7ed..aaefaffe8 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -pnpm test +pnpm push diff --git a/.husky/pre-commit-lint b/.husky/pre-commit-lint deleted file mode 100755 index 58993aaee..000000000 --- a/.husky/pre-commit-lint +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -pnpm lint diff --git a/Makefile b/Makefile index 6a5fee121..9d40cf028 100644 --- a/Makefile +++ b/Makefile @@ -17,9 +17,7 @@ run: brun: build run push: - @pnpm lint - @pnpm test - @pnpm build + @pnpm push clean: rm -rf pnpm-lock.yaml diff --git a/apps/web/src/app/panels/Visualization.tsx b/apps/web/src/app/panels/Visualization.tsx index 13776d755..295a74012 100644 --- a/apps/web/src/app/panels/Visualization.tsx +++ b/apps/web/src/app/panels/Visualization.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { RawJSONVis, NodeLinkVis, PaohVis } from '@graphpolaris/shared/lib/vis'; +import { RawJSONVis, NodeLinkVis, PaohVis, TableVis } from '@graphpolaris/shared/lib/vis'; import { useGraphQueryResult, useQuerybuilderGraph, useVisualizationState } from '@graphpolaris/shared/lib/data-access'; import { Visualizations } from '@graphpolaris/shared/lib/data-access/store/visualizationSlice'; import { LoadingSpinner } from '@graphpolaris/shared/lib/components/LoadingSpinner'; @@ -29,10 +29,16 @@ export const VisualizationPanel = () => { <PaohVis rowHeight={30} hyperedgeColumnWidth={30} gapBetweenRanges={3} /> </div> ); + case Visualizations.Table: + return ( + <div id={Visualizations.Table} className="tabContent w-full h-full"> + <TableVis showBarplot={true} /> + </div> + ); default: return null; } - }, [graphQueryResult]); + }, [graphQueryResult, vis.activeVisualization]); return ( <div className="vis-panel h-full w-full overflow-y-auto" style={graphQueryResult.nodes.length === 0 ? { overflow: 'hidden' } : {}}> diff --git a/apps/web/src/components/navbar/navbar.tsx b/apps/web/src/components/navbar/navbar.tsx index ff7864539..44a609b54 100644 --- a/apps/web/src/components/navbar/navbar.tsx +++ b/apps/web/src/components/navbar/navbar.tsx @@ -85,6 +85,9 @@ export const Navbar = (props: NavbarComponentProps) => { Vis </label> <ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> + <li onClick={() => dispatch(setActiveVisualization(Visualizations.Table))}> + <a>Table</a> + </li> <li onClick={() => dispatch(setActiveVisualization(Visualizations.NodeLink))}> <a>Node Link</a> </li> diff --git a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts index 773e5891d..be7e1efb8 100755 --- a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts +++ b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts @@ -9,17 +9,19 @@ export interface GraphQueryResultFromBackendPayload { }; } +export type NodeAttributes = { [key: string]: unknown } + export interface GraphQueryResultFromBackend { nodes: { id: string; label?: string; - attributes: { [key: string]: unknown }; + attributes: NodeAttributes; }[]; edges: { id: string; label?: string; - attributes: { [key: string]: unknown }; + attributes: NodeAttributes; from: string; to: string; }[]; @@ -31,12 +33,12 @@ export interface GraphQueryResultFromBackend { export interface Node { id: string; label: string; - attributes: { [key: string]: unknown }; + attributes: NodeAttributes; mldata?: any; // FIXME /* type: string[]; */ } export interface Edge { - attributes: { [key: string]: unknown }; + attributes: NodeAttributes; from: string; to: string; id: string; diff --git a/libs/shared/lib/data-access/store/visualizationSlice.ts b/libs/shared/lib/data-access/store/visualizationSlice.ts index 367ceccea..b7415b457 100644 --- a/libs/shared/lib/data-access/store/visualizationSlice.ts +++ b/libs/shared/lib/data-access/store/visualizationSlice.ts @@ -5,6 +5,7 @@ export enum Visualizations { NodeLink = 'NodeLink', Paohvis = 'Paohvis', RawJSON = 'RawJSON', + Table = 'Table', } type VisState = { diff --git a/libs/shared/lib/querybuilder/panel/stories/querybuilder-simple-disconnected.stories.tsx b/libs/shared/lib/querybuilder/panel/stories/querybuilder-simple-disconnected.stories.tsx deleted file mode 100644 index df2bc62e8..000000000 --- a/libs/shared/lib/querybuilder/panel/stories/querybuilder-simple-disconnected.stories.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import { querybuilderSlice, setQuerybuilderNodes, setSchema, store } from '@graphpolaris/shared/lib/data-access/store'; - -import { configureStore } from '@reduxjs/toolkit'; -import { Meta, StoryObj } from '@storybook/react'; -import { Provider } from 'react-redux'; -import QueryBuilderInner from '../querybuilder'; -import { Handles, NodeAttribute, QueryElementTypes, QueryMultiGraphology, toHandleId } from '../../model'; -import { SchemaUtils } from '../../../schema/schema-utils'; -import { ReactFlowProvider } from 'reactflow'; - -const Component: Meta<typeof QueryBuilderInner> = { - component: QueryBuilderInner, - title: 'QueryBuilder/Panel/SimpleDisconnected', - decorators: [(story) => <div>{story()}</div>], -}; - -export const SimpleDisconnected: StoryObj = { - args: { - nodes: [ - { - name: 'entity', - attributes: [ - { name: 'city', type: 'string' }, - { name: 'vip', type: 'bool' }, - { name: 'state', type: 'string' }, - ], - }, - ], - edges: [ - { - name: 'entity:entity', - from: 'entity', - to: 'entity', - collection: 'entity2entity', - attributes: [ - { name: 'arrivalTime', type: 'int' }, - { name: 'departureTime', type: 'int' }, - ], - }, - ], - }, - decorators: [ - (story: any, { args }: any) => { - console.log(args); - - const graph = new QueryMultiGraphology(); - const schema = SchemaUtils.schemaBackend2Graphology({ - nodes: args.nodes, - edges: args.edges, - }); - - store.dispatch(setSchema(schema.export())); - - const entity1 = graph.addPill2Graphology( - { - id: '0', - type: QueryElementTypes.Entity, - x: 100, - y: 100, - name: 'Airport 1', - }, - schema.getNodeAttribute('entity', 'attributes') - ); - const entity2 = graph.addPill2Graphology( - { - id: '10', - type: QueryElementTypes.Entity, - x: 200, - y: 200, - name: 'Airport 2', - }, - schema.getNodeAttribute('entity', 'attributes') - ); - - // graph.addNode('0', { type: QueryElementTypes.Entity, x: 100, y: 100, name: 'Entity Pill' }); - const relation1 = graph.addPill2Graphology({ - id: '1', - type: QueryElementTypes.Relation, - x: 140, - y: 140, - name: 'Flight between airports', - collection: 'Relation Pill', - depth: { min: 0, max: 1 }, - attributes: schema.getEdgeAttribute('entity:entity_entityentity', 'attributes'), - }); - store.dispatch(setQuerybuilderNodes(graph.export())); - - return ( - <Provider store={store}> - <div - style={{ - width: '100%', - height: '95vh', - }} - > - <ReactFlowProvider>{story()}</ReactFlowProvider> - </div> - </Provider> - ); - }, - ], -}; - -export default Component; diff --git a/libs/shared/lib/querybuilder/panel/stories/querybuilder-simple.stories.tsx b/libs/shared/lib/querybuilder/panel/stories/querybuilder-simple.stories.tsx index 68c0849ba..ed0939b84 100644 --- a/libs/shared/lib/querybuilder/panel/stories/querybuilder-simple.stories.tsx +++ b/libs/shared/lib/querybuilder/panel/stories/querybuilder-simple.stories.tsx @@ -81,16 +81,18 @@ export const Simple = { ); // graph.addNode('0', { type: QueryElementTypes.Entity, x: 100, y: 100, name: 'Entity Pill' }); - const relation1 = graph.addPill2Graphology({ - id: '1', - type: QueryElementTypes.Relation, - x: 140, - y: 140, - name: 'Flight between airports', - collection: 'Relation Pill', - depth: { min: 0, max: 1 }, - attributes: schema.getEdgeAttribute('entity:entity_entityentity', 'attributes'), - }); + const relation1 = graph.addPill2Graphology( + { + id: '1', + type: QueryElementTypes.Relation, + x: 140, + y: 140, + name: 'Flight between airports', + collection: 'Relation Pill', + depth: { min: 0, max: 1 }, + }, + schema.getEdgeAttribute('entity:entity_entityentity', 'attributes') + ); // addPill2Graphology( // '2', // { diff --git a/libs/shared/lib/schema/model/graphology.ts b/libs/shared/lib/schema/model/graphology.ts index 773ff2b98..5e107237c 100644 --- a/libs/shared/lib/schema/model/graphology.ts +++ b/libs/shared/lib/schema/model/graphology.ts @@ -1,6 +1,6 @@ import { MultiGraph } from 'graphology'; import { Attributes as GAttributes, NodeEntry, EdgeEntry, SerializedGraph } from 'graphology-types'; -import { SchemaAttribute, SchemaNode } from './FromBackend'; +import { SchemaAttribute, SchemaNode, SchemaEdge } from './FromBackend'; /** Attribute type, consist of a name */ export type SchemaGraphologyNode = GAttributes & SchemaNode; diff --git a/libs/shared/lib/schema/schema-utils/schema-usecases.ts b/libs/shared/lib/schema/schema-utils/schema-usecases.ts index fcd643a90..abbe835f0 100644 --- a/libs/shared/lib/schema/schema-utils/schema-usecases.ts +++ b/libs/shared/lib/schema/schema-utils/schema-usecases.ts @@ -22,14 +22,9 @@ export function schemaExpandRelation(graph: SchemaGraphology): SchemaGraphology const newID = 'RelationNode:' + edge; // console.log('making relationnode', edge, attributes, source, target, newID); newGraph.addNode(newID, { + ...attributes, name: edge, label: edge, - // data: { - // label: edge, - // name: edge, - // attributes: attributes - // }, - ...attributes, attributes: [], x: 0, y: 0, diff --git a/libs/shared/lib/vis/index.ts b/libs/shared/lib/vis/index.ts index 1d8457e76..29b87b7d3 100644 --- a/libs/shared/lib/vis/index.ts +++ b/libs/shared/lib/vis/index.ts @@ -2,4 +2,5 @@ export * from './rawjsonvis'; export * from './nodelink/nodelinkvis'; export * from './paohvis/paohvis'; export * from './semanticsubstrates/semanticsubstrates'; +export * from './table_vis/tableVis'; // export * from './geovis/NodeLinkMap'; diff --git a/libs/shared/lib/vis/simple_table_pagination/components/Pagination.tsx b/libs/shared/lib/vis/simple_table_pagination/components/Pagination.tsx deleted file mode 100644 index 796461dfc..000000000 --- a/libs/shared/lib/vis/simple_table_pagination/components/Pagination.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React , { useRef } from 'react'; -import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; -import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; - -interface PaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - itemsPerPageInput: number; - numItemsArrayReal: number; - totalItems : number; -} - -const Pagination: React.FC<PaginationProps> = ({ currentPage, - totalPages, - onPageChange, - itemsPerPageInput, - numItemsArrayReal, - totalItems }) => { - - const pageNumbers = Array.from({ length: totalPages }, (_, index) => index + 1); - - const firstItem = (currentPage - 1) * itemsPerPageInput + 1; - const lastItem = Math.min(currentPage * itemsPerPageInput, totalPages); - - const goToPreviousPage = () => { - if (currentPage > 1) { - onPageChange(currentPage - 1); - } - }; - - const goToNextPage = () => { - if (currentPage < totalPages) { - onPageChange(currentPage + 1); - } - }; - - return ( - <div className="table-pagination"> - <span className="inline-block m-2 max-w-32 min-w-32"> - {`${firstItem}-${numItemsArrayReal} of ${totalItems}`} - </span> - <button className = "m-1 border-4 border-solid border-neutral p-2 w-11 text-primary" onClick={goToPreviousPage} disabled={currentPage === 1}> - <ArrowBackIosIcon /> - </button> - <button className = "m-1 border-4 border-solid border-neutral p-2 w-11 text-primary" onClick={goToNextPage} disabled={currentPage === totalPages}> - <ArrowForwardIosIcon /> - </button> - </div> - ); -}; - -export default Pagination; \ No newline at end of file diff --git a/libs/shared/lib/vis/simple_table_pagination/components/Table.tsx b/libs/shared/lib/vis/simple_table_pagination/components/Table.tsx deleted file mode 100644 index ad6b7a0ed..000000000 --- a/libs/shared/lib/vis/simple_table_pagination/components/Table.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import Pagination from './Pagination'; - -interface TableProps { - data: any[]; - itemsPerPage: number; -} - -const Table: React.FC<TableProps> = ({ data, itemsPerPage }) => { - - const originalData = [...data]; - - const [sortedData, setSortedData] = useState<any[]>(data); - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); - const [sortColumn, setSortColumn] = useState<string | null>(null); - const [currentPage, setCurrentPage] = useState<number>(1); - - const keys = Object.keys(data[0]); - const totalPages = Math.ceil(sortedData.length / itemsPerPage); - - useEffect(() => { - if (sortColumn !== null) { - const sorted = [...data].sort((a, b) => { - if (sortOrder === 'asc') { - return a[sortColumn] < b[sortColumn] ? -1 : 1; - } else { - return a[sortColumn] > b[sortColumn] ? -1 : 1; - } - }); - setSortedData(sorted); - } - }, [sortOrder, data, sortColumn]); - - useEffect(() => { - setCurrentPage(1); // Reset to the first page when sorting or itemsPerPage changes - }, [sortColumn, sortOrder, itemsPerPage]); - - const onPageChange = (page: number) => { - setCurrentPage(page); - }; - - const toggleSort = (column: string) => { - if (sortColumn === column) { - if (sortOrder === 'asc') { - setSortOrder('desc'); - setSortedData([...sortedData].reverse()); // Reverse the sorted data - } else if (sortOrder === 'desc') { - setSortColumn(null); - setSortOrder('asc'); - setSortedData(originalData); // Reset to the original order - } - } else { - setSortColumn(column); - setSortOrder('asc'); - const sorted = [...originalData].sort((a, b) => { - return a[column] < b[column] ? -1 : 1; - }); - setSortedData(sorted); - } - }; - - // Calculate the startIndex and endIndex based on currentPage and itemsPerPage - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const currentData = sortedData.slice(startIndex, endIndex); - - function isString(value: any): boolean { - return typeof value === 'string'; - } - - return ( - <div className = "text-center font-inter text-primary"> - <table className="text-center my-2 mx-auto table-fixed w-11/12" > - <thead className="thead"> - <tr className="bg-white text-center p-0 pl-2 border-0 h-2 font-weight: 700"> - {keys.map((item, index) => ( - <th - className="th" - key={index} - onClick={() => toggleSort(item)} - > - {item}{' '} - {sortColumn === item && ( - <span>{sortOrder === 'asc' ? '▲' : '▼'}</span> - ) - } - </th> - ))} - </tr> - </thead> - <tbody className="border-l-2 border-t-2 border-r-2 border-b-2 border-white w-20"> - {currentData.map((obj, index) => ( - <tr key={index} className={index % 2 === 0 ? "bg-secondary" : "bg-base-100"}> - {keys.map((item, index) => ( - <td className={`${isString(obj[item]) ? 'text-left' : 'text-center'} px-1 py-0.5 border border-white m-0 overflow-x-hidden truncate`} - key={index} - > - {obj[item]} - </td> - )) - } - </tr> - ))} - </tbody> - </table> - - <Pagination - currentPage={currentPage} - totalPages={totalPages} - onPageChange={onPageChange} - itemsPerPageInput = {itemsPerPage} - numItemsArrayReal = {startIndex+currentData.length} - totalItems={sortedData.length} - /> - - - </div> - ); -}; - -export default Table; diff --git a/libs/shared/lib/vis/simple_table_pagination/simpleTable.tsx b/libs/shared/lib/vis/simple_table_pagination/simpleTable.tsx deleted file mode 100644 index 847a6895c..000000000 --- a/libs/shared/lib/vis/simple_table_pagination/simpleTable.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useGraphQueryResult } from '../../data-access/store'; -import React, { useRef } from 'react'; -import Table from './components/Table'; - -export const SimpleTableVis = React.memo(() => { - - const ref = useRef<HTMLDivElement>(null); - - const graphQueryResult = useGraphQueryResult(); - - const nodes = graphQueryResult.nodes; - const attributesArray = nodes.map((node) => node.attributes); - - return ( - <> - <div className="h-full w-full overflow-hidden" ref={ref}> - {attributesArray.length > 0 && <Table data={attributesArray} itemsPerPage ={10} />} - </div> - </> - ); -}); - -SimpleTableVis.displayName = 'SimepleTableVis'; - -export default SimpleTableVis; diff --git a/libs/shared/lib/vis/table_vis/components/BarPlot.tsx b/libs/shared/lib/vis/table_vis/components/BarPlot.tsx new file mode 100644 index 000000000..ade23d963 --- /dev/null +++ b/libs/shared/lib/vis/table_vis/components/BarPlot.tsx @@ -0,0 +1,150 @@ +import React, { LegacyRef, useEffect, useRef, useState } from 'react'; +import * as d3 from 'd3'; + +type BarPlotProps = { + data: { category: string; count: number }[]; + numBins: number; + typeBarPlot: 'numerical' | 'categorical'; +}; + +export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProps) => { + const svgRef = useRef<SVGSVGElement | null>(null); + + useEffect(() => { + if (!svgRef.current) return; + + const widthSVG: number = +svgRef.current.clientWidth; + const heightSVG: number = +svgRef.current.clientHeight; + + const marginPercentage = { top: 0.15, right: 0.15, bottom: 0.15, left: 0.15 }; + const margin = { + top: marginPercentage.top * heightSVG, + right: marginPercentage.right * heightSVG, + bottom: marginPercentage.bottom * heightSVG, + left: marginPercentage.left * heightSVG, + }; + + const svgPlot = d3.select(svgRef.current); + + svgPlot.selectAll('*').remove(); + const groupMargin = svgPlot.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + const widthSVGwithinMargin: number = widthSVG - margin.left - margin.right; + const heightSVGwithinMargin: number = heightSVG - margin.top - margin.bottom; + + // Barplot for categorical data + if (typeBarplot == 'categorical') { + // Data processing + const dataFiltered = data.filter((item) => item.category !== undefined); + const dataSorted = dataFiltered.sort((a, b) => b.count - a.count); + const maxCount = d3.max(dataSorted, (d) => d.count) ?? 1; + + const xScale = d3 + .scaleBand() + .range([0, widthSVGwithinMargin]) + .domain( + dataSorted.map(function (d) { + return d.category; + }) + ) + .padding(0.2); + + const yScale = d3.scaleLinear().domain([0, maxCount]).range([heightSVGwithinMargin, 0]).nice(); + + groupMargin + .selectAll<SVGRectElement, d3.Bin<string, number>>('barplotCats') + .data(dataSorted) + .enter() + .append('rect') + .attr('x', (d) => xScale(d.category) || 0) + .attr('y', (d) => yScale(d.count)) + .attr('width', xScale.bandwidth()) + .attr('height', (d) => heightSVGwithinMargin - yScale(d.count)) + .attr('class', 'fill-primary stroke-white'); + + const yAxis = d3.axisLeft(yScale).ticks(5); + groupMargin.append('g').call(yAxis); + + // Barplot for numerical data + } else { + const dataCount = data.map((obj) => obj.count); + + const extentData = d3.extent(dataCount); + const [min, max] = extentData as [number, number]; + + const xScale = d3.scaleLinear().range([0, widthSVGwithinMargin]).domain([min, max]).nice(); + + // d3.histogram -> deprecated + // On d3.bin(), .thresholds(x.ticks(numBins)). Creates artifacts: not full rects on the plot + // CHECK THIS: https://dev.to/kevinlien/d3-histograms-and-fixing-the-bin-problem-4ac5 + + const rangeTH: number[] = d3.range(min, max, (max - min) / numBins); + + if (rangeTH.length != numBins) { + rangeTH.pop(); + } + + const histogram = d3 + .bin() + .value(function (d) { + return d; + }) + .domain([min, max]) + .thresholds(rangeTH); + + const bins = histogram(dataCount); + + const yScale = d3 + .scaleLinear() + .range([heightSVGwithinMargin, 0]) + .domain(d3.extent(bins, (d) => d.length) as [number, number]); + + groupMargin + .selectAll<SVGRectElement, d3.Bin<number, number>>('barplotbins') + .data(bins) + .enter() + .append('rect') + .attr('x', 1) + .attr('class', 'fill-primary stroke-white') + .attr('transform', (d) => 'translate(' + xScale(d.x0 || 0) + ',' + yScale(d.length) + ')') + .attr('width', (d) => xScale(d.x1 || 0) - xScale(d.x0 || 0) - 1) + .attr('height', (d) => heightSVGwithinMargin - yScale(d.length)); + + const xAxis = d3.axisBottom(xScale).ticks(5); + + groupMargin + .append('g') + .attr('transform', 'translate(0,' + heightSVGwithinMargin + ')') + .call(xAxis); + } + + svgPlot.selectAll('.tick text').attr('class', 'font-inter text-primary font-semibold').style('stroke', 'none'); + + groupMargin + .append('rect') + .attr('x', 0.0) + .attr('y', 0.0) + .attr('width', widthSVGwithinMargin) + .attr('height', heightSVGwithinMargin) + .attr('rx', 0) + .attr('ry', 0) + .attr('class', 'fill-none stroke-secondary'); + + svgPlot + .append('rect') + .attr('x', 0.0) + .attr('y', 0.0) + .attr('width', widthSVG) + .attr('height', heightSVG) + .attr('rx', 0) + .attr('ry', 0) + .attr('class', 'fill-none stroke-secondary'); + }, [data]); + + return ( + <div className="svg"> + <svg ref={svgRef} className="container" width="100%" height="100%"></svg> + </div> + ); +}; + +export default BarPlot; diff --git a/libs/shared/lib/vis/table_vis/components/Pagination.tsx b/libs/shared/lib/vis/table_vis/components/Pagination.tsx new file mode 100644 index 000000000..8d55c2cbf --- /dev/null +++ b/libs/shared/lib/vis/table_vis/components/Pagination.tsx @@ -0,0 +1,54 @@ +import React, { useRef } from 'react'; +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + itemsPerPageInput: number; + numItemsArrayReal: number; + totalItems: number; +} + +const Pagination: React.FC<PaginationProps> = ({ + currentPage, + totalPages, + onPageChange, + itemsPerPageInput, + numItemsArrayReal, + totalItems, +}) => { + const pageNumbers = Array.from({ length: totalPages }, (_, index) => index + 1); + + const firstItem = (currentPage - 1) * itemsPerPageInput + 1; + const lastItem = Math.min(currentPage * itemsPerPageInput, totalPages); + + const goToPreviousPage = () => { + if (currentPage > 1) { + onPageChange(currentPage - 1); + } + }; + + const goToNextPage = () => { + if (currentPage < totalPages) { + onPageChange(currentPage + 1); + } + }; + + return ( + <div className="table-pagination h-full flex flex-col items-center"> + <span className="inline-block m-2 max-w-32 min-w-32">{`${firstItem}-${numItemsArrayReal} of ${totalItems}`}</span> + <div className="join"> + <button className="m-1 btn btn-outline" onClick={goToPreviousPage} disabled={currentPage === 1}> + <ArrowBackIosIcon /> Previous + </button> + <button className="m-1 btn btn-outline" onClick={goToNextPage} disabled={currentPage === totalPages}> + <ArrowForwardIosIcon /> Next + </button> + </div> + </div> + ); +}; + +export default Pagination; diff --git a/libs/shared/lib/vis/table_vis/components/Table.tsx b/libs/shared/lib/vis/table_vis/components/Table.tsx new file mode 100644 index 000000000..51d37a47e --- /dev/null +++ b/libs/shared/lib/vis/table_vis/components/Table.tsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import * as d3 from 'd3'; +import Pagination from './Pagination'; +import BarPlot from './BarPlot'; +import { NodeAttributes } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; +import { SchemaAttributeTypes } from '@graphpolaris/shared/lib/schema'; + +export type AugmentedNodeAttributes = { attribute: NodeAttributes; type: Record<string, SchemaAttributeTypes> }; + +type TableProps = { + data: AugmentedNodeAttributes[]; + itemsPerPage: number; + showBarPlot: boolean; +}; +type Data2RenderI = { + name: string; + typeData: SchemaAttributeTypes; + data: { category: string; count: number }[]; + numElements: number; +}; + +export const Table = ({ data, itemsPerPage, showBarPlot }: TableProps) => { + const maxUniqueValues = 100; + const barPlotNumBins = 10; + + const [sortedData, setSortedData] = useState<AugmentedNodeAttributes[]>(data); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + const [sortColumn, setSortColumn] = useState<string | null>(null); + const [currentPage, setCurrentPage] = useState<{ + page: number; + startIndex: number; + endIndex: number; + currentData: AugmentedNodeAttributes[]; + } | null>(null); + const [data2Render, setData2Render] = useState<Data2RenderI[]>([]); + const dataColumns = useMemo(() => Object.keys(data[0].attribute), [data]); + const totalPages = Math.ceil(sortedData.length / itemsPerPage); + + useEffect(() => { + if (sortColumn !== null) { + const sorted = [...data].sort((a, b) => { + if (sortOrder === 'asc') { + return (a.attribute as any)[sortColumn] < (b.attribute as any)[sortColumn] ? -1 : 1; + } else { + return (a.attribute as any)[sortColumn] > (b.attribute as any)[sortColumn] ? -1 : 1; + } + }); + setSortedData(sorted); + } + }, [sortOrder, data, sortColumn]); + + useEffect(() => { + onPageChange(1); // Reset to the first page when sorting or itemsPerPage changes + }, [sortColumn, sortOrder, itemsPerPage]); + + const onPageChange = (page: number) => { + const startIndex = (page - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentData = sortedData.slice(startIndex, endIndex); + + setCurrentPage({ + page: page, + startIndex: startIndex, + endIndex: endIndex, + currentData: currentData, + }); + }; + + const toggleSort = (column: string) => { + if (sortColumn === column) { + if (sortOrder === 'asc') { + setSortOrder('desc'); + setSortedData(sortedData.reverse()); // Reverse the sorted data + } else if (sortOrder === 'desc') { + setSortColumn(null); + setSortOrder('asc'); + setSortedData(data); // Reset to the original order + } + } else { + setSortColumn(column); + setSortOrder('asc'); + const sorted = [...data].sort((a, b) => { + return (a.attribute as any)[column] < (b.attribute as any)[column] ? -1 : 1; + }); + setSortedData(sorted); + } + }; + + // Barplot on headers data preparation + + // Data structure to feed the keys-barplot ( name data2Render ) + // Three options: + // 1) numeric->data to histogram barplot + // 2) string with unique values < maxUniqueValues -> data binned barplot + // 3) string with unique values > maxUniqueValues -> show text unique values + + useEffect(() => { + if (!currentPage || currentPage?.currentData?.length <= 0) return; + + let categoryCounts = []; + const firstRowData = currentPage.currentData[0]; + + let _data2Render = Object.keys(firstRowData.attribute).map((dataColumn: string, i) => { + const newData2Render: Data2RenderI = { + name: dataColumn, + typeData: firstRowData.type[dataColumn] || 'string', + data: [], + numElements: 0, + }; + + if (firstRowData.type[dataColumn] === 'string') { + const groupedData = d3.group(data, (d) => d.attribute[dataColumn]); + + categoryCounts = Array.from(groupedData, ([category, items]) => ({ + category: category as string, + count: items.length, + })); + + if (categoryCounts.length > maxUniqueValues) { + newData2Render.numElements = categoryCounts.length; + } else { + newData2Render.numElements = categoryCounts.length; + newData2Render.data = categoryCounts; + } + + // number + // perhaps check for other than string and number. eg. boolean + } else { + categoryCounts = data.map((obj) => ({ + category: 'placeholder', // add something + count: obj.attribute[dataColumn] as number, // fill values of data + })); + + // console.log('number', categoryCounts); + newData2Render.numElements = categoryCounts.length; + newData2Render.data = categoryCounts; + } + return newData2Render; + }); + + setData2Render(_data2Render); + }, [currentPage, data, sortedData]); + + + return ( + <div className="text-center font-inter text-primary"> + {currentPage && currentPage?.currentData?.length > 0 && data2Render?.length > 0 && ( + <> + <table className="text-center my-2 mx-auto table-fixed w-11/12"> + <thead className="thead"> + <tr className="bg-white text-center p-0 pl-2 border-0 h-2 font-weight: 700"> + {dataColumns.map((item, index) => ( + <th className="th cursor-pointer select-none" key={index + item} onClick={() => toggleSort(item)}> + {item} {sortColumn === item && <span>{sortOrder === 'asc' ? '▲' : '▼'}</span>} + </th> + ))} + </tr> + <tr className="svggs align-top"> + {dataColumns.map((item, index) => ( + <th className="th" key={index + item}> + <div className="h-full w-full overflow-hidden"> + {showBarPlot && (data2Render[index].typeData === 'int' || data2Render[index].typeData === 'float') ? ( + <BarPlot typeBarPlot='numerical' numBins={barPlotNumBins} data={data2Render[index].data} /> + ) : !showBarPlot || data2Render[index].numElements > maxUniqueValues ? ( + <div className="h-full text-xs font-normal flex flex-row items-center justify-center gap-1"> + <span>Unique values:</span> + <span className="text-sm">{data2Render[index].numElements}</span> + </div> + ) : ( + <BarPlot typeBarPlot='categorical' numBins={barPlotNumBins} data={data2Render[index].data} /> + )} + </div> + </th> + ))} + </tr> + </thead> + <tbody className="border-l-2 border-t-2 border-r-2 border-b-2 border-white w-20"> + {currentPage.currentData.map((item, index) => ( + <tr key={index} className={index % 2 === 0 ? 'bg-base-200' : 'bg-base-100'}> + {dataColumns.map((col, index) => ( + <td + className={`${item.type[col] === 'string' ? 'text-left' : 'text-center' + } px-1 py-0.5 border border-white m-0 overflow-x-hidden truncate`} + key={index} + > + {item.attribute[col] as string} + </td> + ))} + </tr> + ))} + </tbody> + </table> + + <Pagination + currentPage={currentPage.page} + totalPages={totalPages} + onPageChange={onPageChange} + itemsPerPageInput={itemsPerPage} + numItemsArrayReal={currentPage.startIndex + currentPage.currentData.length} + totalItems={sortedData.length} + /> + </> + )} + </div> + ); +}; + +export default Table; diff --git a/libs/shared/lib/vis/table_vis/tableVis.tsx b/libs/shared/lib/vis/table_vis/tableVis.tsx new file mode 100644 index 000000000..d9c8a89dc --- /dev/null +++ b/libs/shared/lib/vis/table_vis/tableVis.tsx @@ -0,0 +1,40 @@ +import { useGraphQueryResult, useSchemaGraph } from '../../data-access/store'; +import React, { useMemo, useRef } from 'react'; +import {Table, AugmentedNodeAttributes} from './components/Table'; +import { NodeAttributes } from '../../data-access/store/graphQueryResultSlice'; +import { SchemaAttribute } from '../../schema'; + +export const TableVis = React.memo(({ showBarplot }: { showBarplot: boolean }) => { + const ref = useRef<HTMLDivElement>(null); + const graphQueryResult = useGraphQueryResult(); + const schema = useSchemaGraph(); + console.log(schema); + + const attributesArray = useMemo<AugmentedNodeAttributes[]>( + () => + graphQueryResult.nodes.map((node) => { + const types: SchemaAttribute[] = + schema.nodes.find((n) => n.key === node.label)?.attributes?.attributes ?? + schema.edges.find((r) => r.key === node.label)?.attributes?.attributes ?? + []; + + return { + attribute: node.attributes, + type: Object.fromEntries(types.map((t) => ([t.name, t.type]))), + }; + }), + [graphQueryResult.nodes] + ); + + return ( + <> + <div className="h-full w-full overflow-auto" ref={ref}> + {attributesArray.length > 0 && <Table data={attributesArray} itemsPerPage={10} showBarPlot={showBarplot} />} + </div> + </> + ); +}); + +TableVis.displayName = 'TableVis'; + +export default TableVis; diff --git a/libs/shared/lib/vis/simple_table_pagination/simpleTablevis.stories.tsx b/libs/shared/lib/vis/table_vis/tablevis.stories.tsx similarity index 91% rename from libs/shared/lib/vis/simple_table_pagination/simpleTablevis.stories.tsx rename to libs/shared/lib/vis/table_vis/tablevis.stories.tsx index 780170c14..0edeea9fc 100644 --- a/libs/shared/lib/vis/simple_table_pagination/simpleTablevis.stories.tsx +++ b/libs/shared/lib/vis/table_vis/tablevis.stories.tsx @@ -1,19 +1,19 @@ import React from 'react'; import { Meta } from '@storybook/react'; -import { SimpleTableVis } from './simpleTable'; +import { TableVis } from './tableVis'; import { assignNewGraphQueryResult, graphQueryResultSlice, resetGraphQueryResults, store } from '../../data-access/store'; import { configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; import { big2ndChamberQueryResult, smallFlightsQueryResults, mockLargeQueryResults, bigMockQueryResults } from '../../mock-data'; -const Component: Meta<typeof SimpleTableVis> = { +const Component: Meta<typeof TableVis> = { /* 👇 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/SimpleTableVis', - component: SimpleTableVis, + component: TableVis, decorators: [ (story) => ( <Provider store={Mockstore}> diff --git a/package.json b/package.json index 778f83958..101483934 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "sb": "turbo run sb --no-daemon", "lint": "turbo run lint --no-daemon", "test": "turbo run test --no-daemon", - "push": "turbo run push --no-daemon", + "push": "turbo run lint test build-dev --no-daemon", "format": "prettier --write \"**/*.{ts,tsx,md,js}\"", "prepare": "husky install" }, -- GitLab