diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index c47115c66..5d4a720c4 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -33,6 +33,7 @@ import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetr import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard'; import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard'; import SankeyChart from 'Shared/Insights/SankeyChart'; +import { filterMinorPaths } from 'Shared/Insights/SankeyChart/utils' import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard'; import SessionsBy from 'Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy'; import { useInView } from 'react-intersection-observer'; @@ -120,7 +121,7 @@ function WidgetChart(props: Props) { ..._metric.series, ..._metric.excludes, ..._metric.startPoint, - hideExcess: _metric.hideExcess, + hideExcess: false, }); const fetchMetricChartData = ( metric: any, @@ -473,11 +474,12 @@ function WidgetChart(props: Props) { } if (metricType === USER_PATH && data && data.links) { - // return ; + const usedData = _metric.hideExcess ? filterMinorPaths(data) : data; return ( { dashboardStore.drillDownFilter.merge({ filters, page: 1 }); }} diff --git a/frontend/app/components/Dashboard/components/WidgetOptions.tsx b/frontend/app/components/Dashboard/components/WidgetOptions.tsx index 1aeb71b92..1c8f70c2a 100644 --- a/frontend/app/components/Dashboard/components/WidgetOptions.tsx +++ b/frontend/app/components/Dashboard/components/WidgetOptions.tsx @@ -44,6 +44,7 @@ function WidgetOptions() { onClick={(e) => { e.preventDefault(); metric.update({ hideExcess: !metric.hideExcess }); + metric.updateKey('hasChanged', true); }} > @@ -113,6 +114,7 @@ const SeriesTypeOptions = observer(({ metric }: { metric: any }) => { })), onClick: ({ key }: any) => { metric.updateKey('metricOf', key); + metric.updateKey('hasChanged', true) }, }} > diff --git a/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx b/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx index 6835898da..e74505840 100644 --- a/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx +++ b/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx @@ -29,9 +29,10 @@ interface Props { nodeWidth?: number; height?: number; onChartClick?: (filters: any[]) => void; + iterations: number } -const SankeyChart: React.FC = ({data, height = 240, onChartClick}: Props) => { +const SankeyChart: React.FC = ({data, height = 240, onChartClick, iterations}: Props) => { const [highlightedLinks, setHighlightedLinks] = useState([]); const [hoveredLinks, setHoveredLinks] = useState([]); @@ -115,7 +116,7 @@ const SankeyChart: React.FC = ({data, height = 240, onChartClick}: Props) nodePadding={20} sort={true} nodeWidth={4} - iterations={128} + iterations={iterations} // linkCurvature={0.9} onClick={clickHandler} link={({source, target, id, ...linkProps}, index) => ( diff --git a/frontend/app/components/shared/Insights/SankeyChart/utils.ts b/frontend/app/components/shared/Insights/SankeyChart/utils.ts new file mode 100644 index 000000000..3e276fc8a --- /dev/null +++ b/frontend/app/components/shared/Insights/SankeyChart/utils.ts @@ -0,0 +1,140 @@ + +interface Link { + eventType: 'string', + sessionsCount: number, + value: number, + avgTimeFromPrevious: any, + /** + * index in array of nodes + * */ + source: number, + /** + * index in array of nodes + * */ + target: number, + id: string, +} +interface DataNode { + name: string, + eventType: 'string', + avgTimeFromPrevious: any, + id: string, +} +export function filterMinorPaths(data: { links: Link[], nodes: DataNode[] }, startNode: number = 0): Data { + const original: { links: Link[], nodes: DataNode[] } = JSON.parse(JSON.stringify(data)); + const eventType = data.nodes[startNode].eventType; + const sourceLinks: Map = new Map(); + for (const link of original.links) { + if (!sourceLinks.has(link.source)) { + sourceLinks.set(link.source, []); + } + sourceLinks.get(link.source)!.push(link); + } + + const visited: Set = new Set([startNode]); + const queue: number[] = [startNode]; + + const newNodes: Node[] = []; + const oldToNewMap: Map = new Map(); + const otherIndexMap: Map = new Map(); + + function getNewIndexForNode(oldIndex: number): number { + if (oldToNewMap.has(oldIndex)) { + return oldToNewMap.get(oldIndex)!; + } + const oldNode = original.nodes[oldIndex]; + const newIndex = newNodes.length; + newNodes.push({ ...oldNode }); + oldToNewMap.set(oldIndex, newIndex); + return newIndex; + } + + function getOtherIndexForNode(oldIndex: number): number { + if (otherIndexMap.has(oldIndex)) { + return otherIndexMap.get(oldIndex)!; + } + const newIndex = newNodes.length; + newNodes.push({ + name: 'Dropoff', + eventType: eventType, + avgTimeFromPrevious: null, + idd: `other_${oldIndex}`, + }); + otherIndexMap.set(oldIndex, newIndex); + return newIndex; + } + + const newLinks: Link[] = []; + + while (queue.length) { + const current = queue.shift()!; + + const outLinks = sourceLinks.get(current) || []; + if (!outLinks.length) continue; + + const majorLink = outLinks.reduce((prev, curr) => (curr.value > prev.value ? curr : prev), outLinks[0]); + + const minorSessionsSum = outLinks.reduce((sum, link) => link !== majorLink ? sum + (link.sessionsCount || 0) : sum, 0); + const minorValueSum = outLinks.reduce((sum, link) => link !== majorLink ? sum + (link.value || 0) : sum, 0); + + if (majorLink) { + const newSource = getNewIndexForNode(majorLink.source); + const newTarget = getNewIndexForNode(majorLink.target); + + newLinks.push({ + ...majorLink, + source: newSource, + target: newTarget, + }); + + if (!visited.has(majorLink.target)) { + visited.add(majorLink.target); + queue.push(majorLink.target); + } + } + + if (minorValueSum > 0) { + const newSource = getNewIndexForNode(current); + const newTarget = getOtherIndexForNode(current); + + newLinks.push({ + eventType: eventType, + sessionsCount: minorSessionsSum, + value: minorValueSum, + avgTimeFromPrevious: null, + source: newSource, + target: newTarget, + id: `other-${current}`, + }); + } + } + + const dropoffIndices: number[] = []; + const normalIndices: number[] = []; + for (let i = 0; i < newNodes.length; i++) { + if (newNodes[i].name === 'Dropoff') { + dropoffIndices.push(i); + } else { + normalIndices.push(i); + } + } + const finalOrder = normalIndices.concat(dropoffIndices); + + const indexMap: Map = new Map(); + finalOrder.forEach((oldIndex, sortedPos) => { + indexMap.set(oldIndex, sortedPos); + }); + + const sortedNodes = finalOrder.map((idx) => newNodes[idx]); + + for (const link of newLinks) { + link.source = indexMap.get(link.source)!; + link.target = indexMap.get(link.target)!; + } + + return { + ...original, + nodes: sortedNodes, + links: newLinks, + }; +} \ No newline at end of file