ui: add comparison to more charts, add "metric" chart (BigNumChart.tsx)

This commit is contained in:
nick-delirium 2024-11-29 17:34:34 +01:00
parent 08be943ae0
commit 817f039ddc
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
16 changed files with 267 additions and 126 deletions

View file

@ -68,7 +68,7 @@ const PillBar = (props) => {
function CustomMetricLineChart(props: Props) {
const {
data = { chart: [], namesMap: [] },
compData,
compData = { chart: [], namesMap: [] },
params,
colors,
onClick = () => null,
@ -82,6 +82,14 @@ function CustomMetricLineChart(props: Props) {
return item;
});
// we mix 1 original, then 1 comparison, etc
const mergedNameMap: { data: any, isComp: boolean, index: number }[] = [];
for (let i = 0; i < data.namesMap.length; i++) {
mergedNameMap.push({ data: data.namesMap[i], isComp: false, index: i });
if (compData && compData.namesMap[i]) {
mergedNameMap.push({ data: compData.namesMap[i], isComp: true, index: i });
}
}
return (
<ResponsiveContainer height={240} width="100%">
<BarChart
@ -126,39 +134,23 @@ function CustomMetricLineChart(props: Props) {
}}
/>
<Tooltip {...Styles.tooltip} content={CustomTooltip} />
{Array.isArray(data.namesMap) &&
data.namesMap.map((key, index) => (
<Bar
key={key}
name={key}
type="monotone"
dataKey={key}
shape={(barProps) => (
<PillBar {...barProps} fill={colors[index]} />
)}
legendType={key === 'Total' ? 'none' : 'line'}
activeBar={
<PillBar fill={colors[index]} stroke={colors[index]} />
}
/>
))}
{compData
? compData.namesMap.map((key, i) => (
<Bar
key={key}
name={key}
type="monotone"
dataKey={key}
shape={(barProps) => (
<PillBar {...barProps} fill={colors[i]} barKey={i} stroke={colors[i]} striped />
)}
legendType={key === 'Total' ? 'none' : 'line'}
activeBar={
<PillBar fill={colors[i]} stroke={colors[i]} barKey={i} striped />
}
/>
))
: null}
{mergedNameMap.map((item) => (
<Bar
key={item.data}
name={item.data}
type="monotone"
dataKey={item.data}
fill={colors[item.index]}
stroke={colors[item.index]}
shape={(barProps: any) => (
<PillBar {...barProps} fill={colors[item.index]} barKey={item.index} stroke={colors[item.index]} striped={item.isComp} />
)}
legendType={'line'}
activeBar={
<PillBar fill={colors[item.index]} stroke={colors[item.index]} barKey={item.index} striped={item.isComp} />
}
/>
))}
</BarChart>
</ResponsiveContainer>
);

View file

@ -0,0 +1,85 @@
import React from 'react'
import { CompareTag } from "./CustomChartTooltip";
interface Props {
data: { chart: any[], namesMap: string[] };
compData: { chart: any[], namesMap: string[] } | null;
colors: any;
onClick?: (event, index) => void;
yaxis?: any;
label?: string;
hideLegend?: boolean;
}
function BigNumChart(props: Props) {
const {
data = { chart: [], namesMap: [] },
compData = { chart: [], namesMap: [] },
colors,
onClick = () => null,
label = 'Number of Sessions',
} = props;
const values: { value: number, compData?: number, series: string }[] = [];
for (let i = 0; i < data.namesMap.length; i++) {
if (!data.namesMap[i]) {
continue;
}
values.push({
value: data.chart.reduce((acc, curr) => acc + curr[data.namesMap[i]], 0),
compData: compData ? compData.chart.reduce((acc, curr) => acc + curr[compData.namesMap[i]], 0) : undefined,
series: data.namesMap[i],
});
}
console.log(values, data, compData)
return (
<div className={'flex justify-around gap-2 w-full'} style={{ height: 240 }}>
{values.map((val, i) => (
<BigNum
key={i}
color={colors[i]}
series={val.series}
value={val.value}
label={label}
compData={val.compData}
/>
))}
</div>
)
}
function BigNum({ color, series, value, label, compData }: {
color: string,
series: string,
value: number,
label: string,
compData?: number,
}) {
const formattedNumber = (num: number) => {
return Intl.NumberFormat().format(num);
}
const changePercent = React.useMemo(() => {
if (!compData) return 0;
return `${(((value - compData) / compData) * 100).toFixed(2)}%`;
}, [value, compData])
return (
<div className={'flex flex-col gap-2 py-8 items-center'}>
<div className={'flex items-center gap-2 font-semibold text-gray-darkest'}>
<div className={'rounded w-4 h-4'} style={{ background: color }} />
<div>{series}</div>
</div>
<div className={'font-bold leading-none'} style={{ fontSize: 56 }}>
{formattedNumber(value)}
</div>
<div className={'text-disabled-text text-xs'}>
{label}
</div>
{compData ? (
<CompareTag isHigher={value > compData} prevValue={changePercent} />
) : null}
</div>
)
}
export default BigNumChart;

View file

@ -1,7 +1,7 @@
import React from 'react';
import { formatTimeOrDate } from 'App/date';
import cn from 'classnames';
import { ArrowUp, ArrowDown } from 'lucide-react'
import { ArrowUp, ArrowDown } from 'lucide-react';
function CustomTooltip({ active, payload, label }) {
if (!active) return;
@ -54,17 +54,10 @@ function CustomTooltip({ active, payload, label }) {
<div className={'flex items-center gap-2'}>
<div className={'font-semibold'}>{p.value}</div>
{p.prevValue !== null ? (
<div
className={cn(
'px-2 py-1 rounded flex items-center gap-1',
isHigher(p) ? 'bg-green2 text-xs' : 'bg-red2 text-xs'
)}
>
{!isHigher(p) ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
<div>
{p.prevValue}
</div>
</div>
<CompareTag
isHigher={isHigher(p)}
prevValue={p.prevValue}
/>
) : null}
</div>
</div>
@ -74,4 +67,24 @@ function CustomTooltip({ active, payload, label }) {
);
}
export function CompareTag({
isHigher,
prevValue,
}: {
isHigher: boolean;
prevValue: number | string;
}) {
return (
<div
className={cn(
'px-2 py-1 rounded flex items-center gap-1',
isHigher ? 'bg-green2 text-xs' : 'bg-red2 text-xs'
)}
>
{!isHigher ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
<div>{prevValue}</div>
</div>
);
}
export default CustomTooltip;

View file

@ -32,7 +32,7 @@ interface Props {
function CustomMetricLineChart(props: Props) {
const {
data = { chart: [], namesMap: [] },
compData,
compData = { chart: [], namesMap: [] },
params,
colors,
onClick = () => null,
@ -86,13 +86,12 @@ function CustomMetricLineChart(props: Props) {
strokeOpacity={key === 'Total' ? 0 : 0.6}
legendType={key === 'Total' ? 'none' : 'line'}
dot={false}
// strokeDasharray={'4 3'} FOR COPMARISON ONLY
activeDot={{
fill: key === 'Total' ? 'transparent' : colors[index],
}}
/>
) : null)}
{compData ? compData.namesMap.map((key, i) => (
{compData?.namesMap.map((key, i) => data.namesMap[i] ? (
<Line
key={key}
name={key}
@ -110,7 +109,7 @@ function CustomMetricLineChart(props: Props) {
fill: colors[i],
}}
/>
)) : null}
) : null)}
</LineChart>
</ResponsiveContainer>
);

View file

@ -2,6 +2,7 @@ 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;
@ -13,38 +14,64 @@ interface Props {
function ProgressBarChart(props: Props) {
const {
data = { chart: [], namesMap: [] },
compData = { chart: [], namesMap: [] },
colors,
onClick = () => null,
label = 'Number of Sessions',
} = props;
const getTotalForSeries = (series: string) => {
const getTotalForSeries = (series: string, isComp: boolean) => {
if (isComp) {
return compData.chart.reduce((acc, curr) => acc + curr[series], 0);
}
return data.chart.reduce((acc, curr) => acc + curr[series], 0);
}
const values = data.namesMap.map((k, i) => {
const formattedNumber = (num: number) => {
return Intl.NumberFormat().format(num);
}
// we mix 1 original, then 1 comparison, etc
const mergedNameMap: { data: any, isComp: boolean, index: number }[] = [];
for (let i = 0; i < data.namesMap.length; i++) {
if (!data.namesMap[i]) {
continue;
}
mergedNameMap.push({ data: data.namesMap[i], isComp: false, index: i });
if (compData && compData.namesMap[i]) {
mergedNameMap.push({ data: compData.namesMap[i], isComp: true, index: i });
}
}
const values = mergedNameMap.map((k, i) => {
return {
name: k,
value: getTotalForSeries(k)
name: k.data,
value: getTotalForSeries(k.data, k.isComp),
isComp: k.isComp,
index: k.index,
}
})
const highest = values.reduce(
(acc, curr) =>
acc.value > curr.value ? acc : curr,
{ name: '', value: 0 });
const formattedNumber = (num: number) => {
return Intl.NumberFormat().format(num);
}
return (
<div className={'w-full'} style={{ height: 240 }}>
{values.map((val, i) => (
<div key={i} className={'flex items-center gap-1'}>
<div className={'flex items-center'} style={{ flex: 1}}>
<div className={'w-4 h-4 rounded-full mr-2'} style={{ backgroundColor: colors[i] }} />
<div className={'w-4 h-4 rounded-full mr-2'} style={{ backgroundColor: colors[val.index] }} />
<span>{val.name}</span>
</div>
<div className={'flex items-center gap-2'} style={{ flex: 4 }}>
<div style={{ height: 16, borderRadius: 16, backgroundColor: colors[i], width: `${(val.value/highest.value)*100}%` }} />
<div style={{
height: 16,
borderRadius: 16,
backgroundImage: val.isComp ? `linear-gradient(45deg, #ffffff 25%, ${colors[val.index]} 25%, ${colors[val.index]} 50%, #ffffff 50%, #ffffff 75%, ${colors[val.index]} 75%, ${colors[val.index]} 100%)` : undefined,
backgroundSize: val.isComp ? '20px 20px' : undefined,
backgroundColor: val.isComp ? undefined : colors[val.index],
width: `${(val.value/highest.value)*100}%` }}
/>
<div>{formattedNumber(val.value)}</div>
</div>
<div style={{ flex: 1}}/>

View file

@ -70,6 +70,9 @@ export default {
lineHeight: '0.75rem',
color: '#000',
fontSize: '12px'
},
cursor: {
fill: '#eee'
}
},
gradientDef: () => (

View file

@ -9,7 +9,7 @@ import { useStore } from 'App/mstore';
import AreaChart from '../../Widgets/CustomMetricsWidgets/AreaChart';
import BarChart from '../../Widgets/CustomMetricsWidgets/BarChart';
import ProgressBarChart from '../../Widgets/CustomMetricsWidgets/ProgressBarChart';
import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart'
import WidgetDatatable from '../WidgetDatatable/WidgetDatatable';
import WidgetPredefinedChart from '../WidgetPredefinedChart';
import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
@ -274,6 +274,7 @@ function WidgetChart(props: Props) {
return (
<ProgressBarChart
data={chartData}
compData={compData}
params={params}
colors={colors}
onClick={onChartClick}
@ -291,8 +292,12 @@ function WidgetChart(props: Props) {
metric={metric}
data={chartData}
colors={colors}
// params={params}
onClick={onChartClick}
label={
metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
);
}
@ -302,12 +307,32 @@ function WidgetChart(props: Props) {
data={data[0]}
colors={colors}
params={params}
label={
metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
);
}
if (viewType === 'table') {
return null;
}
if (viewType === 'metric') {
return (
<BugNumChart
data={data}
compData={compData}
colors={colors}
onClick={onChartClick}
label={
metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
)
}
}
if (metricType === TABLE) {

View file

@ -11,12 +11,14 @@ const initTableProps = [
dataIndex: 'seriesName',
key: 'seriesName',
sorter: (a, b) => a.seriesName.localeCompare(b.seriesName),
fixed: 'left',
},
{
title: 'Avg.',
dataIndex: 'average',
key: 'average',
sorter: (a, b) => a.average - b.average,
fixed: 'left',
},
];
@ -132,6 +134,7 @@ function WidgetDatatable(props: Props) {
pagination={false}
rowSelection={rowSelection}
size={'small'}
scroll={{ x: 'max-content' }}
/>
{/* 1.23+ export menu floater */}
{/*<div className={'absolute top-0 -right-1'}>*/}

View file

@ -15,6 +15,7 @@ function RangeGranularity({
return calculateGranularities(period.getDuration());
}, [period]);
const menuProps = {
items: granularityOptions,
onClick: (item: any) => onDensityChange(item.key),
@ -22,13 +23,19 @@ function RangeGranularity({
const selected = React.useMemo(() => {
let selected = 'Custom';
for (const option of granularityOptions) {
if (option.key <= density) {
if (option.key === density) {
selected = option.label;
break;
}
}
return selected;
}, [])
}, [period, density])
React.useEffect(() => {
const defaultOption = Math.max(granularityOptions.length - 2, 0);
onDensityChange(granularityOptions[defaultOption].key);
}, [period]);
return (
<AntlikeDropdown
useButtonStyle
@ -41,7 +48,7 @@ function RangeGranularity({
function calculateGranularities(periodDurationMs: number) {
const granularities = [
{ label: 'Minute', durationMs: 60 * 1000 },
{ label: 'By minute', durationMs: 60 * 1000 },
{ label: 'Hourly', durationMs: 60 * 60 * 1000 },
{ label: 'Daily', durationMs: 24 * 60 * 60 * 1000 },
{ label: 'Weekly', durationMs: 7 * 24 * 60 * 60 * 1000 },

View file

@ -42,7 +42,6 @@ function WidgetDateRange({ viewType = undefined, label = 'Time Range', isTimeser
<SelectDateRange
period={period}
onChange={onChangePeriod}
right={true}
isAnt={true}
useButtonStyle={true}
/>

View file

@ -136,21 +136,6 @@ const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
</Button>
</Card>
)}
{metric.series[0] ?
<div className={'rounded-xl border border-gray-lighter'}>
<FilterList
filter={metric.series[0].filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty
onFilterMove={onFilterMove}
excludeFilterKeys={excludeFilterKeys}
onAddFilter={onAddFilter}
/>
</div>
: null}
</>
);
});

View file

@ -6,7 +6,7 @@ import { useStore } from 'App/mstore';
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker';
import { FilterKey } from 'Types/filter/filterType';
import { observer } from 'mobx-react-lite';
import { ChartLine, ChartArea, ChartColumn, ChartBar, ChartPie, Table } from 'lucide-react'
import { ChartLine, ChartArea, ChartColumn, ChartBar, ChartPie, Table, Hash } from 'lucide-react'
function WidgetOptions() {
const { metricStore } = useStore();
@ -23,6 +23,7 @@ function WidgetOptions() {
pieChart: 'Pie',
progressChart: 'Bar',
table: 'Table',
metric: 'Metric',
}
const chartIcons = {
lineChart: <ChartLine size={16} strokeWidth={1} />,
@ -31,6 +32,7 @@ function WidgetOptions() {
pieChart: <ChartPie size={16} strokeWidth={1} />,
progressChart: <ChartBar size={16} strokeWidth={1} />,
table: <Table size={16} strokeWidth={1} />,
metric: <Hash size={16} strokeWidth={1} />,
}
return (
<div>

View file

@ -1,47 +1,50 @@
import React from 'react';
import cn from "classnames";
import WidgetName from "Components/Dashboard/components/WidgetName";
import {useStore} from "App/mstore";
import {useObserver} from "mobx-react-lite";
import AddToDashboardButton from "Components/Dashboard/components/AddToDashboardButton";
import {Button, Space} from "antd";
import CardViewMenu from "Components/Dashboard/components/WidgetView/CardViewMenu";
import cn from 'classnames';
import WidgetName from 'Components/Dashboard/components/WidgetName';
import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite';
import AddToDashboardButton from 'Components/Dashboard/components/AddToDashboardButton';
import { Button, Space } from 'antd';
import CardViewMenu from 'Components/Dashboard/components/WidgetView/CardViewMenu';
interface Props {
onClick?: () => void;
onSave: () => void;
undoChanges?: () => void;
onClick?: () => void;
onSave: () => void;
undoChanges?: () => void;
}
function WidgetViewHeader({onClick, onSave, undoChanges}: Props) {
const {metricStore, dashboardStore} = useStore();
const widget = useObserver(() => metricStore.instance);
function WidgetViewHeader({ onClick, onSave, undoChanges }: Props) {
const { metricStore, dashboardStore } = useStore();
const widget = useObserver(() => metricStore.instance);
return (
<div
className={cn('flex justify-between items-center')}
onClick={onClick}
return (
<div
className={cn(
'flex justify-between items-center bg-white rounded px-4 py-2 border border-gray-lighter'
)}
onClick={onClick}
>
<h1 className="mb-0 text-2xl mr-4 min-w-fit">
<WidgetName
name={widget.name}
onUpdate={(name) => metricStore.merge({ name })}
canEdit={true}
/>
</h1>
<Space>
<AddToDashboardButton metricId={widget.metricId} />
<Button
type="primary"
onClick={onSave}
loading={metricStore.isSaving}
disabled={metricStore.isSaving || !widget.hasChanged}
>
<h1 className="mb-0 text-2xl mr-4 min-w-fit">
<WidgetName name={widget.name}
onUpdate={(name) => metricStore.merge({name})}
canEdit={true}
/>
</h1>
<Space>
<AddToDashboardButton metricId={widget.metricId}/>
<Button
type="primary"
onClick={onSave}
loading={metricStore.isSaving}
disabled={metricStore.isSaving || !widget.hasChanged}
>
Update
</Button>
<CardViewMenu/>
</Space>
</div>
);
Update
</Button>
<CardViewMenu />
</Space>
</div>
);
}
export default WidgetViewHeader;

View file

@ -81,11 +81,11 @@ function SelectDateRange(props: Props) {
setIsCustom(false);
};
const isCustomRange = period ? period.rangeName === CUSTOM_RANGE : false;
const isCustomRange = usedPeriod ? usedPeriod.rangeName === CUSTOM_RANGE : false;
const isUSLocale =
navigator.language === 'en-US' || navigator.language.startsWith('en-US');
const customRange = isCustomRange
? period.rangeFormatted(
? usedPeriod.rangeFormatted(
isUSLocale ? 'MMM dd yyyy, hh:mm a' : 'MMM dd yyyy, HH:mm'
)
: '';
@ -97,6 +97,7 @@ function SelectDateRange(props: Props) {
selectedValue={selectedValue}
onChange={onChange}
isCustomRange={isCustomRange}
isCustom={isCustom}
customRange={customRange}
setIsCustom={setIsCustom}
onApplyDateRange={onApplyDateRange}
@ -189,6 +190,7 @@ function AndDateRange({
},
};
const comparisonValue = isCustomRange && selectedValue ? customRange : selectedValue?.label;
return (
<div className={'relative'}>
{comparison ? (
@ -200,9 +202,7 @@ function AndDateRange({
}
>
<span>
{isCustomRange
? customRange
: `Compare to ${selectedValue ? selectedValue?.label : ''}`}
{`Compare to ${comparisonValue || ''}`}
</span>
<DownOutlined />
</div>

View file

@ -19,7 +19,6 @@ export const FUNNEL = 'funnel';
export const ERRORS = 'errors';
export const USER_PATH = 'pathAnalysis';
export const RETENTION = 'retention';
export const FEATURE_ADOPTION = 'featureAdoption';
export const INSIGHTS = 'insights';
export const PERFORMANCE = 'performance';

View file

@ -125,7 +125,6 @@ export default class MetricStore {
}
}
console.log('ch', obj)
Object.assign(this.instance, obj);
this.instance.updateKey('hasChanged', updateChangeFlag);
}