ui: move barchart to echarts

This commit is contained in:
nick-delirium 2025-01-07 14:47:16 +01:00
parent 81d99bd985
commit 8e1f50e4a3
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
6 changed files with 290 additions and 164 deletions

View file

@ -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 <div ref={chartRef} style={{ width: '100%', height: 240 }} />;
}

View file

@ -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<HTMLDivElement>(null);
const chartUuid = React.useRef<string>(
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 <div ref={chartRef} style={{ width: '100%', height: 240 }} />;
}
export default ColumnChart;

View file

@ -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<string, any> | null = null;
let compSeries: Record<string, any>[] = [];
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<string>();
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,
}
}

View file

@ -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 = `
<div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
<div class="flex gap-2 items-center">
<div style="
border-radius: 99px;
background: ${params.color};
width: 1rem;
height: 1rem;">
</div>
<div class="font-medium text-black">${seriesName}</div>
</div>
<div style="border-left: 2px solid ${
params.color
};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm">
Total:
</div>
<div class="flex items-center gap-1">
<div class="font-medium text-black">${params.value}</div>
${buildCompareTag(params.value, partnerValue)}
</div>
</div>
`;
if (partnerValue !== undefined) {
const partnerColor =
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
str += `<div class="flex gap-2 items-center mt-2">
<div style="
border-radius: 99px;
background: ${partnerColor};
width: 1rem;
height: 1rem;">
</div>
<div class="font-medium">${partnerName}</div>
</div>
<div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2">
<div class="flex items-center gap-1">
<div class="font-medium">${partnerValue ?? '—'}</div>
${buildCompareTag(partnerValue, params.value)}
</div>
</div>`;
}
str += '</div>';
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 partners 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) {
<div class="font-medium text-black">${seriesName}</div>
</div>
<div style="border-left: 2px solid ${params.color};" class="flex flex-col px-2 ml-2">
<div style="border-left: 2px solid ${
params.color
};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm">
${firstTs ? formatTimeOrDate(firstTs) : categoryLabel}
</div>
@ -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 += `
<div class="flex gap-2 items-center mt-2">
<div style="
@ -118,7 +187,7 @@ export function customTooltipFormatter(uuid: string) {
tooltipContent += '</div>';
return tooltipContent;
}
};
}
/**
@ -156,7 +225,6 @@ function buildCompareTag(val: number, prevVal: number): string {
`;
}
/**
* Build category labels (["Sun", "Mon", ...]) from the "current" data only
*/
@ -169,15 +237,13 @@ export function buildCategories(data: DataProps['data']): string[] {
* The `idx` dimension aligns with xAxis = "category"
* (which is dates in our case)
*/
export function createDataset(
id: string,
data: DataProps['data']
) {
export function createDataset(id: string, data: DataProps['data']) {
const dimensions = ['idx', ...data.namesMap];
const source = data.chart.map((item, idx) => {
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;

View file

@ -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 (
<div className="w-full flex flex-col gap-3 ps-3 justify-center" style={{ height: 240 }}>
{groupedData.map((group, i) => (
<div key={i} className={`flex flex-col ${i < groupedData.length - 1 && group.comparison ? 'border-b border-dashed border-[0,0,0,.15] pb-3' : ''}`}>
<div className="flex items-center">
<div className="flex items-center" style={{ flex: 1 }}>
<div
className="w-4 h-4 rounded-full mr-2"
style={{ backgroundColor: colors[group.original.index] }}
/>
<span>{group.original.name}</span>
</div>
<div className="flex items-center gap-2" style={{ flex: 4 }}>
<div
style={{
height: 8,
borderRadius: 8,
backgroundColor: colors[group.original.index],
width: `${(group.original.value/highest)*100}%`
}}
/>
<div>{formattedNumber(group.original.value)}</div>
</div>
<div style={{ flex: 1 }} />
</div>
{group.comparison && (
<div className="flex items-center">
<div className="invisible flex items-center" style={{ flex: 1 }}>
<div
className="w-4 h-4 rounded-full mr-2"
style={{ backgroundColor: colors[group.comparison.index] }}
/>
<span>{group.comparison.name}</span>
</div>
<div className="flex items-center gap-2" style={{ flex: 4 }}>
<div
style={{
height: 8,
borderRadius: 8,
backgroundImage: `repeating-linear-gradient(45deg, #ffffff 0px, #ffffff 1.5px, ${colors[group.comparison.index]} 1.5px, ${colors[group.comparison.index]} 4.5px)`,
backgroundSize: '20px 20px',
width: `${(group.comparison.value/highest)*100}%`
}}
/>
<div>{formattedNumber(group.comparison.value)}</div>
</div>
<div style={{ flex: 1 }} />
</div>
)}
</div>
))}
</div>
);
}
export default ProgressBarChart;

View file

@ -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 (
<ProgressBarChart
<ColumnChart
inGrid={!props.isPreview}
horizontal
data={chartData}
compData={compData}
params={params}