diff --git a/frontend/app/components/Charts/BarChart.tsx b/frontend/app/components/Charts/BarChart.tsx index 4c99efce4..fbc63fd93 100644 --- a/frontend/app/components/Charts/BarChart.tsx +++ b/frontend/app/components/Charts/BarChart.tsx @@ -12,7 +12,6 @@ echarts.use([BarChart]); interface BarChartProps extends DataProps { label?: string; - horizontal?: boolean; } function ORBarChart(props: BarChartProps) { @@ -26,14 +25,14 @@ function ORBarChart(props: BarChartProps) { obs.observe(chartRef.current); const categories = buildCategories(props.data); - const { datasets, series } = buildBarDatasetsAndSeries(props, props.horizontal ?? false); + const { datasets, series } = buildBarDatasetsAndSeries(props); initWindowStorages(chartUuid.current, categories, props.data.chart, props.compData?.chart ?? []); series.forEach((s: any) => { (window as any).__seriesColorMap[chartUuid.current][s.name] = s.itemStyle?.color ?? '#999'; const ds = datasets.find((d) => d.id === s.datasetId); if (!ds) return; - const yDim = props.horizontal ? s.encode.x : s.encode.y; + const yDim = s.encode.y; const yDimIndex = ds.dimensions.indexOf(yDim); if (yDimIndex < 0) return; @@ -46,12 +45,12 @@ function ORBarChart(props: BarChartProps) { const xAxis: any = { - type: props.horizontal ? 'value' : 'category', - data: props.horizontal ? undefined : categories, + type: 'category', + data: categories, }; const yAxis: any = { - type: props.horizontal ? 'category' : 'value', - data: props.horizontal ? categories : undefined, + type: 'value', + data: undefined, name: props.label ?? 'Number of Sessions', nameLocation: 'middle', nameGap: 35, @@ -82,7 +81,7 @@ function ORBarChart(props: BarChartProps) { delete (window as any).__timestampMap[chartUuid.current]; delete (window as any).__timestampCompMap[chartUuid.current]; }; - }, [props.data, props.compData, props.horizontal]); + }, [props.data, props.compData]); return
; } diff --git a/frontend/app/components/Charts/ColumnChart.tsx b/frontend/app/components/Charts/ColumnChart.tsx new file mode 100644 index 000000000..275689b50 --- /dev/null +++ b/frontend/app/components/Charts/ColumnChart.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { defaultOptions, echarts } from './init'; +import { BarChart } from 'echarts/charts'; +import { customTooltipFormatter } from './utils'; +import { buildColumnChart } from './barUtils' + +echarts.use([BarChart]); + +interface DataItem { + time: string; + timestamp: number; + [seriesName: string]: number | string; +} + +export interface DataProps { + data: { + chart: DataItem[]; + namesMap: string[]; + }; + compData?: { + chart: DataItem[]; + namesMap: string[]; + }; +} + +interface ColumnChartProps extends DataProps { + label?: string; +} + +function ColumnChart(props: ColumnChartProps) { + const { data, compData, label } = props; + const chartRef = React.useRef(null); + const chartUuid = React.useRef( + Math.random().toString(36).substring(7) + ); + + React.useEffect(() => { + if (!chartRef.current) return; + const chart = echarts.init(chartRef.current); + (window as any).__seriesValueMap = (window as any).__seriesValueMap ?? {}; + (window as any).__seriesValueMap[chartUuid.current] = {}; + (window as any).__seriesColorMap = (window as any).__seriesColorMap ?? {}; + (window as any).__seriesColorMap[chartUuid.current] = {}; + + const { yAxisData, series } = buildColumnChart(chartUuid.current, data, compData); + + chart.setOption({ + ...defaultOptions, + tooltip: { + ...defaultOptions.tooltip, + formatter: customTooltipFormatter(chartUuid.current), + }, + legend: { + data: series + .filter((s: any) => !s._hideInLegend) + .map((s: any) => s.name), + }, + grid: { + ...defaultOptions.grid, + left: 40, + right: 30, + top: 40, + bottom: 30, + }, + xAxis: { + type: 'value', + boundaryGap: [0, 0.01], + name: label ?? 'Total', + nameLocation: 'middle', + nameGap: 35, + }, + yAxis: { + type: 'category', + data: yAxisData, + }, + series, + }); + + const obs = new ResizeObserver(() => chart.resize()); + obs.observe(chartRef.current); + + return () => { + chart.dispose(); + obs.disconnect(); + delete (window as any).__seriesValueMap[chartUuid.current]; + delete (window as any).__seriesColorMap[chartUuid.current]; + }; + }, [data, compData, label]); + + return
; +} + +export default ColumnChart; diff --git a/frontend/app/components/Charts/barUtils.ts b/frontend/app/components/Charts/barUtils.ts index 70eec9cae..826230935 100644 --- a/frontend/app/components/Charts/barUtils.ts +++ b/frontend/app/components/Charts/barUtils.ts @@ -1,4 +1,4 @@ -import type { DataProps } from './utils'; +import type { DataProps, DataItem } from './utils'; import { createDataset, assignColorsByBaseName } from './utils'; export function createBarSeries( @@ -6,16 +6,13 @@ export function createBarSeries( datasetId: string, dashed: boolean, hideFromLegend: boolean, - horizontal: boolean ) { return data.namesMap.filter(Boolean).map((fullName) => { const baseName = fullName.replace(/^Previous\s+/, ''); - const encode = horizontal - ? { x: fullName, y: 'idx' } - : { x: 'idx', y: fullName }; + const encode = { x: 'idx', y: fullName }; - const borderRadius = horizontal ? [0, 6, 6, 0] : [6, 6, 0, 0]; + const borderRadius = [6, 6, 0, 0]; const decal = dashed ? { symbol: 'line', symbolSize: 10, rotation: 1 } : { symbol: 'none' }; return { name: fullName, @@ -31,15 +28,15 @@ export function createBarSeries( }); } -export function buildBarDatasetsAndSeries(props: DataProps, horizontal = false) { +export function buildBarDatasetsAndSeries(props: DataProps) { const mainDataset = createDataset('current', props.data); - const mainSeries = createBarSeries(props.data, 'current', false, false, horizontal); + const mainSeries = createBarSeries(props.data, 'current', false, false); let compDataset: Record | null = null; let compSeries: Record[] = []; if (props.compData && props.compData.chart?.length) { compDataset = createDataset('previous', props.compData); - compSeries = createBarSeries(props.compData, 'previous', true, true, horizontal); + compSeries = createBarSeries(props.compData, 'previous', true, true); } const datasets = compDataset ? [mainDataset, compDataset] : [mainDataset]; @@ -50,3 +47,95 @@ export function buildBarDatasetsAndSeries(props: DataProps, horizontal = false) return { datasets, series }; } + +function sumSeries(chart: DataItem[], seriesName: string): number { + return chart.reduce((acc, row) => acc + (Number(row[seriesName]) || 0), 0); +} + +export function buildColumnChart(chartUuid: string, data: DataProps['data'], compData: DataProps['compData'],) { + const baseNamesSet = new Set(); + + data.namesMap.filter(Boolean).forEach((fullName) => { + const baseName = fullName.replace(/^Previous\s+/, ''); + baseNamesSet.add(baseName); + }); + + if (compData && compData.chart?.length) { + compData.namesMap.filter(Boolean).forEach((fullName) => { + const baseName = fullName.replace(/^Previous\s+/, ''); + baseNamesSet.add(baseName); + }); + } + + const baseNames = Array.from(baseNamesSet); // e.g. ["Series 1","Series 2"] + + const yAxisData = baseNames; + + const series: any[] = []; + + data.namesMap.filter(Boolean).forEach((fullName) => { + const baseName = fullName.replace(/^Previous\s+/, ''); + const idx = baseNames.indexOf(baseName); + + const val = sumSeries(data.chart, fullName); + const dataArr = new Array(baseNames.length).fill(0); + dataArr[idx] = val; + (window as any).__seriesValueMap[chartUuid][ + `Previous ${fullName}` + ] = val; + series.push({ + name: fullName, + type: 'bar', + barWidth: 16, + data: dataArr, + _hideInLegend: false, + _baseName: baseName, + itemStyle: { + borderRadius: [0, 6, 6, 0], + }, + }); + }); + + if (compData && compData.chart?.length) { + compData.namesMap.filter(Boolean).forEach((fullName) => { + const baseName = fullName.replace(/^Previous\s+/, ''); + const idx = baseNames.indexOf(baseName); + const val = sumSeries(compData.chart, fullName); + + const dataArr = new Array(baseNames.length).fill(0); + dataArr[idx] = val; + (window as any).__seriesValueMap[chartUuid][baseName] = val; + series.push({ + name: fullName, + type: 'bar', + barWidth: 16, + barGap: '1%', + data: dataArr, + _hideInLegend: true, + _baseName: baseName, + itemStyle: { + borderRadius: [0, 6, 6, 0], + decal: { + show: true, + symbol: 'line', + symbolSize: 6, + rotation: 1, + dashArrayX: 4, + dashArrayY: 4, + }, + }, + }); + }); + } + + assignColorsByBaseName(series); + series.forEach((s) => { + (window as any).__seriesColorMap[chartUuid][s.name] = + s.itemStyle.color; + }); + + return { + yAxisData, + series, + } +} \ No newline at end of file diff --git a/frontend/app/components/Charts/utils.ts b/frontend/app/components/Charts/utils.ts index 1d04c05d7..7e42e8349 100644 --- a/frontend/app/components/Charts/utils.ts +++ b/frontend/app/components/Charts/utils.ts @@ -1,6 +1,16 @@ -import { formatTimeOrDate } from "App/date"; +import { formatTimeOrDate } from 'App/date'; -export const colors = ['#394EFF', '#3EAAAF', '#9276da', '#ceba64', "#bc6f9d", '#966fbc', '#64ce86', '#e06da3', '#6dabe0']; +export const colors = [ + '#394EFF', + '#3EAAAF', + '#9276da', + '#ceba64', + '#bc6f9d', + '#966fbc', + '#64ce86', + '#e06da3', + '#6dabe0', +]; //export const colors = ['#6774E2', '#929ACD', '#3EAAAF', '#565D97', '#8F9F9F', '#376F72']; // const colorsTeal = ['#1E889A', '#239DB2', '#28B2C9', '#36C0D7', '#65CFE1']; // const colorsx = ['#256669', '#38999e', '#3eaaaf', '#51b3b7', '#78c4c7', '#9fd5d7', '#c5e6e7'].reverse(); @@ -39,7 +49,6 @@ export function assignColorsByBaseName(series: any[]) { }); } - /** * Show the hovered “current” or “previous” line + the matching partner (if it exists). */ @@ -49,23 +58,80 @@ export function customTooltipFormatter(uuid: string) { // { seriesName, dataIndex, data, marker, color, encode, ... } if (!params) return ''; const { seriesName, dataIndex } = params; + const isPrevious = /^Previous\s+/.test(seriesName); + const baseName = seriesName.replace(/^Previous\s+/, ''); + const partnerName = isPrevious ? baseName : `Previous ${baseName}`; + if (!Array.isArray(params.data)) { + const partnerValue = (window as any).__seriesValueMap?.[uuid]?.[ + seriesName + ]; + let str = ` +
+
+
+
+
${seriesName}
+
+ +
+
+ Total: +
+
+
${params.value}
+ ${buildCompareTag(params.value, partnerValue)} +
+
+ `; + if (partnerValue !== undefined) { + const partnerColor = + (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999'; + str += `
+
+
+
${partnerName}
+
+
+
+
${partnerValue ?? '—'}
+ ${buildCompareTag(partnerValue, params.value)} +
+
`; + } + + str += '
'; + + return str; + } // 'value' of the hovered point const yKey = params.encode.y[0]; // "Series 1" const value = params.data?.[yKey]; - const isPrevious = /^Previous\s+/.test(seriesName); - const baseName = seriesName.replace(/^Previous\s+/, ''); const timestamp = (window as any).__timestampMap?.[uuid]?.[dataIndex]; - const comparisonTimestamp = (window as any).__timestampCompMap?.[uuid]?.[dataIndex]; + const comparisonTimestamp = (window as any).__timestampCompMap?.[uuid]?.[ + dataIndex + ]; // Get partner’s value from some global map - const partnerName = isPrevious ? baseName : `Previous ${baseName}`; - const partnerVal = (window as any).__seriesValueMap?.[uuid]?.[partnerName]?.[dataIndex]; + + const partnerVal = (window as any).__seriesValueMap?.[uuid]?.[ + partnerName + ]?.[dataIndex]; const categoryLabel = (window as any).__categoryMap[uuid] - ? (window as any).__categoryMap[uuid][dataIndex] - : dataIndex; + ? (window as any).__categoryMap[uuid][dataIndex] + : dataIndex; const firstTs = isPrevious ? comparisonTimestamp : timestamp; const secondTs = isPrevious ? timestamp : comparisonTimestamp; @@ -81,7 +147,9 @@ export function customTooltipFormatter(uuid: string) {
${seriesName}
-
+
${firstTs ? formatTimeOrDate(firstTs) : categoryLabel}
@@ -93,7 +161,8 @@ export function customTooltipFormatter(uuid: string) { `; if (partnerVal !== undefined) { - const partnerColor = (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999'; + const partnerColor = + (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999'; tooltipContent += `
{ const row: (number | undefined)[] = [idx]; data.namesMap.forEach((name) => { - const val = typeof item[name] === 'number' ? (item[name] as number) : undefined; + const val = + typeof item[name] === 'number' ? (item[name] as number) : undefined; row.push(val); }); return row; @@ -210,9 +276,9 @@ export function createSeries( _hideInLegend: hideFromLegend, itemStyle: { opacity: 1 }, emphasis: { - focus: 'series', - itemStyle: { opacity: 1 }, - lineStyle: { opacity: 1 }, + focus: 'series', + itemStyle: { opacity: 1 }, + lineStyle: { opacity: 1 }, }, blur: { itemStyle: { opacity: 0.2 }, @@ -240,8 +306,7 @@ export function buildDatasetsAndSeries(props: DataProps) { return { datasets, series }; } - -interface DataItem { +export interface DataItem { time: string; timestamp: number; [seriesName: string]: number | string; diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ProgressBarChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ProgressBarChart.tsx deleted file mode 100644 index 568237b3c..000000000 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ProgressBarChart.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React from 'react'; - -interface Props { - data: { chart: any[], namesMap: string[] }; - compData: { chart: any[], namesMap: string[] } | null; - params: any; - colors: any; - onClick?: (event, index) => void; - yaxis?: any; - label?: string; - hideLegend?: boolean; -} - -function ProgressBarChart(props: Props) { - const { - data = { chart: [], namesMap: [] }, - compData = { chart: [], namesMap: [] }, - colors, - onClick = () => null, - label = 'Number of Sessions', - } = props; - - const getTotalForSeries = (series: string, isComp: boolean) => { - if (isComp) { - if (!compData) return 0; - return compData.chart.reduce((acc, curr) => acc + curr[series], 0); - } - return data.chart.reduce((acc, curr) => acc + curr[series], 0); - } - - const formattedNumber = (num: number) => { - return Intl.NumberFormat().format(num); - } - - // Group the data into pairs (original + comparison) - const groupedData: Array<{ original: any, comparison: any }> = []; - for (let i = 0; i < data.namesMap.length; i++) { - if (!data.namesMap[i]) continue; - - const original = { - name: data.namesMap[i], - value: getTotalForSeries(data.namesMap[i], false), - isComp: false, - index: i - }; - - const comparison = compData && compData.namesMap[i] ? { - name: compData.namesMap[i], - value: getTotalForSeries(compData.namesMap[i], true), - isComp: true, - index: i - } : null; - - groupedData.push({ original, comparison }); - } - - // Find highest value among all data points - const highest = groupedData.reduce((acc, curr) => { - const maxInGroup = Math.max( - curr.original.value, - curr.comparison ? curr.comparison.value : 0 - ); - return Math.max(acc, maxInGroup); - }, 0); - - return ( -
- {groupedData.map((group, i) => ( -
-
-
-
- {group.original.name} -
-
-
-
{formattedNumber(group.original.value)}
-
-
-
- {group.comparison && ( -
-
-
- {group.comparison.name} -
-
-
-
{formattedNumber(group.comparison.value)}
-
-
-
- )} -
- ))} -
- ); -} - -export default ProgressBarChart; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index 23ac44583..b26fad89e 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -2,13 +2,13 @@ import React, { useState, useRef, useEffect } from 'react'; import LineChart from 'App/components/Charts/LineChart' import BarChart from 'App/components/Charts/BarChart' import PieChart from 'App/components/Charts/PieChart' +import ColumnChart from 'App/components/Charts/ColumnChart' import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage'; import { Styles } from 'App/components/Dashboard/Widgets/common'; import { observer } from 'mobx-react-lite'; import { Icon, Loader } from 'UI'; import { useStore } from 'App/mstore'; import FunnelTable from "../../../Funnels/FunnelWidget/FunnelTable"; -import ProgressBarChart from '../../Widgets/CustomMetricsWidgets/ProgressBarChart'; import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart'; import WidgetDatatable from '../WidgetDatatable/WidgetDatatable'; import WidgetPredefinedChart from '../WidgetPredefinedChart'; @@ -335,8 +335,9 @@ function WidgetChart(props: Props) { if (viewType === 'progressChart') { return ( -