From 78b00e1e749d88c9b142e705a9a1655909d8656d Mon Sep 17 00:00:00 2001 From: Leonardo <leomilho@gmail.com> Date: Mon, 13 May 2024 16:41:28 +0200 Subject: [PATCH] feat: table viz simpler bar plots --- .../charts/barplot/barplot.stories.tsx | 2 + .../lib/components/charts/barplot/index.tsx | 189 ++++++++++-------- libs/shared/lib/components/tooltip/index.tsx | 6 +- .../tablevis/components/Table.tsx | 22 +- .../vis/visualizations/tablevis/tablevis.tsx | 9 + 5 files changed, 136 insertions(+), 92 deletions(-) diff --git a/libs/shared/lib/components/charts/barplot/barplot.stories.tsx b/libs/shared/lib/components/charts/barplot/barplot.stories.tsx index 4475cbb08..c7c7da834 100644 --- a/libs/shared/lib/components/charts/barplot/barplot.stories.tsx +++ b/libs/shared/lib/components/charts/barplot/barplot.stories.tsx @@ -20,6 +20,7 @@ export const CategoricalData = { ], numBins: 5, typeBarPlot: 'categorical', + axis: true, }, }; @@ -34,5 +35,6 @@ export const NumericalData = { ], numBins: 5, typeBarPlot: 'numerical', + axis: true, }, }; diff --git a/libs/shared/lib/components/charts/barplot/index.tsx b/libs/shared/lib/components/charts/barplot/index.tsx index 5b44e0209..97ecf9f26 100644 --- a/libs/shared/lib/components/charts/barplot/index.tsx +++ b/libs/shared/lib/components/charts/barplot/index.tsx @@ -1,41 +1,68 @@ import React, { LegacyRef, useEffect, useRef, useState } from 'react'; -import * as d3 from 'd3'; -import { BarplotTooltip } from '@graphpolaris/shared/lib/components/tooltip'; +import { BarPlotTooltip } from '@graphpolaris/shared/lib/components/tooltip'; +import { + Axis, + Bin, + NumberValue, + axisBottom, + axisLeft, + bin, + extent, + format, + max, + range, + scaleBand, + scaleLinear, + select, + Selection, +} from 'd3'; export type BarPlotProps = { data: { category: string; count: number }[]; numBins: number; typeBarPlot: 'numerical' | 'categorical'; + marginPercentage?: { + top: number; + right: number; + bottom: number; + left: number; + }; + className?: string; + maxBarsCount?: number; + strokeWidth?: number; + axis?: boolean; }; -export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProps) => { +export const BarPlot = ({ typeBarPlot, numBins, data, marginPercentage, className, maxBarsCount, strokeWidth, axis }: BarPlotProps) => { const svgRef = useRef<SVGSVGElement | null>(null); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - const [tooltipData, setTooltipData] = useState<{ x: number; y: number; content: React.ReactNode } | null>(null); + const [tooltipData, setTooltipData] = useState<{ x: number; y: number; content: { category: string; count: number } } | null>(null); useEffect(() => { if (!svgRef.current) return; - const widthSVG: number = +svgRef.current.clientWidth; - const heightSVG: number = +svgRef.current.clientHeight; + const bounds = svgRef.current.getBoundingClientRect(); + const widthSVG: number = bounds.width; + const heightSVG: number = bounds.height; setDimensions({ width: widthSVG, height: heightSVG }); - const svgPlot = d3.select(svgRef.current); + const svgPlot = select(svgRef.current); svgPlot.selectAll('*').remove(); - let groupMargin: d3.Selection<SVGGElement, unknown, null, undefined>; + let groupMargin: Selection<SVGGElement, unknown, null, undefined>; let widthSVGwithinMargin: number = 0.0; let heightSVGwithinMargin: number = 0.0; - // Barplot for categorical data - if (typeBarplot == 'categorical') { - let marginPercentage = { - top: 0.19, - right: 0.02, - bottom: 0.19, - left: 0.19, - }; + // BarPlot for categorical data + if (typeBarPlot == 'categorical') { + if (!marginPercentage) + marginPercentage = { + top: 0.19, + right: 0.02, + bottom: 0.19, + left: 0.19, + }; let margin = { top: marginPercentage.top * heightSVG, right: marginPercentage.right * widthSVG, @@ -50,49 +77,42 @@ export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProp // 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() + const dataTopCounts = dataSorted.filter((item, i) => !maxBarsCount || i < maxBarsCount); + const maxCount = max(dataTopCounts, (d) => d.count) ?? 1; + const xScale = scaleBand() .range([0, widthSVGwithinMargin]) .domain( - dataSorted.map(function (d) { + dataTopCounts.map(function (d) { return d.category; - }) + }), ) .padding(0.2); // send this - const yScale = d3.scaleLinear().domain([0, maxCount]).range([heightSVGwithinMargin, 0]); + const yScale = scaleLinear().domain([0, maxCount]).range([heightSVGwithinMargin, 0]); // here out to axis component - const yAxis1 = d3.axisLeft(yScale).tickValues([0]).tickFormat(d3.format('d')); // to show 0 without decimanls - let yAxis2: d3.Axis<d3.NumberValue>; + const yAxis1 = axisLeft(yScale).tickValues([0]).tickFormat(format('d')); // to show 0 without decimanls + let yAxis2: Axis<NumberValue>; if (maxCount < 10) { - yAxis2 = d3.axisLeft(yScale).tickValues([maxCount]).tickFormat(d3.format('d')); + yAxis2 = axisLeft(yScale).tickValues([maxCount]).tickFormat(format('d')); } else { - yAxis2 = d3.axisLeft(yScale).tickValues([maxCount]).tickFormat(d3.format('.2s')); + yAxis2 = axisLeft(yScale).tickValues([maxCount]).tickFormat(format('.2s')); } groupMargin - .selectAll<SVGRectElement, d3.Bin<string, number>>('barplotCats') - .data(dataSorted) + .selectAll<SVGRectElement, Bin<string, number>>('barplotCats') + .data(dataTopCounts) .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('fill', 'hsl(var(--clr-sec--400))') - .attr('stroke', 'none') + .attr('class', 'hover:stroke-black hover:stroke-2 hover:stroke-secondary-400 fill-secondary-400') .on('mouseover', (event, d) => { - const tooltipContent = ( - <div> - <span>{d.category.toString()}</span>: <span>{d.count}</span> - </div> - ); - - setTooltipData({ x: event.pageX + 10, y: event.pageY - 28, content: tooltipContent }); + setTooltipData({ x: event.layerX + 10, y: event.layerY - 28, content: d }); }) .on('mousemove', (event, d) => { if (tooltipData) { @@ -114,17 +134,20 @@ export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProp setTooltipData(null); }); - groupMargin.append('g').call(yAxis1); - groupMargin.append('g').call(yAxis2); + if (axis) { + groupMargin.append('g').call(yAxis1); + groupMargin.append('g').call(yAxis2); + } // Barplot for numerical data } else { - let marginPercentage = { - top: 0.19, - right: 0.17, - bottom: 0.19, - left: 0.4, - }; + if (!marginPercentage) + marginPercentage = { + top: 0.19, + right: 0.02, + bottom: 0.19, + left: 0.19, + }; let margin = { top: marginPercentage.top * heightSVG, right: marginPercentage.right * widthSVG, @@ -135,26 +158,24 @@ export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProp groupMargin = svgPlot.append('g').attr('transform', `translate(${margin.left},${margin.top})`); widthSVGwithinMargin = widthSVG - margin.left - margin.right; heightSVGwithinMargin = heightSVG - margin.top - margin.bottom; - //console.log(typeBarplot, data); const dataCount = data.map((obj) => obj.count); - const extentData = d3.extent(dataCount); + const extentData = extent(dataCount); const [min, max] = extentData as [number, number]; - const xScale = d3.scaleLinear().range([0, widthSVGwithinMargin]).domain([min, max]); //.nice(); + const xScale = 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 + // histogram -> deprecated + // On 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); + const rangeTH: number[] = range(min, max, (max - min) / numBins); if (rangeTH.length != numBins) { rangeTH.pop(); } - const histogram = d3 - .bin() + const histogram = bin() .value(function (d) { return d; }) @@ -162,17 +183,14 @@ export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProp .thresholds(rangeTH); const bins = histogram(dataCount); - //console.log(bins); - const extentBins: [number, number] = d3.extent(bins, (d) => d.length) as [number, number]; + const extentBins: [number, number] = extent(bins, (d) => d.length) as [number, number]; - const yScale = d3 - .scaleLinear() + const yScale = scaleLinear() .range([heightSVGwithinMargin, 0]) - //.domain(d3.extent(bins, (d) => d.length) as [number, number]); .domain([0, extentBins[1]] as [number, number]); groupMargin - .selectAll<SVGRectElement, d3.Bin<number, number>>('barplotbins') + .selectAll<SVGRectElement, Bin<number, number>>('barplotbins') .data(bins) .enter() .append('rect') @@ -183,18 +201,17 @@ export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProp .attr('width', (d) => xScale(d.x1 || 0) - xScale(d.x0 || 0)) .attr('height', (d) => heightSVGwithinMargin - yScale(d.length)); - const xAxis = d3 - .axisBottom(xScale) + const xAxis = axisBottom(xScale) //.tickValues([Math.round((min + max) / 2.0), max]) .tickValues([max]) - .tickFormat(d3.format('.2s')); + .tickFormat(format('.2s')); - let xAxis2: d3.Axis<d3.NumberValue>; + let xAxis2: Axis<NumberValue>; if (min < 10) { - xAxis2 = d3.axisBottom(xScale).tickValues([min]).tickFormat(d3.format('d')); + xAxis2 = axisBottom(xScale).tickValues([min]).tickFormat(format('d')); } else { - xAxis2 = d3.axisBottom(xScale).tickValues([min]).tickFormat(d3.format('.2s')); + xAxis2 = axisBottom(xScale).tickValues([min]).tickFormat(format('.2s')); } groupMargin @@ -207,19 +224,22 @@ export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProp .attr('transform', 'translate(0,' + heightSVGwithinMargin + ')') .call(xAxis); - const yAxis1 = d3.axisLeft(yScale).tickValues([0]).tickFormat(d3.format('d')); // to show 0 without decimanls - //const yAxis2 = d3.axisLeft(yScale).tickValues([extentBins[1]]).tickFormat(d3.format('.2s')); + const yAxis1 = axisLeft(yScale).tickValues([0]).tickFormat(format('d')); // to show 0 without decimanls + //const yAxis2 = axisLeft(yScale).tickValues([extentBins[1]]).tickFormat(format('.2s')); - let yAxis2: d3.Axis<d3.NumberValue>; + let yAxis2: Axis<NumberValue>; if (extentBins[1] < 10) { - yAxis2 = d3.axisLeft(yScale).tickValues([extentBins[1]]).tickFormat(d3.format('d')); + yAxis2 = axisLeft(yScale).tickValues([extentBins[1]]).tickFormat(format('d')); } else { - yAxis2 = d3.axisLeft(yScale).tickValues([extentBins[1]]).tickFormat(d3.format('.2s')); + yAxis2 = axisLeft(yScale).tickValues([extentBins[1]]).tickFormat(format('.2s')); + } + + if (axis) { + // two axis for each number, it solves the 0.0 problem with ".2s" notatation + groupMargin.append('g').call(yAxis1); + groupMargin.append('g').call(yAxis2); } - // two axis for each number, it solves the 0.0 problem with ".2s" notatation - groupMargin.append('g').call(yAxis1); - groupMargin.append('g').call(yAxis2); } svgPlot.selectAll('.domain').style('stroke', 'hsl(var(--clr-sec--400))'); @@ -234,22 +254,21 @@ export const BarPlot = ({ typeBarPlot: typeBarplot, numBins, data }: BarPlotProp .attr('height', heightSVGwithinMargin) .attr('rx', 0) .attr('ry', 0) - .attr('stroke-width', 1) + .attr('stroke-width', strokeWidth || 0) .attr('fill', 'none') .attr('stroke', 'hsl(var(--clr-sec--400))'); - }, [data]); + }, [data, svgRef]); return ( - <div className="svg"> - <svg - ref={svgRef} - className="container" - width="100%" - height="100%" - //preserveAspectRatio="xMidYMid meet" - //viewBox={`0 0 ${dimensions.width} ${dimensions.height}`} - ></svg> - {tooltipData && <BarplotTooltip x={tooltipData.x} y={tooltipData.y} content={tooltipData.content} />} + <div className={!!className ? className : ''}> + <svg ref={svgRef} className="" width="100%" height="100%"></svg> + {tooltipData && ( + <BarPlotTooltip x={tooltipData.x} y={tooltipData.y}> + <div> + <span>{tooltipData.content.category.toString()}</span>: <span>{tooltipData.content.count}</span> + </div> + </BarPlotTooltip> + )} </div> ); }; diff --git a/libs/shared/lib/components/tooltip/index.tsx b/libs/shared/lib/components/tooltip/index.tsx index 4d3d7c991..1234d0eae 100644 --- a/libs/shared/lib/components/tooltip/index.tsx +++ b/libs/shared/lib/components/tooltip/index.tsx @@ -6,13 +6,13 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip'; export interface BarTooltipProps { x: number; y: number; - content: React.ReactNode; + children: React.ReactNode; } -export const BarplotTooltip: React.FC<BarTooltipProps> = ({ x, y, content }) => { +export const BarPlotTooltip = ({ x, y, children }: BarTooltipProps) => { return ( <div className="absolute font-sans bg-light border border-secondary text-secondary p-2" style={{ left: `${x}px`, top: `${y}px` }}> - {content} + {children} </div> ); }; diff --git a/libs/shared/lib/vis/visualizations/tablevis/components/Table.tsx b/libs/shared/lib/vis/visualizations/tablevis/components/Table.tsx index 514c64555..0f9759061 100644 --- a/libs/shared/lib/vis/visualizations/tablevis/components/Table.tsx +++ b/libs/shared/lib/vis/visualizations/tablevis/components/Table.tsx @@ -17,6 +17,7 @@ export type TableProps = { showBarPlot: boolean; showAttributes: string[]; selectedEntity: string; + maxBarsCount: number; }; type Data2RenderI = { name: string; @@ -28,7 +29,7 @@ type Data2RenderI = { const THRESHOLD_WIDTH = 100; -export const Table = ({ data, itemsPerPage, showBarPlot, showAttributes, selectedEntity }: TableProps) => { +export const Table = ({ data, itemsPerPage, showBarPlot, showAttributes, selectedEntity, maxBarsCount }: TableProps) => { const maxUniqueValues = 29; const barPlotNumBins = 10; const fetchAttributes = 0; @@ -228,7 +229,7 @@ export const Table = ({ data, itemsPerPage, showBarPlot, showAttributes, selecte }); setData2Render(_data2Render); - }, [currentPage, data, sortedData, selectedEntity]); + }, [currentPage, data, sortedData, selectedEntity, maxBarsCount]); return ( <> @@ -270,9 +271,22 @@ export const Table = ({ data, itemsPerPage, showBarPlot, showAttributes, selecte {data2Render[index] && (showBarPlot && data2Render[index].showBarPlot && columnWidths[index] > THRESHOLD_WIDTH ? ( data2Render[index]?.typeData === 'int' || data2Render[index]?.typeData === 'float' ? ( - <BarPlot typeBarPlot="numerical" numBins={barPlotNumBins} data={data2Render[index].data} /> + <BarPlot + typeBarPlot="numerical" + numBins={barPlotNumBins} + data={data2Render[index].data} + marginPercentage={{ top: 0.1, right: 0, left: 0, bottom: 0 }} + className="h-[4rem] max-h-[4rem]" + /> ) : ( - <BarPlot typeBarPlot="categorical" numBins={barPlotNumBins} data={data2Render[index].data} /> + <BarPlot + typeBarPlot="categorical" + numBins={barPlotNumBins} + data={data2Render[index].data} + marginPercentage={{ top: 0.1, right: 0, left: 0, bottom: 0 }} + className="h-[4rem] max-h-[4rem]" + maxBarsCount={maxBarsCount} + /> ) ) : ( <div className="font-normal mx-auto flex flex-row items-start justify-center w-full gap-1 text-center text-secondary-700 p-1"> diff --git a/libs/shared/lib/vis/visualizations/tablevis/tablevis.tsx b/libs/shared/lib/vis/visualizations/tablevis/tablevis.tsx index 7d0ffa41e..b1ed34e9b 100644 --- a/libs/shared/lib/vis/visualizations/tablevis/tablevis.tsx +++ b/libs/shared/lib/vis/visualizations/tablevis/tablevis.tsx @@ -13,6 +13,7 @@ export type TableProps = { itemsPerPage: number; displayAttributes: string[]; displayEntity: string; + maxBarsCount: number; }; const configuration: TableProps = { @@ -20,6 +21,7 @@ const configuration: TableProps = { showBarplot: true, displayAttributes: [], displayEntity: '', + maxBarsCount: 10, }; export const TableVis = ({ data, schema, configuration, updateSettings, graphMetadata }: VisualizationPropTypes) => { @@ -58,6 +60,7 @@ export const TableVis = ({ data, schema, configuration, updateSettings, graphMet showBarPlot={configuration.showBarplot} showAttributes={configuration.displayAttributes} selectedEntity={configuration.displayEntity} + maxBarsCount={configuration.maxBarsCount} /> )} </div> @@ -121,6 +124,12 @@ const TableSettings = ({ onChange={(val) => updateSettings({ itemsPerPage: val })} options={[10, 25, 50, 100]} /> + <Input + type="number" + label="Max Bars in Bar Plots" + value={configuration.maxBarsCount} + onChange={(val) => updateSettings({ maxBarsCount: val })} + /> <div> <span className="text-sm">Attributes to display:</span> <Button -- GitLab