diff --git a/frontend/app/components/Charts/SankeyChart.tsx b/frontend/app/components/Charts/SankeyChart.tsx index 15dda871e..ab70b6795 100644 --- a/frontend/app/components/Charts/SankeyChart.tsx +++ b/frontend/app/components/Charts/SankeyChart.tsx @@ -3,8 +3,9 @@ import { echarts, defaultOptions } from './init'; import { SankeyChart } from 'echarts/charts'; import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils'; import { NoContent } from 'App/components/ui'; -import {InfoCircleOutlined} from '@ant-design/icons'; +import { InfoCircleOutlined } from '@ant-design/icons'; import { X } from 'lucide-react'; + echarts.use([SankeyChart]); interface SankeyNode { @@ -15,9 +16,9 @@ interface SankeyNode { } interface SankeyLink { - source: number; - target: number; - value: number; + source: number | string; + target: number | string; + value: number; sessionsCount: number; eventType?: string; } @@ -34,6 +35,42 @@ interface Props { isUngrouped?: boolean; } +function buildSubgraph( + startNodeId: string | number, + nodes: SankeyNode[], + links: SankeyLink[] +) { + const visited = new Set(); + const queue = [startNodeId]; + visited.add(startNodeId); + + const adjacency: Record> = {}; + links.forEach((link) => { + if (!adjacency[link.source]) { + adjacency[link.source] = []; + } + adjacency[link.source].push(link.target); + }); + + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = adjacency[current] || []; + neighbors.forEach((nbr) => { + if (!visited.has(nbr)) { + visited.add(nbr); + queue.push(nbr); + } + }); + } + + const subNodes = nodes.filter((n) => visited.has(n.id)); + const subLinks = links.filter( + (l) => visited.has(l.source) && visited.has(l.target) + ); + + return { subNodes, subLinks }; +} + const EChartsSankey: React.FC = (props) => { const { data, height = 240, onChartClick, isUngrouped } = props; const chartRef = React.useRef(null); @@ -44,31 +81,53 @@ const EChartsSankey: React.FC = (props) => { style={{ minHeight: height }} title={
- + Set a start or end point to visualize the journey. If set, try adjusting filters.
- } show={true} /> ); } + + const [finalNodeCount, setFinalNodeCount] = React.useState(data.nodes.length); + React.useEffect(() => { if (!chartRef.current) return; + + const startNodes = data.nodes.filter((n) => n.depth === 0); + let finalNodes = data.nodes; + let finalLinks = data.links; + + if (startNodes.length > 1) { + const chosenStartNode = startNodes[0]; + const { subNodes, subLinks } = buildSubgraph( + chosenStartNode.id!, + data.nodes, + data.links + ); + finalNodes = subNodes; + finalLinks = subLinks; + } + + const chart = echarts.init(chartRef.current); + const maxDepth = 4; - const filteredNodes = data.nodes.filter((n) => (n.depth ?? 0) <= maxDepth); - const filteredLinks = data.links.filter((l) => { - const sourceNode = data.nodes.find((n) => n.id === l.source); - const targetNode = data.nodes.find((n) => n.id === l.target); - return (sourceNode?.depth ?? 0) <= maxDepth && (targetNode?.depth ?? 0) <= maxDepth; + const filteredNodes = finalNodes.filter((n) => (n.depth ?? 0) <= maxDepth); + const filteredLinks = finalLinks.filter((l) => { + const sourceNode = finalNodes.find((n) => n.id === l.source); + const targetNode = finalNodes.find((n) => n.id === l.target); + return ( + (sourceNode?.depth ?? 0) <= maxDepth && (targetNode?.depth ?? 0) <= maxDepth + ); }); - const nodeValues = new Array(filteredNodes.length).fill(0); + setFinalNodeCount(filteredNodes.length); const echartNodes = filteredNodes @@ -81,8 +140,9 @@ const EChartsSankey: React.FC = (props) => { computedName === 'Others' ? 'rgba(34,44,154,.9)' : n.eventType === 'DROP' - ? '#B5B7C8' - : '#394eff'; + ? '#B5B7C8' + : '#394eff'; + return { name: computedName, depth: n.depth, @@ -93,16 +153,14 @@ const EChartsSankey: React.FC = (props) => { }; }) .sort((a, b) => { + if (a.depth === b.depth) { return getEventPriority(a.type || '') - getEventPriority(b.type || ''); } else { return (a.depth as number) - (b.depth as number); } }); - - const distinctSteps = new Set(data.nodes.map(node => node.depth)).size; - console.log('Number of steps returned by the backend:', distinctSteps); - + const echartLinks = filteredLinks.map((l) => ({ source: echartNodes.findIndex((n) => n.id === l.source), target: echartNodes.findIndex((n) => n.id === l.target), @@ -111,17 +169,14 @@ const EChartsSankey: React.FC = (props) => { lineStyle: { opacity: 0.1 }, })); - - nodeValues.forEach((v, i) => { - const outgoingValues = echartLinks - .filter((l) => l.source === i) - .reduce((p, c) => p + c.value, 0); - const incomingValues = echartLinks - .filter((l) => l.target === i) - .reduce((p, c) => p + c.value, 0); - nodeValues[i] = Math.max(outgoingValues, incomingValues); - }); + if (echartNodes.length === 0) return; + + const startNodeValue = echartLinks + .filter((link) => link.source === 0) + .reduce((sum, link) => sum + link.value, 0); + + const option = { ...defaultOptions, tooltip: { @@ -147,31 +202,49 @@ const EChartsSankey: React.FC = (props) => { label: { show: true, position: 'top', - textShadowColor: "transparent", - textBorderColor: "transparent", + textShadowColor: 'transparent', + textBorderColor: 'transparent', align: 'left', - overflow: "truncate", + overflow: 'truncate', + maxWidth: 30, distance: 3, offset: [-20, 0], - formatter: function(params: any) { - const totalSessions = nodeValues.reduce((sum: number, v: number) => sum + v, 0); - const percentage = totalSessions ? ((params.value / totalSessions) * 100).toFixed(1) + '%' : '0%'; - return `{header|${params.name}}\n{body|${percentage} ${params.value} Sessions}`; + formatter: function (params: any) { + const nodeVal = params.value; + const percentage = startNodeValue + ? ((nodeVal / startNodeValue) * 100).toFixed(1) + '%' + : '0%'; + + return ( + `{header|${params.name}}\n` + + `{body|}{percentage|${percentage}} {sessions|${nodeVal}}` + ); }, rich: { header: { - fontWeight: 'bold', + fontWeight: '600', fontSize: 12, - color: '#333' + color: '#333', + overflow: 'truncate', + paddingBottom:'.5rem', }, body: { fontSize: 12, - color: '#666' - } - } + color: '#000', + }, + percentage: { + fontSize: 12, + color: '#454545', + }, + sessions: { + fontSize: 12, + fontFamily: "mono, 'monospace', sans-serif", + color: '#999999', + }, + }, }, tooltip: { - formatter: sankeyTooltip(echartNodes, nodeValues), + formatter: sankeyTooltip(echartNodes, []), }, nodeAlign: 'left', nodeWidth: 40, @@ -189,8 +262,10 @@ const EChartsSankey: React.FC = (props) => { ], }; + chart.setOption(option); + function getUpstreamNodes(nodeIdx: number, visited = new Set()) { if (visited.has(nodeIdx)) return; visited.add(nodeIdx); @@ -231,7 +306,7 @@ const EChartsSankey: React.FC = (props) => { const baseOpacity = connectedChain.has(idx) ? 1 : 0.35; const extraStyle = idx === hoveredIndex - ? { borderColor: '#000000', borderWidth: 1, borderType: 'dotted' } + ? { borderColor: '#000', borderWidth: 1, borderType: 'dotted' } : {}; return { ...node, @@ -267,7 +342,6 @@ const EChartsSankey: React.FC = (props) => { chart.on('mouseout', function (params: any) { if (params.dataType === 'node') { - // Restore original styles on mouseout. chart.setOption({ series: [ { @@ -279,21 +353,21 @@ const EChartsSankey: React.FC = (props) => { } }); + chart.on('click', function (params: any) { if (!onChartClick) return; if (params.dataType === 'node') { const nodeIndex = params.dataIndex; - // Use filteredNodes here. const node = filteredNodes[nodeIndex]; onChartClick([{ node }]); } else if (params.dataType === 'edge') { const linkIndex = params.dataIndex; - // Use filteredLinks here. const link = filteredLinks[linkIndex]; onChartClick([{ link }]); } }); + const ro = new ResizeObserver(() => chart.resize()); ro.observe(chartRef.current); @@ -303,11 +377,29 @@ const EChartsSankey: React.FC = (props) => { }; }, [data, height, onChartClick]); - const containerStyle: React.CSSProperties = isUngrouped - ? {width: '100%', minHeight: 500, height: '100%', overflowY: 'auto' } - : { width: '100%', height }; + + + + let containerStyle: React.CSSProperties; + if (isUngrouped) { + + + const dynamicMinHeight = finalNodeCount * 15; + containerStyle = { + width: '100%', + minHeight: dynamicMinHeight, + height: '100%', + overflowY: 'auto', + }; + } else { + + containerStyle = { + width: '100%', + height, + }; + } - return
; + return
; }; export default EChartsSankey; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/AddCardSection/AddCardSection.tsx b/frontend/app/components/Dashboard/components/AddCardSection/AddCardSection.tsx index 7ca4644dd..dbb0edce6 100644 --- a/frontend/app/components/Dashboard/components/AddCardSection/AddCardSection.tsx +++ b/frontend/app/components/Dashboard/components/AddCardSection/AddCardSection.tsx @@ -65,12 +65,12 @@ export const tabItems: Record = { type: USER_PATH, description: 'Understand the paths users take through your product.', }, - // { TODO: 1.23+ - // icon: , - // title: 'Retention', - // type: RETENTION, - // description: 'Analyze user retention over specific time periods.', - // }, + + + + + + { icon: , title: 'Heatmaps', @@ -143,12 +143,12 @@ export const tabItems: Record = { type: FilterKey.USER_DEVICE, description: 'Explore the devices used by your users.', }, - // { TODO: 1.23+ maybe - // icon: , - // title: 'Speed Index by Country', - // type: TABLE, - // description: 'Measure performance across different regions.', - // }, + + + + + + ], }; @@ -223,7 +223,7 @@ function CategoryTab({ ]; } - // TODO This code here makes 0 sense + if (selectedCard.cardType === FUNNEL) { cardData.series = []; cardData.series.push(new FilterSeries()); @@ -317,7 +317,7 @@ const AddCardSection = observer( return (
diff --git a/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx b/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx index bb6027292..dea32d6b8 100644 --- a/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx +++ b/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx @@ -140,12 +140,11 @@ const ListView: React.FC = ({ ); const onItemClick = (metric: Widget) => { - if (disableSelection) return; - if (toggleSelection) { - toggleSelection(metric.metricId); - } else { + if (disableSelection) { const path = withSiteId(`/metrics/${metric.metricId}`, siteId); history.push(path); + } else { + toggleSelection?.(metric.metricId); } }; diff --git a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx index e88ce64a6..41117be4e 100644 --- a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx +++ b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx @@ -72,7 +72,7 @@ function MetricsList({ metricStore.updateKey('showMine', !showOwn); }; - // Define dimensions for the empty state illustration + const isFiltered = metricsSearch !== '' || (metricStore.filter.type && metricStore.filter.type !== 'all'); diff --git a/frontend/app/components/Dashboard/components/WidgetChart/LongLoader.tsx b/frontend/app/components/Dashboard/components/WidgetChart/LongLoader.tsx index 3f82c77f5..e6dcd547d 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/LongLoader.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/LongLoader.tsx @@ -16,6 +16,7 @@ function LongLoader({ onClick }: { onClick: () => void }) { '0%': '#394EFF', '100%': '#394EFF' }} + status="active" showInfo={false} />
diff --git a/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx b/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx index dadbaa670..8c4a95770 100644 --- a/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx +++ b/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx @@ -196,14 +196,14 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => { { value: 'custom', label: 'Custom Events' }, ]; return ( - -
- +
+
+
- Journeys with: -
+ Journeys With +
{
- +
{ metric.startType === 'start' @@ -258,7 +258,7 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
- +
); }); diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx b/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx index ffe8064a4..c8c106fee 100644 --- a/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState, useEffect } from 'react'; import { Button, Checkbox, Input, Tooltip } from 'antd'; -import { RedoOutlined } from '@ant-design/icons'; +import { RedoOutlined, CloseCircleFilled } from '@ant-design/icons'; import cn from 'classnames'; import { Loader } from 'UI'; import OutsideClickDetectingDiv from '../../OutsideClickDetectingDiv'; @@ -83,7 +83,9 @@ export function AutocompleteModal({ const applyQuery = () => { const vals = commaQuery ? query.split(',').map((i) => i.trim()) : [query]; - onApply(vals); + // onApply(vals); + const merged = Array.from(new Set([...selectedValues, ...vals])); + onApply(merged); }; const clearSelection = () => { @@ -134,6 +136,7 @@ export function AutocompleteModal({ placeholder={placeholder} className="rounded-lg" autoFocus + allowClear /> <> @@ -159,7 +162,7 @@ export function AutocompleteModal({
@@ -174,10 +177,6 @@ export function AutocompleteModal({ - -
@@ -191,7 +190,7 @@ export function AutocompleteModal({ ); } -// Props interface + interface Props { value: string[]; params?: any; @@ -202,15 +201,34 @@ interface Props { mapValues?: (value: string) => string; } -// AutoCompleteContainer component + export function AutoCompleteContainer(props: Props) { const filterValueContainer = useRef(null); const [showValueModal, setShowValueModal] = useState(false); + const [hovered, setHovered] = useState(false); const isEmpty = props.value.length === 0 || !props.value[0]; const onClose = () => setShowValueModal(false); const onApply = (values: string[]) => { - props.onApplyValues(values); + setTimeout(() => { + props.onApplyValues(values); + setShowValueModal(false); + }, 100); + console.log("closed on apply"); + }; + + + const onClearClick = (event: React.MouseEvent) => { + event.stopPropagation(); + props.onApplyValues([]); setShowValueModal(false); + console.log("closed clear click"); + }; + + const handleContainerClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget || + event.currentTarget.contains(event.target as Node)) { + setShowValueModal(true); + } }; return ( @@ -220,10 +238,13 @@ export function AutoCompleteContainer(props: Props) { } style={{ height: 26 }} ref={filterValueContainer} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={handleContainerClick} >
setTimeout(() => setShowValueModal(true), 0)} - className={'flex items-center gap-2 cursor-pointer'} + className={'flex items-center gap-2 cursor-pointer pr-4'} > {!isEmpty ? ( <> @@ -240,7 +261,7 @@ export function AutoCompleteContainer(props: Props) { /> {props.value.length > 2 && ( )} @@ -252,6 +273,14 @@ export function AutoCompleteContainer(props: Props) { {props.placeholder ? props.placeholder : 'Select value(s)'}
)} + {!isEmpty && hovered && ( +
+ +
+ )}
{showValueModal ? ( onChange(null, { value: item.value }, index)} + {...props} /> ) @@ -179,7 +179,7 @@ function FilterValue(props: Props) { id={`ignore-outside`} className={cn('grid gap-3 w-fit flex-wrap my-1.5', { 'grid-cols-2': filter.hasSource, - //'lg:grid-cols-3': !filter.hasSource, + })} > {renderValueFiled(filter.value)} diff --git a/frontend/app/services/MetricService.ts b/frontend/app/services/MetricService.ts index 0a24c0e4e..4e90ccb4d 100644 --- a/frontend/app/services/MetricService.ts +++ b/frontend/app/services/MetricService.ts @@ -82,7 +82,7 @@ export default class MetricService { } const path = isSaved ? `/cards/${metric.metricId}/chart` : `/cards/try`; if (metric.metricType === USER_PATH) { - data.density = 3; + data.density = 5; data.metricOf = 'sessionCount'; } try { diff --git a/frontend/app/styles/general.css b/frontend/app/styles/general.css index 76dffb1f1..1e21a073d 100644 --- a/frontend/app/styles/general.css +++ b/frontend/app/styles/general.css @@ -123,10 +123,25 @@ box-shadow: 1px 1px 1px 1px rgba(0, 0, 0 0.3); } +.rounded-sm .ant-select-selector{ + border-radius: .25rem !important; +} + + +.rounded-lg .ant-select-selector{ + border-radius: .5rem !important; +} + + +.rounded-xl .ant-select-selector{ + border-radius: .75rem !important; +} + .active-bg { background-color: $active-blue; } + .border-b-light { border-bottom: solid thin $gray-light; }