diff --git a/libs/shared/lib/components/textEditor/plugins/PreviewPlugin.tsx b/libs/shared/lib/components/textEditor/plugins/PreviewPlugin.tsx index 9867fb86568968c57a5e44c6d4cd060d906f3a19..a84ebe2212e15a58c35678a11b7a5616c5db047a 100644 --- a/libs/shared/lib/components/textEditor/plugins/PreviewPlugin.tsx +++ b/libs/shared/lib/components/textEditor/plugins/PreviewPlugin.tsx @@ -5,8 +5,8 @@ import { $generateHtmlFromNodes } from '@lexical/html'; import { VariableType } from '../VariableNode'; import { useVisualization, useGraphQueryResult } from '@graphpolaris/shared/lib/data-access'; import { Visualizations } from '@graphpolaris/shared/lib/vis/components/VisualizationPanel'; -import { Vis1DComponent, type Vis1DProps } from '@graphpolaris/shared/lib/vis/visualizations/vis1D'; -import { getPlotData } from '@graphpolaris/shared/lib/vis/visualizations/vis1D/components/CustomChartPlotly'; +import { Vis1DComponent, type Vis1DProps, getAttributeValues } from '@graphpolaris/shared/lib/vis/visualizations/vis1D'; +import { preparePlotData } from '@graphpolaris/shared/lib/vis/visualizations/vis1D/components/CustomChartPlotly'; import { VisualizationSettingsType } from '@graphpolaris/shared/lib/vis/common'; // @ts-ignore import { newPlot, toImage } from 'plotly.js/dist/plotly'; @@ -25,6 +25,8 @@ export function PreviewPlugin({ contentEditable }: { contentEditable: RefObject< } const result = useGraphQueryResult(); + + /* const getAttributeValues = useCallback( (settings: Vis1DProps & VisualizationSettingsType, attributeKey: string | number) => { if (!settings.nodeLabel || !attributeKey) { @@ -37,6 +39,7 @@ export function PreviewPlugin({ contentEditable }: { contentEditable: RefObject< }, [result], ); + */ const vis = useVisualization(); async function replaceAllAsync(string: string, regexp: RegExp, replacerFunction: CallableFunction) { @@ -61,34 +64,57 @@ export function PreviewPlugin({ contentEditable }: { contentEditable: RefObject< return ` ${value} `; case VariableType.visualization: - const activeVisualization = vis.openVisualizationArray.find(x => x.name == name) as Vis1DProps & VisualizationSettingsType; + const activeVisualization = vis.openVisualizationArray.find((x) => x.name == name) as Vis1DProps & VisualizationSettingsType; if (!activeVisualization) { throw new Error('Tried to render non-existing visualization'); } + let xAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.xAxisLabel!); + let yAxisData: (string | number)[] = []; + let zAxisData: (string | number)[] = []; + + if (activeVisualization.yAxisLabel != null) { + yAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.yAxisLabel); + } + + if (activeVisualization.zAxisLabel != null) { + zAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.zAxisLabel); + } + + //debugger; + const groupBy = activeVisualization.groupData; + const stack = activeVisualization.stack; + const showAxis = true; + + const xAxisLabel = ''; + const yAxisLabel = ''; + const zAxisLabel = ''; - const xAxisData = getAttributeValues(activeVisualization, activeVisualization.xAxisLabel!); - const yAxisData = getAttributeValues(activeVisualization, activeVisualization.yAxisLabel!); - debugger; const plotType = activeVisualization.plotType; - const plotData = getPlotData(xAxisData, plotType, yAxisData); - const plot = await newPlot(document.createElement('div'), plotData, { + const { plotData, layout } = preparePlotData( + xAxisData, + plotType, + yAxisData, + zAxisData, + xAxisLabel, + yAxisLabel, + zAxisLabel, + showAxis, + groupBy, + stack, + ); + + const layout2 = { + ...layout, width: 600, height: 400, title: activeVisualization.title, - font: { - family: 'Inter, sans-serif', - size: 16, - color: '#374151', - }, - xaxis: { - title: 'Category', - }, - yaxis: { - title: 'Value', - }, - }); + }; + + // Generate the plot + const plot = await newPlot(document.createElement('div'), plotData, layout2); + const dataURI = await toImage(plot); return `<img src="${dataURI}" width="300" height="200" alt="${activeVisualization.title}" />`; } diff --git a/libs/shared/lib/vis/visualizations/vis1D/Vis1D.tsx b/libs/shared/lib/vis/visualizations/vis1D/Vis1D.tsx index ebfc2423f95f443d9fe3ed9b010e0595ed72a4d9..8c27baa8e2fd8070f8e110bede376743983c0851 100644 --- a/libs/shared/lib/vis/visualizations/vis1D/Vis1D.tsx +++ b/libs/shared/lib/vis/visualizations/vis1D/Vis1D.tsx @@ -6,6 +6,7 @@ import { CustomChartPlotly, plotTypeOptions } from './components/CustomChartPlot import { Input } from '@graphpolaris/shared/lib/components/inputs'; import { EntityPill } from '@graphpolaris/shared/lib/components/pills/Pill'; import { Button } from '@graphpolaris/shared/lib/components/buttons'; +import { GraphQueryResult } from '@graphpolaris/shared/lib/data-access'; export interface Vis1DProps { plotType: (typeof plotTypeOptions)[number]; // plotly plot type @@ -13,7 +14,10 @@ export interface Vis1DProps { selectedEntity: string; // node label to plot xAxisLabel?: string; yAxisLabel?: string; + zAxisLabel?: string; showAxis: boolean; + groupData?: string; + stack: boolean; } const defaultSettings: Vis1DProps = { @@ -22,13 +26,34 @@ const defaultSettings: Vis1DProps = { selectedEntity: '', xAxisLabel: '', yAxisLabel: '', + zAxisLabel: '', showAxis: true, + groupData: undefined, + stack: false, }; export interface Vis1DVisHandle { exportImageInternal: () => void; } +export const getAttributeValues = (query: GraphQueryResult, selectedEntity: string, attributeKey: string | number | undefined): any[] => { + if (!selectedEntity || !attributeKey) { + return []; + } + + if (attributeKey == ' ') { + return []; + } + return query.nodes + .filter((item) => item.label === selectedEntity) + .map((item) => { + // Check if the attribute exists, return its value if it does, or an empty string otherwise + return item.attributes && attributeKey in item.attributes && item.attributes[attributeKey] != '' + ? item.attributes[attributeKey] + : 'NoData'; + }); +}; + const Vis1D = forwardRef<Vis1DVisHandle, VisualizationPropTypes<Vis1DProps>>(({ data, settings }, refExternal) => { const internalRef = useRef<HTMLDivElement>(null); @@ -67,29 +92,33 @@ const Vis1D = forwardRef<Vis1DVisHandle, VisualizationPropTypes<Vis1DProps>>(({ }, })); - const getAttributeValues = (attributeKey: string | number | undefined) => { - if (!settings.selectedEntity || !attributeKey) { - return []; - } - - return data.nodes - .filter((item) => item.label === settings.selectedEntity && item.attributes && attributeKey in item.attributes) - .map((item) => item.attributes[attributeKey]); - }; - - const xAxisData = useMemo(() => getAttributeValues(settings.xAxisLabel), [data, settings.selectedEntity, settings.xAxisLabel]); - const yAxisData = useMemo(() => getAttributeValues(settings.yAxisLabel), [data, settings.selectedEntity, settings.yAxisLabel]); + const xAxisData = useMemo( + () => getAttributeValues(data, settings.selectedEntity, settings.xAxisLabel), + [data, settings.selectedEntity, settings.xAxisLabel], + ); + const yAxisData = useMemo( + () => getAttributeValues(data, settings.selectedEntity, settings.yAxisLabel), + [data, settings.selectedEntity, settings.yAxisLabel], + ); + const zAxisData = useMemo( + () => getAttributeValues(data, settings.selectedEntity, settings.zAxisLabel), + [data, settings.selectedEntity, settings.zAxisLabel], + ); return ( <div className="h-full w-full flex items-center justify-center overflow-hidden" ref={internalRef}> <CustomChartPlotly xAxisData={xAxisData as string[] | number[]} yAxisData={yAxisData as string[] | number[]} + zAxisData={zAxisData as string[] | number[]} plotType={settings.plotType} title={settings.title} showAxis={settings.showAxis} xAxisLabel={settings.xAxisLabel} yAxisLabel={settings.yAxisLabel} + zAxisLabel={settings.zAxisLabel} + groupBy={settings.groupData} + stack={settings.stack} /> </div> ); @@ -120,7 +149,15 @@ const Vis1DSettings = ({ settings, graphMetadata, updateSettings }: Visualizatio const newAttributeOptions = Object.keys(graphMetadata.nodes.types[settings.selectedEntity].attributes); if (settings.xAxisLabel === '') { updateSettings({ xAxisLabel: newAttributeOptions[0] }); + + // !TODO: instead of contain "datum" chekc type: if it is date + if (newAttributeOptions[0].includes('Datum')) { + updateSettings({ groupData: 'yearly' }); + } else { + updateSettings({ groupData: undefined }); + } } + newAttributeOptions.unshift(' '); setAttributeOptions(newAttributeOptions); } else { } @@ -158,6 +195,9 @@ const Vis1DSettings = ({ settings, graphMetadata, updateSettings }: Visualizatio options={mutablePlotTypes} onChange={(value: string | number) => { updateSettings({ plotType: value as (typeof plotTypeOptions)[number] }); + if (value === 'bar' || value === 'histogram' || value === 'pie') { + updateSettings({ yAxisLabel: '' }); + } }} /> </div> @@ -168,7 +208,14 @@ const Vis1DSettings = ({ settings, graphMetadata, updateSettings }: Visualizatio value={settings.xAxisLabel} options={attributeOptions} onChange={(value) => { - updateSettings({ xAxisLabel: value as string }); + const valueString = value as string; + updateSettings({ xAxisLabel: valueString }); + + if (!valueString.includes('Datum')) { + updateSettings({ groupData: undefined }); + } else { + updateSettings({ groupData: 'monthly' }); + } }} /> </div> @@ -185,6 +232,37 @@ const Vis1DSettings = ({ settings, graphMetadata, updateSettings }: Visualizatio /> </div> )} + {(settings.plotType === 'bar' || settings.plotType === 'scatter' || settings.plotType === 'histogram') && ( + <div className="mb-2"> + <Input + type="dropdown" + label="Color:" + value={settings.zAxisLabel} + options={attributeOptions} + onChange={(value) => { + updateSettings({ zAxisLabel: value as string }); + }} + /> + </div> + )} + {settings.plotType === 'histogram' && ( + <div className="mb-2"> + <Input type="boolean" label="Normalize: " value={settings.stack} onChange={(val) => updateSettings({ stack: val })} /> + </div> + )} + {settings.xAxisLabel?.includes('Datum') && ( + <div className="mb-2"> + <Input + type="dropdown" + label="Group Time:" + value={settings.groupData} + options={['', 'monthly', 'quarterly', 'yearly']} + onChange={(value) => { + updateSettings({ groupData: value as string }); + }} + /> + </div> + )} <div className="mb-2"> <Input type="boolean" label="Show axis" value={settings.showAxis} onChange={(val) => updateSettings({ showAxis: val })} /> </div> diff --git a/libs/shared/lib/vis/visualizations/vis1D/components/CustomChartPlotly.tsx b/libs/shared/lib/vis/visualizations/vis1D/components/CustomChartPlotly.tsx index 2b9a9219d859c211cbf9a41da2bed3f3c1a5ded5..abe5253daf98d4e2fc83593dd0afc48a9d504ef7 100644 --- a/libs/shared/lib/vis/visualizations/vis1D/components/CustomChartPlotly.tsx +++ b/libs/shared/lib/vis/visualizations/vis1D/components/CustomChartPlotly.tsx @@ -2,6 +2,8 @@ import { visualizationColors } from 'config'; import React, { useRef, useEffect, useState } from 'react'; import Plot from 'react-plotly.js'; import { Tooltip, TooltipContent, TooltipTrigger } from '@graphpolaris/shared/lib/components/tooltip'; +import { PlotType } from 'plotly.js'; +import { scaleOrdinal, scaleLinear, scaleQuantize } from 'd3'; const getCSSVariableHSL = (varName: string) => { const rootStyles = getComputedStyle(document.documentElement); @@ -9,121 +11,541 @@ const getCSSVariableHSL = (varName: string) => { return `hsl(${hslValue})`; }; export const plotTypeOptions = ['bar', 'scatter', 'line', 'histogram', 'pie'] as const; +export type SupportedPlotType = (typeof plotTypeOptions)[number]; export interface CustomChartPlotlyProps { xAxisData: string[] | number[]; - plotType: (typeof plotTypeOptions)[number]; + yAxisData: string[] | number[]; + zAxisData?: string[] | number[]; + plotType: SupportedPlotType; title: string; showAxis: boolean; - yAxisData: string[] | number[]; xAxisLabel?: string; yAxisLabel?: string; + zAxisLabel?: string; + groupBy?: string; + stack: boolean; } -export const getPlotData = ( +const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableData?: (string | number)[]) => { + // Function to parse the date-time string into a JavaScript Date object + const parseDate = (dateStr: string) => { + // Remove nanoseconds part and use just the standard "YYYY-MM-DD HH:MM:SS" part + const cleanedDateStr = dateStr.split('.')[0]; + return new Date(cleanedDateStr); + }; + + // Grouping logic + const groupedData = xAxisData.reduce( + (acc, dateStr, index) => { + const date = parseDate(dateStr); + let groupKey: string; + + if (groupBy === 'yearly') { + groupKey = date.getFullYear().toString(); // Group by year (e.g., "2012") + } else if (groupBy === 'quarterly') { + const month = date.getMonth() + 1; // Adjust month for zero-indexed months + const quarter = Math.floor((month - 1) / 3) + 1; // Calculate quarter (Q1-Q4) + groupKey = `${date.getFullYear()}-Q${quarter}`; + } else if (groupBy === 'monthly') { + // Group by month, e.g., "2012-07" + groupKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`; + } else { + // Default case: group by year (or some other grouping logic) + groupKey = date.getFullYear().toString(); + } + + // Initialize the group if it doesn't exist + if (!acc[groupKey]) { + acc[groupKey] = additionalVariableData + ? typeof additionalVariableData[0] === 'number' + ? 0 // Initialize sum for numbers + : [] // Initialize array for strings + : 0; // Initialize count for no additional data + } + + // Aggregate additional variable if provided + if (additionalVariableData) { + if (typeof additionalVariableData[index] === 'number') { + acc[groupKey] = (acc[groupKey] as number) + (additionalVariableData[index] as number); + } else if (typeof additionalVariableData[index] === 'string') { + acc[groupKey] = [...(acc[groupKey] as string[]), additionalVariableData[index] as string]; + } + } else { + // Increment the count if no additionalVariableData + acc[groupKey] = (acc[groupKey] as number) + 1; + } + + return acc; + }, + {} as Record<string, number | string[]>, + ); + + // Extract grouped data into arrays for Plotly + const xValuesGrouped = Object.keys(groupedData); + const yValuesGrouped = Object.values(groupedData); + + return { xValuesGrouped, yValuesGrouped }; +}; + +const computeStringTickValues = (xValues: any[], maxTicks: number, maxLabelLength: number): any[] => { + const truncatedValues = xValues.map((label) => (label.length > maxLabelLength ? `${label.slice(0, maxLabelLength)}…` : label)); + + return truncatedValues; +}; + +export const preparePlotData = ( xAxisData: (string | number)[], - plotType: (typeof plotTypeOptions)[number], + plotType: SupportedPlotType, yAxisData?: (string | number)[], -): Partial<Plotly.PlotData>[] => { + zAxisData?: (string | number)[], + xAxisLabel?: string, + yAxisLabel?: string, + zAxisLabel?: string, + showAxis = true, + groupBy?: string, + stack?: boolean, +): { plotData: Partial<Plotly.PlotData>[]; layout: Partial<Plotly.Layout> } => { + const primaryColor = getCSSVariableHSL('--clr-sec--400'); + const lengthLabelsX = 7; // !TODO computed number of elements based + const lengthLabelsY = 8; // !TODO computed number of elements based const mainColors = visualizationColors.GPCat.colors[14]; - const primaryColor = getCSSVariableHSL('--clr-sec--400'); + const sharedTickFont = { + family: 'monospace', + size: 12, + color: '#374151', // !TODO get GP value + }; + let xValues: (string | number)[] = []; let yValues: (string | number)[] = []; - if (plotType === 'scatter' || plotType === 'line') { - if (xAxisData.length != 0 && yAxisData && yAxisData.length != 0) { + let colorScale: any; + let colorDataZ: string[] = []; + let colorbar: any = {}; + + if (zAxisData && zAxisData.length > 0 && typeof zAxisData[0] === 'number') { + const mainColorsSeq = visualizationColors.GPSeq.colors[9]; + const numericZAxisData = zAxisData.filter((item): item is number => typeof item === 'number'); + const zMin = numericZAxisData.reduce((min, val) => (val < min ? val : min), zAxisData[0]); + const zMax = numericZAxisData.reduce((max, val) => (val > max ? val : max), zAxisData[0]); + + // !TODO: option to have a linear or quantize scale + colorScale = scaleQuantize<string>().domain([zMin, zMax]).range(mainColorsSeq); + + colorDataZ = zAxisData?.map((item) => colorScale(item) || primaryColor); + + colorbar = { + title: 'Color Legend', + tickvals: [zMin, zMax], + ticktext: [`${zMin}`, `${zMax}`], + }; + } else { + const uniqueZAxisData = Array.from(new Set(zAxisData)); + + if (zAxisData && uniqueZAxisData) { + colorScale = scaleOrdinal<string>().domain(uniqueZAxisData.map(String)).range(mainColors); + + colorDataZ = zAxisData?.map((item) => colorScale(String(item)) || primaryColor); + const sortedDomain = uniqueZAxisData.sort(); + colorbar = { + title: 'Color Legend', + tickvals: sortedDomain, + ticktext: sortedDomain.map((val) => String(val)), + tickmode: 'array', + }; + } + } + + if (!groupBy) { + if (xAxisData.length !== 0 && yAxisData && yAxisData.length !== 0) { xValues = xAxisData; yValues = yAxisData; - } else if (xAxisData.length != 0 && yAxisData && yAxisData.length == 0) { + } else if (xAxisData.length !== 0 && (!yAxisData || yAxisData.length === 0)) { xValues = xAxisData; yValues = xAxisData.map((_, index) => index + 1); - } else if (xAxisData.length == 0 && yAxisData && yAxisData.length != 0) { + } else if (xAxisData.length === 0 && yAxisData && yAxisData.length !== 0) { xValues = yAxisData.map((_, index) => index + 1); yValues = yAxisData; - } else if (xAxisData.length == 0 && yAxisData && yAxisData.length == 0) { - } else { } } else { - xValues = xAxisData; - yValues = xAxisData.map((_, index) => index + 1); + if (groupBy) { + if (yAxisData && yAxisData.length !== 0) { + const { xValuesGrouped, yValuesGrouped } = groupByTime(xAxisData as string[], groupBy, yAxisData); + xValues = xValuesGrouped; + yValues = yValuesGrouped.flat(); + } else { + const { xValuesGrouped, yValuesGrouped } = groupByTime(xAxisData as string[], groupBy); + xValues = xValuesGrouped; + yValues = yValuesGrouped.flat(); + } + } else { + xValues = xAxisData; + yValues = xAxisData.map((_, index) => index + 1); + } } - switch (plotType) { - case 'bar': - return [ - { - type: 'bar', - x: xValues, - y: yValues, - marker: { color: primaryColor }, - }, - ]; - case 'scatter': - return [ - { - type: 'scatter', - x: xValues, - y: yValues, - mode: 'markers', - marker: { color: primaryColor, size: 12 }, - }, - ]; - case 'line': - return [ - { - type: 'scatter', - x: xValues, - y: yValues, - mode: 'lines', - line: { color: primaryColor }, - }, - ]; - case 'histogram': - // !TODO: Apply for other data types? - if (typeof xAxisData[0] === 'string') { - const frequencyMap = xAxisData.reduce( - (acc, item) => { - acc[item] = (acc[item] || 0) + 1; - return acc; - }, - {} as Record<string, number>, - ); + let sortedLabels: string[] = []; + let sortedFrequencies = []; + + let truncatedXLabels: string[] = []; + let truncatedYLabels: string[] = []; + let yAxisRange: number[] = []; + + if (typeof xValues[0] === 'string') { + truncatedXLabels = computeStringTickValues(xValues, 2, lengthLabelsX); + } + + if (typeof yValues[0] === 'string' && (plotType === 'scatter' || plotType === 'line')) { + truncatedYLabels = computeStringTickValues(yValues, 2, lengthLabelsY); + } + const plotData = (() => { + switch (plotType) { + case 'bar': + if (typeof xAxisData[0] === 'string' && groupBy == undefined) { + const frequencyMap = xAxisData.reduce( + (acc, item) => { + acc[item] = (acc[item] || 0) + 1; + return acc; + }, + {} as Record<string, number>, + ); - const sortedEntries = Object.entries(frequencyMap).sort((a, b) => b[1] - a[1]); - const sortedLabels = sortedEntries.map(([label]) => label); - const sortedFrequencies = sortedEntries.map(([, frequency]) => frequency); + const sortedEntries = Object.entries(frequencyMap).sort((a, b) => b[1] - a[1]); + sortedLabels = sortedEntries.map(([label]) => String(label)); + sortedFrequencies = sortedEntries.map(([, frequency]) => frequency); + + // !TODO: y ranges: max value showed is rounded, eg 54 -> 50 + // need to specify tickvales and ticktext + + const maxYValue = Math.max(...sortedFrequencies); + yAxisRange = [0, maxYValue]; + + return [ + { + type: 'bar' as PlotType, + x: xValues, + y: yValues, + marker: { + color: colorDataZ?.length != 0 ? colorDataZ : primaryColor, + }, + customdata: sortedLabels, + hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>', + }, + ]; + } else { + return [ + { + type: 'bar' as PlotType, + x: xValues, + y: yValues, + marker: { color: primaryColor }, + customdata: xValues, + hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>', + }, + ]; + } + + case 'scatter': + return [ + { + type: 'scatter' as PlotType, + x: xValues, + y: yValues, + mode: 'markers' as 'markers', + marker: { + color: zAxisData && zAxisData.length > 0 ? colorDataZ : primaryColor, + size: 7, + stroke: 1, + }, + customdata: + xValues.length === 0 + ? yValues.map((y) => `Y: ${y}`) + : yValues.length === 0 + ? xValues.map((x) => `X: ${x}`) + : xValues.map((x, index) => { + const zValue = zAxisData && zAxisData.length > 0 ? zAxisData[index] : null; + return zValue ? `X: ${x} | Y: ${yValues[index]} | Color: ${zValue}` : `X: ${x} | Y: ${yValues[index]}`; + }), + hovertemplate: '<b>%{customdata}</b><extra></extra>', + }, + ]; + case 'line': return [ { - type: 'bar', - x: sortedLabels, - y: sortedFrequencies, - marker: { color: primaryColor }, + type: 'scatter' as PlotType, + x: xValues, + y: yValues, + mode: 'lines' as 'lines', + line: { color: primaryColor }, + customdata: xValues.map((label) => (label === 'undefined' || label === 'null' || label === '' ? 'nonData' : '')), + hovertemplate: '<b>%{customdata}</b><extra></extra>', }, ]; - } else { + case 'histogram': + if (typeof xAxisData[0] === 'string') { + if (zAxisData && zAxisData?.length > 0) { + const frequencyMap = xAxisData.reduce( + (acc, item, index) => { + const color = zAxisData ? colorScale(zAxisData[index]) : primaryColor; + + if (!acc[item]) { + acc[item] = { + count: 0, + colors: [], + zValues: [], + zValueCounts: {}, + }; + } + + acc[item].count++; + acc[item].colors.push(color); + acc[item].zValues.push(zAxisData[index].toString()); + // Group and count zValues + const zValue = zAxisData[index] || '(Empty)'; + acc[item].zValueCounts[zValue] = (acc[item].zValueCounts[zValue] || 0) + 1; + + return acc; + }, + {} as Record< + string, + { + count: number; + colors: string[]; + zValues: string[]; + zValueCounts: Record<string, number>; // To store grouped counts + } + >, + ); + const colorToLegendName = new Map(); + const sortedCategories = Object.entries(frequencyMap).sort((a, b) => b[1].count - a[1].count); + + const tracesByColor: Record<string, { x: string[]; y: number[] }> = {}; + + sortedCategories.forEach(([label, { colors, zValues }]) => { + colors.forEach((color, idx) => { + const zValue = zValues[idx]; + + if (!colorToLegendName.has(color)) { + colorToLegendName.set(color, zValue); + } + + if (!tracesByColor[color]) { + tracesByColor[color] = { x: [], y: [] }; + } + tracesByColor[color].x.push(label); + tracesByColor[color].y.push(1); + }); + }); + + sortedLabels = sortedCategories.map((element) => element[0]); + + const traces = Array.from(colorToLegendName.entries()).map(([color, legendName]) => { + const colorData = tracesByColor[color]; + const categoryCountMap: Record<string, number> = {}; + + sortedLabels.forEach((label) => { + categoryCountMap[label] = frequencyMap[label].count; + }); + const yValues = colorData.x.map((label, idx) => { + const totalCount = categoryCountMap[label]; + const countForColor = colorData.y[idx]; + return stack ? (countForColor / totalCount) * 100 : countForColor; + }); + + const customdata = colorData.x.map((label, idx) => { + const colorTranslation = colorToLegendName.get(color) === ' ' ? '(Empty)' : colorToLegendName.get(color); + const percentage = ((100 * frequencyMap[label].zValueCounts[colorTranslation]) / frequencyMap[label].count).toFixed(1); + return [label, !stack ? frequencyMap[label]?.zValueCounts[colorTranslation] || 0 : percentage, colorTranslation || ' ']; + }); + return { + x: colorData.x, + y: yValues, + type: 'bar' as PlotType, + name: legendName, + marker: { color: color }, + customdata: customdata, + hovertemplate: + '<b>X: %{customdata[0]}</b><br>' + '<b>Y: %{customdata[1]}</b><br>' + '<b>Color: %{customdata[2]}</b><extra></extra>', + ...(stack ? { stackgroup: 'one' } : {}), + }; + }); + + return traces; + } else { + const frequencyMap = xAxisData.reduce( + (acc, item) => { + acc[item] = (acc[item] || 0) + 1; + return acc; + }, + {} as Record<string, number>, + ); + + const sortedEntries = Object.entries(frequencyMap).sort((a, b) => b[1] - a[1]); + + sortedLabels = sortedEntries.map(([label]) => String(label)); + sortedFrequencies = sortedEntries.map(([, frequency]) => frequency); + + return [ + { + type: 'bar' as PlotType, + x: sortedLabels, + y: sortedFrequencies, + marker: { color: primaryColor }, + customdata: sortedLabels, + hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>', + }, + ]; + } + } else { + if (zAxisData && zAxisData?.length > 0) { + const binCount = 20; // Number of bins (you can make this configurable) + const numericXAxisData = xAxisData.map((val) => Number(val)).filter((val) => !isNaN(val)); + + const xMin = numericXAxisData.reduce((min, val) => Math.min(min, val), Infinity); + const xMax = numericXAxisData.reduce((max, val) => Math.max(max, val), -Infinity); + + const binSize = (xMax - xMin) / binCount; + + // Create bins + const bins = Array.from({ length: binCount }, (_, i) => ({ + range: [xMin + i * binSize, xMin + (i + 1) * binSize], + count: 0, + zValueCounts: {} as Record<string, number>, // To track zAxisData counts per bin + })); + + // Assign data points to bins + numericXAxisData.forEach((xValue, index) => { + const zValue = zAxisData ? zAxisData[index] || '(Empty)' : '(Empty)'; + const binIndex = Math.floor((xValue - xMin) / binSize); + const bin = bins[Math.min(binIndex, bins.length - 1)]; // Ensure the last value falls into the final bin + + bin.count++; + bin.zValueCounts[zValue] = (bin.zValueCounts[zValue] || 0) + 1; + }); + + const colorToLegendName = new Map(); + const tracesByColor: Record<string, { x: string[]; y: number[] }> = {}; + + bins.forEach((bin, binIndex) => { + const binLabel = `[${bin.range[0].toFixed(1)}, ${bin.range[1].toFixed(1)})`; + + Object.entries(bin.zValueCounts).forEach(([zValue, count]) => { + const color = zAxisData ? colorScale(zValue) : primaryColor; + + if (!colorToLegendName.has(color)) { + colorToLegendName.set(color, zValue); + } + + if (!tracesByColor[color]) { + tracesByColor[color] = { x: [], y: [] }; + } + + tracesByColor[color].x.push(binLabel); + tracesByColor[color].y.push(stack ? (count / bin.count) * 100 : count); + }); + }); + + const traces = Array.from(colorToLegendName.entries()).map(([color, legendName]) => { + const colorData = tracesByColor[color]; + const customdata = colorData.x.map((binLabel, idx) => { + const countForColor = colorData.y[idx]; + const percentage = stack ? countForColor.toFixed(1) + '%' : countForColor.toFixed(0); + return [binLabel, countForColor, percentage, legendName]; + }); + + return { + x: colorData.x, + y: colorData.y, + type: 'bar' as PlotType, + name: legendName, + marker: { color }, + customdata, + autobinx: true, + hovertemplate: + '<b>Bin: %{customdata[0]}</b><br>' + + '<b>Count/Percentage: %{customdata[2]}</b><br>' + + '<b>Group: %{customdata[3]}</b><extra></extra>', + ...(stack ? { stackgroup: 'one' } : {}), + }; + }); + + return traces; + } else { + // No zAxisData, simple histogram logic + return [ + { + type: 'histogram' as PlotType, + x: xAxisData, + marker: { color: primaryColor }, + customdata: xAxisData, + }, + ]; + } + } + case 'pie': return [ { - type: 'histogram', - x: xAxisData, - marker: { color: primaryColor }, + type: 'pie' as PlotType, + labels: xValues.map(String), + values: xAxisData, + marker: { colors: mainColors }, }, ]; - } - case 'pie': - return [ - { - type: 'pie', - labels: xValues.map(String), - values: xAxisData, - marker: { colors: mainColors }, - }, - ]; - - default: - return []; - } + default: + return []; + } + })(); + + const layout: Partial<Plotly.Layout> = { + barmode: 'stack', + xaxis: { + title: { + text: showAxis ? (xAxisLabel ? xAxisLabel : '') : '', + standoff: 30, + }, + tickfont: sharedTickFont, + showgrid: false, + visible: showAxis, + ...(typeof xAxisData[0] === 'string' || (plotType === 'histogram' && sortedLabels.length > 0) + ? { type: 'category', categoryarray: sortedLabels, categoryorder: 'array' } + : {}), + showline: true, + zeroline: false, + tickvals: typeof xValues[0] == 'string' ? xValues : undefined, + ticktext: typeof xValues[0] == 'string' ? truncatedXLabels : undefined, + }, + + yaxis: { + showgrid: false, + visible: showAxis, + showline: true, + zeroline: false, + tickfont: sharedTickFont, + title: { + text: showAxis ? (yAxisLabel ? yAxisLabel : '') : '', + standoff: 30, + }, + tickvals: typeof yValues[0] === 'string' && (plotType === 'scatter' || plotType === 'line') ? yValues : undefined, + ticktext: typeof yValues[0] === 'string' && (plotType === 'scatter' || plotType === 'line') ? truncatedYLabels : undefined, + }, + font: { + family: 'Inter', + size: 12, + color: '#374151', + }, + hoverlabel: { + bgcolor: 'rgba(255, 255, 255, 0.8)', + bordercolor: 'rgba(0, 0, 0, 0.2)', + font: { + family: 'monospace', + size: 14, + color: '#374151', + }, + }, + }; + return { plotData, layout }; }; export const CustomChartPlotly: React.FC<CustomChartPlotlyProps> = ({ @@ -134,6 +556,10 @@ export const CustomChartPlotly: React.FC<CustomChartPlotlyProps> = ({ yAxisData, xAxisLabel, yAxisLabel, + groupBy, + zAxisData, + zAxisLabel, + stack, }) => { const internalRef = useRef<HTMLDivElement>(null); const [divSize, setDivSize] = useState({ width: 0, height: 0 }); @@ -182,10 +608,72 @@ export const CustomChartPlotly: React.FC<CustomChartPlotlyProps> = ({ setHoveredPoint(null); }; + const { plotData, layout } = preparePlotData( + xAxisData, + plotType, + yAxisData, + zAxisData, + xAxisLabel, + yAxisLabel, + zAxisLabel, + showAxis, + groupBy, + stack, + ); + // !TODO: implement pattern fill for nonData + /* + useEffect(() => { + const svg = document.querySelector('svg'); + if (svg) { + // Create or find the `defs` section + let defs = svg.querySelector('defs'); + if (!defs) { + defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svg.insertBefore(defs, svg.firstChild); + } + + // Check if the pattern already exists + let pattern = defs.querySelector('#diagonalHatch'); + if (!pattern) { + // Create the diagonal hatch pattern + pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern'); + pattern.setAttribute('id', 'diagonalHatch'); + pattern.setAttribute('width', '6'); + pattern.setAttribute('height', '6'); + pattern.setAttribute('patternTransform', 'rotate(45)'); + pattern.setAttribute('patternUnits', 'userSpaceOnUse'); + + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('width', '2'); + rect.setAttribute('height', '6'); + rect.setAttribute('fill', '#cccccc'); + + pattern.appendChild(rect); + defs.appendChild(pattern); + } + + //const bars = select('.points').selectAll('path').nodes(); + const bars = document.querySelectorAll('.points path'); + //console.log(bars); + if (plotType === 'histogram') { + bars.forEach((bar, index) => { + const customData = (plotData[0] as any).customdata[index]; + //console.log(select(bar), customData, customData == 'nonData'); + select(bar).style('fill', 'rgb(250, 0, 0)'); + + if (customData == 'nonData') { + //select(bar).style('fill', 'url(#diagonalHatch)'); + } + //console.log(bar); + }); + } + } + }, [plotData]); + */ return ( <div className="h-full w-full flex items-center justify-center overflow-hidden relative" ref={internalRef}> <Plot - data={getPlotData(xAxisData, plotType, yAxisData)} + data={plotData} config={{ responsive: true, scrollZoom: false, @@ -193,38 +681,11 @@ export const CustomChartPlotly: React.FC<CustomChartPlotlyProps> = ({ displaylogo: false, }} layout={{ + ...layout, width: divSize.width, height: divSize.height, title: title, dragmode: false, - font: { - family: 'Inter, sans-serif', - size: 12, - color: '#374151', - }, - xaxis: { - title: showAxis ? (xAxisLabel ? xAxisLabel : '') : '', - showgrid: false, - visible: showAxis, - showline: true, - zeroline: false, - }, - yaxis: { - title: showAxis ? (yAxisLabel ? yAxisLabel : '') : '', - showgrid: false, - visible: showAxis, - showline: true, - zeroline: false, - }, - hoverlabel: { - bgcolor: 'rgba(255, 255, 255, 0.8)', - bordercolor: 'rgba(0, 0, 0, 0.2)', - font: { - family: 'Inter, sans-serif', - size: 14, - color: '#374151', - }, - }, }} onHover={handleHover} onUnhover={handleUnhover}